squishling 0.5.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 +74 -0
- data/README.md +3 -3
- data/docs/adapters.md +86 -18
- data/docs/configuration.md +3 -3
- data/docs/failures.md +55 -12
- data/docs/harnesses.md +15 -1
- data/docs/measuring-tokens.md +4 -0
- data/docs/observers.md +3 -3
- data/docs/routing.md +9 -3
- data/docs/schemas.md +13 -0
- data/docs/testing.md +7 -3
- data/docs/verifying.md +4 -2
- data/lib/squishling/adapter/error_body.rb +9 -2
- data/lib/squishling/adapter.rb +62 -9
- data/lib/squishling/adapters/openai_compatible.rb +58 -51
- data/lib/squishling/adapters/openai_options.rb +45 -3
- data/lib/squishling/adapters/openai_transport.rb +123 -0
- data/lib/squishling/adapters/ruby_llm.rb +51 -15
- data/lib/squishling/class_methods.rb +2 -2
- data/lib/squishling/configuration.rb +2 -1
- data/lib/squishling/errors.rb +7 -5
- data/lib/squishling/invoker.rb +24 -6
- data/lib/squishling/observer.rb +2 -2
- data/lib/squishling/params.rb +2 -2
- data/lib/squishling/result.rb +13 -0
- data/lib/squishling/router.rb +3 -2
- data/lib/squishling/run/reports.rb +63 -0
- data/lib/squishling/run.rb +5 -22
- data/lib/squishling/schema.rb +44 -4
- data/lib/squishling/secret.rb +45 -0
- data/lib/squishling/squawk.rb +7 -4
- data/lib/squishling/testing/state.rb +2 -1
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling/wrapper.rb +19 -1
- data/lib/squishling.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14b40817f3f49682f1ed311aa5c05007dfc7f55957c9cf89683fd26fea962aa7
|
|
4
|
+
data.tar.gz: 32b93a239eaa8f5994675a24fca6598e5e0a9edeb8d77efa97ebcf9783db6aa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 184955d83ece437fbf86d7f93c2103a1c61302d8df9b70f7430b49b2896c7aab42e04d1f7503203979ff1087ed8e05b67baeb767c9283c43f4bafa82c720abd8
|
|
7
|
+
data.tar.gz: 351cdbe7b06b587e2c236ff75012fae0bf2fa526613a2d79e6b6c37aac643547bc22cfc74f73cb2e965dc31dd5f0234a7addf034fce8b1de5ea8dd74dab529d3
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,80 @@ 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
|
+
|
|
10
84
|
## [0.5.0] - 2026-10-28
|
|
11
85
|
|
|
12
86
|
### Added
|
data/README.md
CHANGED
|
@@ -151,7 +151,7 @@ A squishling class needs a **purpose** (the system prompt) and an **output schem
|
|
|
151
151
|
validated on both paths). A squished call goes to the LLM when:
|
|
152
152
|
|
|
153
153
|
- its `squish_when` predicate is truthy for these inputs,
|
|
154
|
-
- 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
|
|
155
155
|
- the Ruby implementation calls `squish!`.
|
|
156
156
|
|
|
157
157
|
Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM output.
|
|
@@ -210,8 +210,8 @@ sees it right where the value goes, and the purpose stays short and about the go
|
|
|
210
210
|
- **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
|
|
211
211
|
what to return when every attempt fails.
|
|
212
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)
|
|
214
|
-
error messages and logs otherwise.
|
|
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.
|
|
215
215
|
- **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
|
|
216
216
|
pass to `squish!`, and the source you choose to append are sent to the provider. The one addition: when
|
|
217
217
|
[escalation](docs/configuration.md#models-and-escalation) moves to the next step, that step also sees the previous
|
data/docs/adapters.md
CHANGED
|
@@ -37,6 +37,19 @@ Everything works as it did before adapters: configure provider API keys with `Ru
|
|
|
37
37
|
use `provider:` for models missing from RubyLLM's registry. See [Configuration](configuration.md). Transport-level
|
|
38
38
|
retries are RubyLLM's (`RubyLLM.config.max_retries`, `request_timeout`).
|
|
39
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
|
+
|
|
40
53
|
This is the only built-in adapter that supports `type: :judgment` judges ([System One decision models](harnesses.md#the-judge)).
|
|
41
54
|
|
|
42
55
|
## OpenAI-compatible endpoints
|
|
@@ -56,28 +69,52 @@ end
|
|
|
56
69
|
| Option | Default | Description |
|
|
57
70
|
|---|---|---|
|
|
58
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. |
|
|
59
|
-
| `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,
|
|
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. |
|
|
60
73
|
| `headers` | `{}` | Extra request headers, e.g. `{ "OpenAI-Organization" => "org-…" }` or a gateway's own. |
|
|
61
74
|
| `default_model` | `nil` | The model for steps that don't name one. Without it, a step with no model raises `ConfigurationError`. |
|
|
62
|
-
| `
|
|
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. |
|
|
63
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. |
|
|
64
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. |
|
|
65
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. |
|
|
66
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
|
+
|
|
67
92
|
**How params map.** `temperature` → `temperature`; `max_output_tokens` → `max_tokens_param`; `thinking: { effort: }` →
|
|
68
93
|
`reasoning_effort` (`true`/`false` leave the model's default, and a `budget:` or `display:` raises
|
|
69
94
|
`ConfigurationError`, since Chat Completions has no field for them). Any other param (`top_p`, `seed`, …) goes into
|
|
70
95
|
the request body as given. Squishling's own keys (`model`, `messages`, `response_format`, `n`, `stream`) always win.
|
|
71
96
|
`provider:` has no meaning here and is ignored.
|
|
72
97
|
|
|
73
|
-
**Failures.**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
81
118
|
|
|
82
119
|
## Writing your own
|
|
83
120
|
|
|
@@ -105,12 +142,21 @@ class AcmeAdapter < Squishling::Adapter::Base
|
|
|
105
142
|
)
|
|
106
143
|
rescue Acme::AuthError
|
|
107
144
|
raise configuration_error(request, "Acme rejected the credentials")
|
|
108
|
-
rescue Acme::
|
|
109
|
-
#
|
|
110
|
-
raise
|
|
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}")
|
|
111
148
|
rescue Acme::Error => e
|
|
112
149
|
raise llm_error(request, "Acme failed (#{e.class})")
|
|
113
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
|
|
114
160
|
end
|
|
115
161
|
|
|
116
162
|
Squishling.configure { |config| config.adapter = AcmeAdapter.new }
|
|
@@ -124,7 +170,7 @@ Squishling.register_adapter(:acme, AcmeAdapter)
|
|
|
124
170
|
|
|
125
171
|
| Field | |
|
|
126
172
|
|---|---|
|
|
127
|
-
| `label` | `"Class#method"`, to prefix your error messages (the `configuration_error` / `llm_error` helpers do; `rejected_request_error(request, via:, body:, http_status:)` builds the error for a rejected request, from a JSON String or String-keyed Hash body). |
|
|
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). |
|
|
128
174
|
| `model`, `provider` | The escalation step's, or `nil` when it names none (use your own default). What `provider:` means is up to you. |
|
|
129
175
|
| `instructions` | The system prompt. |
|
|
130
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. |
|
|
@@ -146,14 +192,36 @@ answer](failures.md#a-cutoff-or-a-wrong-answer).
|
|
|
146
192
|
|
|
147
193
|
### The error contract
|
|
148
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
|
+
|
|
149
200
|
| Raise | When | What Squishling does |
|
|
150
201
|
|---|---|---|
|
|
151
|
-
| `Squishling::ConfigurationError` | Bad credentials,
|
|
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. |
|
|
152
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`). |
|
|
153
205
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
157
225
|
`Adapter::Response` and Squishling raises `ConfigurationError`.
|
|
158
226
|
|
|
159
227
|
### Optional hooks
|
data/docs/configuration.md
CHANGED
|
@@ -33,8 +33,8 @@ end
|
|
|
33
33
|
| `default_provider` | `nil` | The provider for `default_model`/`default_escalation` steps that don't name one. What it means depends on the adapter: RubyLLM needs it only for models missing from its registry (see below); the OpenAI-compatible adapter ignores it. |
|
|
34
34
|
| `default_params` | `{}` | Generation params for every call (temperature, reasoning effort, top_p, …), overridable per class, per method, and per escalation step. See [Generation params](#generation-params). |
|
|
35
35
|
| `default_max_total_output_tokens`, `default_max_total_input_tokens` | `nil` (no limit) | The most output (or input) tokens one call may use across all its requests; once reached, the call raises `BudgetExhaustedError` instead of making another attempt. Overridable per class (`squishling max_total_output_tokens: ...`), per method, and per `squish!` call. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
|
|
36
|
-
| `squawk` | `nil` | A callable run after every LLM attempt (see `squawk_on`) with the raw output, for sending it to an error tracker or tracing tool. Also settable per class and per method. See [Observing every attempt](failures.md#observing-every-attempt-squawk). |
|
|
37
|
-
| `squawk_on` | `:all` | Which attempts reach the `squawk` hook: `:all`, `:rejections` (errors only), `:retries` (errors plus any accepted attempt after the first), `:final` (the last attempt the escalation allows), or a list of them (any one holding runs the hook). Also settable per class and per method. |
|
|
36
|
+
| `squawk` | `nil` | A callable run after every LLM attempt (see `squawk_on`), and once more when a token budget or a disagreement stops the call, with the raw output, for sending it to an error tracker or tracing tool. Also settable per class and per method. See [Observing every attempt](failures.md#observing-every-attempt-squawk). |
|
|
37
|
+
| `squawk_on` | `:all` | Which attempts reach the `squawk` hook: `:all`, `:rejections` (errors only), `:retries` (errors plus any accepted attempt after the first), `:final` (the last attempt the escalation allows), or a list of them (any one holding runs the hook). A call [stopped](failures.md#a-call-that-is-stopped) by a token budget or a disagreement is always reported. Also settable per class and per method. |
|
|
38
38
|
| `observer` | `nil` | An around-style hook for every provider request: `:coolhand`, any object responding to `around(call)`, or `false` for none. `nil` uses the [Coolhand observer](coolhand.md) when the `coolhand` gem is configured. See [Observers](observers.md). |
|
|
39
39
|
| `default_harness` | `nil` (`:escalation`) | How every squishling class that doesn't declare its own uses its escalation: `:escalation`, `:squishsum`, `:judged_squishsum`, a [registered custom harness](harnesses.md#custom-harnesses), or a Hash with `type:` and options. See [Harnesses](harnesses.md). |
|
|
40
40
|
| `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings on each escalation and when a fallback is used. Never includes the raw model response; see [what ends up in errors and logs](failures.md#what-ends-up-in-errors-and-logs). |
|
|
@@ -242,7 +242,7 @@ end
|
|
|
242
242
|
|
|
243
243
|
- Keys that Squishling or the adapter control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
|
|
244
244
|
`response_format`, `text`, `output_config`, `tools`, `tool_choice`, `functions`, `function_call`, `schema`, and the camelCase and plural
|
|
245
|
-
spellings other providers use, such as `systemInstruction`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
|
|
245
|
+
spellings other providers use, such as `systemInstruction`, `cachedContent`/`cached_content`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
|
|
246
246
|
raise `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
|
|
247
247
|
- **Nested containers** such as Gemini's `generationConfig` (and `generation_config` for Gemini Interactions) and
|
|
248
248
|
Mistral Conversations' `completion_args` accept ordinary settings, but reject the keys inside them that carry the
|
data/docs/failures.md
CHANGED
|
@@ -8,9 +8,9 @@ A failed attempt moves on to the next one; once the last fails, the error is rai
|
|
|
8
8
|
|
|
9
9
|
| Failure | What Squishling does |
|
|
10
10
|
|---|---|
|
|
11
|
-
| Rate limit, 5xx, overload, timeout, connection error | The [adapter](adapters.md) retries at the HTTP level first (RubyLLM: `RubyLLM.config.max_retries`, default 3; OpenAI-compatible: `max_retries:`, default 2). If it still fails, moves to the next attempt in a fresh chat; on the last attempt, raises `Squishling::LLMError` with
|
|
12
|
-
| Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
|
|
13
|
-
| Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `Squishling::RequestRejectedError` (a `ConfigurationError`) naming the error class, the HTTP status, and the provider's error status/code when its body carries one (`INVALID_ARGUMENT`, `invalid_request_error`). They are also on `error.provider_status` and `error.provider_code` (nil when absent); only plain tokens are kept, never the provider's message. The message points at a param only when the provider named it, and otherwise says the params, output schema, or input may be invalid. The provider's own message is on `cause`
|
|
11
|
+
| Rate limit, 5xx, overload, timeout, connection error | The [adapter](adapters.md) retries at the HTTP level first (RubyLLM: `RubyLLM.config.max_retries`, default 3; OpenAI-compatible: `max_retries:`, default 2). If it still fails, moves to the next attempt in a fresh chat; on the last attempt, raises `Squishling::LLMError` with a `cause` when the adapter has one: the RubyLLM adapter always does, a [sanitized stand-in](#the-cause-of-a-provider-failure); the OpenAI-compatible adapter only for transport errors (the error itself, which carries no response body), not for HTTP statuses. |
|
|
12
|
+
| Bad API key, billing or quota problem (402), unknown model or endpoint (404), missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
|
|
13
|
+
| Provider rejects the request (400 Bad Request, or another 4xx such as 409 or 422), e.g. an unsupported `temperature` or a schema it won't accept | Raises `Squishling::RequestRejectedError` (a `ConfigurationError`) naming the error class, the HTTP status, and the provider's error status/code when its body carries one (`INVALID_ARGUMENT`, `invalid_request_error`). They are also on `error.provider_status` and `error.provider_code` (nil when absent); only plain tokens are kept, never the provider's message. The message points at a param only when the provider named it, and otherwise says the params, output schema, or input may be invalid. The provider's own message is not on `cause` either unless you opt in (see [the cause of a provider failure](#the-cause-of-a-provider-failure)). It is never retried, so if you know a given code means "this input was bad", rescue it and decide for yourself. A 400 that reports an overlong input is the exception: the adapter raises an `LLMError` for it (the built-in adapters recognize the structured `context_length_exceeded` code; RubyLLM's own `ContextLengthExceededError` is honored too, and the OpenAI-compatible adapter also recognizes the usual wording of servers that send none), since a bigger model may take it. Which of the two a failure is, is the adapter's call (see [the error contract](adapters.md#the-error-contract)); a wording it doesn't recognize stays a rejected request. Never escalated, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
|
|
14
14
|
| Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Moves to the next attempt, then raises `Squishling::InvalidOutputError`. When the provider reported a max-tokens cutoff, the error says so and [`truncated?`](#a-cutoff-or-a-wrong-answer) is true. |
|
|
15
15
|
| Malformed or truncated JSON | Moves to the next attempt, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
|
|
16
16
|
| JSON that doesn't match the schema (wrong types, missing or extra keys, `null` in a non-`optional` field, a broken [conditional rule](schemas.md#contracts-beyond-the-schema), root not an object) | Moves to the next attempt with the validation errors, then raises `InvalidOutputError` |
|
|
@@ -65,9 +65,10 @@ Model output can echo sensitive input, so the raw response is kept in one place:
|
|
|
65
65
|
the message, the logger, and the next attempt's prompt.
|
|
66
66
|
- A fixed message when a response contains a value JSON can't represent (`1e400` parses to `Infinity`, or a string
|
|
67
67
|
with invalid UTF-8).
|
|
68
|
-
- For a failed provider request (`LLMError`, and the `ConfigurationError` for a
|
|
69
|
-
key), only the error class and HTTP status, plus a
|
|
70
|
-
key,
|
|
68
|
+
- For a failed provider request (`LLMError`, and the `ConfigurationError` for a rejected request, bad credentials, or a forbidden
|
|
69
|
+
key), only the error class and HTTP status, plus a rejection's provider status and code when they are plain tokens. The provider's own message can echo request content or part of an API
|
|
70
|
+
key, and error trackers report the whole `cause` chain, so it is not on `cause` either unless you
|
|
71
|
+
[opt in](#the-cause-of-a-provider-failure).
|
|
71
72
|
- At most the first 20 of these errors, plus a count of the rest, so a very large malformed response can't flood
|
|
72
73
|
the retry message or the log.
|
|
73
74
|
- For a [`DisagreementError`](harnesses.md#when-the-harness-fails), only what Squishling wrote: a `:judgment` judge's
|
|
@@ -99,6 +100,26 @@ The same applies to anything you log yourself, such as `error.message` in a [fal
|
|
|
99
100
|
only where you are willing to store model output. To send the output of every attempt somewhere on purpose, use
|
|
100
101
|
[`squawk`](#observing-every-attempt-squawk).
|
|
101
102
|
|
|
103
|
+
### The cause of a provider failure
|
|
104
|
+
|
|
105
|
+
A provider's own exception (RubyLLM's, say) carries the provider's response body in its message and `response`, and the
|
|
106
|
+
body can echo your request. Error trackers such as Sentry report the whole `cause` chain, so an `LLMError`,
|
|
107
|
+
`ConfigurationError`, or `RequestRejectedError` raised for a failed request does not carry that exception as its
|
|
108
|
+
`cause`. The [RubyLLM adapter](adapters.md#ruby_llm) puts a `Squishling::Adapter::ProviderFailure` there instead: its
|
|
109
|
+
message is the original's class and HTTP status (`RubyLLM::RateLimitError, HTTP 429`), and it has the original's
|
|
110
|
+
backtrace, but none of its message, response, or other state. Transport errors that carry no body (the OpenAI-compatible
|
|
111
|
+
adapter's timeouts and connection failures) are passed through as they are.
|
|
112
|
+
|
|
113
|
+
To get the original back for debugging, accept that it reaches your tracker and set `keep_cause: true`:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Squishling.configure do |config|
|
|
117
|
+
config.adapter = { type: :ruby_llm, keep_cause: true }
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Then `error.cause` is the `RubyLLM::Error` itself, with its message and `response`.
|
|
122
|
+
|
|
102
123
|
## Capping a call's cost (token budgets)
|
|
103
124
|
|
|
104
125
|
`attempts:` limits retries per step, but a call that fails every attempt still pays for all of them, and with
|
|
@@ -143,8 +164,8 @@ Things to know:
|
|
|
143
164
|
## Observing every attempt (`squawk`)
|
|
144
165
|
|
|
145
166
|
`squawk` is an opt-in hook for sending what the model returned to an error tracker or tracing tool. It runs after
|
|
146
|
-
every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, below), and
|
|
147
|
-
set it:
|
|
167
|
+
every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, below), and once more when a token
|
|
168
|
+
budget or a disagreement stops the call. It does nothing unless you set it:
|
|
148
169
|
|
|
149
170
|
```ruby
|
|
150
171
|
Squishling.configure do |config|
|
|
@@ -157,8 +178,8 @@ end
|
|
|
157
178
|
| Keyword | Value |
|
|
158
179
|
|---|---|
|
|
159
180
|
| `output` | The raw response: a Hash or String, or `nil` when the provider call itself failed |
|
|
160
|
-
| `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt |
|
|
161
|
-
| `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length; `attempt` jumps past the attempts a repeated reply skips; under a harness, the sample's or judge's own attempts), `final` (the last attempt, or a rejection after which none follows), `repeated` (`true` when the attempt returned the same reply as the one before it, in the same chat, which skips the step's remaining attempts), `model`, `provider`, `params`, `input` (the JSON sent: arguments and named context only), `usage` (token counts, when the adapter reports them), `finish_reason` (why the provider stopped, e.g. `:stop` or `:max_tokens`; `nil` when unknown or the call failed), `role` (`nil` for an escalation attempt, `:sample`, or `:judge`), `sample` (`"a"`, `"b"`, ... for a sample's attempt, else `nil`) |
|
|
181
|
+
| `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt, or the `BudgetExhaustedError` or `DisagreementError` that stopped the call |
|
|
182
|
+
| `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length, or for a [stopped call](#a-call-that-is-stopped) the attempts made; `attempt` jumps past the attempts a repeated reply skips; under a harness, the sample's or judge's own attempts), `final` (the last attempt, or a rejection after which none follows), `repeated` (`true` when the attempt returned the same reply as the one before it, in the same chat, which skips the step's remaining attempts), `model`, `provider`, `params`, `input` (the JSON sent: arguments and named context only), `usage` (token counts, when the adapter reports them), `finish_reason` (why the provider stopped, e.g. `:stop` or `:max_tokens`; `nil` when unknown or the call failed), `role` (`nil` for an escalation attempt, `:sample`, or `:judge`), `sample` (`"a"`, `"b"`, ... for a sample's attempt, else `nil`), `stopped` (`nil` for an attempt; `:budget` or `:disagreement` for the [stopped call's report](#a-call-that-is-stopped)) |
|
|
162
183
|
|
|
163
184
|
- Set it globally with `config.squawk`, per class with `squishling squawk: ...`, or per method with
|
|
164
185
|
`squish :triage, squawk: ...`. The method's hook wins over the class's, which wins over the configured one;
|
|
@@ -191,6 +212,28 @@ end
|
|
|
191
212
|
- It isn't called for a `ConfigurationError`, for deterministic and fallback returns, or for an attempt where your own
|
|
192
213
|
`squish_validate` raises (that exception propagates first).
|
|
193
214
|
|
|
215
|
+
### A call that is stopped
|
|
216
|
+
|
|
217
|
+
A call that a [token budget](#capping-a-calls-cost-token-budgets) or a
|
|
218
|
+
[disagreement](harnesses.md#when-the-harness-fails) stops has no attempt of its own to report: the attempt before a
|
|
219
|
+
budget stop was reported as not final, and a disagreement's samples were both accepted. So the call gets one more
|
|
220
|
+
report, whatever `squawk_on:` is set to:
|
|
221
|
+
|
|
222
|
+
- `error` is the `BudgetExhaustedError` or `DisagreementError`, `output` is `nil` (nothing was sent), `metadata[:final]`
|
|
223
|
+
is `true`, and `metadata[:stopped]` is `:budget` or `:disagreement` (it is `nil` for every attempt's report).
|
|
224
|
+
- `attempt` and `attempts` are `error.attempts`, or `0` when that is `nil` (a disagreement, or a budget that ran out
|
|
225
|
+
before a sample or judge made its first attempt); a stop before a retry counts the attempts already made. `model` is
|
|
226
|
+
the last of `error.models`, and `provider`, `params`, `usage`, `role`, and `sample` are `nil`.
|
|
227
|
+
- It is sent once, after the attempts that led to it and before `squish_fallback` runs, for the built-in harnesses and
|
|
228
|
+
for a [custom one](harnesses.md#reporting-to-squawk) that raises either error. A call to another squished method
|
|
229
|
+
from your own code (a `squish_validate`, say) reports its own stop, and the outer call does not report that error
|
|
230
|
+
again. The attempts before it keep `final: false`, so `squawk_on: :final` sees a budget or disagreement ending only
|
|
231
|
+
through this report.
|
|
232
|
+
- Like any attempt's report, it runs your hook inline: an exception the hook raises propagates in place of the
|
|
233
|
+
`BudgetExhaustedError` or `DisagreementError` (which stays on `cause`), so `squish_fallback` does not run.
|
|
234
|
+
- `squawk: false`, or no hook, sends nothing. Ending on the last invalid attempt is not a stop: that attempt already
|
|
235
|
+
carried `final: true`.
|
|
236
|
+
|
|
194
237
|
## Output checks (`squish_validate`)
|
|
195
238
|
|
|
196
239
|
The schema covers shape and types. For rules it can't express, such as cross-field arithmetic or a lookup against
|
|
@@ -224,11 +267,11 @@ end
|
|
|
224
267
|
| Error | Raised when |
|
|
225
268
|
|---|---|
|
|
226
269
|
| `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
|
|
227
|
-
| `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400, as `RequestRejectedError`, with `provider_status` and `provider_code`) |
|
|
270
|
+
| `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (a 400 or other rejected 4xx, as `RequestRejectedError`, with `provider_status` and `provider_code`) |
|
|
228
271
|
| `Squishling::InvalidOutputError` | LLM output still invalid (schema or `squish_validate`) after every attempt in the escalation, or a deterministic/fallback return that doesn't match the schema |
|
|
229
272
|
| `Squishling::DisagreementError` | A subclass of `InvalidOutputError`: a [squishsum harness](harnesses.md#when-the-harness-fails)'s samples disagreed and no judge accepted either. `candidates`, `verdict`, and `reason` describe what happened |
|
|
230
273
|
| `Squishling::BudgetExhaustedError` | A subclass of `InvalidOutputError`: the call used up its [token budget](#capping-a-calls-cost-token-budgets) before an attempt was accepted. `limit`, `max`, and `used` describe the budget |
|
|
231
|
-
| `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after the adapter's own retries, including context-length errors (`cause` holds the
|
|
274
|
+
| `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after the adapter's own retries, including context-length errors (`cause` holds a [sanitized stand-in](#the-cause-of-a-provider-failure), when the adapter has one) |
|
|
232
275
|
|
|
233
276
|
Errors raised by your own Ruby code are not wrapped.
|
|
234
277
|
|
data/docs/harnesses.md
CHANGED
|
@@ -91,6 +91,13 @@ A judged harness with neither raises `ConfigurationError` before any sample is r
|
|
|
91
91
|
step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
|
|
92
92
|
(a chat judge's params, though, do merge over the method's).
|
|
93
93
|
|
|
94
|
+
**What the judge is sent.** The judge's provider receives the operation's purpose, the call's full input (the same
|
|
95
|
+
method arguments and named context the samples saw), and **both candidate outputs**, whatever they hold. None of it is
|
|
96
|
+
capped or filtered, and there is no opt-out short of not using a judged harness. When the judge is a different
|
|
97
|
+
provider than the sampled model (a `judge:` step with its own `provider:`, or an escalation's next step on another
|
|
98
|
+
provider), the input and both outputs go to that provider as well, so only name a judge you would trust with the
|
|
99
|
+
input itself.
|
|
100
|
+
|
|
94
101
|
### Chat judges
|
|
95
102
|
|
|
96
103
|
By default the judge is a chat model, asked for a strict `{ "verdict": "a" | "b" | "neither", "reason": "..." }`.
|
|
@@ -299,7 +306,10 @@ results, the same type the deterministic path returns.
|
|
|
299
306
|
| `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
|
|
300
307
|
|
|
301
308
|
`call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
|
|
302
|
-
result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
|
|
309
|
+
result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation. The check is
|
|
310
|
+
by object identity, which suits the typed results of an object schema. When the schema isn't an object, a result is a
|
|
311
|
+
bare JSON value, and an immediate such as `1` or `true` counts as produced whatever its source, so there the check can't
|
|
312
|
+
catch a hand-built value.
|
|
303
313
|
|
|
304
314
|
### When a custom harness fails
|
|
305
315
|
|
|
@@ -328,6 +338,10 @@ and `attempts` count within that sample, judge, or escalation. `metadata[:repeat
|
|
|
328
338
|
sent back the reply it was just rejected for; the primitive then skips the rest of that step's attempts (the next
|
|
329
339
|
step's `attempt` jumps past them, and a sample ends with its `InvalidOutputError`).
|
|
330
340
|
|
|
341
|
+
A call that ends on the token budget or on a `DisagreementError` (the built-in squishsum harnesses', or one your
|
|
342
|
+
`call` raises) is reported once more, as [a stopped call](failures.md#a-call-that-is-stopped), with `metadata[:stopped]`
|
|
343
|
+
set and no output. Squishling does it around your `call`, so a custom harness needs no code for it.
|
|
344
|
+
|
|
331
345
|
### Threads
|
|
332
346
|
|
|
333
347
|
Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
|
data/docs/measuring-tokens.md
CHANGED
|
@@ -72,6 +72,10 @@ Squishling.configure do |config|
|
|
|
72
72
|
end
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
A call that stops on its [token budget](failures.md#capping-a-calls-cost-token-budgets) or a disagreement is also
|
|
76
|
+
reported once, with `metadata[:stopped]` set (`:budget` or `:disagreement`) and no `usage`, so the hook above logs
|
|
77
|
+
it with empty counts; check `metadata[:stopped]` to count it apart from an attempt.
|
|
78
|
+
|
|
75
79
|
Each line carries the `"Class#method"` that made the call, so grouping by label gives you tokens per squishling. A call
|
|
76
80
|
that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
|
|
77
81
|
been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
|
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
|
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
|
|
@@ -84,6 +89,14 @@ fields; rename them (`class` to `ticket_class`). The reserved names are `class`,
|
|
|
84
89
|
`deconstruct_keys`, and a few Ruby and Squishling internals (`initialize*`, `__send__`, `squishling_squished`).
|
|
85
90
|
Other names, such as `method`, `format`, or `type`, work as usual. This applies to nested objects and array items too.
|
|
86
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
|
+
|
|
87
100
|
On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
|
|
88
101
|
against the schema, and so is anything else you return (`nil`, a string, another schema's result), which raises
|
|
89
102
|
`Squishling::InvalidOutputError` unless it matches.
|
data/docs/testing.md
CHANGED
|
@@ -18,7 +18,8 @@ Both give your tests the helpers below and put back, after each test, whatever t
|
|
|
18
18
|
still hold what the helpers set), so the rest of your `Squishling.configure` survives.
|
|
19
19
|
|
|
20
20
|
The helpers change global state (`Squishling.config` and the stubbed classes), so tests that use them must not run
|
|
21
|
-
in parallel threads.
|
|
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.
|
|
22
23
|
|
|
23
24
|
## Scripting the model
|
|
24
25
|
|
|
@@ -132,8 +133,8 @@ stub_squishling_error(PatternGenerator, :llm_error) # also :configurat
|
|
|
132
133
|
Attributes that don't satisfy the method's `output_schema` raise `Squishling::Testing::SetupError` when you stub, so a
|
|
133
134
|
stand-in can't drift from the real result type. Pass `method: :triage` for another squished method. The stubs are
|
|
134
135
|
removed after each test. A stub applies to that class only, not to its subclasses, which keep their own behavior. The
|
|
135
|
-
hook that makes a stub possible stays on the class for the life of the process,
|
|
136
|
-
one per test.
|
|
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.
|
|
137
138
|
|
|
138
139
|
## Running parts of a method without a model
|
|
139
140
|
|
|
@@ -171,6 +172,9 @@ capture.calls.map { |call| call.metadata[:attempt] } # output:, error:, and m
|
|
|
171
172
|
`with_squawk` swaps `config.squawk` for the block and puts the previous hook back, so an app-wide hook that enqueues
|
|
172
173
|
a job isn't run in tests. A `squawk:` set on a class or method still takes precedence over the configured one.
|
|
173
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
|
+
|
|
174
178
|
An observer you configured *before* `fake_squishling` keeps working while the fake is installed (it is chained behind
|
|
175
179
|
the fake's own). Configure it first: one assigned afterwards replaces the fake's, and then sample and judge scripts
|
|
176
180
|
can't tell which request is which.
|