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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -0
- data/README.md +25 -9
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +53 -18
- data/docs/failures.md +123 -18
- data/docs/harnesses.md +24 -14
- data/docs/measuring-tokens.md +8 -6
- data/docs/routing.md +5 -0
- data/docs/schemas.md +11 -2
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +57 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +92 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +21 -9
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +11 -2
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +6 -23
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +66 -42
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +57 -4
- metadata +40 -21
data/docs/failures.md
CHANGED
|
@@ -8,26 +8,48 @@ A failed attempt moves on to the next one; once the last fails, the error is rai
|
|
|
8
8
|
|
|
9
9
|
| Failure | What Squishling does |
|
|
10
10
|
|---|---|
|
|
11
|
-
| Rate limit, 5xx, overload, timeout, connection error |
|
|
11
|
+
| Rate limit, 5xx, overload, timeout, connection error | The [adapter](adapters.md) retries at the HTTP level first (RubyLLM: `RubyLLM.config.max_retries`, default 3; OpenAI-compatible: `max_retries:`, default 2). If it still fails, moves to the next attempt in a fresh chat; on the last attempt, raises `Squishling::LLMError` with the original exception as its `cause` (when the adapter has one: the RubyLLM adapter always does; the OpenAI-compatible adapter for transport errors, not for HTTP statuses). |
|
|
12
12
|
| Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
|
|
13
|
-
| Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError` naming the error class
|
|
14
|
-
| Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Moves to the next attempt, then raises `Squishling::InvalidOutputError` |
|
|
13
|
+
| Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `Squishling::RequestRejectedError` (a `ConfigurationError`) naming the error class, the HTTP status, and the provider's error status/code when its body carries one (`INVALID_ARGUMENT`, `invalid_request_error`). They are also on `error.provider_status` and `error.provider_code` (nil when absent); only plain tokens are kept, never the provider's message. The message points at a param only when the provider named it, and otherwise says the params, output schema, or input may be invalid. The provider's own message is on `cause` when the adapter has one (RubyLLM's does; with the OpenAI-compatible adapter `cause` carries only the status). It is never retried, so if you know a given code means "this input was bad", rescue it and decide for yourself. Never escalated, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
|
|
14
|
+
| Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Moves to the next attempt, then raises `Squishling::InvalidOutputError`. When the provider reported a max-tokens cutoff, the error says so and [`truncated?`](#a-cutoff-or-a-wrong-answer) is true. |
|
|
15
15
|
| Malformed or truncated JSON | Moves to the next attempt, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
|
|
16
16
|
| JSON that doesn't match the schema (wrong types, missing or extra keys, `null` in a non-`optional` field, a broken [conditional rule](schemas.md#contracts-beyond-the-schema), root not an object) | Moves to the next attempt with the validation errors, then raises `InvalidOutputError` |
|
|
17
17
|
| Schema-valid output that [`squish_validate`](#output-checks-squish_validate) rejects | Moves to the next attempt with your messages, then raises `InvalidOutputError` |
|
|
18
|
+
| The call has used its [token budget](#capping-a-calls-cost-token-budgets) | Stops before the next attempt and raises `Squishling::BudgetExhaustedError` (an `InvalidOutputError`) |
|
|
18
19
|
| With a [squishsum harness](harnesses.md): two valid samples that differ, with no judge or a judge that rejects both | Raises `Squishling::DisagreementError` (an `InvalidOutputError`), carrying both typed `candidates` |
|
|
19
20
|
|
|
20
21
|
Squishling validates output itself with [json_schemer](https://github.com/davishmcclurg/json_schemer), because
|
|
21
|
-
|
|
22
|
-
model, provider, params, and `
|
|
23
|
-
what it got wrong.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
adapters don't, and some providers don't enforce strict mode. When the next attempt is on the same step (same
|
|
23
|
+
model, provider, params, `forward_rejected:`, and `retry_temperature:`), the re-ask happens in the same conversation, so the model sees
|
|
24
|
+
what it got wrong. If that re-ask returns the same reply it was just told was invalid, Squishling doesn't spend the
|
|
25
|
+
step's remaining attempts: it moves straight to the next step (or raises `InvalidOutputError`, when this was the
|
|
26
|
+
last), so size `attempts:` for replies that can differ. Set `retry_temperature:` on a step to vary the temperature
|
|
27
|
+
of its same-chat retries. A different step gets a fresh chat with the original input plus the rejected output
|
|
28
|
+
(truncated to 4,000 characters) and its errors, so that output is sent to the next step's provider, which may not be the one that
|
|
29
|
+
produced it. Set `forward_rejected: false` on a step to leave both out. `InvalidOutputError` exposes `errors`,
|
|
30
|
+
`error_details` (the same messages, each tagged with where it came from; see below), `raw`
|
|
31
|
+
(the last response), `attempts`, `models` (the model tried on each attempt), and `finish_reason` (why the provider
|
|
32
|
+
stopped on the last one). For a [`DisagreementError`](harnesses.md#when-the-harness-fails), `raw` holds both
|
|
33
|
+
candidates, `attempts` and `finish_reason` are `nil`, and `models` has one entry per role (sample a, sample b, then
|
|
34
|
+
the judge). With a `logger` configured, every
|
|
29
35
|
escalation is logged as a warning.
|
|
30
36
|
|
|
37
|
+
### A cutoff or a wrong answer
|
|
38
|
+
|
|
39
|
+
An empty or half-written reply can mean the model was cut off at the output cap, not that it answered wrongly. The
|
|
40
|
+
provider's stop reason tells them apart: `InvalidOutputError#finish_reason` and `squawk`'s `metadata[:finish_reason]`
|
|
41
|
+
hold it as a Symbol (`:stop`, `:max_tokens`, `:content_filter`, or the provider's own spelling; `nil` when it reported
|
|
42
|
+
none), and `truncated?` is true for `:max_tokens`. A truncated error's message and the log line say so.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
rescue Squishling::InvalidOutputError => e
|
|
46
|
+
e.truncated? # => true: raise max_output_tokens, rather than tuning the prompt or validator
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Providers that count thinking against the cap need `max_output_tokens` to leave room for the thinking budget plus the
|
|
50
|
+
answer: `thinking: { budget: 8192 }` with `max_output_tokens: 8192` leaves none, so every reply that uses its full
|
|
51
|
+
budget is cut off before any JSON is written.
|
|
52
|
+
|
|
31
53
|
### What ends up in errors and logs
|
|
32
54
|
|
|
33
55
|
Model output can echo sensitive input, so the raw response is kept in one place: `InvalidOutputError#raw` (plus the opt-in `squawk` hook below).
|
|
@@ -37,13 +59,14 @@ Model output can echo sensitive input, so the raw response is kept in one place:
|
|
|
37
59
|
snippet of the response.
|
|
38
60
|
- JSON Schema errors, which name the schema path and the rule that failed. They never contain the rejected value, but
|
|
39
61
|
they do name an extra key the model added (`object property at /foo is a disallowed additional property`), and
|
|
40
|
-
that key name is chosen by the model.
|
|
62
|
+
that key name is chosen by the model. These are the only entries Squishling itself fills with model-chosen text; see
|
|
63
|
+
[telling the entries apart](#telling-the-entries-apart).
|
|
41
64
|
- Your own `squish_validate` messages, verbatim. If you interpolate result values into them, those values reach
|
|
42
65
|
the message, the logger, and the next attempt's prompt.
|
|
43
66
|
- A fixed message when a response contains a value JSON can't represent (`1e400` parses to `Infinity`, or a string
|
|
44
67
|
with invalid UTF-8).
|
|
45
68
|
- For a failed provider request (`LLMError`, and the `ConfigurationError` for a 400, bad credentials, or a forbidden
|
|
46
|
-
key), only the error class and HTTP status. The provider's own message can echo request content or part of an API
|
|
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
|
|
47
70
|
key, so it stays on `cause` (`error.cause.message`) for you to read or log deliberately.
|
|
48
71
|
- At most the first 20 of these errors, plus a count of the rest, so a very large malformed response can't flood
|
|
49
72
|
the retry message or the log.
|
|
@@ -51,14 +74,77 @@ Model output can echo sensitive input, so the raw response is kept in one place:
|
|
|
51
74
|
choice and probability. A chat judge's `reason` is model-written, so it is available as `#reason` but never in the
|
|
52
75
|
message or logs.
|
|
53
76
|
|
|
77
|
+
#### Telling the entries apart
|
|
78
|
+
|
|
79
|
+
`InvalidOutputError#errors` is an Array of Strings. `#error_details` is the same list as
|
|
80
|
+
`Squishling::InvalidOutputError::Detail` values (`message`, `source`), so a sink with its own control syntax (a chat
|
|
81
|
+
webhook, HTML, a markdown renderer) can escape only what needs it:
|
|
82
|
+
|
|
83
|
+
| `source` | Where the message came from | `model_derived?` |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `:schema` | A JSON Schema error. It can name a key the model chose. | `true` |
|
|
86
|
+
| `:validator` | Your own [`squish_validate`](#output-checks-squish_validate) message, verbatim. Never flagged, so escape it too if you interpolate result values into it. | `false` |
|
|
87
|
+
| `:parse` | A message about a response or return value that didn't parse (not valid JSON, empty, a value JSON can't represent). | `false` |
|
|
88
|
+
| `:squishling` | Other text Squishling or a [custom harness](harnesses.md#when-the-harness-fails) wrote, such as the "... and N more errors" notice. | `false` |
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
squish_fallback do |error, **|
|
|
92
|
+
trusted = error.error_details.reject(&:model_derived?).map(&:message)
|
|
93
|
+
notify_channel("squishling rejected output: #{trusted.first(2).join('; ')}")
|
|
94
|
+
# ...
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
54
98
|
The same applies to anything you log yourself, such as `error.message` in a [fallback](#fallbacks). Use `error.raw`
|
|
55
99
|
only where you are willing to store model output. To send the output of every attempt somewhere on purpose, use
|
|
56
100
|
[`squawk`](#observing-every-attempt-squawk).
|
|
57
101
|
|
|
102
|
+
## Capping a call's cost (token budgets)
|
|
103
|
+
|
|
104
|
+
`attempts:` limits retries per step, but a call that fails every attempt still pays for all of them, and with
|
|
105
|
+
reasoning models the failed attempts are often the longest. A token budget caps what one call may use across every
|
|
106
|
+
request it makes, whichever [harness](harnesses.md) it runs:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class TicketTriager
|
|
110
|
+
include Squishling
|
|
111
|
+
|
|
112
|
+
squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, { model: "claude-sonnet-5-5", attempts: 2 }],
|
|
113
|
+
max_total_output_tokens: 25_000,
|
|
114
|
+
max_total_input_tokens: 200_000
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Before each request, Squishling checks the tokens used so far by the call's earlier requests (the escalation's
|
|
119
|
+
attempts, a squishsum harness's samples, and a judge's). Once either total has reached its limit, it makes no more
|
|
120
|
+
requests and raises `Squishling::BudgetExhaustedError`. It is an `InvalidOutputError` (so
|
|
121
|
+
[`squish_fallback`](#fallbacks) receives it, and existing `rescue InvalidOutputError` code keeps working), and
|
|
122
|
+
`rescue Squishling::BudgetExhaustedError` tells it apart from a plain validation failure:
|
|
123
|
+
|
|
124
|
+
- `limit` is `:max_total_output_tokens` or `:max_total_input_tokens`, `max` its value, and `used` the tokens spent
|
|
125
|
+
so far (`{ input_tokens:, output_tokens: }`).
|
|
126
|
+
- `errors` starts with the budget line, followed by the validation errors of the last rejected output, which is in
|
|
127
|
+
`raw` (`nil` when no output was rejected, e.g. the samples were valid but there was no budget left for the judge);
|
|
128
|
+
`attempts` and `models` are the requests made.
|
|
129
|
+
- Like any other error message, it carries no model output.
|
|
130
|
+
|
|
131
|
+
Things to know:
|
|
132
|
+
|
|
133
|
+
- The first request always runs, and a request already in flight is never cut short, so a call can end above the
|
|
134
|
+
limit by one request (for the squishsum harnesses, one round of samples).
|
|
135
|
+
- Tokens are the counts the [adapter](adapters.md) reports (`output_tokens` includes a reasoning model's thinking
|
|
136
|
+
tokens for providers that count them that way). A request that failed reports none, and a provider that reports no
|
|
137
|
+
usage never exhausts a budget.
|
|
138
|
+
- The limits are positive Integers. Set them with `Squishling.configure` (`default_max_total_output_tokens`,
|
|
139
|
+
`default_max_total_input_tokens`), `squishling`, `squish :method, ...`, or a [`squish!`](routing.md#handing-off-to-the-llm-with-squish) call.
|
|
140
|
+
The more specific level wins, limit by limit, and `false` lifts a limit set at a wider level.
|
|
141
|
+
- To limit a single response instead, use the `max_output_tokens` [generation param](configuration.md#generation-params).
|
|
142
|
+
|
|
58
143
|
## Observing every attempt (`squawk`)
|
|
59
144
|
|
|
60
145
|
`squawk` is an opt-in hook for sending what the model returned to an error tracker or tracing tool. It runs after
|
|
61
|
-
every LLM attempt, accepted or rejected, and does nothing unless you
|
|
146
|
+
every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, below), and does nothing unless you
|
|
147
|
+
set it:
|
|
62
148
|
|
|
63
149
|
```ruby
|
|
64
150
|
Squishling.configure do |config|
|
|
@@ -72,11 +158,29 @@ end
|
|
|
72
158
|
|---|---|
|
|
73
159
|
| `output` | The raw response: a Hash or String, or `nil` when the provider call itself failed |
|
|
74
160
|
| `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt |
|
|
75
|
-
| `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length; under a harness, the sample's or judge's own attempts), `final` (the last attempt), `model`, `provider`, `params`, `input` (the JSON sent: arguments and named context only), `usage` (token counts, when
|
|
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`) |
|
|
76
162
|
|
|
77
163
|
- Set it globally with `config.squawk`, per class with `squishling squawk: ...`, or per method with
|
|
78
164
|
`squish :triage, squawk: ...`. The method's hook wins over the class's, which wins over the configured one;
|
|
79
165
|
`squawk: false` silences an inherited hook. Subclasses and `squish!` calls inherit it.
|
|
166
|
+
- Limit which attempts reach the hook with `squawk_on:`: `:all` (the default), `:rejections` (only attempts that
|
|
167
|
+
errored), `:retries` (those plus any accepted attempt after the first), or `:final` (the last attempt the escalation
|
|
168
|
+
allows, accepted or not; `metadata[:final]`). A classifier that passes on the first try nearly every time can post
|
|
169
|
+
only when something went wrong:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
squishling squawk: ->(error:, **) { Tracer.record(error) if error }, squawk_on: :retries
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Pass a list to combine conditions; the hook runs when any one holds. `squawk_on: %i[rejections final]` reports
|
|
176
|
+
every error, plus the last attempt when it is reached and succeeds. A call accepted on an earlier attempt never
|
|
177
|
+
reaches `:final`. A level's value replaces the one above it; lists are never merged across levels.
|
|
178
|
+
|
|
179
|
+
Set it globally with `config.squawk_on`, per class with `squishling squawk_on: ...`, or per method with
|
|
180
|
+
`squish :triage, squawk_on: ...`. It resolves on its own, method then class then configured, so
|
|
181
|
+
`config.squawk_on = :retries` filters every hook, including a class's own, unless that class or method overrides it.
|
|
182
|
+
Under a [harness](harnesses.md), `attempt`, `attempts` and `final` count per sample or judge, so a sample's
|
|
183
|
+
first-attempt success is skipped even while another sample retries, and `:final` fires once per sample and judge.
|
|
80
184
|
- Any object that responds to `call` works. The hook receives only the keywords it declares, unless it takes `**`,
|
|
81
185
|
so a lambda that wants just `error:` is fine and new metadata fields won't break it.
|
|
82
186
|
- It runs inline, so keep it quick. Exceptions it raises propagate unchanged and fail the call, like any of your own
|
|
@@ -120,17 +224,18 @@ end
|
|
|
120
224
|
| Error | Raised when |
|
|
121
225
|
|---|---|
|
|
122
226
|
| `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
|
|
123
|
-
| `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
|
|
227
|
+
| `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400, as `RequestRejectedError`, with `provider_status` and `provider_code`) |
|
|
124
228
|
| `Squishling::InvalidOutputError` | LLM output still invalid (schema or `squish_validate`) after every attempt in the escalation, or a deterministic/fallback return that doesn't match the schema |
|
|
125
229
|
| `Squishling::DisagreementError` | A subclass of `InvalidOutputError`: a [squishsum harness](harnesses.md#when-the-harness-fails)'s samples disagreed and no judge accepted either. `candidates`, `verdict`, and `reason` describe what happened |
|
|
126
|
-
| `Squishling::
|
|
230
|
+
| `Squishling::BudgetExhaustedError` | A subclass of `InvalidOutputError`: the call used up its [token budget](#capping-a-calls-cost-token-budgets) before an attempt was accepted. `limit`, `max`, and `used` describe the budget |
|
|
231
|
+
| `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after the adapter's own retries, including context-length errors (`cause` holds the original, when the adapter has one) |
|
|
127
232
|
|
|
128
233
|
Errors raised by your own Ruby code are not wrapped.
|
|
129
234
|
|
|
130
235
|
## Fallbacks
|
|
131
236
|
|
|
132
237
|
Use `squish_fallback` to decide what happens when the LLM path fails with `InvalidOutputError` (including a
|
|
133
|
-
`DisagreementError`) or `LLMError`. It's the one fallback for every [harness](harnesses.md).
|
|
238
|
+
`DisagreementError` or `BudgetExhaustedError`) or `LLMError`. It's the one fallback for every [harness](harnesses.md).
|
|
134
239
|
It receives the error plus the method's inputs as keywords, and runs against the instance:
|
|
135
240
|
|
|
136
241
|
```ruby
|
data/docs/harnesses.md
CHANGED
|
@@ -58,8 +58,8 @@ Both samples run on the escalation's first step, with its model, provider, and p
|
|
|
58
58
|
- **They're independent.** Each sample has its own chat, and the two requests are sent concurrently on separate
|
|
59
59
|
threads. Only the provider requests run on those threads. Parsing, schema validation, `squish_validate`,
|
|
60
60
|
`compare:`, and the judge all run on the calling thread, so your Squishling callbacks never run concurrently.
|
|
61
|
-
RubyLLM instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for
|
|
62
|
-
each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
|
|
61
|
+
With the RubyLLM adapter, instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for
|
|
62
|
+
`chat.ruby_llm`) do see each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
|
|
63
63
|
- **Each one is retried like an escalation step.** A sample with invalid output is re-asked in its own chat, up to
|
|
64
64
|
the first step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
|
|
65
65
|
chat. A sample still failing after its attempts fails the call with `InvalidOutputError` or `LLMError`, as
|
|
@@ -83,7 +83,7 @@ whose typed result is returned (`squished?` is `true`), or it picks `neither`, w
|
|
|
83
83
|
The judge is:
|
|
84
84
|
|
|
85
85
|
1. the `judge:` step, if declared: a model name, or a Hash with `model:`, `provider:`, `params:`, and `attempts:`
|
|
86
|
-
(as in an escalation step, without `priority
|
|
86
|
+
(as in an escalation step, without `priority:`, `rotate:`, or `include_when:`), plus `type:`; or
|
|
87
87
|
2. the escalation's next step after the sampled one, with its attempts. Steps tied on `priority:` rotate between calls
|
|
88
88
|
(see [Rotating models](configuration.md#rotating-models-within-a-priority)), so which model that is can vary.
|
|
89
89
|
|
|
@@ -108,8 +108,10 @@ valid verdict raises `InvalidOutputError`. A chat judge's params are the method'
|
|
|
108
108
|
### System One judgment models (Jev)
|
|
109
109
|
|
|
110
110
|
A judge can also be a System One decision model, such as TypeSafe's Jev, via
|
|
111
|
-
[RubyLLM judgments](https://rubyllm.com/judgments/) (RubyLLM 2.1+).
|
|
112
|
-
|
|
111
|
+
[RubyLLM judgments](https://rubyllm.com/judgments/) (RubyLLM 2.1+). This needs an [adapter](adapters.md) that
|
|
112
|
+
supports judgments, which today means the `:ruby_llm` adapter; on any other, a `type: :judgment` judge raises
|
|
113
|
+
`ConfigurationError` before a request is made (a `type: :chat` judge works with every adapter). Decision models return
|
|
114
|
+
calibrated probabilities rather than text, so they are much faster and cheaper than a chat judge.
|
|
113
115
|
|
|
114
116
|
```ruby
|
|
115
117
|
squishling harness: {
|
|
@@ -122,8 +124,8 @@ Squishling asks one `choice` question (`a`, `b`, or `neither`), with the judge p
|
|
|
122
124
|
operation's purpose, input, output schema, and candidates as the judgment input. A pick of `a` or `b` is
|
|
123
125
|
accepted only when its probability is at least `min_confidence:` (default `0.8`). Otherwise, as with `neither`,
|
|
124
126
|
`DisagreementError` is raised with the choice and its probability in its `reason`. A judgment judge sends only its own
|
|
125
|
-
`params:` (as RubyLLM `provider_options:`). `attempts:` retries a failed request.
|
|
126
|
-
credentials in RubyLLM as usual.
|
|
127
|
+
`params:` (as RubyLLM `provider_options:`). `attempts:` retries a failed request. Judgments are a RubyLLM feature, so
|
|
128
|
+
they need the `:ruby_llm` adapter, with the provider's credentials configured in RubyLLM as usual.
|
|
127
129
|
|
|
128
130
|
### The default judge prompt
|
|
129
131
|
|
|
@@ -166,6 +168,10 @@ end
|
|
|
166
168
|
| `attempts` | `nil` |
|
|
167
169
|
| `models` | The model behind each role: sample `a`, sample `b`, then the judge if one ran (one entry each, not one per attempt) |
|
|
168
170
|
|
|
171
|
+
A [token budget](failures.md#capping-a-calls-cost-token-budgets) covers every request of the call, the samples
|
|
172
|
+
and the judge's included. When it is spent before the next round of retries or before the judge can run, the call raises
|
|
173
|
+
`BudgetExhaustedError` (also an `InvalidOutputError`, and also sent to the fallback) instead of a `DisagreementError`.
|
|
174
|
+
|
|
169
175
|
Other failures are unchanged. A sample or judge that never produces valid output raises `InvalidOutputError`, a
|
|
170
176
|
failed request on the last attempt raises `LLMError`, and setup mistakes (including a provider 400 on either
|
|
171
177
|
sample) raise `ConfigurationError`, which is never sent to the fallback.
|
|
@@ -256,7 +262,7 @@ the harness is declared, before any request is made, and an unknown, missing, or
|
|
|
256
262
|
|
|
257
263
|
| Type | Accepts | `call` receives |
|
|
258
264
|
|---|---|---|
|
|
259
|
-
| `:step` | A model name, or a Hash with `model:`, `provider:`, `params:`, `attempts:`, `forward_rejected:` | A normalized, frozen step; pass it to `run.escalate`, or in the Array given to `run.samples` |
|
|
265
|
+
| `:step` | A model name, or a Hash with `model:`, `provider:`, `params:`, `attempts:`, `forward_rejected:`, `retry_temperature:` | A normalized, frozen step; pass it to `run.escalate`, or in the Array given to `run.samples` |
|
|
260
266
|
| `:proc` | A Proc | The Proc |
|
|
261
267
|
| `:string`, `:symbol`, `:integer`, `:boolean` | A String, a Symbol (or String, converted), an Integer, `true` or `false` | The value |
|
|
262
268
|
|
|
@@ -288,7 +294,7 @@ results, the same type the deterministic path returns.
|
|
|
288
294
|
| `run.stage(declaration)` | A stage from a model name or a step Hash (a single step is a stage of one attempt). It keeps its own provider, and its `params:` merge over the method's, as a [judge](#the-judge) step does |
|
|
289
295
|
| `run.escalate(*stages)` | Makes each attempt in order until an output passes the schema and `squish_validate`, in the same chat after invalid output and in a fresh one (told about the rejected output) on a new step. With no argument, it runs the escalation: exactly what `:escalation` does |
|
|
290
296
|
| `run.samples(stages)` | Takes an Array of stages (so `run.samples([stage, stage])`, not `run.samples(stage)`; a bare step counts as a stage of one attempt): one sample per stage, each in its own chat and retried like an escalation step, with the requests of every round sent concurrently. Returns the outputs in order. Each stage must be one model's attempts: mixed steps raise `ConfigurationError`. Samples are named `"a"`, `"b"`, ... (up to 26) |
|
|
291
|
-
| `run.judge(judge: nil, instructions: nil)` | Resolves a [judge](#the-judge) now (so a missing one raises `ConfigurationError` before any request) and returns it. `judge:` is a model name, or a [judge Hash](#the-judge) (`model:`, `provider:`, `params:`, `attempts:`, `type:`, `min_confidence:`; no `priority
|
|
297
|
+
| `run.judge(judge: nil, instructions: nil)` | Resolves a [judge](#the-judge) now (so a missing one raises `ConfigurationError` before any request) and returns it. `judge:` is a model name, or a [judge Hash](#the-judge) (`model:`, `provider:`, `params:`, `attempts:`, `type:`, `min_confidence:`; no `priority:`, `rotate:`, or `include_when:`), and `instructions:` a non-blank String or a Proc. `judge.verdict(first, second)` returns a `Squishling::Judge::Verdict` with `choice` (`:a`, `:b`, or `:neither`), `reason`, and `detail`, and `judge.step` is its first attempt. A chat judge's `reason` is model-written, so use `detail` (Squishling's own text, `nil` for a chat judge), not `reason`, in messages and logs. Without `judge:`, the judge is the escalation's second stage |
|
|
292
298
|
| `run.agree?(first, second, compare = nil)` | `compare` (evaluated against the instance with both results and the method's inputs) or, without one, exact equality |
|
|
293
299
|
| `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
|
|
294
300
|
|
|
@@ -302,8 +308,10 @@ A harness fails the way the built-ins do, so `squish_fallback` and the error cla
|
|
|
302
308
|
- **Attempts run out.** `run.escalate` and `run.samples` raise `InvalidOutputError` (invalid output after the last
|
|
303
309
|
attempt) or `LLMError` (a failed request on the last attempt) themselves, with the same attributes as always.
|
|
304
310
|
- **Your rule rejects.** Raise one of those errors yourself. `Squishling::DisagreementError.new(candidates:,
|
|
305
|
-
models:)` is the one for samples that don't agree, and `Squishling::InvalidOutputError.new(errors: [...],
|
|
306
|
-
anything else. Both go to [`squish_fallback`](failures.md#fallbacks).
|
|
311
|
+
models:)` is the one for samples that don't agree, and `Squishling::InvalidOutputError.new(errors: [...],
|
|
312
|
+
source: "Harness")` for anything else. Both go to [`squish_fallback`](failures.md#fallbacks). A String error is
|
|
313
|
+
tagged `:squishling` in [`error_details`](failures.md#telling-the-entries-apart); pass a
|
|
314
|
+
`Squishling::InvalidOutputError::Detail` to tag it yourself.
|
|
307
315
|
- **Setup mistakes** raise `ConfigurationError`, which is never retried and never sent to the fallback.
|
|
308
316
|
- **Your own exceptions** (from `call`, `compare:`, or `squish_validate`) propagate unchanged. Squishling doesn't wrap or
|
|
309
317
|
swallow them.
|
|
@@ -313,10 +321,12 @@ available to your code as the results and in `InvalidOutputError#raw`, and to `s
|
|
|
313
321
|
|
|
314
322
|
### Reporting to `squawk`
|
|
315
323
|
|
|
316
|
-
Every attempt a primitive makes is reported to the [`squawk`](failures.md#observing-every-attempt-squawk) hook
|
|
317
|
-
usage, so [token measuring](measuring-tokens.md) works unchanged. `metadata[:role]` says what the attempt was for:
|
|
324
|
+
Every attempt a primitive makes is reported to the [`squawk`](failures.md#observing-every-attempt-squawk) hook
|
|
325
|
+
(unless narrowed with `squawk_on:`), with usage, so [token measuring](measuring-tokens.md) works unchanged. `metadata[:role]` says what the attempt was for:
|
|
318
326
|
`nil` for an escalation attempt, `:sample` (with `metadata[:sample]` set to `"a"`, `"b"`, ...), or `:judge`. `attempt`
|
|
319
|
-
and `attempts` count within that sample, judge, or escalation.
|
|
327
|
+
and `attempts` count within that sample, judge, or escalation. `metadata[:repeated]` is `true` for an attempt that
|
|
328
|
+
sent back the reply it was just rejected for; the primitive then skips the rest of that step's attempts (the next
|
|
329
|
+
step's `attempt` jumps past them, and a sample ends with its `InvalidOutputError`).
|
|
320
330
|
|
|
321
331
|
### Threads
|
|
322
332
|
|
data/docs/measuring-tokens.md
CHANGED
|
@@ -6,11 +6,13 @@ first tells you when the second is worth paying.
|
|
|
6
6
|
## What Squishling gives you
|
|
7
7
|
|
|
8
8
|
- `result.squished?` says which path served a call, so you can count LLM calls per class.
|
|
9
|
-
- The [`squawk` hook](failures.md#observing-every-attempt-squawk) runs after every LLM attempt and receives the
|
|
9
|
+
- The [`squawk` hook](failures.md#observing-every-attempt-squawk) runs after every LLM attempt (by default; see `squawk_on` below) and receives the
|
|
10
10
|
method (`label`, e.g. `"ShipmentUpdate#call"`), the model and provider, which attempt it was, and the token usage
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
11
|
+
the adapter reported. That is enough to attribute spend to a squishling method without any other tooling.
|
|
12
|
+
- A [token budget](failures.md#capping-a-calls-cost-token-budgets) (`max_total_output_tokens:`, `max_total_input_tokens:`)
|
|
13
|
+
caps what a single call may use across all its attempts, so a call that keeps failing can't spend without limit.
|
|
14
|
+
- Cost in dollars isn't reported by Squishling's adapters. Multiply the reported tokens by your model's price, or, with
|
|
15
|
+
the RubyLLM adapter, read it from RubyLLM directly (see [OpenTelemetry](#opentelemetry), or RubyLLM's `config.instrumenter`).
|
|
14
16
|
|
|
15
17
|
## Deciding when to write the code
|
|
16
18
|
|
|
@@ -56,8 +58,8 @@ template each call belongs to; see [Coolhand Labs](coolhand.md).
|
|
|
56
58
|
|
|
57
59
|
### Custom: the `squawk` hook
|
|
58
60
|
|
|
59
|
-
`config.squawk` runs after every LLM attempt, accepted or rejected, and is handed the method's label, the model,
|
|
60
|
-
and the token usage
|
|
61
|
+
`config.squawk` runs after every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, which makes token totals undercount, so keep `:all` for measuring), and is handed the method's label, the model,
|
|
62
|
+
and the token usage the adapter reported. A lambda can take just the keywords it needs:
|
|
61
63
|
|
|
62
64
|
```ruby
|
|
63
65
|
Squishling.configure do |config|
|
data/docs/routing.md
CHANGED
|
@@ -61,6 +61,9 @@ This is the workflow from [Elastic Software](https://everythingengineer.substack
|
|
|
61
61
|
|
|
62
62
|
Callers never change, because both paths return the same result class.
|
|
63
63
|
|
|
64
|
+
To keep a particular model off certain inputs (a client that needs a BAA, say), give its escalation step an
|
|
65
|
+
`include_when:` Proc; see [Excluding a model per call](configuration.md#excluding-a-model-per-call).
|
|
66
|
+
|
|
64
67
|
## Entry points
|
|
65
68
|
|
|
66
69
|
Squish methods other than `call` with `squish`, optionally overriding the class-level settings per method:
|
|
@@ -91,6 +94,7 @@ end
|
|
|
91
94
|
- `output_schema:` (or a schema block)
|
|
92
95
|
- `model:` or `escalation:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
|
|
93
96
|
- `harness:` (see [Harnesses](harnesses.md), including your own)
|
|
97
|
+
- `max_total_output_tokens:` and `max_total_input_tokens:` (see [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets))
|
|
94
98
|
- `when:`, a predicate proc
|
|
95
99
|
- `validate:` and `fallback:` (see [Failure handling](failures.md))
|
|
96
100
|
|
|
@@ -134,6 +138,7 @@ end
|
|
|
134
138
|
| `purpose:` | Replaces the purpose |
|
|
135
139
|
| `model:` or `escalation:`, `provider:`, `params:` | E.g. send this call to a stronger model, or a whole [escalation](configuration.md#models-and-escalation), when Ruby fails. A `provider:` needs a `model:` or `escalation:`; `params:` merge key by key over the declared ones. |
|
|
136
140
|
| `harness:` | Replaces the declared [harness](harnesses.md), e.g. `harness: :judged_squishsum` to double-check a hand-off |
|
|
141
|
+
| `max_total_output_tokens:`, `max_total_input_tokens:` | Cap what this call may spend, e.g. a tighter budget for a hand-off; `false` lifts a declared cap. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
|
|
137
142
|
|
|
138
143
|
- **The output schema can't be overridden.** The call still returns the method's result type.
|
|
139
144
|
- **Failures** go through the normal LLM path: a declared `squish_fallback` is used, otherwise
|
data/docs/schemas.md
CHANGED
|
@@ -49,8 +49,10 @@ end
|
|
|
49
49
|
|
|
50
50
|
## Strict schemas only
|
|
51
51
|
|
|
52
|
-
Squishling only supports **strict** output schemas: it always asks
|
|
53
|
-
|
|
52
|
+
Squishling only supports **strict** output schemas: it always asks the adapter for strict structured output (the
|
|
53
|
+
OpenAI-compatible adapter's `structured_output: :json_object` and `:none` modes are the explicit exception, for servers
|
|
54
|
+
that lack it, where the schema goes into the prompt instead), and a schema declaring `strict: false` raises
|
|
55
|
+
`Squishling::ConfigurationError`. Squishling validates every answer against the schema either way. RubyLLM 2.0 on its own would send a
|
|
54
56
|
schema with optional properties non-strict; Squishling always sets the flag explicitly, so it stays strict.
|
|
55
57
|
|
|
56
58
|
With raw hashes, follow your provider's strict-mode rules (e.g. list every property in `required` and set
|
|
@@ -75,6 +77,13 @@ r.to_h # deep Hash with symbol keys
|
|
|
75
77
|
r.squished? # => true when it came from the LLM
|
|
76
78
|
```
|
|
77
79
|
|
|
80
|
+
A property can't share a name with a method the result depends on, because the `Data` member would replace it.
|
|
81
|
+
Squishling raises `ConfigurationError` when the schema is built (before any request), naming the clashing
|
|
82
|
+
fields; rename them (`class` to `ticket_class`). The reserved names are `class`, `is_a?`, `kind_of?`, `hash`, `inspect`, `to_s`,
|
|
83
|
+
`to_h`, `members`, `with`, `dup`, `send`, `public_send`, `eql?`, `==`, `[]`, `squished?`, `object_id`,
|
|
84
|
+
`deconstruct_keys`, and a few Ruby and Squishling internals (`initialize*`, `__send__`, `squishling_squished`).
|
|
85
|
+
Other names, such as `method`, `format`, or `type`, work as usual. This applies to nested objects and array items too.
|
|
86
|
+
|
|
78
87
|
On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
|
|
79
88
|
against the schema, and so is anything else you return (`nil`, a string, another schema's result), which raises
|
|
80
89
|
`Squishling::InvalidOutputError` unless it matches.
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`require "squishling/testing"` adds test support: a scripted [adapter](adapters.md) that answers in place of a
|
|
4
|
+
provider and records what was sent to it, stand-ins for the callers of squished methods, replies derived from an output
|
|
5
|
+
schema, and assertions. It is opt-in (`require "squishling"` doesn't load it), needs no test framework or mocking
|
|
6
|
+
library, and works with RSpec (without rspec-mocks), Minitest, and Mocha alike. It makes no network request.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Minitest
|
|
10
|
+
include Squishling::Testing::Minitest
|
|
11
|
+
|
|
12
|
+
# RSpec
|
|
13
|
+
RSpec.configure { |config| config.include Squishling::Testing::RSpec }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Both give your tests the helpers below and put back, after each test, whatever the helpers changed
|
|
17
|
+
(`Squishling::Testing.reset!`). They touch only the adapter, observer, and stubs they installed (and only while they
|
|
18
|
+
still hold what the helpers set), so the rest of your `Squishling.configure` survives.
|
|
19
|
+
|
|
20
|
+
The helpers change global state (`Squishling.config` and the stubbed classes), so tests that use them must not run
|
|
21
|
+
in parallel threads.
|
|
22
|
+
|
|
23
|
+
## Scripting the model
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
fake = fake_squishling do |llm|
|
|
27
|
+
llm.step("gemini-2.5-flash").reply({ regex: "[bad(" }, { regex: "Run.*" }) # in order; the last one repeats
|
|
28
|
+
llm.step("gemini-2.5-pro").fail(:rate_limit)
|
|
29
|
+
llm.step(:any).reply({ regex: "fallback.*" }) # anything no rule above claims
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = RegexInferrer.call(sample_turns: turns)
|
|
33
|
+
|
|
34
|
+
assert_equal "Run.*", result.regex
|
|
35
|
+
assert_equal 2, fake.requests.size
|
|
36
|
+
assert_sent_arguments fake, { sample_turns: turns } # what left the process
|
|
37
|
+
assert_includes fake.requests.last.errors, "regex is not a valid Ruby regex" # the re-ask carried them
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A reply is one of:
|
|
41
|
+
|
|
42
|
+
| Reply | The model… |
|
|
43
|
+
|---|---|
|
|
44
|
+
| a `Hash` | answered with this object |
|
|
45
|
+
| a `String` | returned this text (malformed or fenced JSON, truncated output) |
|
|
46
|
+
| `nil` | returned nothing (a refusal or a cut-off) |
|
|
47
|
+
| `Squishling::Testing::Reply.new(content, model: "m", usage: { input_tokens: 10, output_tokens: 3 })` | answered, and the provider reported this model and these token counts (`squawk`'s `metadata[:usage]`, the observer's `Usage`) |
|
|
48
|
+
| an `Exception` | made the request raise it |
|
|
49
|
+
| `.fail(intent)` | the provider failed: see below |
|
|
50
|
+
|
|
51
|
+
`reply` takes several, `.fail` adds one, and `.once` makes running out of replies an error instead of repeating the last.
|
|
52
|
+
|
|
53
|
+
**Provider failures by intent.** You don't need to know which SDK error maps to which Squishling error:
|
|
54
|
+
|
|
55
|
+
| Intent | Raises |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `:rate_limit`, `:server_error`, `:overloaded`, `:timeout`, `:context_length` | `LLMError` (the next attempt is tried) |
|
|
58
|
+
| `:bad_request`, `:unauthorized`, `:forbidden` | `ConfigurationError` (never retried, never given to a fallback) |
|
|
59
|
+
|
|
60
|
+
**Selecting what a rule answers.** `step(model)`, `step(provider: :vertexai)`, and `step(:any)` answer escalation
|
|
61
|
+
attempts; `attempt: 2` narrows a step rule to one attempt of the escalation. Rules are matched most specific first
|
|
62
|
+
(a sample or the judge, then the rule naming the most of model, provider and attempt, then `step(:any)`), and by
|
|
63
|
+
declaration order within that.
|
|
64
|
+
Scripts are keyed by model, so a rotated tier (`priority:`) gets the same answers whichever model goes first. Which
|
|
65
|
+
model goes first still depends on how many calls the class has had, so don't assert the order of `fake.models` for a
|
|
66
|
+
rotated tier unless the class is new to the test (or pin the tier with `rotate: false`).
|
|
67
|
+
A request no rule covers raises `Squishling::Testing::UnscriptedRequestError` naming the squished method; it isn't
|
|
68
|
+
a `Squishling::Error`, so `squish_fallback` can't hide it.
|
|
69
|
+
|
|
70
|
+
### Harnesses
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
fake_squishling do |llm|
|
|
74
|
+
llm.sample("a").reply(valid_reply_for(Triager))
|
|
75
|
+
llm.sample("b").reply(valid_reply_for(Triager, team: "billing"))
|
|
76
|
+
llm.judge.verdict(:a, reason: "a fits") # a chat judge
|
|
77
|
+
llm.judge.judgment(:b, confidence: 0.92) # a System One judge (type: :judgment, min_confidence:)
|
|
78
|
+
llm.judge.fail_judgment(:timeout)
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each sample has its own script, so the order its worker thread happens to run in can't change what it gets. The
|
|
83
|
+
scripts, the request log, and the conversation tracking are taken under one lock. `DisagreementError#candidates`
|
|
84
|
+
and `#verdict` are the real ones; rescue them as you would in production.
|
|
85
|
+
|
|
86
|
+
## Inspecting what was sent
|
|
87
|
+
|
|
88
|
+
`fake.requests` is every provider request, in order, as `Squishling::Testing::Request`:
|
|
89
|
+
|
|
90
|
+
| | |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `arguments`, `context` | the method's arguments and the `squish_context` / `squish!` context the model was sent |
|
|
93
|
+
| `system_prompt`, `output_schema` | the purpose (with `append_to_purpose` sections) and the strict schema |
|
|
94
|
+
| `model`, `provider`, `params` | the step it went to |
|
|
95
|
+
| `role`, `sample`, `attempt`, `attempts` | `nil`/`:sample`/`:judge`, `"a"`, and where it is in the escalation |
|
|
96
|
+
| `conversation` | the chat it belongs to |
|
|
97
|
+
| `errors` | the validation messages a retry or escalation carried back to the model |
|
|
98
|
+
| `forwarded_rejected` | the rejected output forwarded to the next step (`nil` with `forward_rejected: false`) |
|
|
99
|
+
| `candidates` | for a judge, the two samples |
|
|
100
|
+
| `messages`, `reply`, `failed?` | the conversation as sent, and the `Adapter::Response` (or error) that came back |
|
|
101
|
+
|
|
102
|
+
`fake.conversations` groups the requests by chat: a retry on the same step shares one, and an escalation to a new
|
|
103
|
+
step starts a new one. `fake.requests_for(sample: "a")`, `fake.models`, and `fake.arguments` are shortcuts.
|
|
104
|
+
|
|
105
|
+
## Asserting the LLM was not called
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
assert_no_llm_call { RegexInferrer.call(sample_turns: easy_turns) } # then: refute result.squished?
|
|
109
|
+
assert_llm_calls fake, 2
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`assert_no_llm_call` fails even if the code under test rescued the error that stopped the call.
|
|
113
|
+
|
|
114
|
+
**A safety net for the whole suite.** In a suite-wide `before` hook, `forbid_provider_calls!` makes any request that
|
|
115
|
+
reaches the provider raise `ProviderCallError` naming the squished method (`"RegexInferrer#call reached the provider…"`),
|
|
116
|
+
so a missing script fails at the call, not far from it. Installing a fake replaces it.
|
|
117
|
+
|
|
118
|
+
## Stubbing a squished class for its callers
|
|
119
|
+
|
|
120
|
+
Callers of a squished class shouldn't run Squishling at all. These make `Klass.call` return a real, schema-validated
|
|
121
|
+
typed result, or raise the real error class, without `allow(...).to receive`:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
stub_squishling_result(PatternGenerator, regex: "x") # squished? is true
|
|
125
|
+
stub_squishling_result(PatternGenerator, { regex: "x" }, squished: false) # as the Ruby path would return it
|
|
126
|
+
stub_squishling_result(PatternGenerator, regex: "x") { ... } # for the block only
|
|
127
|
+
|
|
128
|
+
stub_squishling_error(PatternGenerator, :invalid_output, errors: ["bad"], raw: "{}", attempts: 2, models: %w[a b])
|
|
129
|
+
stub_squishling_error(PatternGenerator, :llm_error) # also :configuration, :disagreement (candidates:, verdict:, reason:)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Attributes that don't satisfy the method's `output_schema` raise `Squishling::Testing::SetupError` when you stub, so a
|
|
133
|
+
stand-in can't drift from the real result type. Pass `method: :triage` for another squished method. The stubs are
|
|
134
|
+
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.
|
|
137
|
+
|
|
138
|
+
## Running parts of a method without a model
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
squishling_validate(Klass, { "regex" => "[bad(" }, text: "x") # => ["regex is not a valid Ruby regex"]
|
|
142
|
+
squishling_fallback_for(Klass, Squishling::LLMError.new("x"), text: "x") # the typed fallback result
|
|
143
|
+
squishling_preview(Klass, text: "x")
|
|
144
|
+
# => { system_prompt:, user_message:, output_schema:, step: } # for snapshot tests; makes no request
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Arguments are keywords, or `args: [...]` for positional ones; `receiver:` supplies an instance for a class whose
|
|
148
|
+
constructor takes arguments; `method:` names a squished method other than `call`. (A squished method with a parameter
|
|
149
|
+
called `args`, `receiver`, or `method` can't be given it as a keyword here.) `squishling_preview` doesn't advance model rotation.
|
|
150
|
+
|
|
151
|
+
## Replies derived from the schema
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
valid_reply_for(Triager) # => { "priority" => "example", "team" => "example" }
|
|
155
|
+
valid_reply_for(Triager, team: "billing") # overrides
|
|
156
|
+
invalid_reply_for(Triager, violating: :score) # out of its range, enum, or type
|
|
157
|
+
invalid_reply_for(Triager, violating: :score, how: :missing) # :enum, :range, :type, :null, :missing
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Both accept a squished class, a `Squishling::Schema`, or a schema Hash, and are checked against the schema before they
|
|
161
|
+
are returned, so a test that doesn't care about the content stays valid when the schema changes. A schema it can't
|
|
162
|
+
satisfy on its own (a `pattern`, an unsatisfiable conditional) raises `SetupError` and asks for overrides.
|
|
163
|
+
|
|
164
|
+
## `squawk` and observers
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
capture = with_squawk(capture_squawk) { Klass.call(text: "x") }
|
|
168
|
+
capture.calls.map { |call| call.metadata[:attempt] } # output:, error:, and metadata: of every attempt
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`with_squawk` swaps `config.squawk` for the block and puts the previous hook back, so an app-wide hook that enqueues
|
|
172
|
+
a job isn't run in tests. A `squawk:` set on a class or method still takes precedence over the configured one.
|
|
173
|
+
|
|
174
|
+
An observer you configured *before* `fake_squishling` keeps working while the fake is installed (it is chained behind
|
|
175
|
+
the fake's own). Configure it first: one assigned afterwards replaces the fake's, and then sample and judge scripts
|
|
176
|
+
can't tell which request is which.
|
|
177
|
+
|
|
178
|
+
## How it works
|
|
179
|
+
|
|
180
|
+
`fake_squishling` assigns a `Squishling::Testing::Adapter` (a `Squishling::Adapter::Base`) as `config.adapter`, and an
|
|
181
|
+
observer that tells it which role, sample, and attempt each request is. Because it sits behind the same interface as
|
|
182
|
+
the real adapters, it needs no knowledge of RubyLLM or any provider, and it is covered by Squishling's own specs.
|
|
183
|
+
Call `Squishling::Testing.install` and `.reset!` yourself if you use a test framework other than the two above.
|