squishling 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +118 -31
  4. data/docs/configuration.md +75 -21
  5. data/docs/coolhand.md +66 -0
  6. data/docs/failures.md +73 -9
  7. data/docs/harnesses.md +326 -0
  8. data/docs/measuring-tokens.md +140 -0
  9. data/docs/naming.md +43 -0
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +36 -27
  12. data/docs/schemas.md +24 -5
  13. data/lib/squishling/appendices.rb +3 -3
  14. data/lib/squishling/class_methods.rb +60 -29
  15. data/lib/squishling/collisions.rb +54 -0
  16. data/lib/squishling/configuration.rb +32 -0
  17. data/lib/squishling/definition.rb +56 -27
  18. data/lib/squishling/errors.rb +22 -1
  19. data/lib/squishling/harness/base.rb +136 -0
  20. data/lib/squishling/harness/escalation.rb +22 -0
  21. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  22. data/lib/squishling/harness/option_types.rb +93 -0
  23. data/lib/squishling/harness/registry.rb +69 -0
  24. data/lib/squishling/harness/squishsum.rb +22 -0
  25. data/lib/squishling/harness.rb +79 -0
  26. data/lib/squishling/invoker.rb +26 -188
  27. data/lib/squishling/judge.rb +186 -0
  28. data/lib/squishling/llm_client.rb +180 -0
  29. data/lib/squishling/model_path.rb +82 -25
  30. data/lib/squishling/observer.rb +100 -0
  31. data/lib/squishling/observers/coolhand.rb +86 -0
  32. data/lib/squishling/output_check.rb +109 -0
  33. data/lib/squishling/params.rb +47 -6
  34. data/lib/squishling/rotation.rb +26 -0
  35. data/lib/squishling/router.rb +8 -2
  36. data/lib/squishling/run/messages.rb +42 -0
  37. data/lib/squishling/run.rb +296 -0
  38. data/lib/squishling/source.rb +5 -5
  39. data/lib/squishling/squawk.rb +52 -0
  40. data/lib/squishling/version.rb +1 -1
  41. data/lib/squishling.rb +56 -7
  42. metadata +25 -3
