squishling 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +26 -10
- data/docs/adapters.md +257 -0
- data/docs/configuration.md +54 -19
- data/docs/failures.md +170 -22
- data/docs/harnesses.md +39 -15
- data/docs/measuring-tokens.md +12 -6
- data/docs/observers.md +3 -3
- data/docs/routing.md +14 -3
- data/docs/schemas.md +24 -2
- data/docs/testing.md +187 -0
- data/docs/verifying.md +86 -0
- data/lib/squishling/adapter/error_body.rb +54 -0
- data/lib/squishling/adapter.rb +328 -0
- data/lib/squishling/adapters/openai_compatible.rb +311 -0
- data/lib/squishling/adapters/openai_options.rb +114 -0
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +229 -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 +58 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +94 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +44 -14
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +13 -4
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +7 -24
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +50 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +60 -53
- data/lib/squishling/schema.rb +50 -9
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +37 -4
- 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 +74 -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/wrapper.rb +19 -1
- data/lib/squishling.rb +60 -4
- metadata +43 -21
data/docs/observers.md
CHANGED
|
@@ -60,7 +60,7 @@ Anything else raises `ConfigurationError` when you assign it.
|
|
|
60
60
|
| Field | |
|
|
61
61
|
|---|---|
|
|
62
62
|
| `label` | `"Class#method"` |
|
|
63
|
-
| `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved) |
|
|
63
|
+
| `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved). It is the whole prompt, including any source you pass to `append_to_purpose` and whatever your `purpose` procs return, so treat it as sensitive: don't log it to a place the prompt itself shouldn't go |
|
|
64
64
|
| `handed_off` | `true` when a `squish!` call sent the call to the LLM (typically from a `rescue`), `false` when `squish_when` or a missing implementation did |
|
|
65
65
|
| `harness` | The harness type (`:escalation`, `:squishsum`, `:judged_squishsum`, or a [custom one](harnesses.md#custom-harnesses)) |
|
|
66
66
|
| `role` | `nil` for an escalation attempt, `:sample` or `:judge`, as in `squawk`'s `metadata` |
|
|
@@ -69,8 +69,8 @@ Anything else raises `ConfigurationError` when you assign it.
|
|
|
69
69
|
| `model`, `provider` | The step's model and provider (`provider` is `nil` unless declared) |
|
|
70
70
|
| `request` | `:chat`, or `:judgment` for a System One judge |
|
|
71
71
|
|
|
72
|
-
It carries metadata
|
|
73
|
-
the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
|
|
72
|
+
It carries metadata, plus the rendered `purpose`. The method's inputs, its context, and the model's output never reach an
|
|
73
|
+
observer: it gets the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
|
|
74
74
|
|
|
75
75
|
## Errors
|
|
76
76
|
|
data/docs/routing.md
CHANGED
|
@@ -17,7 +17,7 @@ A squished call runs its Ruby implementation unless one of these sends it to the
|
|
|
17
17
|
| Trigger | Declared where | Decided |
|
|
18
18
|
|---|---|---|
|
|
19
19
|
| `squish_when` (or `when:`) predicate is truthy | class, or per method | before Ruby runs |
|
|
20
|
-
| The method has no implementation: it isn't defined, or
|
|
20
|
+
| The method has no implementation: it isn't defined, or its own body raises `NotImplementedError` | the method body | when Ruby gives up |
|
|
21
21
|
| `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Handing off to the LLM](#handing-off-to-the-llm-with-squish) |
|
|
22
22
|
|
|
23
23
|
With no predicate and a working implementation, every call runs Ruby.
|
|
@@ -41,8 +41,14 @@ is validated against the schema and turned into the same typed result the LLM pa
|
|
|
41
41
|
already has a `result` (see [Naming and collisions](naming.md)). Invalid deterministic output raises
|
|
42
42
|
`Squishling::InvalidOutputError` too, so a hardened path can't silently drift from the contract.
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
Only the method's own stub routes: a `NotImplementedError` raised directly in the method body (`raise
|
|
45
|
+
NotImplementedError` in a `def` or a `define_method` block, including one in a module prepended before
|
|
46
|
+
`include Squishling`, or in a squished parent reached through `super`). One raised by code the method calls
|
|
47
|
+
(a helper, another object, a block, or a `super` into a parent that isn't squished) is a bug in that code, not a
|
|
48
|
+
missing implementation, so it propagates and nothing is sent to the provider. Keep the `raise` in the method body:
|
|
49
|
+
a shared `not_implemented!` helper, or a decorator prepended before `include Squishling` that wraps the method with
|
|
50
|
+
its own `super` frame, hides the stub and the error propagates (use `squish_when { true }` or `squish!` to
|
|
51
|
+
send those calls to the LLM).
|
|
46
52
|
|
|
47
53
|
Each call is routed on its own, including a squished method that calls itself on smaller inputs (those
|
|
48
54
|
recursive calls must return schema-valid values too). A subclass
|
|
@@ -61,6 +67,9 @@ This is the workflow from [Elastic Software](https://everythingengineer.substack
|
|
|
61
67
|
|
|
62
68
|
Callers never change, because both paths return the same result class.
|
|
63
69
|
|
|
70
|
+
To keep a particular model off certain inputs (a client that needs a BAA, say), give its escalation step an
|
|
71
|
+
`include_when:` Proc; see [Excluding a model per call](configuration.md#excluding-a-model-per-call).
|
|
72
|
+
|
|
64
73
|
## Entry points
|
|
65
74
|
|
|
66
75
|
Squish methods other than `call` with `squish`, optionally overriding the class-level settings per method:
|
|
@@ -91,6 +100,7 @@ end
|
|
|
91
100
|
- `output_schema:` (or a schema block)
|
|
92
101
|
- `model:` or `escalation:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
|
|
93
102
|
- `harness:` (see [Harnesses](harnesses.md), including your own)
|
|
103
|
+
- `max_total_output_tokens:` and `max_total_input_tokens:` (see [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets))
|
|
94
104
|
- `when:`, a predicate proc
|
|
95
105
|
- `validate:` and `fallback:` (see [Failure handling](failures.md))
|
|
96
106
|
|
|
@@ -134,6 +144,7 @@ end
|
|
|
134
144
|
| `purpose:` | Replaces the purpose |
|
|
135
145
|
| `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
146
|
| `harness:` | Replaces the declared [harness](harnesses.md), e.g. `harness: :judged_squishsum` to double-check a hand-off |
|
|
147
|
+
| `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
148
|
|
|
138
149
|
- **The output schema can't be overridden.** The call still returns the method's result type.
|
|
139
150
|
- **Failures** go through the normal LLM path: a declared `squish_fallback` is used, otherwise
|
data/docs/schemas.md
CHANGED
|
@@ -30,6 +30,11 @@ output_schema(
|
|
|
30
30
|
)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
A raw schema's `$ref`s must point inside the schema (`"#/$defs/address"`). Squishling resolves them when it builds the
|
|
34
|
+
schema, so one that points nowhere (a remote URI, which is never fetched, or a `$defs` entry that doesn't exist)
|
|
35
|
+
raises `Squishling::ConfigurationError` then, and [`Squishling.verify`](verifying.md) reports it, instead of failing on
|
|
36
|
+
the first response.
|
|
37
|
+
|
|
33
38
|
## Describing fields
|
|
34
39
|
|
|
35
40
|
Every DSL type, and `optional`, takes a `description:`. It is sent to the provider as guidance for that field
|
|
@@ -49,8 +54,10 @@ end
|
|
|
49
54
|
|
|
50
55
|
## Strict schemas only
|
|
51
56
|
|
|
52
|
-
Squishling only supports **strict** output schemas: it always asks
|
|
53
|
-
|
|
57
|
+
Squishling only supports **strict** output schemas: it always asks the adapter for strict structured output (the
|
|
58
|
+
OpenAI-compatible adapter's `structured_output: :json_object` and `:none` modes are the explicit exception, for servers
|
|
59
|
+
that lack it, where the schema goes into the prompt instead), and a schema declaring `strict: false` raises
|
|
60
|
+
`Squishling::ConfigurationError`. Squishling validates every answer against the schema either way. RubyLLM 2.0 on its own would send a
|
|
54
61
|
schema with optional properties non-strict; Squishling always sets the flag explicitly, so it stays strict.
|
|
55
62
|
|
|
56
63
|
With raw hashes, follow your provider's strict-mode rules (e.g. list every property in `required` and set
|
|
@@ -75,6 +82,21 @@ r.to_h # deep Hash with symbol keys
|
|
|
75
82
|
r.squished? # => true when it came from the LLM
|
|
76
83
|
```
|
|
77
84
|
|
|
85
|
+
A property can't share a name with a method the result depends on, because the `Data` member would replace it.
|
|
86
|
+
Squishling raises `ConfigurationError` when the schema is built (before any request), naming the clashing
|
|
87
|
+
fields; rename them (`class` to `ticket_class`). The reserved names are `class`, `is_a?`, `kind_of?`, `hash`, `inspect`, `to_s`,
|
|
88
|
+
`to_h`, `members`, `with`, `dup`, `send`, `public_send`, `eql?`, `==`, `[]`, `squished?`, `object_id`,
|
|
89
|
+
`deconstruct_keys`, and a few Ruby and Squishling internals (`initialize*`, `__send__`, `squishling_squished`).
|
|
90
|
+
Other names, such as `method`, `format`, or `type`, work as usual. This applies to nested objects and array items too.
|
|
91
|
+
|
|
92
|
+
With a hand-written Hash schema, every object schema that becomes a result (and so a nested result) must also say
|
|
93
|
+
`type: "object"`; `allOf` parts and unions with several non-null branches aren't typed, so they are exempt. A schema with
|
|
94
|
+
`properties` alone accepts a String or Array too (JSON Schema applies `properties` only to objects), which would end
|
|
95
|
+
up in the typed result where a nested result belongs. Squishling raises `ConfigurationError` when the schema is built
|
|
96
|
+
(and `Squishling.verify` reports it) for the root, a nested property, array `items`, the object branch of an
|
|
97
|
+
`optional`, or a `$ref` target. A union that includes it, like `["object", "null"]`, is fine. The DSL always sets
|
|
98
|
+
the type for you.
|
|
99
|
+
|
|
78
100
|
On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
|
|
79
101
|
against the schema, and so is anything else you return (`nil`, a string, another schema's result), which raises
|
|
80
102
|
`Squishling::InvalidOutputError` unless it matches.
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`require "squishling/testing"` adds test support: a scripted [adapter](adapters.md) that answers in place of a
|
|
4
|
+
provider and records what was sent to it, stand-ins for the callers of squished methods, replies derived from an output
|
|
5
|
+
schema, and assertions. It is opt-in (`require "squishling"` doesn't load it), needs no test framework or mocking
|
|
6
|
+
library, and works with RSpec (without rspec-mocks), Minitest, and Mocha alike. It makes no network request.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Minitest
|
|
10
|
+
include Squishling::Testing::Minitest
|
|
11
|
+
|
|
12
|
+
# RSpec
|
|
13
|
+
RSpec.configure { |config| config.include Squishling::Testing::RSpec }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Both give your tests the helpers below and put back, after each test, whatever the helpers changed
|
|
17
|
+
(`Squishling::Testing.reset!`). They touch only the adapter, observer, and stubs they installed (and only while they
|
|
18
|
+
still hold what the helpers set), so the rest of your `Squishling.configure` survives.
|
|
19
|
+
|
|
20
|
+
The helpers change global state (`Squishling.config` and the stubbed classes), so tests that use them must not run
|
|
21
|
+
in parallel threads. That includes `no_llm_call` / `assert_no_llm_call`, which swap the process-wide `config.adapter`
|
|
22
|
+
for the block: a request another thread makes meanwhile is forbidden too, and may be counted in the block's calls.
|
|
23
|
+
|
|
24
|
+
## Scripting the model
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
fake = fake_squishling do |llm|
|
|
28
|
+
llm.step("gemini-2.5-flash").reply({ regex: "[bad(" }, { regex: "Run.*" }) # in order; the last one repeats
|
|
29
|
+
llm.step("gemini-2.5-pro").fail(:rate_limit)
|
|
30
|
+
llm.step(:any).reply({ regex: "fallback.*" }) # anything no rule above claims
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
result = RegexInferrer.call(sample_turns: turns)
|
|
34
|
+
|
|
35
|
+
assert_equal "Run.*", result.regex
|
|
36
|
+
assert_equal 2, fake.requests.size
|
|
37
|
+
assert_sent_arguments fake, { sample_turns: turns } # what left the process
|
|
38
|
+
assert_includes fake.requests.last.errors, "regex is not a valid Ruby regex" # the re-ask carried them
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A reply is one of:
|
|
42
|
+
|
|
43
|
+
| Reply | The model… |
|
|
44
|
+
|---|---|
|
|
45
|
+
| a `Hash` | answered with this object |
|
|
46
|
+
| a `String` | returned this text (malformed or fenced JSON, truncated output) |
|
|
47
|
+
| `nil` | returned nothing (a refusal or a cut-off) |
|
|
48
|
+
| `Squishling::Testing::Reply.new(content, model: "m", usage: { input_tokens: 10, output_tokens: 3 })` | answered, and the provider reported this model and these token counts (`squawk`'s `metadata[:usage]`, the observer's `Usage`) |
|
|
49
|
+
| an `Exception` | made the request raise it |
|
|
50
|
+
| `.fail(intent)` | the provider failed: see below |
|
|
51
|
+
|
|
52
|
+
`reply` takes several, `.fail` adds one, and `.once` makes running out of replies an error instead of repeating the last.
|
|
53
|
+
|
|
54
|
+
**Provider failures by intent.** You don't need to know which SDK error maps to which Squishling error:
|
|
55
|
+
|
|
56
|
+
| Intent | Raises |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `:rate_limit`, `:server_error`, `:overloaded`, `:timeout`, `:context_length` | `LLMError` (the next attempt is tried) |
|
|
59
|
+
| `:bad_request`, `:unauthorized`, `:forbidden` | `ConfigurationError` (never retried, never given to a fallback) |
|
|
60
|
+
|
|
61
|
+
**Selecting what a rule answers.** `step(model)`, `step(provider: :vertexai)`, and `step(:any)` answer escalation
|
|
62
|
+
attempts; `attempt: 2` narrows a step rule to one attempt of the escalation. Rules are matched most specific first
|
|
63
|
+
(a sample or the judge, then the rule naming the most of model, provider and attempt, then `step(:any)`), and by
|
|
64
|
+
declaration order within that.
|
|
65
|
+
Scripts are keyed by model, so a rotated tier (`priority:`) gets the same answers whichever model goes first. Which
|
|
66
|
+
model goes first still depends on how many calls the class has had, so don't assert the order of `fake.models` for a
|
|
67
|
+
rotated tier unless the class is new to the test (or pin the tier with `rotate: false`).
|
|
68
|
+
A request no rule covers raises `Squishling::Testing::UnscriptedRequestError` naming the squished method; it isn't
|
|
69
|
+
a `Squishling::Error`, so `squish_fallback` can't hide it.
|
|
70
|
+
|
|
71
|
+
### Harnesses
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
fake_squishling do |llm|
|
|
75
|
+
llm.sample("a").reply(valid_reply_for(Triager))
|
|
76
|
+
llm.sample("b").reply(valid_reply_for(Triager, team: "billing"))
|
|
77
|
+
llm.judge.verdict(:a, reason: "a fits") # a chat judge
|
|
78
|
+
llm.judge.judgment(:b, confidence: 0.92) # a System One judge (type: :judgment, min_confidence:)
|
|
79
|
+
llm.judge.fail_judgment(:timeout)
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Each sample has its own script, so the order its worker thread happens to run in can't change what it gets. The
|
|
84
|
+
scripts, the request log, and the conversation tracking are taken under one lock. `DisagreementError#candidates`
|
|
85
|
+
and `#verdict` are the real ones; rescue them as you would in production.
|
|
86
|
+
|
|
87
|
+
## Inspecting what was sent
|
|
88
|
+
|
|
89
|
+
`fake.requests` is every provider request, in order, as `Squishling::Testing::Request`:
|
|
90
|
+
|
|
91
|
+
| | |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `arguments`, `context` | the method's arguments and the `squish_context` / `squish!` context the model was sent |
|
|
94
|
+
| `system_prompt`, `output_schema` | the purpose (with `append_to_purpose` sections) and the strict schema |
|
|
95
|
+
| `model`, `provider`, `params` | the step it went to |
|
|
96
|
+
| `role`, `sample`, `attempt`, `attempts` | `nil`/`:sample`/`:judge`, `"a"`, and where it is in the escalation |
|
|
97
|
+
| `conversation` | the chat it belongs to |
|
|
98
|
+
| `errors` | the validation messages a retry or escalation carried back to the model |
|
|
99
|
+
| `forwarded_rejected` | the rejected output forwarded to the next step (`nil` with `forward_rejected: false`) |
|
|
100
|
+
| `candidates` | for a judge, the two samples |
|
|
101
|
+
| `messages`, `reply`, `failed?` | the conversation as sent, and the `Adapter::Response` (or error) that came back |
|
|
102
|
+
|
|
103
|
+
`fake.conversations` groups the requests by chat: a retry on the same step shares one, and an escalation to a new
|
|
104
|
+
step starts a new one. `fake.requests_for(sample: "a")`, `fake.models`, and `fake.arguments` are shortcuts.
|
|
105
|
+
|
|
106
|
+
## Asserting the LLM was not called
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
assert_no_llm_call { RegexInferrer.call(sample_turns: easy_turns) } # then: refute result.squished?
|
|
110
|
+
assert_llm_calls fake, 2
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`assert_no_llm_call` fails even if the code under test rescued the error that stopped the call.
|
|
114
|
+
|
|
115
|
+
**A safety net for the whole suite.** In a suite-wide `before` hook, `forbid_provider_calls!` makes any request that
|
|
116
|
+
reaches the provider raise `ProviderCallError` naming the squished method (`"RegexInferrer#call reached the provider…"`),
|
|
117
|
+
so a missing script fails at the call, not far from it. Installing a fake replaces it.
|
|
118
|
+
|
|
119
|
+
## Stubbing a squished class for its callers
|
|
120
|
+
|
|
121
|
+
Callers of a squished class shouldn't run Squishling at all. These make `Klass.call` return a real, schema-validated
|
|
122
|
+
typed result, or raise the real error class, without `allow(...).to receive`:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
stub_squishling_result(PatternGenerator, regex: "x") # squished? is true
|
|
126
|
+
stub_squishling_result(PatternGenerator, { regex: "x" }, squished: false) # as the Ruby path would return it
|
|
127
|
+
stub_squishling_result(PatternGenerator, regex: "x") { ... } # for the block only
|
|
128
|
+
|
|
129
|
+
stub_squishling_error(PatternGenerator, :invalid_output, errors: ["bad"], raw: "{}", attempts: 2, models: %w[a b])
|
|
130
|
+
stub_squishling_error(PatternGenerator, :llm_error) # also :configuration, :disagreement (candidates:, verdict:, reason:)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Attributes that don't satisfy the method's `output_schema` raise `Squishling::Testing::SetupError` when you stub, so a
|
|
134
|
+
stand-in can't drift from the real result type. Pass `method: :triage` for another squished method. The stubs are
|
|
135
|
+
removed after each test. A stub applies to that class only, not to its subclasses, which keep their own behavior. The
|
|
136
|
+
hook that makes a stub possible stays on the class for the life of the process, even after `Testing.reset!` has
|
|
137
|
+
removed the stub (it is inert, but keeps the class alive), so stub the classes of your suite, not one per test.
|
|
138
|
+
|
|
139
|
+
## Running parts of a method without a model
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
squishling_validate(Klass, { "regex" => "[bad(" }, text: "x") # => ["regex is not a valid Ruby regex"]
|
|
143
|
+
squishling_fallback_for(Klass, Squishling::LLMError.new("x"), text: "x") # the typed fallback result
|
|
144
|
+
squishling_preview(Klass, text: "x")
|
|
145
|
+
# => { system_prompt:, user_message:, output_schema:, step: } # for snapshot tests; makes no request
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Arguments are keywords, or `args: [...]` for positional ones; `receiver:` supplies an instance for a class whose
|
|
149
|
+
constructor takes arguments; `method:` names a squished method other than `call`. (A squished method with a parameter
|
|
150
|
+
called `args`, `receiver`, or `method` can't be given it as a keyword here.) `squishling_preview` doesn't advance model rotation.
|
|
151
|
+
|
|
152
|
+
## Replies derived from the schema
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
valid_reply_for(Triager) # => { "priority" => "example", "team" => "example" }
|
|
156
|
+
valid_reply_for(Triager, team: "billing") # overrides
|
|
157
|
+
invalid_reply_for(Triager, violating: :score) # out of its range, enum, or type
|
|
158
|
+
invalid_reply_for(Triager, violating: :score, how: :missing) # :enum, :range, :type, :null, :missing
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Both accept a squished class, a `Squishling::Schema`, or a schema Hash, and are checked against the schema before they
|
|
162
|
+
are returned, so a test that doesn't care about the content stays valid when the schema changes. A schema it can't
|
|
163
|
+
satisfy on its own (a `pattern`, an unsatisfiable conditional) raises `SetupError` and asks for overrides.
|
|
164
|
+
|
|
165
|
+
## `squawk` and observers
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
capture = with_squawk(capture_squawk) { Klass.call(text: "x") }
|
|
169
|
+
capture.calls.map { |call| call.metadata[:attempt] } # output:, error:, and metadata: of every attempt
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`with_squawk` swaps `config.squawk` for the block and puts the previous hook back, so an app-wide hook that enqueues
|
|
173
|
+
a job isn't run in tests. A `squawk:` set on a class or method still takes precedence over the configured one.
|
|
174
|
+
|
|
175
|
+
The capture also records the [report of a stopped call](failures.md#a-call-that-is-stopped) (`metadata[:stopped]` is
|
|
176
|
+
`:budget` or `:disagreement`). `stub_error` replaces the method without running Squishling, so it reports nothing.
|
|
177
|
+
|
|
178
|
+
An observer you configured *before* `fake_squishling` keeps working while the fake is installed (it is chained behind
|
|
179
|
+
the fake's own). Configure it first: one assigned afterwards replaces the fake's, and then sample and judge scripts
|
|
180
|
+
can't tell which request is which.
|
|
181
|
+
|
|
182
|
+
## How it works
|
|
183
|
+
|
|
184
|
+
`fake_squishling` assigns a `Squishling::Testing::Adapter` (a `Squishling::Adapter::Base`) as `config.adapter`, and an
|
|
185
|
+
observer that tells it which role, sample, and attempt each request is. Because it sits behind the same interface as
|
|
186
|
+
the real adapters, it needs no knowledge of RubyLLM or any provider, and it is covered by Squishling's own specs.
|
|
187
|
+
Call `Squishling::Testing.install` and `.reset!` yourself if you use a test framework other than the two above.
|
data/docs/verifying.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Verifying squished classes
|
|
2
|
+
|
|
3
|
+
Most mistakes in a squished class fail when the class loads (a bad `escalation:`, `params:`, `harness:` or `squawk:`
|
|
4
|
+
raises `ConfigurationError` as the declaration runs). The output schema and its typed result are built on the first
|
|
5
|
+
call, and the keywords your `include_when:` and `squish_when` procs take are only used when a call is routed. So
|
|
6
|
+
Squishling keeps a list of the classes that include it and can check them all without making a request, for example
|
|
7
|
+
in CI.
|
|
8
|
+
|
|
9
|
+
## `Squishling.verify` and `verify!`
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
report = Squishling.verify # every squished class
|
|
13
|
+
report.ok? # => false
|
|
14
|
+
puts report
|
|
15
|
+
# Squishling found 2 problems:
|
|
16
|
+
# Triage#route: escalation step "claude-haiku-4-5" include_when: requires tier:, which is not an input of the method
|
|
17
|
+
# Invoice#call: invalid data member: total=
|
|
18
|
+
|
|
19
|
+
Squishling.verify! # raises Squishling::VerificationError (a ConfigurationError) naming every problem
|
|
20
|
+
Squishling.verify(Triage, Invoice) # just these classes
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`verify` returns a `Squishling::Verification::Report` (`ok?`, `size`, `problems`, `to_s`). Each problem has a
|
|
24
|
+
`label` and a `message`. `verify!` returns the report when it is clean and otherwise raises
|
|
25
|
+
`Squishling::VerificationError`, which has `report` and `problems`. Every problem is listed, not just the first.
|
|
26
|
+
|
|
27
|
+
For each squished method (inherited ones too, with the subclass's settings) it checks:
|
|
28
|
+
|
|
29
|
+
- **The output schema:** that it builds (and is strict), is valid JSON Schema, and produces a typed result, so a
|
|
30
|
+
property name that can't be a result member (`total=`), an object with `properties` but no `type: "object"`, or a
|
|
31
|
+
`$ref` that doesn't resolve within the schema (a remote URI is never fetched; a missing `$defs` entry) is reported.
|
|
32
|
+
A method with no output schema or no purpose is reported, because its LLM path would raise. The default `call`
|
|
33
|
+
method is skipped when you don't implement or configure it.
|
|
34
|
+
- **What the layers resolve to:** the escalation, generation params and `squawk` that the method, its class and
|
|
35
|
+
`Squishling.config` add up to.
|
|
36
|
+
- **The harness:** that it is still registered (not removed or replaced since the class declared it), and that a
|
|
37
|
+
`type: :judgment` judge has an adapter that supports judgments.
|
|
38
|
+
- **Keywords:** that each `include_when:` and the `squish_when` block can be called with the method's inputs as keywords
|
|
39
|
+
(`instance_exec(**inputs, &proc)`): no required keyword that isn't an input, and no input a lambda (or a proc that names
|
|
40
|
+
keywords) can't take. Add `**` to accept any. A method that takes `**` or has no implementation can receive any
|
|
41
|
+
keyword, so nothing is reported for it. `squish_fallback` and `squish_validate` are called with the inputs too, but
|
|
42
|
+
aren't checked.
|
|
43
|
+
- **The adapter** (once): that `config.adapter`, or RubyLLM's, can be built.
|
|
44
|
+
|
|
45
|
+
A problem in an inherited method is reported once for each subclass, since each is checked with its own settings.
|
|
46
|
+
|
|
47
|
+
Nothing is sent to a provider, and your own code (the Ruby implementation, `squish_when`, the procs) is not run.
|
|
48
|
+
|
|
49
|
+
## Only loaded classes are checked
|
|
50
|
+
|
|
51
|
+
The list holds the classes that have been loaded. In Rails, load everything first:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
Rails.application.eager_load!
|
|
55
|
+
Squishling.verify!
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The list holds its classes weakly, so it doesn't keep classes alive across code reloads, and a named class whose
|
|
59
|
+
constant now holds a different object (replaced by a reload but not yet collected) is left out. Anonymous classes are
|
|
60
|
+
included. `Squishling.squished_classes` returns the list.
|
|
61
|
+
|
|
62
|
+
## In CI
|
|
63
|
+
|
|
64
|
+
**Rake.** In Rails the `squishling:verify` task is added for you and eager loads the app first. Elsewhere, load it in your
|
|
65
|
+
`Rakefile`:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
load "squishling/tasks/squishling.rake"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`bundle exec rake squishling:verify` prints the report and exits non-zero when there are problems. Load your classes
|
|
72
|
+
before the task runs (an `:environment` task, if you have one, is invoked first). The Railtie adds the task in Rails
|
|
73
|
+
when Squishling is required after Rails (as Bundler does), so a Rails app needn't load the file itself.
|
|
74
|
+
|
|
75
|
+
**RSpec.** `require "squishling/rspec"` adds a matcher whose failure message is the report:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
require "squishling/rspec"
|
|
79
|
+
|
|
80
|
+
RSpec.describe "squished classes" do
|
|
81
|
+
before(:all) { Rails.application.eager_load! }
|
|
82
|
+
|
|
83
|
+
it { expect(Squishling).to be_squishling_valid }
|
|
84
|
+
it { expect(InvoiceParser).to be_squishling_valid } # or just one class, or an Array of them
|
|
85
|
+
end
|
|
86
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Adapter
|
|
5
|
+
# Reads the machine-readable fields of a provider's error body: the status (OpenAI-style `type`, Gemini's
|
|
6
|
+
# `status`), the code, and the name of the field it objected to. Only short plain tokens come out; the message
|
|
7
|
+
# and anything else a provider might echo from the request never do.
|
|
8
|
+
module ErrorBody
|
|
9
|
+
# A status or code is an enumeration-like word (no hyphens, so an API key like sk-... never passes); a param is
|
|
10
|
+
# a field path (messages[0].content), which is only compared with the developer's own param names and never
|
|
11
|
+
# repeated.
|
|
12
|
+
WORD = /\A[A-Za-z0-9][\w.]{0,39}\z/
|
|
13
|
+
PATH = /\A[\w.\-\[\]]{1,64}\z/
|
|
14
|
+
CONTEXT_LENGTH_EXCEEDED = "context_length_exceeded"
|
|
15
|
+
private_constant :WORD, :PATH, :CONTEXT_LENGTH_EXCEEDED
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# body: a JSON String, a Hash with String keys, or an Array wrapping the Hash (Gemini does that). http_status
|
|
20
|
+
# drops a code that only repeats it. Returns { status:, code:, param: }, each a String or nil.
|
|
21
|
+
def read(body, http_status: nil)
|
|
22
|
+
detail = error(body)
|
|
23
|
+
return { status: nil, code: nil, param: nil } unless detail
|
|
24
|
+
|
|
25
|
+
code = token(detail["code"], WORD)
|
|
26
|
+
{ status: token(detail["status"], WORD) || token(detail["type"], WORD),
|
|
27
|
+
code: (code unless code == http_status.to_s),
|
|
28
|
+
param: token(detail["param"], PATH) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The body's `error` Hash, or nil when it has none (or isn't JSON). For adapters that need more of it than
|
|
32
|
+
# #read returns, e.g. to recognize a wording; they must not repeat it.
|
|
33
|
+
def error(body)
|
|
34
|
+
data = body.is_a?(String) ? JSON.parse(body.dup.force_encoding(Encoding::UTF_8)) : body
|
|
35
|
+
data = data.first if data.is_a?(Array)
|
|
36
|
+
detail = data["error"] if data.is_a?(Hash)
|
|
37
|
+
detail if detail.is_a?(Hash)
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the body's code or type is the structured "context_length_exceeded" (what OpenAI sends for a prompt too
|
|
43
|
+
# long for the model). Only that field is read, never the message.
|
|
44
|
+
def context_length?(body)
|
|
45
|
+
(error(body)&.values_at("code", "type") || []).include?(CONTEXT_LENGTH_EXCEEDED)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def token(value, pattern)
|
|
49
|
+
value = value.to_s if value.is_a?(Integer)
|
|
50
|
+
value if value.is_a?(String) && value.valid_encoding? && value.match?(pattern)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|