squishling 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -1
- data/README.md +63 -15
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +89 -24
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +129 -20
- data/docs/harnesses.md +186 -48
- data/docs/measuring-tokens.md +36 -42
- data/docs/observers.md +80 -0
- data/docs/routing.md +7 -2
- data/docs/schemas.md +33 -6
- 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 +72 -8
- data/lib/squishling/definition.rb +95 -17
- data/lib/squishling/errors.rb +97 -12
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +94 -0
- data/lib/squishling/harness/registry.rb +41 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +25 -99
- data/lib/squishling/invoker.rb +28 -233
- data/lib/squishling/judge.rb +78 -21
- data/lib/squishling/llm_client.rb +73 -109
- data/lib/squishling/model_path.rb +117 -27
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +109 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +15 -27
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +31 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +79 -0
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +320 -0
- 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 +89 -4
- metadata +53 -21
data/docs/harnesses.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Harnesses: escalation, squishsum, and
|
|
1
|
+
# Harnesses: escalation, squishsum, and your own
|
|
2
2
|
|
|
3
3
|
A squished call's **harness** decides how it uses its [escalation](configuration.md#models-and-escalation):
|
|
4
4
|
|
|
@@ -7,13 +7,12 @@ A squished call's **harness** decides how it uses its [escalation](configuration
|
|
|
7
7
|
| `:escalation` (default) | Tries each attempt in order until an output passes the schema and `squish_validate`. | 1 or more |
|
|
8
8
|
| `:squishsum` | Asks the escalation's **first step** twice, concurrently. Identical outputs are accepted; different ones raise `Squishling::DisagreementError`. | 2 or more |
|
|
9
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
|
-
|
|
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).
|
|
12
13
|
|
|
13
14
|
Use a squishsum harness when a confidently wrong answer costs more than a second request: one sample can make a
|
|
14
|
-
mistake, but two independent samples rarely make the same one.
|
|
15
|
-
should come from different models: a model rarely disagrees with itself at low temperature, but a cheap and a
|
|
16
|
-
stronger model often disagree on exactly the cases the cheap one gets confidently wrong.
|
|
15
|
+
mistake, but two independent samples rarely make the same one.
|
|
17
16
|
|
|
18
17
|
```ruby
|
|
19
18
|
class TicketTriager
|
|
@@ -47,25 +46,25 @@ The first level that declares one wins: `squish!`, then the method, then the cla
|
|
|
47
46
|
|
|
48
47
|
| Option | Harnesses | Description |
|
|
49
48
|
|---|---|---|
|
|
50
|
-
| `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`,
|
|
51
|
-
| `compare:` | squishsum
|
|
52
|
-
| `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled
|
|
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. |
|
|
53
52
|
| `judge_instructions:` | judged ones | A String, or a Proc evaluated against the instance, that replaces the [default judge prompt](#the-default-judge-prompt). |
|
|
54
53
|
|
|
55
54
|
## Samples
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
(an [ensemble](#ensembles) runs them on its first and second steps):
|
|
56
|
+
Both samples run on the escalation's first step, with its model, provider, and params:
|
|
59
57
|
|
|
60
58
|
- **They're independent.** Each sample has its own chat, and the two requests are sent concurrently on separate
|
|
61
59
|
threads. Only the provider requests run on those threads. Parsing, schema validation, `squish_validate`,
|
|
62
60
|
`compare:`, and the judge all run on the calling thread, so your Squishling callbacks never run concurrently.
|
|
63
|
-
RubyLLM instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for
|
|
64
|
-
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.
|
|
65
63
|
- **Each one is retried like an escalation step.** A sample with invalid output is re-asked in its own chat, up to
|
|
66
|
-
|
|
64
|
+
the first step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
|
|
67
65
|
chat. A sample still failing after its attempts fails the call with `InvalidOutputError` or `LLMError`, as
|
|
68
|
-
usual.
|
|
66
|
+
usual. Later escalation steps are never used for samples (a judge may use the next one, see
|
|
67
|
+
[The judge](#the-judge)).
|
|
69
68
|
- **Agreement is exact by default.** Free-text fields seldom match word for word, so pass `compare:` to decide
|
|
70
69
|
which fields must match:
|
|
71
70
|
|
|
@@ -75,32 +74,6 @@ Under `:squishsum`, both samples run on the escalation's first step, with its mo
|
|
|
75
74
|
|
|
76
75
|
When the samples agree, the first one is returned.
|
|
77
76
|
|
|
78
|
-
## Ensembles
|
|
79
|
-
|
|
80
|
-
`:ensemble` and `:judged_ensemble` sample two *different* steps instead of one step twice. Sample `a` is the
|
|
81
|
-
escalation's first step and sample `b` its second, each with its own model, provider, and params:
|
|
82
|
-
|
|
83
|
-
```ruby
|
|
84
|
-
class TicketTriager
|
|
85
|
-
include Squishling
|
|
86
|
-
|
|
87
|
-
squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"],
|
|
88
|
-
harness: :judged_ensemble # a = Haiku, b = Sonnet, Opus judges when they differ
|
|
89
|
-
# purpose, output_schema, ...
|
|
90
|
-
end
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
- A step's `attempts:` are that sample's retries (here Haiku gets two attempts, Sonnet one), exactly as in
|
|
94
|
-
[Samples](#samples). Steps after the second are never sampled.
|
|
95
|
-
- Everything else matches squishsum: independence and concurrency, `compare:`, `DisagreementError`, and the
|
|
96
|
-
judge. `DisagreementError#models` names the model behind each sample.
|
|
97
|
-
- The judge defaults to the escalation's **third** step. With only two steps, a `:judged_ensemble` needs `judge:`.
|
|
98
|
-
An ensemble with fewer than two steps, or a judged one with neither a third step nor `judge:`, raises
|
|
99
|
-
`ConfigurationError` before any request.
|
|
100
|
-
- A global `default_harness = :ensemble` therefore needs every class to declare at least two steps, and a
|
|
101
|
-
`squish!(model: ...)` override (one step) can't be combined with an ensemble harness.
|
|
102
|
-
- Steps are told apart by value, so two adjacent steps with the same model, provider, and params count as one step.
|
|
103
|
-
|
|
104
77
|
## The judge
|
|
105
78
|
|
|
106
79
|
With a judged harness, two valid samples that disagree go to a judge. The judge picks candidate `a` or `b`,
|
|
@@ -110,9 +83,9 @@ whose typed result is returned (`squished?` is `true`), or it picks `neither`, w
|
|
|
110
83
|
The judge is:
|
|
111
84
|
|
|
112
85
|
1. the `judge:` step, if declared: a model name, or a Hash with `model:`, `provider:`, `params:`, and `attempts:`
|
|
113
|
-
(as in an escalation step, without `
|
|
114
|
-
2. the escalation's next step after the sampled
|
|
115
|
-
|
|
86
|
+
(as in an escalation step, without `priority:`, `rotate:`, or `include_when:`), 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.
|
|
116
89
|
|
|
117
90
|
A judged harness with neither raises `ConfigurationError` before any sample is requested. A `judge:`
|
|
118
91
|
step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
|
|
@@ -135,8 +108,10 @@ valid verdict raises `InvalidOutputError`. A chat judge's params are the method'
|
|
|
135
108
|
### System One judgment models (Jev)
|
|
136
109
|
|
|
137
110
|
A judge can also be a System One decision model, such as TypeSafe's Jev, via
|
|
138
|
-
[RubyLLM judgments](https://rubyllm.com/judgments/) (RubyLLM 2.1+).
|
|
139
|
-
|
|
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.
|
|
140
115
|
|
|
141
116
|
```ruby
|
|
142
117
|
squishling harness: {
|
|
@@ -149,8 +124,8 @@ Squishling asks one `choice` question (`a`, `b`, or `neither`), with the judge p
|
|
|
149
124
|
operation's purpose, input, output schema, and candidates as the judgment input. A pick of `a` or `b` is
|
|
150
125
|
accepted only when its probability is at least `min_confidence:` (default `0.8`). Otherwise, as with `neither`,
|
|
151
126
|
`DisagreementError` is raised with the choice and its probability in its `reason`. A judgment judge sends only its own
|
|
152
|
-
`params:` (as RubyLLM `provider_options:`). `attempts:` retries a failed request.
|
|
153
|
-
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.
|
|
154
129
|
|
|
155
130
|
### The default judge prompt
|
|
156
131
|
|
|
@@ -193,6 +168,169 @@ end
|
|
|
193
168
|
| `attempts` | `nil` |
|
|
194
169
|
| `models` | The model behind each role: sample `a`, sample `b`, then the judge if one ran (one entry each, not one per attempt) |
|
|
195
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
|
+
|
|
196
175
|
Other failures are unchanged. A sample or judge that never produces valid output raises `InvalidOutputError`, a
|
|
197
176
|
failed request on the last attempt raises `LLMError`, and setup mistakes (including a provider 400 on either
|
|
198
177
|
sample) raise `ConfigurationError`, which is never sent to the fallback.
|
|
178
|
+
|
|
179
|
+
## Custom harnesses
|
|
180
|
+
|
|
181
|
+
A harness is a class that decides how to use the escalation: which samples to ask for, how to compare them, who
|
|
182
|
+
breaks a tie. Register one under a name and it works anywhere a built-in type does: `squishling harness: :name`,
|
|
183
|
+
`squish :method, harness: { type: :name, ... }`, `squish!(harness: ...)`, and `config.default_harness`. It gets
|
|
184
|
+
everything the built-ins get: model, provider, and params resolution, schema validation and
|
|
185
|
+
[`squish_validate`](failures.md#output-checks-squish_validate), retries and escalation,
|
|
186
|
+
[`squawk`](failures.md#observing-every-attempt-squawk) reports with token usage, concurrent samples, and the
|
|
187
|
+
[failure behavior](#when-a-custom-harness-fails) (`squish_fallback`, `DisagreementError`).
|
|
188
|
+
|
|
189
|
+
### Worked example: two models and a tiebreaker
|
|
190
|
+
|
|
191
|
+
Ask two cheap models. If they agree, done. If they don't, ask a third:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class Tiebreaker < Squishling::Harness::Base
|
|
195
|
+
option :tiebreaker, :step, required: true # a model name, or a step Hash
|
|
196
|
+
option :compare, :proc # optional, like squishsum's compare:
|
|
197
|
+
|
|
198
|
+
def call(run, tiebreaker:, compare: nil)
|
|
199
|
+
raise Squishling::ConfigurationError, "#{run.label}: needs two models" if run.stages.size < 2
|
|
200
|
+
|
|
201
|
+
first, second = run.samples(run.stages.first(2)) # the escalation's first two steps, asked concurrently
|
|
202
|
+
return first if run.agree?(first, second, compare)
|
|
203
|
+
|
|
204
|
+
run.escalate(tiebreaker) # they differ: a third opinion
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
Squishling.register_harness(:tiebreaker, Tiebreaker) # e.g. in an initializer
|
|
209
|
+
|
|
210
|
+
class TicketTriager
|
|
211
|
+
include Squishling
|
|
212
|
+
|
|
213
|
+
squishling escalation: ["claude-haiku-4-5", "claude-sonnet-5-5"],
|
|
214
|
+
harness: { type: :tiebreaker, tiebreaker: "claude-opus-5-5",
|
|
215
|
+
compare: ->(a, b, **) { a.priority == b.priority } }
|
|
216
|
+
purpose "Assign a priority and a team."
|
|
217
|
+
output_schema do
|
|
218
|
+
string :priority, enum: %w[low medium high]
|
|
219
|
+
string :team
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`TicketTriager.call(...)` sends the ticket to Haiku and Sonnet at the same time. A pair that agrees on `priority` returns
|
|
225
|
+
Haiku's result; otherwise Opus decides. Either way you get the same typed, validated result as every other path.
|
|
226
|
+
`run.stages` groups identical neighbouring steps into one stage (they are one model's attempts), so this harness needs
|
|
227
|
+
two different models in the escalation.
|
|
228
|
+
|
|
229
|
+
For small harnesses, pass a block instead of a class. It becomes the body of a `Squishling::Harness::Base` subclass:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
Squishling.register_harness(:best_of_three) do
|
|
233
|
+
def call(run)
|
|
234
|
+
results = run.samples([run.stages.first] * 3)
|
|
235
|
+
results.max_by { |result| results.count { |other| run.agree?(result, other) } } # the most-agreed-with sample
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Registering
|
|
241
|
+
|
|
242
|
+
`Squishling.register_harness(name, klass = nil, &block)` takes a `Squishling::Harness::Base` subclass or a block, and
|
|
243
|
+
returns the class. `Squishling.harnesses` lists every name, built-ins first.
|
|
244
|
+
|
|
245
|
+
- The registry is global, and a harness is looked up when it is **declared**, so an unknown name fails up front.
|
|
246
|
+
Register in an initializer, before the classes that use the name load.
|
|
247
|
+
- A name is lowercase letters, digits, and underscores. Registering a name twice, or one of `:escalation`,
|
|
248
|
+
`:squishsum`, `:judged_squishsum`, raises `ConfigurationError`.
|
|
249
|
+
- A class must define `call` taking the run as its first argument and each declared option as a keyword (a keyword
|
|
250
|
+
for an option that may be left out needs a default), and be creatable without arguments. This is checked at
|
|
251
|
+
registration.
|
|
252
|
+
- Subclass `Squishling::Harness::Base` directly. The built-in harness classes are implementation, not an extension
|
|
253
|
+
point; write against `Run` as they do.
|
|
254
|
+
- `Squishling.reset_harnesses!` forgets the registered harnesses (the built-ins stay). It is meant for specs.
|
|
255
|
+
`Squishling.reset_config!` leaves them, since they're registered once at boot.
|
|
256
|
+
|
|
257
|
+
### Options
|
|
258
|
+
|
|
259
|
+
Declare each option with `option name, type, required: false`, before registering the class. They are checked when
|
|
260
|
+
the harness is declared, before any request is made, and an unknown, missing, or mistyped option raises
|
|
261
|
+
`ConfigurationError`. A `nil` counts as not given, and `call` receives only the options that were given.
|
|
262
|
+
|
|
263
|
+
| Type | Accepts | `call` receives |
|
|
264
|
+
|---|---|---|
|
|
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` |
|
|
266
|
+
| `:proc` | A Proc | The Proc |
|
|
267
|
+
| `:string`, `:symbol`, `:integer`, `:boolean` | A String, a Symbol (or String, converted), an Integer, `true` or `false` | The value |
|
|
268
|
+
|
|
269
|
+
An option with a shape of its own takes a callable as its type instead. It gets the value, the declaration site (a
|
|
270
|
+
label for error messages), and the option's name, and returns the normalized value, or raises `ConfigurationError`. The
|
|
271
|
+
Hashes, Arrays, and Strings in the result are copied and frozen:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
option :word, ->(value, label, name) do
|
|
275
|
+
raise Squishling::ConfigurationError, "#{label}: #{name}: must be a String" unless value.is_a?(String)
|
|
276
|
+
|
|
277
|
+
value.upcase
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
To check options against each other, define `def self.validate_options!(options, label)` and raise
|
|
282
|
+
`Squishling::ConfigurationError` (include the `label`) to reject.
|
|
283
|
+
|
|
284
|
+
### The run
|
|
285
|
+
|
|
286
|
+
`call(run, **options)` gets a `Squishling::Run`: one squished call, with these primitives. Each returns typed
|
|
287
|
+
results, the same type the deterministic path returns.
|
|
288
|
+
|
|
289
|
+
| Method | What it does |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `run.receiver`, `run.inputs`, `run.label` | The instance, the method's inputs, and `"Class#method"` |
|
|
292
|
+
| `run.steps` | The escalation, one step per attempt |
|
|
293
|
+
| `run.stages` | The escalation grouped by model: `[[haiku, haiku], [sonnet]]` for `[{ model: haiku, attempts: 2 }, sonnet]`. A stage is the attempts of one model |
|
|
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 |
|
|
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 |
|
|
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) |
|
|
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 |
|
|
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 |
|
|
299
|
+
| `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
|
|
300
|
+
|
|
301
|
+
`call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
|
|
302
|
+
result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
|
|
303
|
+
|
|
304
|
+
### When a custom harness fails
|
|
305
|
+
|
|
306
|
+
A harness fails the way the built-ins do, so `squish_fallback` and the error classes behave predictably:
|
|
307
|
+
|
|
308
|
+
- **Attempts run out.** `run.escalate` and `run.samples` raise `InvalidOutputError` (invalid output after the last
|
|
309
|
+
attempt) or `LLMError` (a failed request on the last attempt) themselves, with the same attributes as always.
|
|
310
|
+
- **Your rule rejects.** Raise one of those errors yourself. `Squishling::DisagreementError.new(candidates:,
|
|
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.
|
|
315
|
+
- **Setup mistakes** raise `ConfigurationError`, which is never retried and never sent to the fallback.
|
|
316
|
+
- **Your own exceptions** (from `call`, `compare:`, or `squish_validate`) propagate unchanged. Squishling doesn't wrap or
|
|
317
|
+
swallow them.
|
|
318
|
+
|
|
319
|
+
Keep model output out of the messages you raise and the lines you `run.log`: it can quote the input. The output is
|
|
320
|
+
available to your code as the results and in `InvalidOutputError#raw`, and to `squawk` if you opt in.
|
|
321
|
+
|
|
322
|
+
### Reporting to `squawk`
|
|
323
|
+
|
|
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:
|
|
326
|
+
`nil` for an escalation attempt, `:sample` (with `metadata[:sample]` set to `"a"`, `"b"`, ...), or `:judge`. `attempt`
|
|
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`).
|
|
330
|
+
|
|
331
|
+
### Threads
|
|
332
|
+
|
|
333
|
+
Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
|
|
334
|
+
`squish_validate`, and `compare:` all run on the calling thread, and the routing state stays fiber-local. Call a run's
|
|
335
|
+
primitives from the thread `call` runs on: from another one they raise `ConfigurationError`. Don't keep unsynchronized
|
|
336
|
+
state at class level in a harness, since one instance is created per call but the class is shared.
|
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
|
|
|
@@ -31,8 +33,8 @@ stays small, the debt you haven't taken on is cheaper than the code.
|
|
|
31
33
|
|---|---|---|
|
|
32
34
|
| [Coolhand Labs](#coolhand-labs) | Picking up your squishling calls automatically and measuring their cost and accuracy | The `coolhand` gem and an initializer |
|
|
33
35
|
| [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
|
-
| [
|
|
35
|
-
| [
|
|
36
|
+
| [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)` |
|
|
37
|
+
| [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | RubyLLM's tracer and an exporter |
|
|
36
38
|
|
|
37
39
|
### Coolhand Labs
|
|
38
40
|
|
|
@@ -51,12 +53,13 @@ end
|
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
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.
|
|
56
|
+
`config.base_url` at a Coolhand-compatible endpoint of your own. Squishling also tells Coolhand which workload and
|
|
57
|
+
template each call belongs to; see [Coolhand Labs](coolhand.md).
|
|
55
58
|
|
|
56
59
|
### Custom: the `squawk` hook
|
|
57
60
|
|
|
58
|
-
`config.squawk` runs after every LLM attempt, accepted or rejected, and is handed the method's label, the model,
|
|
59
|
-
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:
|
|
60
63
|
|
|
61
64
|
```ruby
|
|
62
65
|
Squishling.configure do |config|
|
|
@@ -73,43 +76,39 @@ Each line carries the `"Class#method"` that made the call, so grouping by label
|
|
|
73
76
|
that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
|
|
74
77
|
been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
|
|
75
78
|
[Observing every attempt](failures.md#observing-every-attempt-squawk) for every keyword and the rules for exceptions.
|
|
79
|
+
Under a [harness](harnesses.md), including a [custom one](harnesses.md#custom-harnesses), every sample and judge
|
|
80
|
+
request reaches the hook too. `metadata[:role]` (`nil`, `:sample`, or `:judge`) and `metadata[:sample]` (`"a"`, `"b"`, ...)
|
|
81
|
+
split the spend by what each request was for.
|
|
76
82
|
|
|
77
|
-
###
|
|
83
|
+
### Your own observer
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Rails, `ActiveSupport::Notifications` is used automatically.
|
|
85
|
+
An [observer](observers.md) wraps each provider request and gets the request's token usage back from `yield`, so it
|
|
86
|
+
can meter spend knowing which method, escalation level, sample, or judge made the request. Any object that responds to
|
|
87
|
+
`around(call)` is one:
|
|
83
88
|
|
|
84
89
|
```ruby
|
|
85
|
-
class
|
|
86
|
-
def
|
|
87
|
-
if
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
block_given? ? yield : nil
|
|
90
|
+
class SpendObserver
|
|
91
|
+
def around(call)
|
|
92
|
+
usage = yield # input_tokens, output_tokens (nil if unreported), and model; never the content
|
|
93
|
+
tags = ["label:#{call.label}", "role:#{call.role || 'squish'}", "model:#{usage.model}"]
|
|
94
|
+
Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags:)
|
|
95
|
+
Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags:)
|
|
92
96
|
end
|
|
93
97
|
end
|
|
94
98
|
|
|
95
|
-
|
|
99
|
+
Squishling.configure { |config| config.observer = SpendObserver.new }
|
|
96
100
|
```
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
`squawk` hook above needs no wrapping.)
|
|
101
|
-
|
|
102
|
-
```ruby
|
|
103
|
-
RubyLLM.workflow("ShipmentUpdate", metadata: { carrier: "acme-freight" }) do
|
|
104
|
-
ShipmentUpdate.call(carrier: "acme-freight", payload: body)
|
|
105
|
-
end
|
|
106
|
-
```
|
|
102
|
+
An observer sees usage, never the model's output or your inputs. See [Observers](observers.md) for the `call` fields
|
|
103
|
+
and the rules.
|
|
107
104
|
|
|
108
105
|
### OpenTelemetry
|
|
109
106
|
|
|
110
107
|
RubyLLM 2.1+ ships its own OpenTelemetry tracer. Each request becomes a span with the model, request settings and
|
|
111
108
|
token usage (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`), following the OpenTelemetry GenAI
|
|
112
|
-
conventions. It does not record message text. Your app owns the SDK and exporters
|
|
109
|
+
conventions. It does not record message text. Your app owns the SDK and exporters, and the spans work with any OTLP
|
|
110
|
+
backend, [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
|
|
111
|
+
included.
|
|
113
112
|
|
|
114
113
|
```ruby
|
|
115
114
|
# Gemfile
|
|
@@ -121,16 +120,9 @@ OpenTelemetry::SDK.configure # reads the standard OTEL_* environment vari
|
|
|
121
120
|
RubyLLM::OpenTelemetry.enable # starts tracing RubyLLM requests
|
|
122
121
|
```
|
|
123
122
|
|
|
124
|
-
Spans don't say which squishling made the request,
|
|
125
|
-
[
|
|
126
|
-
|
|
127
|
-
### LangSmith
|
|
128
|
-
|
|
129
|
-
LangSmith accepts OpenTelemetry traces, so the OpenTelemetry setup above covers it: point the OTLP exporter at
|
|
130
|
-
LangSmith. See LangSmith's
|
|
131
|
-
[OpenTelemetry docs](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
|
|
132
|
-
for the endpoint and headers. A community Ruby gem, [`langsmith-sdk`](https://github.com/felipekb/langsmith-ruby-sdk),
|
|
133
|
-
also exists; it isn't published by LangChain.
|
|
123
|
+
Spans don't say which squishling made the request. To attribute them, wrap the call in `RubyLLM.workflow("Name")`, or
|
|
124
|
+
use an [observer](observers.md) that adds the label to the current span. RubyLLM's `config.instrumenter` also reports
|
|
125
|
+
a `usage.ruby_llm` event per request, with tokens and cost, if you would rather meter than trace.
|
|
134
126
|
|
|
135
127
|
## Privacy
|
|
136
128
|
|
|
@@ -144,5 +136,7 @@ append. Tools that sit on the request path are a separate route out of your proc
|
|
|
144
136
|
send them. That is the sanctioned route for model output; see
|
|
145
137
|
[Observing every attempt](failures.md#observing-every-attempt-squawk). The examples above log only metadata.
|
|
146
138
|
- **OpenTelemetry** with RubyLLM's tracer records usage and request settings, not message text.
|
|
139
|
+
- **An observer** sees timing, token usage, and the call's metadata (method, model, level), never the model's output or
|
|
140
|
+
your inputs.
|
|
147
141
|
|
|
148
142
|
Check what your tool stores before pointing it at inputs that contain personal data.
|
data/docs/observers.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Observers
|
|
2
|
+
|
|
3
|
+
An observer wraps every provider request Squishling makes, so another tool can see it while it happens: open a
|
|
4
|
+
tracing span, tag the request, set a thread-local. It is the around-style counterpart to [`squawk`](failures.md#observing-every-attempt-squawk),
|
|
5
|
+
which reports each attempt only after it is over.
|
|
6
|
+
|
|
7
|
+
Squishling ships one, for [Coolhand Labs](coolhand.md), and uses it automatically when the `coolhand` gem is configured.
|
|
8
|
+
Any object that responds to `around(call)` is an observer:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class TracingObserver
|
|
12
|
+
def around(call)
|
|
13
|
+
Tracer.in_span("squishling #{call.label}", attributes: { "model" => call.model, "role" => call.role.to_s }) do
|
|
14
|
+
yield
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Squishling.configure { |config| config.observer = TracingObserver.new }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`around` must yield exactly once. The block makes the request, so the observer runs around it, on the thread that
|
|
23
|
+
makes it: for the concurrent samples of the squishsum harnesses, that is a worker thread, so thread-locals set by the
|
|
24
|
+
observer are visible to the HTTP call. What `around` returns is ignored. One that never yields, or yields twice,
|
|
25
|
+
raises `ConfigurationError`.
|
|
26
|
+
|
|
27
|
+
## What `yield` returns
|
|
28
|
+
|
|
29
|
+
The block returns a `Squishling::Observer::Usage`: the request's `input_tokens` and `output_tokens` (nil when the
|
|
30
|
+
provider didn't report them) and the `model` that answered. That is enough to meter spend:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
class SpendObserver
|
|
34
|
+
def around(call)
|
|
35
|
+
usage = yield
|
|
36
|
+
Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags: ["label:#{call.label}"])
|
|
37
|
+
Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags: ["label:#{call.label}"])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Usage never includes the model's output; Squishling returns the real response to its own caller, whatever `around`
|
|
43
|
+
returns. If the request fails, the error is raised through `yield`, so a `begin/ensure` in `around` still runs.
|
|
44
|
+
|
|
45
|
+
## `config.observer`
|
|
46
|
+
|
|
47
|
+
| Value | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `nil` (default) | The Coolhand observer when `coolhand` is loaded and configured, otherwise none |
|
|
50
|
+
| `:coolhand` | The Coolhand observer, always. Raises `ConfigurationError` if the gem is missing or too old |
|
|
51
|
+
| any object responding to `around` | That observer |
|
|
52
|
+
| `false` | None, even when Coolhand is configured |
|
|
53
|
+
|
|
54
|
+
Anything else raises `ConfigurationError` when you assign it.
|
|
55
|
+
|
|
56
|
+
## The `call`
|
|
57
|
+
|
|
58
|
+
`around` receives a `Squishling::Observer::Call` (a `Data`) describing the request:
|
|
59
|
+
|
|
60
|
+
| Field | |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `label` | `"Class#method"` |
|
|
63
|
+
| `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved) |
|
|
64
|
+
| `handed_off` | `true` when a `squish!` call sent the call to the LLM (typically from a `rescue`), `false` when `squish_when` or a missing implementation did |
|
|
65
|
+
| `harness` | The harness type (`:escalation`, `:squishsum`, `:judged_squishsum`, or a [custom one](harnesses.md#custom-harnesses)) |
|
|
66
|
+
| `role` | `nil` for an escalation attempt, `:sample` or `:judge`, as in `squawk`'s `metadata` |
|
|
67
|
+
| `sample` | `"a"`, `"b"`, ... for a sample, else `nil` |
|
|
68
|
+
| `attempt`, `attempts` | This request's 1-based position in the steps it was asked on, and how many there are: the escalation's expanded steps (or, for a custom harness, those passed to `run.escalate`), the sample's stage, or the judge's steps |
|
|
69
|
+
| `model`, `provider` | The step's model and provider (`provider` is `nil` unless declared) |
|
|
70
|
+
| `request` | `:chat`, or `:judgment` for a System One judge |
|
|
71
|
+
|
|
72
|
+
It carries metadata only. The method's inputs, its context, and the model's output never reach an observer: it gets
|
|
73
|
+
the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
An observer is your code: an exception it raises propagates unchanged, and is never retried, escalated, or handed to
|
|
78
|
+
`squish_fallback`. Squishling maps a failed request onto `LLMError` or `ConfigurationError` inside the block, so `around` sees those and
|
|
79
|
+
can rescue them or let them pass. If `around` rescues one and returns normally, Squishling raises it again: an
|
|
80
|
+
observer can watch a failure, not swallow it.
|
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:
|
|
@@ -90,7 +93,8 @@ end
|
|
|
90
93
|
- `append_to_purpose:` (added to the class's, see [Appending to the purpose](#appending-to-the-purpose))
|
|
91
94
|
- `output_schema:` (or a schema block)
|
|
92
95
|
- `model:` or `escalation:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
|
|
93
|
-
- `harness:` (see [Harnesses](harnesses.md))
|
|
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
|
|
@@ -210,7 +215,7 @@ Source is read with Ruby's own parser (Prism) the first time it's needed and cac
|
|
|
210
215
|
"context": { "customer_tier": "enterprise", "product": "API" } }
|
|
211
216
|
```
|
|
212
217
|
|
|
213
|
-
- **Squishsum
|
|
218
|
+
- **Squishsum harnesses:** both samples get exactly this input, and a [judge](harnesses.md#the-judge) gets
|
|
214
219
|
the same purpose and input plus both samples' outputs.
|
|
215
220
|
- **Retries and escalation:** another attempt of the same step gets the validation errors in the same conversation. A
|
|
216
221
|
later [escalation](configuration.md#models-and-escalation) step, which may be a different provider (say, local
|
data/docs/schemas.md
CHANGED
|
@@ -30,10 +30,29 @@ output_schema(
|
|
|
30
30
|
)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
## Describing fields
|
|
34
|
+
|
|
35
|
+
Every DSL type, and `optional`, takes a `description:`. It is sent to the provider as guidance for that field
|
|
36
|
+
and isn't enforced; validation is by type and shape only.
|
|
37
|
+
|
|
38
|
+
**Best practice:** put per-field guidance here rather than in the `purpose`. The description travels with the
|
|
39
|
+
field in the schema the model fills in, so the instruction sits right where the value goes and next to the type
|
|
40
|
+
or `enum` it explains. The purpose can then stay short and about the goal. For rules that must hold, use an
|
|
41
|
+
`enum` or `squish_validate`; a description is a request, not a guarantee.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
output_schema do
|
|
45
|
+
string :language, description: "Lowercase language name, e.g. 'ruby'."
|
|
46
|
+
optional(:regex, description: "The proposed regex source, without delimiters or flags.") { string }
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
33
50
|
## Strict schemas only
|
|
34
51
|
|
|
35
|
-
Squishling only supports **strict** output schemas: it always asks
|
|
36
|
-
|
|
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
|
|
37
56
|
schema with optional properties non-strict; Squishling always sets the flag explicitly, so it stays strict.
|
|
38
57
|
|
|
39
58
|
With raw hashes, follow your provider's strict-mode rules (e.g. list every property in `required` and set
|
|
@@ -58,6 +77,13 @@ r.to_h # deep Hash with symbol keys
|
|
|
58
77
|
r.squished? # => true when it came from the LLM
|
|
59
78
|
```
|
|
60
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
|
+
|
|
61
87
|
On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
|
|
62
88
|
against the schema, and so is anything else you return (`nil`, a string, another schema's result), which raises
|
|
63
89
|
`Squishling::InvalidOutputError` unless it matches.
|
|
@@ -70,7 +96,7 @@ field nullable with the DSL's `optional`. The key stays required, but its value
|
|
|
70
96
|
```ruby
|
|
71
97
|
output_schema do
|
|
72
98
|
array :symptoms, of: :string # always a list; [] means "none"
|
|
73
|
-
optional :allergies
|
|
99
|
+
optional :allergies, description: "[] if the record says none; null if it doesn't mention allergies" do
|
|
74
100
|
array of: :string
|
|
75
101
|
end
|
|
76
102
|
optional :vitals do # a typed Data object, or nil
|
|
@@ -84,8 +110,8 @@ end
|
|
|
84
110
|
`optional` produces `anyOf: [<schema>, {type: "null"}]`, which is strict-compatible. Squishling types the non-null
|
|
85
111
|
branch as usual: `vitals` is a `Data` object or `nil`, and a list of objects is a list of `Data` objects. In a raw
|
|
86
112
|
JSON Schema, `type: ["array", "null"]` works too. A union with more than one non-null branch is ambiguous, so its
|
|
87
|
-
values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in
|
|
88
|
-
|
|
113
|
+
values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in the field's
|
|
114
|
+
`description:`, as `allergies` does above.
|
|
89
115
|
|
|
90
116
|
## Contracts beyond the schema
|
|
91
117
|
|
|
@@ -111,7 +137,8 @@ Providers' strict modes don't support conditional keywords (`if`/`then`/`else` f
|
|
|
111
137
|
`dependentSchemas` from `dependent`), so Squishling keeps them out of the schema it sends and enforces them itself.
|
|
112
138
|
Output that breaks a rule is rejected like any other invalid output: the call moves on to the next attempt in its
|
|
113
139
|
[escalation](configuration.md#models-and-escalation) with the errors. The model doesn't see these rules
|
|
114
|
-
up front, so state them in your purpose
|
|
140
|
+
up front, so state them in the `description:` of the field they govern (here `status`) or in your purpose. A
|
|
141
|
+
description declared inside a `given` block is local-only too, so it is never sent.
|
|
115
142
|
|
|
116
143
|
For anything a schema can't express (sums, lookups against your data), use
|
|
117
144
|
[`squish_validate`](failures.md#output-checks-squish_validate).
|