data/docs/coolhand.md ADDED
@@ -0,0 +1,66 @@
1
+ # Coolhand Labs
2
+
3
+ Squishling ships an [observer](observers.md) for [Coolhand Labs](https://coolhandlabs.com/). With it, every LLM call a
4
+ squished method makes is filed under a named **workload** and **template** in Coolhand. Squishling knows exactly
5
+ where each call sits (which method, which escalation level, which sample, which judge), so it declares that for you.
6
+
7
+ ## Setup
8
+
9
+ Add and configure the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem as described in
10
+ [Measuring token spend](measuring-tokens.md#coolhand-labs). **That's all.** Squishling defaults to the Coolhand
11
+ observer whenever Coolhand is already available in your codebase: loaded, with an `api_key`, and not disabled
12
+ (`config.enabled = false`). Nothing is added to Squishling's dependencies, and nothing happens when the gem isn't
13
+ there. It needs a version of the `coolhand` gem that has `Coolhand.with_context` (declared workloads); with an older
14
+ one, the observer stays off.
15
+
16
+ To turn it off, set `observer` to `false`:
17
+
18
+ ```ruby
19
+ Squishling.configure do |config|
20
+ config.observer = false # never declare, even though Coolhand is configured
21
+ end
22
+ ```
23
+
24
+ The other settings:
25
+
26
+ ```ruby
27
+ Squishling.configure do |config|
28
+ config.observer = :coolhand # always declare; raises ConfigurationError now if the gem is missing or too old
29
+ config.observer = nil # the default: declare when Coolhand is loaded and configured
30
+ end
31
+ ```
32
+
33
+ To report somewhere else, or as well, use any [other observer](observers.md). `config.observer` holds one.
34
+
35
+ ## What is declared
36
+
37
+ Each request runs inside `Coolhand.with_context(reporter: "squishling", workload: ..., template: ...)`, so Coolhand's
38
+ Net::HTTP interception tags it with the declaration.
39
+
40
+ | | Key | Name |
41
+ |---|---|---|
42
+ | Workload | `Class#method` | `Class#method` (its `description` is `Class#method`, or `Class#method (rescue)` when a [`squish!`](routing.md#handing-off-to-the-llm-with-squish) call handed it to the LLM) |
43
+ | Template | `Class#method/<role>` | `Class#method · <role>` |
44
+
45
+ `<role>` is where the request sits:
46
+
47
+ | Role | Request |
48
+ |---|---|
49
+ | `squish:L<n>` | Attempt *n* (1-based) of the [escalation](configuration.md#models-and-escalation), counting each attempt of each step (a [custom harness](harnesses.md#custom-harnesses) that escalates stage by stage restarts at L1 for each) |
50
+ | `sample:a`, `sample:b`, ... | A [squishsum](harnesses.md) sample (which runs on a worker thread; the declaration is made on that thread) |
51
+ | `judge` | A harness's judge, chat or System One (all of a judge's attempts share this one template) |
52
+
53
+ So `Parser#call/squish:L2` is the second attempt of `Parser#call`, e.g. `Parser#call · squish L2` in Coolhand.
54
+
55
+ - The model is deliberately not in the key. It is on the log, and the position is the stable identity.
56
+ - Repeated attempts on the same step count as separate levels, because the level is the attempt's position in the
57
+ expanded escalation.
58
+ - A class name long enough to push a key past Coolhand's 255-character limit is left undeclared rather than
59
+ breaking the call.
60
+ - Squishling's declaration is made inside any `Coolhand.with_context` you wrapped around the call, so for the fields it
61
+ sets, Squishling's wins.
62
+ - The description never contains the purpose text, which can be a proc that differs per call. The workload's key
63
+ and name stay `Class#method` either way, so calls that went through `squish!` and calls that didn't share one
64
+ workload.
65
+ - `observer = :coolhand` loads the gem when it is assigned, so assign it after `coolhand` is available (the default
66
+ needs no ordering).
data/docs/failures.md CHANGED
@@ -10,19 +10,82 @@ A failed attempt moves on to the next one; once the last fails, the error is rai
10
10
  |---|---|
11
11
  | Rate limit, 5xx, overload, timeout, connection error | RubyLLM retries at the HTTP level first (`RubyLLM.config.max_retries`, default 3). If it still fails, moves to the next attempt in a fresh chat; on the last attempt, raises `Squishling::LLMError` with the original exception as its `cause`. |
12
12
  | Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
13
- | Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError` with the provider's message and the params in use. Never escalated, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
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 and HTTP status, and the params in use; the provider's own message is on `cause`. 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` |
15
15
  | Malformed or truncated JSON | Moves to the next attempt, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
16
16
  | JSON that doesn't match the schema (wrong types, missing or extra keys, `null` in a non-`optional` field, a broken [conditional rule](schemas.md#contracts-beyond-the-schema), root not an object) | Moves to the next attempt with the validation errors, then raises `InvalidOutputError` |
17
17
  | Schema-valid output that [`squish_validate`](#output-checks-squish_validate) rejects | Moves to the next attempt with your messages, then raises `InvalidOutputError` |
18
+ | With a [squishsum harness](harnesses.md): two valid samples that differ, with no judge or a judge that rejects both | Raises `Squishling::DisagreementError` (an `InvalidOutputError`), carrying both typed `candidates` |
18
19
 
19
20
  Squishling validates output itself with [json_schemer](https://github.com/davishmcclurg/json_schemer), because
20
21
  RubyLLM doesn't, and some providers don't enforce strict mode. When the next attempt is on the same step (same
21
- model, provider, and params), the re-ask happens in the same conversation, so the model sees what it got wrong. A
22
- different step gets a fresh chat with the original input plus the rejected output and its errors, so that output is
23
- sent to the next step's provider, which may not be the one that produced it. `InvalidOutputError` exposes `errors`,
24
- `raw` (the last response), `attempts`, and `models` (the model tried on each attempt). With a `logger` configured,
25
- every escalation is logged as a warning.
22
+ model, provider, params, and `forward_rejected:`), the re-ask happens in the same conversation, so the model sees
23
+ what it got wrong. A different step gets a fresh chat with the original input plus the rejected output (truncated to
24
+ 4,000 characters) and its errors, so that output is sent to the next step's provider, which may not be the one that
25
+ produced it. Set `forward_rejected: false` on a step to leave both out. `InvalidOutputError` exposes `errors`, `raw`
26
+ (the last response), `attempts`, and `models` (the model tried on each attempt); for a
27
+ [`DisagreementError`](harnesses.md#when-the-harness-fails), `raw` holds both candidates, `attempts` is `nil`, and
28
+ `models` has one entry per role (sample a, sample b, then the judge). With a `logger` configured, every
29
+ escalation is logged as a warning.
30
+
31
+ ### What ends up in errors and logs
32
+
33
+ Model output can echo sensitive input, so the raw response is kept in one place: `InvalidOutputError#raw` (plus the opt-in `squawk` hook below).
34
+ `InvalidOutputError#message`, `#errors`, and `config.logger` warnings are built from these:
35
+
36
+ - For unparseable JSON, only the position (`response was not valid JSON (at line 1 column 7)`), never the parser's
37
+ snippet of the response.
38
+ - JSON Schema errors, which name the schema path and the rule that failed. They never contain the rejected value, but
39
+ 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.
41
+ - Your own `squish_validate` messages, verbatim. If you interpolate result values into them, those values reach
42
+ the message, the logger, and the next attempt's prompt.
43
+ - A fixed message when a response contains a value JSON can't represent (`1e400` parses to `Infinity`, or a string
44
+ with invalid UTF-8).
45
+ - 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
47
+ key, so it stays on `cause` (`error.cause.message`) for you to read or log deliberately.
48
+ - At most the first 20 of these errors, plus a count of the rest, so a very large malformed response can't flood
49
+ the retry message or the log.
50
+ - For a [`DisagreementError`](harnesses.md#when-the-harness-fails), only what Squishling wrote: a `:judgment` judge's
51
+ choice and probability. A chat judge's `reason` is model-written, so it is available as `#reason` but never in the
52
+ message or logs.
53
+
54
+ The same applies to anything you log yourself, such as `error.message` in a [fallback](#fallbacks). Use `error.raw`
55
+ only where you are willing to store model output. To send the output of every attempt somewhere on purpose, use
56
+ [`squawk`](#observing-every-attempt-squawk).
57
+
58
+ ## Observing every attempt (`squawk`)
59
+
60
+ `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 set it:
62
+
63
+ ```ruby
64
+ Squishling.configure do |config|
65
+ config.squawk = lambda do |output:, metadata:, error:|
66
+ ObservabilitySolution.record(output, metadata, error) if error
67
+ end
68
+ end
69
+ ```
70
+
71
+ | Keyword | Value |
72
+ |---|---|
73
+ | `output` | The raw response: a Hash or String, or `nil` when the provider call itself failed |
74
+ | `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 RubyLLM reports them), `role` (`nil` for an escalation attempt, `:sample`, or `:judge`), `sample` (`"a"`, `"b"`, ... for a sample's attempt, else `nil`) |
76
+
77
+ - Set it globally with `config.squawk`, per class with `squishling squawk: ...`, or per method with
78
+ `squish :triage, squawk: ...`. The method's hook wins over the class's, which wins over the configured one;
79
+ `squawk: false` silences an inherited hook. Subclasses and `squish!` calls inherit it.
80
+ - Any object that responds to `call` works. The hook receives only the keywords it declares, unless it takes `**`,
81
+ so a lambda that wants just `error:` is fine and new metadata fields won't break it.
82
+ - It runs inline, so keep it quick. Exceptions it raises propagate unchanged and fail the call, like any of your own
83
+ code, so rescue inside the hook if an outage in your tracing tool shouldn't.
84
+ - `output` is the same object the result is built from, so treat it as read-only.
85
+ - Under a [harness](harnesses.md), it runs for every sample and judge attempt too, each with its own `input`, and
86
+ `role`/`sample` say which one it was. A [custom harness](harnesses.md#reporting-to-squawk) reports the same way.
87
+ - It isn't called for a `ConfigurationError`, for deterministic and fallback returns, or for an attempt where your own
88
+ `squish_validate` raises (that exception propagates first).
26
89
 
27
90
  ## Output checks (`squish_validate`)
28
91
 
@@ -57,16 +120,17 @@ end
57
120
  | Error | Raised when |
58
121
  |---|---|
59
122
  | `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
60
- | `Squishling::ConfigurationError` | Missing instructions or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_instructions` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
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) |
61
124
  | `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
+ | `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 |
62
126
  | `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after RubyLLM's own retries, including context-length errors (`cause` holds the original) |
63
127
 
64
128
  Errors raised by your own Ruby code are not wrapped.
65
129
 
66
130
  ## Fallbacks
67
131
 
68
- Use `squish_fallback` to decide what happens when every attempt fails, with `InvalidOutputError` or
69
- `LLMError`.
132
+ 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).
70
134
  It receives the error plus the method's inputs as keywords, and runs against the instance:
71
135
 
72
136
  ```ruby
data/docs/harnesses.md ADDED
@@ -0,0 +1,326 @@
1
+ # Harnesses: escalation, squishsum, and your own
2
+
3
+ A squished call's **harness** decides how it uses its [escalation](configuration.md#models-and-escalation):
4
+
5
+ | Harness | What it does | Requests per call |
6
+ |---|---|---|
7
+ | `:escalation` (default) | Tries each attempt in order until an output passes the schema and `squish_validate`. | 1 or more |
8
+ | `:squishsum` | Asks the escalation's **first step** twice, concurrently. Identical outputs are accepted; different ones raise `Squishling::DisagreementError`. | 2 or more |
9
+ | `:judged_squishsum` | Like `:squishsum`, but when the two outputs differ, a **judge** picks one or rejects both. | 2 or more, plus 1 or more judge requests when they differ |
10
+
11
+ Need a different trade-off, such as two models plus a tiebreaker or a majority vote? Register your own with
12
+ [`Squishling.register_harness`](#custom-harnesses).
13
+
14
+ Use a squishsum harness when a confidently wrong answer costs more than a second request: one sample can make a
15
+ mistake, but two independent samples rarely make the same one.
16
+
17
+ ```ruby
18
+ class TicketTriager
19
+ include Squishling
20
+
21
+ squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5"],
22
+ harness: :judged_squishsum # two Haiku samples; Sonnet judges when they differ
23
+ purpose "Assign a priority and a team."
24
+ output_schema do
25
+ string :priority, enum: %w[low medium high]
26
+ string :team
27
+ end
28
+ end
29
+ ```
30
+
31
+ ## Declaring a harness
32
+
33
+ `harness:` takes a type, or a Hash with `type:` and its options:
34
+
35
+ ```ruby
36
+ Squishling.configure { |c| c.default_harness = :squishsum } # every class without its own
37
+
38
+ squishling harness: :judged_squishsum # this class (and subclasses)
39
+ squish :triage, harness: { type: :squishsum, # one method
40
+ compare: ->(a, b, **) { a.priority == b.priority } }
41
+ squish!(harness: :escalation) # one call (see routing.md)
42
+ ```
43
+
44
+ The first level that declares one wins: `squish!`, then the method, then the class, then
45
+ `Squishling.config.default_harness`, then `:escalation`. Options aren't merged across levels.
46
+
47
+ | Option | Harnesses | Description |
48
+ |---|---|---|
49
+ | `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`, or a [registered](#custom-harnesses) name (required in the Hash form). |
50
+ | `compare:` | squishsum ones | `->(a, b, **inputs) { ... }`, evaluated against the instance with the two typed results and the method's inputs. Truthy means the samples agree. Default: the two outputs are exactly equal. |
51
+ | `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled one. |
52
+ | `judge_instructions:` | judged ones | A String, or a Proc evaluated against the instance, that replaces the [default judge prompt](#the-default-judge-prompt). |
53
+
54
+ ## Samples
55
+
56
+ Both samples run on the escalation's first step, with its model, provider, and params:
57
+
58
+ - **They're independent.** Each sample has its own chat, and the two requests are sent concurrently on separate
59
+ threads. Only the provider requests run on those threads. Parsing, schema validation, `squish_validate`,
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 `chat.ruby_llm`) do see
62
+ each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
63
+ - **Each one is retried like an escalation step.** A sample with invalid output is re-asked in its own chat, up to
64
+ the first step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
65
+ chat. A sample still failing after its attempts fails the call with `InvalidOutputError` or `LLMError`, as
66
+ usual. Later escalation steps are never used for samples (a judge may use the next one, see
67
+ [The judge](#the-judge)).
68
+ - **Agreement is exact by default.** Free-text fields seldom match word for word, so pass `compare:` to decide
69
+ which fields must match:
70
+
71
+ ```ruby
72
+ squishling harness: { type: :squishsum, compare: ->(a, b, **) { a.priority == b.priority && a.team == b.team } }
73
+ ```
74
+
75
+ When the samples agree, the first one is returned.
76
+
77
+ ## The judge
78
+
79
+ With a judged harness, two valid samples that disagree go to a judge. The judge picks candidate `a` or `b`,
80
+ whose typed result is returned (`squished?` is `true`), or it picks `neither`, which raises
81
+ `DisagreementError`.
82
+
83
+ The judge is:
84
+
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:` or `rotate:`), plus `type:`; or
87
+ 2. the escalation's next step after the sampled one, with its attempts. Steps tied on `priority:` rotate between calls
88
+ (see [Rotating models](configuration.md#rotating-models-within-a-priority)), so which model that is can vary.
89
+
90
+ A judged harness with neither raises `ConfigurationError` before any sample is requested. A `judge:`
91
+ step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
92
+ (a chat judge's params, though, do merge over the method's).
93
+
94
+ ### Chat judges
95
+
96
+ By default the judge is a chat model, asked for a strict `{ "verdict": "a" | "b" | "neither", "reason": "..." }`.
97
+ It gets the same context the samples had, plus the samples themselves:
98
+
99
+ - **System prompt:** the judge prompt, then the operation's own purpose (including any `append_to_purpose`
100
+ sections).
101
+ - **User message:** JSON with the samples' `input` (`arguments` and `context`), the `output_schema`, and the
102
+ `candidates` `a` and `b`.
103
+
104
+ A verdict that doesn't match its schema is retried per the judge step's `attempts:`; a judge that never returns a
105
+ valid verdict raises `InvalidOutputError`. A chat judge's params are the method's
106
+ [generation params](configuration.md#generation-params) with its own `params:` merged over them.
107
+
108
+ ### System One judgment models (Jev)
109
+
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+). Decision models return calibrated
112
+ probabilities rather than text, so they are much faster and cheaper than a chat judge.
113
+
114
+ ```ruby
115
+ squishling harness: {
116
+ type: :judged_squishsum,
117
+ judge: { model: "jev-latest", type: :judgment, min_confidence: 0.8 }
118
+ }
119
+ ```
120
+
121
+ Squishling asks one `choice` question (`a`, `b`, or `neither`), with the judge prompt as its instructions and the
122
+ operation's purpose, input, output schema, and candidates as the judgment input. A pick of `a` or `b` is
123
+ accepted only when its probability is at least `min_confidence:` (default `0.8`). Otherwise, as with `neither`,
124
+ `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. Configure the provider's
126
+ credentials in RubyLLM as usual.
127
+
128
+ ### The default judge prompt
129
+
130
+ ```text
131
+ You are an impartial judge. Two independent attempts at the same operation returned different outputs,
132
+ candidate "a" and candidate "b". Both already match the required output format, so judge only their
133
+ content. You are given the operation's purpose and its input. Decide which candidate correctly and
134
+ faithfully carries out the purpose for this input. If both do (for example, they differ only in
135
+ wording), choose either one, preferring "a". Choose "neither" only when both are wrong or you can't tell
136
+ whether either is correct. Never combine them or invent a third answer.
137
+ ```
138
+
139
+ It is available as `Squishling::Harness::DEFAULT_JUDGE_INSTRUCTIONS`. Replace it with `judge_instructions:`:
140
+
141
+ ```ruby
142
+ squishling harness: { type: :judged_squishsum,
143
+ judge_instructions: "Pick the candidate whose priority follows our SLA rules: ..." }
144
+ ```
145
+
146
+ ## When the harness fails
147
+
148
+ A disagreement raises `Squishling::DisagreementError`, a subclass of `InvalidOutputError`. It's raised when the
149
+ samples differ with no judge, or when the judge rejects both. Like any failure on the LLM path, it goes to
150
+ [`squish_fallback`](failures.md#fallbacks), which can still return one of the candidates:
151
+
152
+ ```ruby
153
+ squish_fallback do |error, **|
154
+ raise error unless error.is_a?(Squishling::DisagreementError)
155
+
156
+ error.candidates.first # already typed and validated; squished? is true
157
+ end
158
+ ```
159
+
160
+ | Attribute | Value |
161
+ |---|---|
162
+ | `candidates` | The two typed results, `[a, b]` |
163
+ | `verdict` | `nil` when there was no judge; `:neither` when the judge rejected both |
164
+ | `reason` | The judge's reason (or, for a judgment model, the choice and its probability). A chat judge's reason is model-written, so it is not part of `message` or the logs; a judgment model's choice and probability are |
165
+ | `raw` | Both candidates as Hashes, `[a.to_h, b.to_h]` |
166
+ | `attempts` | `nil` |
167
+ | `models` | The model behind each role: sample `a`, sample `b`, then the judge if one ran (one entry each, not one per attempt) |
168
+
169
+ Other failures are unchanged. A sample or judge that never produces valid output raises `InvalidOutputError`, a
170
+ failed request on the last attempt raises `LLMError`, and setup mistakes (including a provider 400 on either
171
+ sample) raise `ConfigurationError`, which is never sent to the fallback.
172
+
173
+ ## Custom harnesses
174
+
175
+ A harness is a class that decides how to use the escalation: which samples to ask for, how to compare them, who
176
+ breaks a tie. Register one under a name and it works anywhere a built-in type does: `squishling harness: :name`,
177
+ `squish :method, harness: { type: :name, ... }`, `squish!(harness: ...)`, and `config.default_harness`. It gets
178
+ everything the built-ins get: model, provider, and params resolution, schema validation and
179
+ [`squish_validate`](failures.md#output-checks-squish_validate), retries and escalation,
180
+ [`squawk`](failures.md#observing-every-attempt-squawk) reports with token usage, concurrent samples, and the
181
+ [failure behavior](#when-a-custom-harness-fails) (`squish_fallback`, `DisagreementError`).
182
+
183
+ ### Worked example: two models and a tiebreaker
184
+
185
+ Ask two cheap models. If they agree, done. If they don't, ask a third:
186
+
187
+ ```ruby
188
+ class Tiebreaker < Squishling::Harness::Base
189
+ option :tiebreaker, :step, required: true # a model name, or a step Hash
190
+ option :compare, :proc # optional, like squishsum's compare:
191
+
192
+ def call(run, tiebreaker:, compare: nil)
193
+ raise Squishling::ConfigurationError, "#{run.label}: needs two models" if run.stages.size < 2
194
+
195
+ first, second = run.samples(run.stages.first(2)) # the escalation's first two steps, asked concurrently
196
+ return first if run.agree?(first, second, compare)
197
+
198
+ run.escalate(tiebreaker) # they differ: a third opinion
199
+ end
200
+ end
201
+
202
+ Squishling.register_harness(:tiebreaker, Tiebreaker) # e.g. in an initializer
203
+
204
+ class TicketTriager
205
+ include Squishling
206
+
207
+ squishling escalation: ["claude-haiku-4-5", "claude-sonnet-5-5"],
208
+ harness: { type: :tiebreaker, tiebreaker: "claude-opus-5-5",
209
+ compare: ->(a, b, **) { a.priority == b.priority } }
210
+ purpose "Assign a priority and a team."
211
+ output_schema do
212
+ string :priority, enum: %w[low medium high]
213
+ string :team
214
+ end
215
+ end
216
+ ```
217
+
218
+ `TicketTriager.call(...)` sends the ticket to Haiku and Sonnet at the same time. A pair that agrees on `priority` returns
219
+ Haiku's result; otherwise Opus decides. Either way you get the same typed, validated result as every other path.
220
+ `run.stages` groups identical neighbouring steps into one stage (they are one model's attempts), so this harness needs
221
+ two different models in the escalation.
222
+
223
+ For small harnesses, pass a block instead of a class. It becomes the body of a `Squishling::Harness::Base` subclass:
224
+
225
+ ```ruby
226
+ Squishling.register_harness(:best_of_three) do
227
+ def call(run)
228
+ results = run.samples([run.stages.first] * 3)
229
+ results.max_by { |result| results.count { |other| run.agree?(result, other) } } # the most-agreed-with sample
230
+ end
231
+ end
232
+ ```
233
+
234
+ ### Registering
235
+
236
+ `Squishling.register_harness(name, klass = nil, &block)` takes a `Squishling::Harness::Base` subclass or a block, and
237
+ returns the class. `Squishling.harnesses` lists every name, built-ins first.
238
+
239
+ - The registry is global, and a harness is looked up when it is **declared**, so an unknown name fails up front.
240
+ Register in an initializer, before the classes that use the name load.
241
+ - A name is lowercase letters, digits, and underscores. Registering a name twice, or one of `:escalation`,
242
+ `:squishsum`, `:judged_squishsum`, raises `ConfigurationError`.
243
+ - A class must define `call` taking the run as its first argument and each declared option as a keyword (a keyword
244
+ for an option that may be left out needs a default), and be creatable without arguments. This is checked at
245
+ registration.
246
+ - Subclass `Squishling::Harness::Base` directly. The built-in harness classes are implementation, not an extension
247
+ point; write against `Run` as they do.
248
+ - `Squishling.reset_harnesses!` forgets the registered harnesses (the built-ins stay). It is meant for specs.
249
+ `Squishling.reset_config!` leaves them, since they're registered once at boot.
250
+
251
+ ### Options
252
+
253
+ Declare each option with `option name, type, required: false`, before registering the class. They are checked when
254
+ the harness is declared, before any request is made, and an unknown, missing, or mistyped option raises
255
+ `ConfigurationError`. A `nil` counts as not given, and `call` receives only the options that were given.
256
+
257
+ | Type | Accepts | `call` receives |
258
+ |---|---|---|
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` |
260
+ | `:proc` | A Proc | The Proc |
261
+ | `:string`, `:symbol`, `:integer`, `:boolean` | A String, a Symbol (or String, converted), an Integer, `true` or `false` | The value |
262
+
263
+ An option with a shape of its own takes a callable as its type instead. It gets the value, the declaration site (a
264
+ label for error messages), and the option's name, and returns the normalized value, or raises `ConfigurationError`. The
265
+ Hashes, Arrays, and Strings in the result are copied and frozen:
266
+
267
+ ```ruby
268
+ option :word, ->(value, label, name) do
269
+ raise Squishling::ConfigurationError, "#{label}: #{name}: must be a String" unless value.is_a?(String)
270
+
271
+ value.upcase
272
+ end
273
+ ```
274
+
275
+ To check options against each other, define `def self.validate_options!(options, label)` and raise
276
+ `Squishling::ConfigurationError` (include the `label`) to reject.
277
+
278
+ ### The run
279
+
280
+ `call(run, **options)` gets a `Squishling::Run`: one squished call, with these primitives. Each returns typed
281
+ results, the same type the deterministic path returns.
282
+
283
+ | Method | What it does |
284
+ |---|---|
285
+ | `run.receiver`, `run.inputs`, `run.label` | The instance, the method's inputs, and `"Class#method"` |
286
+ | `run.steps` | The escalation, one step per attempt |
287
+ | `run.stages` | The escalation grouped by model: `[[haiku, haiku], [sonnet]]` for `[{ model: haiku, attempts: 2 }, sonnet]`. A stage is the attempts of one model |
288
+ | `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
+ | `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
+ | `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:` or `rotate:`), 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
+ | `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
+ | `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
294
+
295
+ `call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
296
+ result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
297
+
298
+ ### When a custom harness fails
299
+
300
+ A harness fails the way the built-ins do, so `squish_fallback` and the error classes behave predictably:
301
+
302
+ - **Attempts run out.** `run.escalate` and `run.samples` raise `InvalidOutputError` (invalid output after the last
303
+ attempt) or `LLMError` (a failed request on the last attempt) themselves, with the same attributes as always.
304
+ - **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: [...], source: "Harness")` for
306
+ anything else. Both go to [`squish_fallback`](failures.md#fallbacks).
307
+ - **Setup mistakes** raise `ConfigurationError`, which is never retried and never sent to the fallback.
308
+ - **Your own exceptions** (from `call`, `compare:`, or `squish_validate`) propagate unchanged. Squishling doesn't wrap or
309
+ swallow them.
310
+
311
+ Keep model output out of the messages you raise and the lines you `run.log`: it can quote the input. The output is
312
+ available to your code as the results and in `InvalidOutputError#raw`, and to `squawk` if you opt in.
313
+
314
+ ### Reporting to `squawk`
315
+
316
+ Every attempt a primitive makes is reported to the [`squawk`](failures.md#observing-every-attempt-squawk) hook, with
317
+ usage, so [token measuring](measuring-tokens.md) works unchanged. `metadata[:role]` says what the attempt was for:
318
+ `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.
320
+
321
+ ### Threads
322
+
323
+ Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
324
+ `squish_validate`, and `compare:` all run on the calling thread, and the routing state stays fiber-local. Call a run's
325
+ primitives from the thread `call` runs on: from another one they raise `ConfigurationError`. Don't keep unsynchronized
326
+ state at class level in a harness, since one instance is created per call but the class is shared.
@@ -0,0 +1,140 @@
1
+ # Measuring token spend
2
+
3
+ A squished path costs tokens on every call. A Ruby path costs engineer time once, plus upkeep. Measuring the
4
+ first tells you when the second is worth paying.
5
+
6
+ ## What Squishling gives you
7
+
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
10
+ method (`label`, e.g. `"ShipmentUpdate#call"`), the model and provider, which attempt it was, and the token usage
11
+ RubyLLM reported. That is enough to attribute spend to a squishling method without any other tooling.
12
+ - Cost in dollars comes from RubyLLM's model pricing. Multiply the reported tokens by your model's price, or read it
13
+ from RubyLLM directly (see [OpenTelemetry](#opentelemetry), or RubyLLM's `config.instrumenter`).
14
+
15
+ ## Deciding when to write the code
16
+
17
+ For each squished path, over a month:
18
+
19
+ ```
20
+ token cost = calls × (input tokens × input price + output tokens × output price)
21
+ code cost = time to write it + upkeep (new cases, format changes, on-call)
22
+ ```
23
+
24
+ Write the Ruby when the token cost keeps exceeding the code cost, and narrow `squish_when` so only the inputs
25
+ Ruby can't handle still go to the LLM. See [Hardening a path](routing.md#hardening-a-path). If the token cost
26
+ stays small, the debt you haven't taken on is cheaper than the code.
27
+
28
+ ## Tools
29
+
30
+ | Option | Good for | Setup |
31
+ |---|---|---|
32
+ | [Coolhand Labs](#coolhand-labs) | Picking up your squishling calls automatically and measuring their cost and accuracy | The `coolhand` gem and an initializer |
33
+ | [Custom: the `squawk` hook](#custom-the-squawk-hook) | Logging or metrics in your own stack, with no new dependency | A lambda and one config line |
34
+ | [Your own observer](#your-own-observer) | Wrapping each request in your tracing or metrics tool, knowing which method, level, and sample it was | An object with `around(call)` |
35
+ | [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | RubyLLM's tracer and an exporter |
36
+
37
+ ### Coolhand Labs
38
+
39
+ Add and initialize the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem. It intercepts the LLM
40
+ requests your app makes, so it picks up your squishling calls automatically and measures their cost, and their
41
+ accuracy through Coolhand's feedback tools. No changes to your squishling classes.
42
+
43
+ ```ruby
44
+ # Gemfile
45
+ gem "coolhand"
46
+
47
+ # config/initializers/coolhand.rb
48
+ Coolhand.configure do |config|
49
+ config.api_key = ENV.fetch("COOLHAND_API_KEY")
50
+ end
51
+ ```
52
+
53
+ Get an API key at [coolhandlabs.com](https://coolhandlabs.com/). To keep the data in your own infrastructure, point
54
+ `config.base_url` at a Coolhand-compatible endpoint of your own. Squishling also tells Coolhand which workload and
55
+ template each call belongs to; see [Coolhand Labs](coolhand.md).
56
+
57
+ ### Custom: the `squawk` hook
58
+
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 RubyLLM reported. A lambda can take just the keywords it needs:
61
+
62
+ ```ruby
63
+ Squishling.configure do |config|
64
+ config.squawk = lambda do |metadata:, **|
65
+ usage = metadata[:usage] || {} # nil when the provider call failed; counts it didn't report are absent
66
+ Rails.logger.info("squishling #{metadata[:label]} model=#{metadata[:model]} " \
67
+ "attempt=#{metadata[:attempt]}/#{metadata[:attempts]} " \
68
+ "in=#{usage[:input_tokens]} out=#{usage[:output_tokens]}")
69
+ end
70
+ end
71
+ ```
72
+
73
+ Each line carries the `"Class#method"` that made the call, so grouping by label gives you tokens per squishling. A call
74
+ that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
75
+ been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
76
+ [Observing every attempt](failures.md#observing-every-attempt-squawk) for every keyword and the rules for exceptions.
77
+ Under a [harness](harnesses.md), including a [custom one](harnesses.md#custom-harnesses), every sample and judge
78
+ request reaches the hook too. `metadata[:role]` (`nil`, `:sample`, or `:judge`) and `metadata[:sample]` (`"a"`, `"b"`, ...)
79
+ split the spend by what each request was for.
80
+
81
+ ### Your own observer
82
+
83
+ An [observer](observers.md) wraps each provider request and gets the request's token usage back from `yield`, so it
84
+ can meter spend knowing which method, escalation level, sample, or judge made the request. Any object that responds to
85
+ `around(call)` is one:
86
+
87
+ ```ruby
88
+ class SpendObserver
89
+ def around(call)
90
+ usage = yield # input_tokens, output_tokens (nil if unreported), and model; never the content
91
+ tags = ["label:#{call.label}", "role:#{call.role || 'squish'}", "model:#{usage.model}"]
92
+ Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags:)
93
+ Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags:)
94
+ end
95
+ end
96
+
97
+ Squishling.configure { |config| config.observer = SpendObserver.new }
98
+ ```
99
+
100
+ An observer sees usage, never the model's output or your inputs. See [Observers](observers.md) for the `call` fields
101
+ and the rules.
102
+
103
+ ### OpenTelemetry
104
+
105
+ RubyLLM 2.1+ ships its own OpenTelemetry tracer. Each request becomes a span with the model, request settings and
106
+ token usage (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`), following the OpenTelemetry GenAI
107
+ conventions. It does not record message text. Your app owns the SDK and exporters, and the spans work with any OTLP
108
+ backend, [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
109
+ included.
110
+
111
+ ```ruby
112
+ # Gemfile
113
+ gem "opentelemetry-sdk"
114
+ gem "opentelemetry-exporter-otlp" # or your backend's exporter
115
+
116
+ # config/initializers/opentelemetry.rb
117
+ OpenTelemetry::SDK.configure # reads the standard OTEL_* environment variables
118
+ RubyLLM::OpenTelemetry.enable # starts tracing RubyLLM requests
119
+ ```
120
+
121
+ Spans don't say which squishling made the request. To attribute them, wrap the call in `RubyLLM.workflow("Name")`, or
122
+ use an [observer](observers.md) that adds the label to the current span. RubyLLM's `config.instrumenter` also reports
123
+ a `usage.ruby_llm` event per request, with tokens and cost, if you would rather meter than trace.
124
+
125
+ ## Privacy
126
+
127
+ Squishling only sends the model what you name: the method arguments, any `squish_context` values, and any source you
128
+ append. Tools that sit on the request path are a separate route out of your process, and they see both sides:
129
+
130
+ - **Request interceptors, such as `coolhand`,** record the prompts (your purpose text and those inputs) and the
131
+ model's responses. Coolhand sends them to coolhandlabs.com unless you point `config.base_url` at an endpoint of your
132
+ own.
133
+ - **A `squawk` hook that forwards `output:` or `metadata[:input]`** sends model output and your inputs wherever you
134
+ send them. That is the sanctioned route for model output; see
135
+ [Observing every attempt](failures.md#observing-every-attempt-squawk). The examples above log only metadata.
136
+ - **OpenTelemetry** with RubyLLM's tracer records usage and request settings, not message text.
137
+ - **An observer** sees timing, token usage, and the call's metadata (method, model, level), never the model's output or
138
+ your inputs.
139
+
140
+ Check what your tool stores before pointing it at inputs that contain personal data.