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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fb55da97a0a032802afcd90b8e56d497a42194206ad90f07d71bc46c70a9e49
4
- data.tar.gz: 79430c869cda0726357c42c1ad1fdc7cbf9108a00ebcd56814df4e45acf99db6
3
+ metadata.gz: 14b40817f3f49682f1ed311aa5c05007dfc7f55957c9cf89683fd26fea962aa7
4
+ data.tar.gz: 32b93a239eaa8f5994675a24fca6598e5e0a9edeb8d77efa97ebcf9783db6aa4
5
5
  SHA512:
6
- metadata.gz: 95f83f1f99d86b5b2fddcc0d5e7d845aff73dbb7cc85620eafb93f999edd69181e8a72c726409cdb6ec11fa1286264947c48ac5f3467c1fb368755ef8ad95b62
7
- data.tar.gz: 4ad6fbec6185bb9e4369c972b9f561bce840db75a96ba02c619fe82d1726e15230330b8f457c740017b08e412b3da2c364fd40f657db3128b28cab9c01ba993b
6
+ metadata.gz: 184955d83ece437fbf86d7f93c2103a1c61302d8df9b70f7430b49b2896c7aab42e04d1f7503203979ff1087ed8e05b67baeb767c9283c43f4bafa82c720abd8
7
+ data.tar.gz: 351cdbe7b06b587e2c236ff75012fae0bf2fa526613a2d79e6b6c37aac643547bc22cfc74f73cb2e965dc31dd5f0234a7addf034fce8b1de5ea8dd74dab529d3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,155 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-10-31
11
+
12
+ The v0.5.0 red-team follow-ups (#70): data-egress, secret-leakage, and misclassification fixes. Several of them change
13
+ behavior that used to work, so read **Changed** before upgrading.
14
+
15
+ ### Added
16
+
17
+ - The OpenAI-compatible adapter takes `allow_insecure_http:` (default `false`), `max_response_bytes:` (default 8 MiB), and
18
+ `total_timeout:` (default `open_timeout + 2 * read_timeout`, per attempt). See **Changed** for what they guard.
19
+ (#74, #86)
20
+ - `keep_cause: true` on the RubyLLM adapter keeps the provider's original exception as `cause`. (#76, #84)
21
+ - `Adapter::Base#http_failure(request, status:, body:, via:)`, an overridable `context_length?(status:, body:)` hook, and
22
+ `sanitized_cause(error, status:)` for custom adapters: one shared mapping from an HTTP status to `LLMError`,
23
+ `ConfigurationError`, or `RequestRejectedError`, and a body-free `cause`. See [Adapters](docs/adapters.md). (#72, #76,
24
+ #84, #88)
25
+ - `squawk` metadata gains `stopped:` (`:budget`, `:disagreement`, or `nil` for an ordinary attempt). (#73, #87)
26
+
27
+ ### Changed
28
+
29
+ - **Behavior change:** a `NotImplementedError` raised by code a squished method calls (an abstract method on another
30
+ object, a helper, a block) now propagates instead of sending the method's arguments to the provider. Only the
31
+ method's own stub routes to the LLM: `def x = raise NotImplementedError`, `define_method`, a prepended module, and an
32
+ undefined method. To use the LLM from a rescue deeper in the stack, call `squish!` in the method. (#71, #83)
33
+ - **Behavior change:** `error.cause` from the RubyLLM adapter is now an `Adapter::ProviderFailure` that names only the
34
+ original's class and HTTP status (`RubyLLM::RateLimitError, HTTP 429`) and keeps its backtrace, because the original's
35
+ message is the provider's response body, which can echo request content and which error trackers such as Sentry report
36
+ from the cause chain. This reverses 0.4.0's choice to keep the original: code that read `e.cause.message` or
37
+ `e.cause.is_a?(RubyLLM::...)` needs `keep_cause: true`. The OpenAI-compatible adapter's transport causes carry no body
38
+ and are unchanged. (#76, #84)
39
+ - **Behavior change:** a provider failure is classified by the adapter from its HTTP status and structured error
40
+ `code`/`type`, no longer from the provider's message text. Both adapters now agree: 408, 413, 429, and 5xx are
41
+ `LLMError`; 401, 402, 403, 404, and 3xx are `ConfigurationError`; every other 4xx is a `RequestRejectedError`. On
42
+ RubyLLM, 402, 404, and 422 were retryable `LLMError`s. A context overflow is an `LLMError` (so it retries and
43
+ escalates) only when the body's `code`/`type` is `context_length_exceeded`, the adapter recognizes it (the
44
+ OpenAI-compatible adapter still matches the usual wordings, no longer a bare "maximum ... tokens"), or RubyLLM raised
45
+ its `ContextLengthExceededError`; one that is only in the message text now raises `RequestRejectedError`. (#72, #88)
46
+ - **Behavior change:** the OpenAI-compatible adapter refuses to send an `api_key` or `Authorization` header over
47
+ `http://` to a host that isn't loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), raising
48
+ `ConfigurationError`. A LAN or Docker setup such as `http://ollama:11434/v1` needs `allow_insecure_http: true`.
49
+ Successful responses over `max_response_bytes` raise `LLMError`, requests send `Accept-Encoding: identity` and a
50
+ compressed successful reply is refused, and `total_timeout:` bounds a whole attempt, not each read. None of those is
51
+ retried by the adapter. A failure status (4xx, 5xx, redirect) is classified by its status even when its body is
52
+ compressed or over the cap. (#74, #86)
53
+ - **Behavior change:** a hand-written Hash `output_schema` with `properties` but no `type: "object"`, at the root or
54
+ nested (a property, array `items`, an `optional` branch, a `$ref` target), now raises `ConfigurationError` when the
55
+ schema is built, and `Squishling.verify` reports it. Add `type: "object"` (or a union that includes it). Schematist
56
+ schemas are unaffected. (#77, #81)
57
+ - **Behavior change:** a schema with a remote or unresolvable `$ref` now raises `ConfigurationError` when it is built
58
+ (and in `verify`), not a raw `JSONSchemer::UnknownRef` on the first reply. A Hash or Array argument that contains
59
+ itself raises `ConfigurationError`, not `SystemStackError`. `cached_content` joins the reserved `params` keys. (#78,
60
+ #85)
61
+ - **Behavior change:** the `squawk` hook now also runs once when a call ends on `BudgetExhaustedError` or
62
+ `DisagreementError`, with `output: nil`, `final: true`, and `stopped:` set, so `squawk_on: :final` and `:rejections`
63
+ see those endings. A hook that assumes every call has output or usage should skip calls where `metadata[:stopped]` is
64
+ set. (#73, #87)
65
+ - `Marshal.dump` of an `OpenAICompatible` adapter (or a config holding one) now raises `TypeError`, and its API key and
66
+ header values show as `[REDACTED]` in `to_yaml` and `as_json`, as they already did in `inspect`. Rebuild the adapter
67
+ from its options instead of caching a dump. (#75, #82)
68
+
69
+ ### Fixed
70
+
71
+ - A provider 400 whose error `message`, `code`, or `type` is not valid UTF-8 (for example an echo cut mid-character) now
72
+ raises `RequestRejectedError` like any other 400, instead of a bare `ArgumentError` that is not a Squishling error.
73
+ - A `ConfigurationError` for a rejected `config.adapter`, `observer`, or `squawk` now names the object's class instead of
74
+ its `inspect` output, which could print an instance variable such as an access token.
75
+ - `Schema::CACHE` holds its keys weakly, so schema classes replaced by a Rails code reload can be collected. Two equal
76
+ Hash schemas now share one cached `Schema`. (#78, #85)
77
+
78
+ ### Documented
79
+
80
+ - `Observer::Call#purpose` is the fully rendered system prompt, including `append_to_purpose` source and procs: treat it
81
+ as sensitive. `Testing::Stubs` hooks persist after `Testing.reset!`; `Testing.no_llm_call` swaps the process-wide
82
+ adapter, so it is not safe under parallel test threads; `Run#produced?` compares by identity. (#78, #85)
83
+
84
+ ## [0.5.0] - 2026-10-28
85
+
86
+ ### Added
87
+
88
+ - Pluggable adapters: provider requests go through an adapter (`Squishling::Adapter::Base`, a stateless
89
+ `complete(request)`), set with `config.adapter` or registered with `Squishling.register_adapter`. Two ship:
90
+ `:ruby_llm` (the previous behavior) and `:openai_compatible`, plain `Net::HTTP` against any `/chat/completions`
91
+ endpoint with retries and strict `json_schema` output, which keeps keys and provider text out of errors, logs, and
92
+ `inspect`. See [Adapters](docs/adapters.md). (#47)
93
+ - `include_when:` on an escalation step: a Proc called like `squish_when` (the method's inputs as keywords, on the
94
+ instance) that leaves the step out of a call when falsy, for example a model without a BAA. Steps are filtered before
95
+ rotation, and a call left with no steps raises `ConfigurationError`. (#46)
96
+ - `squishling/testing` (opt-in, `require "squishling/testing"`): a scripted adapter that records requests and
97
+ conversations, `assert_no_llm_call`/`forbid_provider_calls!`, schema-validated `stub_result`/`stub_error`,
98
+ `validate`/`fallback_for`/`preview`, schema-driven `valid_reply_for`/`invalid_reply_for`, squawk capture, and
99
+ Minitest and RSpec mixins. See [Testing](docs/testing.md). (#44, #50)
100
+ - `Squishling.verify`/`verify!` check every squished class without a request (output schema and typed result, resolved
101
+ escalation, params and squawk, harness and judge, the keywords `include_when:`/`squish_when` take, and the adapter),
102
+ reporting every problem; `verify!` raises `VerificationError`. Squished classes are tracked in a weak registry
103
+ (`Squishling.squished_classes`). A `squishling:verify` rake task (with a Rails Railtie) and a `be_squishling_valid`
104
+ RSpec matcher (`require "squishling/rspec"`) wrap it. See [Verifying squished classes](docs/verifying.md). (#48, #67)
105
+ - Per-call token budgets: `max_total_output_tokens:` and `max_total_input_tokens:` (on `squishling`, `squish`,
106
+ `squish!`, and `default_max_total_*` in `Squishling.configure`; layered limit by limit, `false` lifts one) cap what one
107
+ call spends across its escalation attempts, squishsum sample rounds, and judge. A request in flight is never cut short,
108
+ so a call can end above the limit by one request. Once a limit is reached the call raises `BudgetExhaustedError`, an
109
+ `InvalidOutputError`, so `squish_fallback` still receives it. (#55, #68)
110
+ - `squawk_on:` (`:all`, `:rejections`, `:retries`, `:final`, or a list) filters which attempts reach the `squawk` hook,
111
+ settable with `config.squawk_on`, `squishling squawk_on:`, and `squish :method, squawk_on:`. The default `:all` keeps
112
+ the old behavior. (#58, #69)
113
+ - `Adapter::Response#finish_reason` (a Symbol such as `:stop` or `:max_tokens`), filled by both built-in adapters.
114
+ `InvalidOutputError` gains `finish_reason` and `truncated?`, with a fixed note in its message and log line when the
115
+ reply was cut off, and `squawk` metadata gains `finish_reason`. (#54, #60)
116
+ - `InvalidOutputError#error_details`: `InvalidOutputError::Detail` entries (`message`, `source` of `:schema`,
117
+ `:validator`, `:parse` or `:squishling`) with a `model_derived?` predicate that is true only for schema errors, which
118
+ can name a key the model chose. Escape just those when forwarding messages to a chat webhook, HTML, or markdown.
119
+ `#errors` is still an Array of Strings (now frozen). (#57, #62)
120
+ - `RequestRejectedError` (a `ConfigurationError`) for a provider's 400, with `provider_status` and `provider_code` read
121
+ as plain tokens from the error body (`INVALID_ARGUMENT`, `invalid_request_error`). The provider's message is still
122
+ never repeated. (#52, #53)
123
+ - `retry_temperature:` on an escalation step sets the temperature for same-chat retries only. (#56, #61)
124
+
125
+ ### Changed
126
+
127
+ - **Breaking change:** `ruby_llm` is no longer a runtime dependency of the gem. Apps that use the RubyLLM adapter (the
128
+ default when no adapter is configured and `ruby_llm` is loadable) must now list `gem "ruby_llm"` in their own
129
+ Gemfile, and `require "ruby_llm"` before `RubyLLM.configure` if they don't use `Bundler.require`; or switch to
130
+ `config.adapter = :openai_compatible`. (#47)
131
+ - **Behavior change:** a schema property named like a method the typed result needs (`class`, `hash`, `to_h`,
132
+ `members`, `inspect`, `is_a?`, ...) now raises `ConfigurationError` when the schema is built, before any request,
133
+ instead of replacing that method on the result class and failing late. Rename such fields (`class` to
134
+ `ticket_class`); the reserved names are listed in [Output schemas](docs/schemas.md). (#43, #49)
135
+ - **Behavior change:** when a same-chat retry returns the exact reply it was just rejected for, Squishling skips the
136
+ step's remaining attempts and moves to the next step (or raises `InvalidOutputError` right away on the last step);
137
+ samples end early the same way. `InvalidOutputError#attempts` counts requests actually made, `squawk` metadata gains
138
+ `repeated:`, and `final:` now means no attempt follows a rejection. Size a step's `attempts:` for replies that can
139
+ differ. (#56, #61)
140
+ - A provider 400's "Check params" hint now names a param only when the provider's error body names one you configured,
141
+ and otherwise says the params, output schema, or input may be invalid. An array-wrapped overlong-context 400 from the
142
+ OpenAI-compatible adapter is now an `LLMError`. (#53)
143
+
144
+ ### Fixed
145
+
146
+ - A raw Hash `output_schema` that omits `type: "object"` no longer lets a non-object reply (a String, Array, or number)
147
+ reach the typed result as a `NoMethodError`; it is now invalid output, retried, escalated, and sent to
148
+ `squish_fallback` like any other. Schemas built with Schematist always had `type: "object"`.
149
+ - `RequestRejectedError`'s `provider_status` and `provider_code` no longer accept a hyphenated token, so a key-shaped
150
+ string a gateway echoes into an error body is never repeated in our message. The RubyLLM adapter's "invalid params"
151
+ `ConfigurationError` now names the param keys, not their values.
152
+
153
+ ### Internal
154
+
155
+ - Development dependencies: `ruby_llm` 2.1.1, `coolhand` 0.10.0, and `rubocop` 1.92.1; CI pins `ruby/setup-ruby` v1.335.0
156
+ so the matrix resolves Ruby 3.3.13, 3.4.12, and 4.0.9. A `finish-release` skill now tags and publishes a reviewed
157
+ release-prep PR. (#45, #51, #59, #63, #64, #65)
158
+
10
159
  ## [0.4.0] - 2026-10-18
11
160
 
12
161
  ### Added
data/README.md CHANGED
@@ -130,12 +130,17 @@ end
130
130
  gem "squishling"
131
131
  ```
132
132
 
133
- Requires Ruby 3.3+ and RubyLLM 2.1+. Configure your provider API keys in RubyLLM as usual, then optionally set a
134
- universal model, or an escalation of models to try in order:
133
+ Requires Ruby 3.3+ and an [adapter](docs/adapters.md) to reach a model: the `ruby_llm` gem (2.1+; Squishling uses it
134
+ automatically when it is in your bundle), or any OpenAI-compatible endpoint, which needs no extra gem. Squishling no
135
+ longer installs or requires `ruby_llm` for you, so add `gem "ruby_llm"` next to it if you use RubyLLM (and
136
+ `require "ruby_llm"` before `RubyLLM.configure` if your app doesn't load its Gemfile with `Bundler.require`). With RubyLLM,
137
+ configure your provider API keys there as usual. Then choose an adapter, and optionally set a universal model, or an
138
+ escalation of models to try in order:
135
139
 
136
140
  ```ruby
137
141
  Squishling.configure do |config|
138
- config.default_model = "claude-sonnet-5-5" # falls back to RubyLLM's default when nil
142
+ config.adapter = :ruby_llm # or { type: :openai_compatible, base_url: "http://localhost:11434/v1" }
143
+ config.default_model = "claude-sonnet-5-5" # falls back to the adapter's default when nil
139
144
  # or: config.default_escalation = [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
140
145
  end
141
146
  ```
@@ -146,7 +151,7 @@ A squishling class needs a **purpose** (the system prompt) and an **output schem
146
151
  validated on both paths). A squished call goes to the LLM when:
147
152
 
148
153
  - its `squish_when` predicate is truthy for these inputs,
149
- - the method has no implementation (it isn't defined, or raises `NotImplementedError`), or
154
+ - the method has no implementation (it isn't defined, or its own body raises `NotImplementedError`), or
150
155
  - the Ruby implementation calls `squish!`.
151
156
 
152
157
  Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM output.
@@ -162,11 +167,14 @@ sees it right where the value goes, and the purpose stays short and about the go
162
167
  returned as the same typed `Data` objects. `squished?` tells you which path served a call.
163
168
  - **Your code as context**: `append_to_purpose` adds sections to the prompt, including a class's or method's
164
169
  own Ruby source.
165
- - **Any RubyLLM provider and model**: OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more.
166
- Set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
170
+ - **[Bring your own adapter](docs/adapters.md)**: Squishling doesn't depend on any one LLM client. Use the
171
+ [RubyLLM](https://rubyllm.com) adapter (OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more),
172
+ the OpenAI-compatible adapter (OpenAI, Ollama, vLLM, LM Studio, Together, Groq, … over plain HTTP), or write your
173
+ own in one method.
174
+ - **Any provider and model**: set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
167
175
  reasoning effort, top_p, …).
168
176
  - **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `priority:` (models tied on priority take turns going first), provider,
169
- params, and `forward_rejected:`. Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
177
+ params, `forward_rejected:`, `retry_temperature:`, and `include_when:` (leave a model out of calls where a Proc is falsy). Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
170
178
  cross providers, e.g. a local Qwen model on Ollama, then Anthropic Claude Haiku on AWS Bedrock, then Claude Opus:
171
179
 
172
180
  ```ruby
@@ -195,12 +203,15 @@ sees it right where the value goes, and the purpose stays short and about the go
195
203
  - [`:judged_squishsum`](docs/harnesses.md#the-judge): a chat or Jev judge picks between disagreeing samples
196
204
  - [Your own](docs/harnesses.md#custom-harnesses): `Squishling.register_harness` plugs a custom harness into the same
197
205
  machinery (samples, judges, `squawk`, `squish_fallback`)
206
+ - **Cost caps**: `max_total_output_tokens:` and `max_total_input_tokens:` stop a call that keeps failing from
207
+ spending without limit (see [Capping a call's cost](docs/failures.md#capping-a-calls-cost-token-budgets)).
198
208
  - **Output contracts**: beyond the strict schema, conditional rules (`given`) and Ruby checks (`squish_validate`)
199
209
  reject bad output and trigger the next attempt.
200
210
  - **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
201
211
  what to return when every attempt fails.
202
- - **Observability**: the opt-in [`squawk`](docs/failures.md#observing-every-attempt-squawk) hook sends every attempt's
203
- raw output to your error tracker. Model output stays out of error messages and logs otherwise.
212
+ - **Observability**: the opt-in [`squawk`](docs/failures.md#observing-every-attempt-squawk) hook sends each attempt's
213
+ raw output (or only the rejected and retried ones), and the end of a call that a token budget or a disagreement
214
+ stopped, to your error tracker. Model output stays out of error messages and logs otherwise.
204
215
  - **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
205
216
  pass to `squish!`, and the source you choose to append are sent to the provider. The one addition: when
206
217
  [escalation](docs/configuration.md#models-and-escalation) moves to the next step, that step also sees the previous
@@ -211,23 +222,28 @@ sees it right where the value goes, and the purpose stays short and about the go
211
222
  - [Explainer site](https://coolhand-labs.github.io/squishling/): interactive walkthrough, spectrum slider, and
212
223
  token-cost calculator
213
224
  - [Configuration](docs/configuration.md): options, models and escalation, providers, generation params, inheritance
225
+ - [Adapters](docs/adapters.md): the RubyLLM and OpenAI-compatible adapters, and writing your own
214
226
  - [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_to_purpose`, hardening a path,
215
227
  what the LLM sees
216
228
  - [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty, contracts
217
229
  - [Failure handling](docs/failures.md): escalation, `squish_validate`, error classes, fallbacks
218
230
  - [Harnesses](docs/harnesses.md): escalation, squishsum, judges (chat or Jev), and custom harnesses
231
+ - [Verifying squished classes](docs/verifying.md): `Squishling.verify!`, a rake task, and an RSpec matcher that check
232
+ every class ahead of runtime, without a request
219
233
  - [Naming and collisions](docs/naming.md): the methods `include Squishling` adds and what happens when a name is taken
220
234
  - [Measuring token spend](docs/measuring-tokens.md): see what each squished path costs, with Coolhand Labs,
221
235
  the `squawk` hook, your own observer, or OpenTelemetry
222
236
  - [Observers](docs/observers.md): wrap every provider request, and the built-in [Coolhand Labs](docs/coolhand.md) observer
223
237
  that files each call under its workload and template
238
+ - [Testing](docs/testing.md): `require "squishling/testing"`: a scripted adapter, call assertions, result and error
239
+ stubs, and schema-driven fixtures, for Minitest and RSpec
224
240
  - [Live examples](examples/README.md): end-to-end tests against Anthropic Claude Haiku and OpenAI GPT-6 Luna
225
241
 
226
242
  ## Development
227
243
 
228
244
  ```sh
229
245
  bin/setup
230
- bundle exec rake # RSpec (offline; RubyLLM is stubbed) + RuboCop
246
+ bundle exec rake # RSpec (offline; adapters are faked or stubbed) + RuboCop
231
247
  ```
232
248
 
233
249
  See [AGENTS.md](AGENTS.md) for repo conventions, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
data/docs/adapters.md ADDED
@@ -0,0 +1,257 @@
1
+ # Adapters
2
+
3
+ An **adapter** is how Squishling reaches a model. Squishling builds the request (system prompt, conversation so far,
4
+ strict output schema, generation params), the adapter makes one completion and returns the model's output, and
5
+ Squishling does the rest: validation, `squish_validate`, retries and escalation, harnesses, `squawk`, and fallbacks.
6
+ Two adapters ship, and you can write your own.
7
+
8
+ | Adapter | Reaches | Needs |
9
+ |---|---|---|
10
+ | [`:ruby_llm`](#ruby_llm) | OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and every provider [RubyLLM](https://rubyllm.com) supports | `gem "ruby_llm"` |
11
+ | [`:openai_compatible`](#openai-compatible-endpoints) | Any server that speaks OpenAI's Chat Completions API: OpenAI, Ollama, vLLM, LM Studio, llama.cpp, Together, Groq, … | nothing (Ruby's `Net::HTTP`) |
12
+ | [Your own](#writing-your-own) | Anything | a class with one method |
13
+
14
+ Squishling itself depends on neither RubyLLM nor any provider SDK.
15
+
16
+ ## Choosing one
17
+
18
+ ```ruby
19
+ Squishling.configure do |config|
20
+ config.adapter = :ruby_llm # a built-in or registered adapter, by name
21
+ # config.adapter = { type: :openai_compatible, base_url: "http://localhost:11434/v1", default_model: "llama3.2" }
22
+ # config.adapter = MyAdapter.new # or any adapter instance
23
+ end
24
+ ```
25
+
26
+ An adapter is built when you assign it, so a missing gem or a bad option fails at boot, not on the first request.
27
+ There is one adapter per process; every squishling class, method, and escalation step goes through it.
28
+
29
+ **When you set none**, Squishling uses the RubyLLM adapter if the `ruby_llm` gem is in your bundle, so apps that
30
+ configured RubyLLM before adapters existed keep working. Squishling no longer requires `ruby_llm` when it loads, so an
31
+ app that doesn't load its Gemfile with `Bundler.require` should `require "ruby_llm"` before `RubyLLM.configure`. Without the gem, the first squished call
32
+ raises `Squishling::ConfigurationError` saying what to add.
33
+
34
+ ## `:ruby_llm`
35
+
36
+ Everything works as it did before adapters: configure provider API keys with `RubyLLM.configure`, name models, and
37
+ use `provider:` for models missing from RubyLLM's registry. See [Configuration](configuration.md). Transport-level
38
+ retries are RubyLLM's (`RubyLLM.config.max_retries`, `request_timeout`).
39
+
40
+ | Option | Default | Description |
41
+ |---|---|---|
42
+ | `keep_cause` | `false` | RubyLLM's exception holds the provider's response body, which can echo your request, and error trackers report the whole `cause` chain. By default the errors the adapter raises carry a sanitized `cause` (the class and HTTP status only). `true` keeps RubyLLM's original exception there. See [the cause of a provider failure](failures.md#the-cause-of-a-provider-failure). |
43
+
44
+ **Failures** are sorted by the HTTP status of the error's response, the same way as for the OpenAI-compatible adapter
45
+ below (an error with no response, such as a timeout, is sorted by its class). RubyLLM's own error classes are not what
46
+ decides: on a 400 its middleware picks a class from the provider's message text, so a bad parameter whose message says
47
+ "per day" would otherwise look like a rate limit. The one RubyLLM class that is honored over the status is
48
+ `RubyLLM::ContextLengthExceededError`, an `LLMError`, since RubyLLM is where each provider's wording for an overlong
49
+ prompt is known. RubyLLM picks that class from the message on a 400, with wording broader than the OpenAI-compatible
50
+ adapter's, so a bad parameter whose message happens to match is retried as an overflow rather than rejected. Each of
51
+ these errors carries a sanitized `cause` (see `keep_cause` above).
52
+
53
+ This is the only built-in adapter that supports `type: :judgment` judges ([System One decision models](harnesses.md#the-judge)).
54
+
55
+ ## OpenAI-compatible endpoints
56
+
57
+ ```ruby
58
+ Squishling.configure do |config|
59
+ config.adapter = {
60
+ type: :openai_compatible,
61
+ base_url: "http://localhost:11434/v1", # where /chat/completions lives
62
+ api_key: ENV["LLM_API_KEY"], # optional for local servers
63
+ default_model: "llama3.2" # for steps that name no model
64
+ }
65
+ config.default_model = "llama3.2"
66
+ end
67
+ ```
68
+
69
+ | Option | Default | Description |
70
+ |---|---|---|
71
+ | `base_url` | `https://api.openai.com/v1` | The API root. Requests go to `{base_url}/chat/completions`. Must be `http(s)` and carry no credentials, query, or fragment. |
72
+ | `api_key` | `ENV["OPENAI_API_KEY"]` for OpenAI's own endpoint, else none | A String, or a callable returning one (called once per completion, outside the adapter's retry handling, so rotating keys work and its exceptions reach you unchanged). Sent as a bearer token; omitted when blank. The key is sent as-is, and not over plain `http://` to another machine unless `allow_insecure_http: true` (below). `OPENAI_API_KEY` is read only for OpenAI's own `https://api.openai.com`, so it can never go to another host: pass `api_key:` for any other endpoint that needs one. |
73
+ | `headers` | `{}` | Extra request headers, e.g. `{ "OpenAI-Organization" => "org-…" }` or a gateway's own. |
74
+ | `default_model` | `nil` | The model for steps that don't name one. Without it, a step with no model raises `ConfigurationError`. |
75
+ | `allow_insecure_http` | `false` | A credential (the `api_key`, or a header named `Authorization`, `Proxy-Authorization`, `api-key`, or `X-Api-Key`; other headers aren't inspected) is never sent over plain `http://` to a host that isn't this machine: building the adapter (or, for a callable key, making the request) raises `ConfigurationError`. Loopback hosts (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`) are exempt, judged by the name as written and never resolved, so a `*.localhost` name counts even on a resolver that doesn't map it to loopback (use the bare `localhost`, or an IP, if you can't rely on that). Set `true` to send it anyway, for a trusted LAN or container network. With no credential, `http://` to any host is fine. |
76
+ | `open_timeout` / `read_timeout` | `10` / `120` | Seconds. `read_timeout` restarts at every read, so it alone doesn't bound a request. |
77
+ | `total_timeout` | `open_timeout + 2 * read_timeout` | Seconds one attempt (each retry gets its own) may take from connecting to the last byte of the response. Past it the call fails with an `LLMError` that isn't retried by the adapter. |
78
+ | `max_response_bytes` | `8388608` (8 MiB) | The most a response body may hold, an error body included, checked against `Content-Length` and the bytes actually read. Over it, a successful response fails with an `LLMError` that isn't retried by the adapter; a failure status drops the body and is classified by its status. Raise it only if you expect very large completions. |
79
+ | `max_retries` | `2` | Timeouts, connection and protocol failures, HTTP 408, 429, and 5xx are retried this many times with exponential backoff (honoring `Retry-After`, up to 30 seconds) before they surface as an `LLMError`. A timed-out request may still have been processed by the server, so a retry can be billed twice; set `max_retries: 0` if that matters more than resilience. |
80
+ | `max_tokens_param` | `:max_completion_tokens` | The body key for `max_output_tokens`. Use `:max_tokens` for servers that don't know the newer name. |
81
+ | `structured_output` | `:json_schema` | `:json_schema` asks for strict structured output. For servers without it use `:json_object`, or `:none`: the schema then goes into the system prompt in words. Squishling validates every answer itself either way. |
82
+
83
+ **Credentials stay out of serializers.** The adapter holds the `api_key` and the `headers` (which may carry an
84
+ `api-key` or `Authorization` value) in an opaque wrapper, not in plain instance variables, so logging or caching the
85
+ adapter or `Squishling.config` can't write them out: `inspect`, `to_s`, `pp`, `to_yaml`, `as_json` (including
86
+ ActiveSupport's `instance_values` walk) and `to_json` show `[REDACTED]`, and `Marshal.dump` raises a `TypeError`
87
+ instead of dumping the key. This guards against accidental serialization (a logged config, an error tracker, a cache
88
+ dump), not against code running in your process, which can still reach the value. If you write your own adapter,
89
+ keep credentials out of plain instance variables the same way, or define `encode_with`, `as_json`, and
90
+ `marshal_dump` to omit them.
91
+
92
+ **How params map.** `temperature` → `temperature`; `max_output_tokens` → `max_tokens_param`; `thinking: { effort: }` →
93
+ `reasoning_effort` (`true`/`false` leave the model's default, and a `budget:` or `display:` raises
94
+ `ConfigurationError`, since Chat Completions has no field for them). Any other param (`top_p`, `seed`, …) goes into
95
+ the request body as given. Squishling's own keys (`model`, `messages`, `response_format`, `n`, `stream`) always win.
96
+ `provider:` has no meaning here and is ignored.
97
+
98
+ **Failures.** The HTTP status decides (see [the status mapping](#the-error-contract)): 408, 413, 429, 5xx, timeouts, and
99
+ connection errors raise `LLMError` once the adapter's own retries are used up; 401, 402, 403, 404, and a redirect raise
100
+ `ConfigurationError`; any other 4xx (400, 409, 422, …) raises `RequestRejectedError`, never retried or escalated. The
101
+ one exception is a 400 that reports an overlong context, which is an `LLMError` too, since it depends on the input (a
102
+ bigger model may take it). This adapter recognizes one when the error's `code` or `type` is `context_length_exceeded`, or,
103
+ for servers that send neither (vLLM, Ollama, llama.cpp), when its message uses the usual wording ("context length",
104
+ "context window", "context size", "too many tokens", "prompt is too long"). The message is only matched against that
105
+ wording, never repeated; the match is deliberately narrow, so a wording it doesn't know is a rejected request, not a
106
+ retry. Messages carry the status or error class only; a rejected request also carries the server's error status and code
107
+ when they are plain tokens (as `provider_status` and `provider_code`). The server's message, the response body, and the
108
+ API key never appear in an error or log. Redirects aren't followed, so the key can't be sent to another host.
109
+
110
+ **Response limits.** The adapter sends `Accept-Encoding: identity` (it replaces any `Accept-Encoding` header you set)
111
+ and reads the body in chunks. A successful (2xx) response that is compressed anyway, one over `max_response_bytes`,
112
+ and any response that takes longer than `total_timeout` each raise an `LLMError` naming the limit (never the URL or
113
+ any of the response) and are not retried by the adapter, since the same request would meet the same limit;
114
+ escalation and `squish_fallback` handle it like any other `LLMError`. A failure status (4xx, 5xx, or a redirect) is
115
+ classified by its status alone even when its body is compressed or over the cap: the body is dropped unread, so a
116
+ gateway's gzipped 401 is still a `ConfigurationError`, not a transport failure that `squish_fallback` would absorb,
117
+ and a 503 is still retried.
118
+
119
+ ## Writing your own
120
+
121
+ Subclass `Squishling::Adapter::Base`, implement `complete`, and return a `Squishling::Adapter::Response`:
122
+
123
+ ```ruby
124
+ class AcmeAdapter < Squishling::Adapter::Base
125
+ def initialize(client: Acme::Client.new)
126
+ super()
127
+ @client = client
128
+ end
129
+
130
+ def complete(request)
131
+ reply = @client.chat(
132
+ model: request.model,
133
+ system: request.instructions,
134
+ messages: request.messages, # [{ role: :user | :assistant, content: "…" }, …]
135
+ json_schema: request.schema["schema"], # strict; Squishling validates the answer regardless
136
+ **request.params
137
+ )
138
+ Squishling::Adapter::Response.new(
139
+ content: reply.text, # the raw String (or a Hash you already parsed)
140
+ model: reply.model,
141
+ tokens: { input_tokens: reply.usage.in, output_tokens: reply.usage.out }
142
+ )
143
+ rescue Acme::AuthError
144
+ raise configuration_error(request, "Acme rejected the credentials")
145
+ rescue Acme::HTTPError => e
146
+ # sorted by status like the built-in adapters; only the status and a rejection's status/code tokens are repeated
147
+ raise http_failure(request, status: e.status, body: e.response_body, via: "Acme, HTTP #{e.status}")
148
+ rescue Acme::Error => e
149
+ raise llm_error(request, "Acme failed (#{e.class})")
150
+ end
151
+
152
+ private
153
+
154
+ # Optional: what an overlong prompt looks like for Acme. The default (super) already recognizes a 400 whose error
155
+ # code or type is "context_length_exceeded"; add whatever else Acme's errors tell you, without reading its message.
156
+ # body is the provider's error body, a JSON String or a Hash (or nil); ErrorBody.error reads either safely.
157
+ def context_length?(status:, body:)
158
+ super || (status == 400 && Squishling::Adapter::ErrorBody.error(body)&.[]("code") == "prompt_too_long")
159
+ end
160
+ end
161
+
162
+ Squishling.configure { |config| config.adapter = AcmeAdapter.new }
163
+ # or register it by name, to use config.adapter = :acme (or { type: :acme, client: … }):
164
+ Squishling.register_adapter(:acme, AcmeAdapter)
165
+ ```
166
+
167
+ ### The request
168
+
169
+ `Squishling::Adapter::Request` carries everything for one completion:
170
+
171
+ | Field | |
172
+ |---|---|
173
+ | `label` | `"Class#method"`, to prefix your error messages (the `configuration_error` / `llm_error` helpers do; `http_failure(request, status:, body:, via:)` sorts an HTTP failure; `rejected_request_error(request, via:, body:, http_status:)` builds the error for a rejected request, from a JSON String or String-keyed Hash body). |
174
+ | `model`, `provider` | The escalation step's, or `nil` when it names none (use your own default). What `provider:` means is up to you. |
175
+ | `instructions` | The system prompt. |
176
+ | `messages` | The conversation so far, ending with the user message to answer. After invalid output, Squishling appends your previous answer and a retry message; you never keep history. |
177
+ | `schema` | `{ "name", "description", "schema", "strict" => true }`: the strict structured-output payload, without the conditional keywords providers can't take. |
178
+ | `params` | The resolved [generation params](configuration.md#generation-params): the portable `:temperature`, `:max_output_tokens`, `:thinking`, plus provider-specific keys. Squishling has already rejected the keys it owns. Translate the portable ones for your provider; pass the rest through if it takes them. |
179
+
180
+ ### The response
181
+
182
+ `Squishling::Adapter::Response.new(content:, model:, tokens:, finish_reason:)`. `content` is the model's output as a
183
+ String, or a Hash if you parsed it; `nil` or empty counts as invalid output and is retried. `model` is the id the
184
+ provider reports. `tokens` is a Hash (`:input_tokens`, `:output_tokens`, and anything else you have); `{}` when
185
+ unknown. These feed [`squawk`'s `usage`](failures.md#observing-every-attempt-squawk), the [observer](observers.md)'s `Usage`, and the
186
+ [token budget](failures.md#capping-a-calls-cost-token-budgets), which counts `:input_tokens` and `:output_tokens`
187
+ (a response with none never exhausts a budget).
188
+ `finish_reason` is why the provider stopped, as a Symbol: `:stop`, `:max_tokens` (the output cap cut the reply off),
189
+ `:content_filter`, `:tool_calls`, or the provider's own spelling; leave it out (`nil`) when unknown. Map your
190
+ provider's "length" reason to `:max_tokens`, so Squishling can [tell a cutoff from a wrong
191
+ answer](failures.md#a-cutoff-or-a-wrong-answer).
192
+
193
+ ### The error contract
194
+
195
+ Classifying a provider failure is the adapter's job; what happens next is Squishling's.
196
+
197
+ **An adapter defines** which error a failure is. Only the adapter knows its provider's status codes, error bodies, and
198
+ how it says a prompt is too long:
199
+
200
+ | Raise | When | What Squishling does |
201
+ |---|---|---|
202
+ | `Squishling::ConfigurationError` | Bad credentials, billing, an unknown model or endpoint: anything a retry can't fix | Raised at once. Never retried, escalated, or sent to `squish_fallback`. |
203
+ | `Squishling::RequestRejectedError` (a `ConfigurationError`) | The provider refused the request itself: a 400 or another 4xx for a bad param or schema | Same as `ConfigurationError`, so a setup mistake can't be hidden by a fallback on every call. |
204
+ | `Squishling::LLMError` | Rate limit, 5xx, timeout, connection failure, an overlong context: anything worth another go | Moves to the next attempt; raised from the last one (and goes to `squish_fallback`). |
205
+
206
+ **Squishling manages** everything that follows from the class: retries, escalation, the fallback, the token budget, and
207
+ `squawk`. It never looks at a provider's status, code, or message; it sees only the error class your adapter raised.
208
+
209
+ For an HTTP provider, `http_failure(request, status:, body:, via:)` does the sorting, so every adapter treats a status
210
+ the same way: 408, 413, 429, 5xx, no status at all (a timeout), and a success status (a reply that was unusable) are
211
+ `LLMError`s; 401, 402, 403, 404, and a redirect are `ConfigurationError`s;
212
+ every other 4xx is a `RequestRejectedError`. `via` ("Acme, HTTP 400") is the only text repeated, plus the status and
213
+ code tokens of a rejection. Between those, one case depends on the provider: a rejected request whose input was too
214
+ long for the model should be an `LLMError`, since a bigger model may take it. `http_failure` asks the
215
+ `context_length?(status:, body:)` hook. Its default recognizes a 400 whose error `code` or `type` is
216
+ `context_length_exceeded` and never reads the message; override it (calling `super`) to recognize what your provider
217
+ sends. Where the adapter can't tell, leave it: the failure stays a `RequestRejectedError`, which is never absorbed.
218
+
219
+ Keep the provider's own message out of every error you raise: it can echo request content or part of a key, and ours
220
+ reach logs and error trackers, and so do trackers that walk the `cause` chain. If the provider's exception carries a
221
+ response body (an HTTP client error), don't leave it as the `cause` that raising inside a `rescue` sets: pass
222
+ `cause: sanitized_cause(error, status: …)`, which builds a stand-in with only the class, the status, and the
223
+ backtrace. An exception that carries no body (a timeout, a refused connection) can stay as the `cause`. Any other
224
+ exception propagates unwrapped and is never retried, so map your transport's errors. Return anything but an
225
+ `Adapter::Response` and Squishling raises `ConfigurationError`.
226
+
227
+ ### Optional hooks
228
+
229
+ - **`judgments?` and `judge(input, questions:, step:, label:)`**: System One decision models for `type: :judgment` judges.
230
+ `judge` returns an object answering `[:winner]` with something that responds to `choice` and `probabilities`.
231
+ Without `judgments?`, such a judge raises `ConfigurationError` before any request. If the object has `tokens` (with
232
+ `input` and `output` readers), they count toward the call's token budget.
233
+ - **`capture_context` and `with_context(context) { … }`**: samples (the `:squishsum` harnesses) send their requests from
234
+ worker threads. `capture_context` runs on the caller's thread and returns whatever your tracing needs; `with_context`
235
+ re-establishes it around each worker's request. The RubyLLM adapter uses this for its instrumentation workflow.
236
+
237
+ ### Thread safety
238
+
239
+ Adapters are called concurrently from worker threads when a harness takes samples. Keep no unsynchronized mutable
240
+ state (a per-request HTTP connection, as the OpenAI-compatible adapter does, is the simple way).
241
+
242
+ ### Testing against your adapter
243
+
244
+ To test your *application* (not an adapter), use [`squishling/testing`](testing.md): a scripted adapter with request
245
+ inspection, call assertions, and stubs. To unit-test an adapter you wrote, a canned one is enough:
246
+
247
+ ```ruby
248
+ class CannedAdapter < Squishling::Adapter::Base
249
+ def initialize(*replies) = (@replies = replies)
250
+
251
+ def complete(request)
252
+ Squishling::Adapter::Response.new(content: @replies.shift, model: request.model, tokens: {})
253
+ end
254
+ end
255
+
256
+ Squishling.configure { |config| config.adapter = CannedAdapter.new({ "label" => "bug" }) }
257
+ ```