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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +26 -10
  4. data/docs/adapters.md +257 -0
  5. data/docs/configuration.md +54 -19
  6. data/docs/failures.md +170 -22
  7. data/docs/harnesses.md +39 -15
  8. data/docs/measuring-tokens.md +12 -6
  9. data/docs/observers.md +3 -3
  10. data/docs/routing.md +14 -3
  11. data/docs/schemas.md +24 -2
  12. data/docs/testing.md +187 -0
  13. data/docs/verifying.md +86 -0
  14. data/lib/squishling/adapter/error_body.rb +54 -0
  15. data/lib/squishling/adapter.rb +328 -0
  16. data/lib/squishling/adapters/openai_compatible.rb +311 -0
  17. data/lib/squishling/adapters/openai_options.rb +114 -0
  18. data/lib/squishling/adapters/openai_transport.rb +123 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +229 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +58 -6
  24. data/lib/squishling/definition.rb +90 -16
  25. data/lib/squishling/errors.rb +94 -7
  26. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  27. data/lib/squishling/harness/option_types.rb +2 -1
  28. data/lib/squishling/harness/registry.rb +8 -36
  29. data/lib/squishling/invoker.rb +44 -14
  30. data/lib/squishling/judge.rb +16 -3
  31. data/lib/squishling/llm_client.rb +66 -132
  32. data/lib/squishling/model_path.rb +60 -13
  33. data/lib/squishling/named_registry.rb +64 -0
  34. data/lib/squishling/observer.rb +13 -4
  35. data/lib/squishling/output_check.rb +13 -9
  36. data/lib/squishling/params.rb +7 -24
  37. data/lib/squishling/railtie.rb +10 -0
  38. data/lib/squishling/result.rb +50 -16
  39. data/lib/squishling/rotation.rb +5 -0
  40. data/lib/squishling/router.rb +3 -2
  41. data/lib/squishling/rspec.rb +18 -0
  42. data/lib/squishling/run/messages.rb +40 -3
  43. data/lib/squishling/run/rejections.rb +43 -0
  44. data/lib/squishling/run/reports.rb +63 -0
  45. data/lib/squishling/run.rb +60 -53
  46. data/lib/squishling/schema.rb +50 -9
  47. data/lib/squishling/secret.rb +45 -0
  48. data/lib/squishling/squawk.rb +37 -4
  49. data/lib/squishling/tasks/squishling.rake +18 -0
  50. data/lib/squishling/testing/adapter.rb +194 -0
  51. data/lib/squishling/testing/checks.rb +39 -0
  52. data/lib/squishling/testing/direct.rb +41 -0
  53. data/lib/squishling/testing/errors.rb +18 -0
  54. data/lib/squishling/testing/fixtures.rb +206 -0
  55. data/lib/squishling/testing/helpers.rb +71 -0
  56. data/lib/squishling/testing/minitest.rb +32 -0
  57. data/lib/squishling/testing/observer.rb +31 -0
  58. data/lib/squishling/testing/reply.rb +60 -0
  59. data/lib/squishling/testing/request.rb +75 -0
  60. data/lib/squishling/testing/rspec.rb +30 -0
  61. data/lib/squishling/testing/script.rb +189 -0
  62. data/lib/squishling/testing/state.rb +74 -0
  63. data/lib/squishling/testing/stubs.rb +112 -0
  64. data/lib/squishling/testing.rb +126 -0
  65. data/lib/squishling/verification.rb +39 -0
  66. data/lib/squishling/verifier.rb +179 -0
  67. data/lib/squishling/version.rb +1 -1
  68. data/lib/squishling/wrapper.rb +19 -1
  69. data/lib/squishling.rb +60 -4
  70. 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 only. The method's inputs, its context, and the model's output never reach an observer: it gets
73
- the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
72
+ It carries metadata, plus the rendered `purpose`. The method's inputs, its context, and the model's output never reach an
73
+ observer: it gets the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
74
74
 
75
75
  ## Errors
76
76
 
data/docs/routing.md CHANGED
@@ -17,7 +17,7 @@ A squished call runs its Ruby implementation unless one of these sends it to the
17
17
  | Trigger | Declared where | Decided |
18
18
  |---|---|---|
19
19
  | `squish_when` (or `when:`) predicate is truthy | class, or per method | before Ruby runs |
20
- | The method has no implementation: it isn't defined, or it raises `NotImplementedError` | the method body | when Ruby gives up |
20
+ | The method has no implementation: it isn't defined, or its own body raises `NotImplementedError` | the method body | when Ruby gives up |
21
21
  | `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Handing off to the LLM](#handing-off-to-the-llm-with-squish) |
22
22
 
23
23
  With no predicate and a working implementation, every call runs Ruby.
@@ -41,8 +41,14 @@ is validated against the schema and turned into the same typed result the LLM pa
41
41
  already has a `result` (see [Naming and collisions](naming.md)). Invalid deterministic output raises
42
42
  `Squishling::InvalidOutputError` too, so a hardened path can't silently drift from the contract.
43
43
 
44
- A `NotImplementedError` raised anywhere inside the method, including from code it calls, also routes to the
45
- LLM.
44
+ Only the method's own stub routes: a `NotImplementedError` raised directly in the method body (`raise
45
+ NotImplementedError` in a `def` or a `define_method` block, including one in a module prepended before
46
+ `include Squishling`, or in a squished parent reached through `super`). One raised by code the method calls
47
+ (a helper, another object, a block, or a `super` into a parent that isn't squished) is a bug in that code, not a
48
+ missing implementation, so it propagates and nothing is sent to the provider. Keep the `raise` in the method body:
49
+ a shared `not_implemented!` helper, or a decorator prepended before `include Squishling` that wraps the method with
50
+ its own `super` frame, hides the stub and the error propagates (use `squish_when { true }` or `squish!` to
51
+ send those calls to the LLM).
46
52
 
47
53
  Each call is routed on its own, including a squished method that calls itself on smaller inputs (those
48
54
  recursive calls must return schema-valid values too). A subclass
@@ -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 RubyLLM for strict structured output, and a
53
- schema declaring `strict: false` raises `Squishling::ConfigurationError`. RubyLLM 2.0 on its own would send a
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