squishling 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +140 -0
- data/README.md +118 -31
- data/docs/configuration.md +75 -21
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +73 -9
- data/docs/harnesses.md +326 -0
- data/docs/measuring-tokens.md +140 -0
- data/docs/naming.md +43 -0
- data/docs/observers.md +80 -0
- data/docs/routing.md +36 -27
- data/docs/schemas.md +24 -5
- data/lib/squishling/appendices.rb +3 -3
- data/lib/squishling/class_methods.rb +60 -29
- data/lib/squishling/collisions.rb +54 -0
- data/lib/squishling/configuration.rb +32 -0
- data/lib/squishling/definition.rb +56 -27
- data/lib/squishling/errors.rb +22 -1
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +93 -0
- data/lib/squishling/harness/registry.rb +69 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +79 -0
- data/lib/squishling/invoker.rb +26 -188
- data/lib/squishling/judge.rb +186 -0
- data/lib/squishling/llm_client.rb +180 -0
- data/lib/squishling/model_path.rb +82 -25
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +109 -0
- data/lib/squishling/params.rb +47 -6
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/router.rb +8 -2
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/source.rb +5 -5
- data/lib/squishling/squawk.rb +52 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +56 -7
- metadata +25 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fb55da97a0a032802afcd90b8e56d497a42194206ad90f07d71bc46c70a9e49
|
|
4
|
+
data.tar.gz: 79430c869cda0726357c42c1ad1fdc7cbf9108a00ebcd56814df4e45acf99db6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95f83f1f99d86b5b2fddcc0d5e7d845aff73dbb7cc85620eafb93f999edd69181e8a72c726409cdb6ec11fa1286264947c48ac5f3467c1fb368755ef8ad95b62
|
|
7
|
+
data.tar.gz: 4ad6fbec6185bb9e4369c972b9f561bce840db75a96ba02c619fe82d1726e15230330b8f457c740017b08e412b3da2c364fd40f657db3128b28cab9c01ba993b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Custom harnesses: `Squishling.register_harness` (with `Squishling.harnesses` and `reset_harnesses!`) and
|
|
15
|
+
`Squishling::Harness::Base` let you write your own harness and use it anywhere a built-in works (`harness:`,
|
|
16
|
+
`squish!`, `default_harness`). A harness gets a `Squishling::Run` with `escalate`, `samples`, `judge`, `agree?`,
|
|
17
|
+
`stage`, and `log`, so it keeps model, provider, and params resolution, `squish_validate`, concurrent samples, and the
|
|
18
|
+
normal failure handling; it can only return typed results its `Run` produced, and its options are validated when it
|
|
19
|
+
is declared. The built-in `:escalation`, `:squishsum`, and `:judged_squishsum` now run on the same interface.
|
|
20
|
+
`squawk` metadata gains `role:` and `sample:`. See [Custom harnesses](docs/harnesses.md#custom-harnesses). (#31)
|
|
21
|
+
- Round-robin models: escalation steps that share a `priority:` form a tier. Each call rotates which model in the tier
|
|
22
|
+
goes first (counted per class and method, shared across threads), and the tier's other models are tried before the
|
|
23
|
+
next priority. `rotate: false` on a step pins its tier to the order written. (#38)
|
|
24
|
+
- `config.observer`: an around-style hook (`around(call) { ... }`) wrapped around every provider request, on the thread
|
|
25
|
+
that makes it, so a `:squishsum` sample's observer runs on its worker thread. It is told the request's `Observer::Call`
|
|
26
|
+
and gets back only an `Observer::Usage` (token counts and model), never the response. An observer that yields twice
|
|
27
|
+
or never raises `ConfigurationError`, and one that rescues the request's error has it raised again. See
|
|
28
|
+
[Observers](docs/observers.md). (#25, #40)
|
|
29
|
+
- A bundled Coolhand observer (`observer = :coolhand`) that declares each call's workload (`Class#method`) and template
|
|
30
|
+
(`squish:L<n>`, `sample:a`, or `judge`) through `Coolhand.with_context`. The workload's description is
|
|
31
|
+
`Class#method`, or `Class#method (rescue)` when a `squish!` call handed the call to the LLM (`Observer::Call#handed_off`),
|
|
32
|
+
never the purpose text. It needs coolhand 0.9.0 or newer. See
|
|
33
|
+
[Coolhand Labs](docs/coolhand.md). (#40)
|
|
34
|
+
- A docs page and landing-page section on the architecture, an explainer site in `site/` deployed to GitHub Pages, and
|
|
35
|
+
per-field `description:` documented as the best practice over `purpose` guidance. (#30, #33, #34, #36)
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Behavior change:** when the `coolhand` gem is loaded and configured (an API key, not disabled), Squishling now
|
|
40
|
+
declares each request's workload and template to it automatically. Nothing changes for apps without Coolhand. To
|
|
41
|
+
opt out, set `config.observer = false`.
|
|
42
|
+
- **Behavior change:** two escalation steps may now share a `priority:` (they used to raise `duplicate order:`), and
|
|
43
|
+
they rotate per call as described above. If you relied on a fixed order between tied steps, give them distinct
|
|
44
|
+
priorities or set `rotate: false`.
|
|
45
|
+
- `order:` on an escalation step is deprecated in favor of `priority:`. It still works, with a deprecation warning, and
|
|
46
|
+
giving both on one step raises `ConfigurationError`. Judge and harness steps take no `priority:` or `rotate:`. (#38)
|
|
47
|
+
- **Behavior change:** `LLMError` and the `ConfigurationError` for a rejected request (400), bad credentials, or a
|
|
48
|
+
forbidden key no longer repeat the provider's own message, which can echo request content or part of an API key into
|
|
49
|
+
logs and error trackers. They carry the error class and HTTP status (`RubyLLM::RateLimitError, HTTP 429`); the
|
|
50
|
+
provider's message is still on `cause`. If you matched on provider text in `e.message`, read `e.cause.message`
|
|
51
|
+
instead.
|
|
52
|
+
- `docs/measuring-tokens.md` now points to observers for bringing your own metrics instead of walking through a
|
|
53
|
+
hand-rolled one. (#40)
|
|
54
|
+
|
|
55
|
+
### Removed
|
|
56
|
+
|
|
57
|
+
- **Breaking change:** the `:ensemble` and `:judged_ensemble` harnesses added in 0.3.0 are gone. `harness: :ensemble`
|
|
58
|
+
or `:judged_ensemble` (and `default_harness`) now raise `ConfigurationError` (unknown harness). Use `:squishsum` or
|
|
59
|
+
`:judged_squishsum`, which sample the first escalation step twice with the judge defaulting to the next step, or
|
|
60
|
+
write a custom harness. (#22, #24)
|
|
61
|
+
|
|
62
|
+
### Internal
|
|
63
|
+
|
|
64
|
+
- CI gains an exhale duplication and complexity gate (`bin/exhale`), and dependabot bumps `rubocop` (1.92), `ruby/setup-ruby`,
|
|
65
|
+
and `actions/upload-artifact`. These are development-only changes. (#27, #28, #29, #41)
|
|
66
|
+
|
|
67
|
+
## [0.3.0] - 2026-10-10
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- Harnesses: a `harness:` option (on `squishling`, `squish`, `squish!`, and `default_harness` in
|
|
72
|
+
`Squishling.configure`) chooses how a call uses its escalation. `:escalation` is the existing behavior and stays
|
|
73
|
+
the default. `:squishsum` asks the first escalation step twice, concurrently, and accepts the result only when the
|
|
74
|
+
two samples agree (exact match, or a custom `compare:`). `:judged_squishsum` sends a disagreement to a judge: the
|
|
75
|
+
next escalation step or a dedicated `judge:` step, either a chat model or a Jev-style decision model through
|
|
76
|
+
`RubyLLM.judge`, with a default prompt you can override through `judge_instructions:`. Samples that still disagree
|
|
77
|
+
raise the new `Squishling::DisagreementError < InvalidOutputError`, which carries both typed results, and
|
|
78
|
+
`squish_fallback` remains the only fallback. See [Harnesses](docs/harnesses.md). (#16)
|
|
79
|
+
- `:ensemble` and `:judged_ensemble` harnesses: like the squishsum ones, but the two concurrent samples come from the
|
|
80
|
+
escalation's first and second steps (a cross-model check) instead of the first step twice, each retrying within its
|
|
81
|
+
own step's `attempts:`. A judged ensemble's judge defaults to the third step, or `judge:`, and
|
|
82
|
+
`DisagreementError#models` names the model behind each sample. An ensemble needs at least two distinct steps (adjacent
|
|
83
|
+
identical steps count as one step's attempts) or it raises `ConfigurationError` before any request. (#20)
|
|
84
|
+
- `squawk`, an opt-in hook for sending what the model returned to an error tracker or tracing tool. It is called after
|
|
85
|
+
every LLM attempt (every sample and judge attempt under a harness) with `output:`, `metadata:`, and `error:`, and
|
|
86
|
+
can be set on `Squishling.configure`, `squishling`, and `squish`. It does nothing by default, and exceptions it
|
|
87
|
+
raises propagate unchanged. (#15)
|
|
88
|
+
- `forward_rejected:` on escalation steps. When an attempt is rejected and the next step starts, the previous
|
|
89
|
+
output is forwarded to that step's provider by default (unchanged behavior); set `forward_rejected: false` to start
|
|
90
|
+
that step from the original input alone. (#14)
|
|
91
|
+
- `include Squishling` raises `ConfigurationError` when the class inherits a method Squishling would override (for
|
|
92
|
+
example `Sinatra::Base.call`) instead of silently shadowing it. See [Naming and collisions](docs/naming.md). (#17)
|
|
93
|
+
|
|
94
|
+
### Documentation
|
|
95
|
+
|
|
96
|
+
- A new guide, [Measuring token spend](docs/measuring-tokens.md), covers working out what each squished path costs
|
|
97
|
+
(with Coolhand Labs, the `squawk` hook, OpenTelemetry, LangSmith, or RubyLLM's instrumenter), and the README is
|
|
98
|
+
reworked around using Squishling to harden code paths as the economics justify it. (#21)
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- **Breaking:** the DSL method `instructions` is now `purpose`, and `append_instructions` is now `append_to_purpose`,
|
|
103
|
+
including the `instructions:`/`append_instructions:` keywords on `squishling`, `squish`, and `squish!`. There are no
|
|
104
|
+
aliases. Rename them in your classes: `instructions "..."` becomes `purpose "..."`, and
|
|
105
|
+
`append_instructions: [...]` becomes `append_to_purpose: [...]`. The error for a missing prompt now says
|
|
106
|
+
"has no purpose". (#17)
|
|
107
|
+
- **Breaking:** `ruby_llm` must now be `~> 2.1` (was `~> 2.0`); run `bundle update ruby_llm`. (#16)
|
|
108
|
+
- **Breaking:** every value a method with an `output_schema` returns is now validated and typed, not only Hashes. On
|
|
109
|
+
the Ruby path and in `squish_fallback`, a `nil`, String, Array, or other non-Hash return that used to pass through
|
|
110
|
+
now raises `InvalidOutputError`; return a Hash (or `result(...)`) that matches the schema. A result of the schema's
|
|
111
|
+
own class passes through, another schema's result is re-validated, and values JSON can't represent (`NaN`,
|
|
112
|
+
`Infinity`, cycles) raise `InvalidOutputError` instead of a raw JSON error. Inner calls (`super`, or the method
|
|
113
|
+
called from `squish_when` or a fallback) still only type Hashes, so overrides can reshape values. (#13)
|
|
114
|
+
- **Behavior change:** a provider now travels with the model declared at the same level on a class too. A subclass
|
|
115
|
+
that declares its own `model:` or `escalation:` no longer inherits its parent's `provider:`, which could send the
|
|
116
|
+
whole payload to the parent's provider under another provider's model name. If you relied on that, set `provider:`
|
|
117
|
+
next to the subclass's model.
|
|
118
|
+
- **Behavior change:** a `provider:` with no model beside it now raises `ConfigurationError` instead of being
|
|
119
|
+
silently ignored (the default provider was used). `squish ..., provider: ...` needs a `model:` or `escalation:` in
|
|
120
|
+
the same call, as `squish!` already did; `squishling provider: ...` needs one in the call or already declared on that
|
|
121
|
+
class. Move the `provider:` next to a model, or use `config.default_provider` with the default model.
|
|
122
|
+
- The `result` alias for `squishling_result` is no longer added when the class already has a `result` (its own or
|
|
123
|
+
inherited); use `squishling_result` there. (#17)
|
|
124
|
+
- `InvalidOutputError#message` and `config.logger` warnings no longer quote the model's response. For unparseable JSON
|
|
125
|
+
they report only the line and column; the raw output stays in `InvalidOutputError#raw`. (#15)
|
|
126
|
+
|
|
127
|
+
### Fixed
|
|
128
|
+
|
|
129
|
+
- A number a model writes outside the range JSON can represent (such as `1e400`, which parses to `Infinity`), or a
|
|
130
|
+
string with invalid UTF-8, was accepted as valid, while the same value from a Ruby method was rejected. It is now
|
|
131
|
+
invalid output on the LLM path too, and no longer raises a bare `JSON::GeneratorError` when `:squishsum` compares samples.
|
|
132
|
+
|
|
133
|
+
### Security
|
|
134
|
+
|
|
135
|
+
- Params can no longer replace the strict output format or tools through the containers that also hold ordinary
|
|
136
|
+
settings: `generationConfig`/`generation_config` (Google Gemini) and `completion_args` (Mistral Conversations) now
|
|
137
|
+
reject their format and tool keys, with symbol or string keys, and a non-Hash value for them is rejected.
|
|
138
|
+
`tool_config` is reserved at the top level. Settings such as `topK` still work. (#12)
|
|
139
|
+
- The previous model's rejected output forwarded to the next escalation step is capped at 4,000 characters
|
|
140
|
+
(`Invoker::MAX_FORWARDED_CHARS`), with a truncation marker. (#14)
|
|
141
|
+
- Model output is kept out of error messages and logs (see the `InvalidOutputError#message` change above); `squawk`
|
|
142
|
+
is the one sanctioned way for it to leave the process. (#15) A chat judge's `reason` is model-written, so
|
|
143
|
+
`DisagreementError#message` and the logs no longer include it; read it from `DisagreementError#reason`. A
|
|
144
|
+
`:judgment` judge's message still carries the choice and probability, and a choice other than `a`, `b`, or `neither`
|
|
145
|
+
is no longer repeated.
|
|
146
|
+
- Only the first 20 validation errors (plus a count of the rest) are fed back to the model, logged, and put in
|
|
147
|
+
`InvalidOutputError#message` for one invalid output, so a very large malformed response can no longer produce a
|
|
148
|
+
megabyte-sized retry message or log line.
|
|
149
|
+
|
|
10
150
|
## [0.2.0] - 2026-10-09
|
|
11
151
|
|
|
12
152
|
### Added
|
data/README.md
CHANGED
|
@@ -1,46 +1,68 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<img src="assets/squishling-logo.png" alt="Squishling" width="420">
|
|
3
|
+
</h1>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong><a href="https://coolhand-labs.github.io/squishling/">📖 Read the Squishling explainer →</a></strong>
|
|
7
|
+
</p>
|
|
2
8
|
|
|
3
9
|
[](https://github.com/Coolhand-Labs/squishling/actions/workflows/ci.yml)
|
|
10
|
+
[](https://badge.fury.io/rb/squishling)
|
|
11
|
+
|
|
12
|
+
**Don't just use AI to write code. Use AI to not need code.** Write code only where it's worth maintaining.
|
|
13
|
+
|
|
14
|
+
A squished method either runs its Ruby implementation, with the LLM as a failover, or routes some callers
|
|
15
|
+
through an LLM as a way of eliminating (or discovering) the cost of maintaining them as deterministic code.
|
|
16
|
+
Either way it returns the same strict-schema-validated, typed result, so callers can't tell the difference.
|
|
4
17
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
18
|
+
Think of code as a cache for AI judgment. Every line you write is something to maintain, so the LLM is the
|
|
19
|
+
default and code has to earn its place: you build it for the inputs that carry the volume, and the LLM keeps
|
|
20
|
+
handling the rest. New to the idea? Read
|
|
21
|
+
[Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai) or browse the
|
|
22
|
+
[talk slides](https://michael.carroll.io/talks/2026/elastic-software/).
|
|
8
23
|
|
|
9
|
-
|
|
10
|
-
|
|
24
|
+
Or try it on the [explainer site](https://coolhand-labs.github.io/squishling/):
|
|
25
|
+
|
|
26
|
+
- [Spectrum slider](https://coolhand-labs.github.io/squishling/#spectrum): drag from traditional software to fully
|
|
27
|
+
LLM and see the tradeoff at each point
|
|
28
|
+
- [Token-cost calculator](https://coolhand-labs.github.io/squishling/#number): price your tech debt in tokens
|
|
29
|
+
against code upkeep
|
|
30
|
+
- [Walkthrough](https://coolhand-labs.github.io/squishling/#squishling): describe it, tune it, pay for accuracy,
|
|
31
|
+
harden the common shapes
|
|
11
32
|
|
|
12
33
|
## Use cases
|
|
13
34
|
|
|
14
|
-
###
|
|
35
|
+
### Zero-code integrations
|
|
15
36
|
|
|
16
37
|
Accept a new data source today, before anyone writes a parser. Declare what you want back and leave the
|
|
17
38
|
method unimplemented: every call goes to the LLM, and its output is validated against your schema.
|
|
18
39
|
|
|
19
40
|
```ruby
|
|
20
|
-
class
|
|
41
|
+
class ShipmentUpdate
|
|
21
42
|
include Squishling
|
|
22
43
|
|
|
23
|
-
|
|
44
|
+
purpose "Normalize this carrier's tracking webhook into our shipment update."
|
|
24
45
|
output_schema do
|
|
25
|
-
string
|
|
26
|
-
|
|
27
|
-
string
|
|
28
|
-
string :external_id
|
|
46
|
+
string :status, enum: %w[label_created in_transit out_for_delivery delivered exception]
|
|
47
|
+
string :tracking_number
|
|
48
|
+
string :location
|
|
29
49
|
end
|
|
30
50
|
# No `def call` yet, so every webhook goes to the LLM.
|
|
31
51
|
end
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
event.squished? # => true
|
|
53
|
+
update = ShipmentUpdate.call(carrier: "acme-freight", payload: request.raw_post)
|
|
54
|
+
update.status # => "out_for_delivery"
|
|
55
|
+
update.squished? # => true
|
|
37
56
|
```
|
|
38
57
|
|
|
39
|
-
When one
|
|
40
|
-
`squish_when { |
|
|
58
|
+
When one carrier carries the volume, write `def call` for it and add
|
|
59
|
+
`squish_when { |carrier:, **| carrier != "ups" }`. UPS then runs in Ruby, everything else stays on the
|
|
41
60
|
LLM, and callers don't change. See [Hardening a path](docs/routing.md#hardening-a-path).
|
|
42
61
|
|
|
43
|
-
|
|
62
|
+
Validation guarantees the *shape* of the result, not that it's true. Where a wrong value is costly (money,
|
|
63
|
+
identity), cross-check it against another source or keep that path in Ruby.
|
|
64
|
+
|
|
65
|
+
### Rescue errors your code can't handle yet
|
|
44
66
|
|
|
45
67
|
Keep the Ruby you have for the inputs it understands, and hand the rest to the LLM instead of failing. When the
|
|
46
68
|
parser raises, `squish!` sends this call to the LLM with the error and the parser's own source as context.
|
|
@@ -49,8 +71,8 @@ parser raises, `squish!` sends this call to the LLM with the error and the parse
|
|
|
49
71
|
class InvoiceParser
|
|
50
72
|
include Squishling
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
purpose "Extract the invoice fields from the vendor's document."
|
|
75
|
+
append_to_purpose "The Ruby parser that handles well-formed invoices:", self # this class's source
|
|
54
76
|
output_schema do
|
|
55
77
|
string :invoice_number
|
|
56
78
|
number :total
|
|
@@ -60,7 +82,7 @@ class InvoiceParser
|
|
|
60
82
|
invoice = VendorFormats.fetch(vendor).parse(document)
|
|
61
83
|
result(invoice_number: invoice.number, total: invoice.total)
|
|
62
84
|
rescue VendorFormats::ParseError => e
|
|
63
|
-
squish!(
|
|
85
|
+
squish!(append_to_purpose: "The parser failed on this document; the error is in the context.",
|
|
64
86
|
context: { parse_error: e })
|
|
65
87
|
end
|
|
66
88
|
end
|
|
@@ -73,13 +95,42 @@ Both calls return the same result class. If the LLM can't deliver either, `squis
|
|
|
73
95
|
return, or the error is raised with the original `ParseError` as its cause. See
|
|
74
96
|
[Handing off to the LLM](docs/routing.md#handing-off-to-the-llm-with-squish).
|
|
75
97
|
|
|
98
|
+
### Retire code you no longer need
|
|
99
|
+
|
|
100
|
+
Go the other way too. Find the parsers and handlers that serve a thin tail of your traffic and squish them. When a
|
|
101
|
+
path's monthly token cost is lower than what its code costs to maintain, delete the code and relish the feeling of a
|
|
102
|
+
slimmer code base (or let your AI coding agent relish it).
|
|
103
|
+
|
|
104
|
+
### Measure your tech debt in tokens
|
|
105
|
+
|
|
106
|
+
Code you haven't written is debt you haven't taken on. A squished path has a running cost you can read off a
|
|
107
|
+
meter, so "should we write a parser for this?" becomes arithmetic: what the path costs in tokens each month,
|
|
108
|
+
against what it costs to write and maintain the code. Write it when the first number is bigger.
|
|
109
|
+
|
|
110
|
+
Add and initialize the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem, and it picks up your
|
|
111
|
+
squishling calls automatically and measures their accuracy and cost:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# Gemfile
|
|
115
|
+
gem "coolhand"
|
|
116
|
+
|
|
117
|
+
# config/initializers/coolhand.rb
|
|
118
|
+
Coolhand.configure do |config|
|
|
119
|
+
config.api_key = ENV.fetch("COOLHAND_API_KEY")
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`squished?` tells you which path served a call. Coolhand records your LLM requests and responses; see
|
|
124
|
+
[what each tool sees](docs/measuring-tokens.md#privacy). Want to roll your own metrics? Check out
|
|
125
|
+
[our guide](docs/measuring-tokens.md) for doing it with other tools.
|
|
126
|
+
|
|
76
127
|
## Installation
|
|
77
128
|
|
|
78
129
|
```ruby
|
|
79
130
|
gem "squishling"
|
|
80
131
|
```
|
|
81
132
|
|
|
82
|
-
Requires Ruby 3.3+ and RubyLLM 2.
|
|
133
|
+
Requires Ruby 3.3+ and RubyLLM 2.1+. Configure your provider API keys in RubyLLM as usual, then optionally set a
|
|
83
134
|
universal model, or an escalation of models to try in order:
|
|
84
135
|
|
|
85
136
|
```ruby
|
|
@@ -91,7 +142,7 @@ end
|
|
|
91
142
|
|
|
92
143
|
## How it works
|
|
93
144
|
|
|
94
|
-
A squishling class needs **
|
|
145
|
+
A squishling class needs a **purpose** (the system prompt) and an **output schema** (the shape of the result,
|
|
95
146
|
validated on both paths). A squished call goes to the LLM when:
|
|
96
147
|
|
|
97
148
|
- its `squish_when` predicate is truthy for these inputs,
|
|
@@ -100,17 +151,22 @@ validated on both paths). A squished call goes to the LLM when:
|
|
|
100
151
|
|
|
101
152
|
Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM output.
|
|
102
153
|
|
|
154
|
+
**Best practice: describe fields in the schema, not the purpose.** Put per-field guidance in a `description:` on
|
|
155
|
+
the field (`string :status, description: "..."`, or `optional(:eta, description: "...") { string }`). The model
|
|
156
|
+
sees it right where the value goes, and the purpose stays short and about the goal. See
|
|
157
|
+
[Describing fields](docs/schemas.md#describing-fields).
|
|
158
|
+
|
|
103
159
|
## Features
|
|
104
160
|
|
|
105
161
|
- **One contract, two paths**: Ruby returns and LLM output are validated against the same strict schema and
|
|
106
162
|
returned as the same typed `Data` objects. `squished?` tells you which path served a call.
|
|
107
|
-
- **Your code as context**: `
|
|
163
|
+
- **Your code as context**: `append_to_purpose` adds sections to the prompt, including a class's or method's
|
|
108
164
|
own Ruby source.
|
|
109
165
|
- **Any RubyLLM provider and model**: OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more.
|
|
110
166
|
Set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
|
|
111
167
|
reasoning effort, top_p, …).
|
|
112
|
-
- **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `
|
|
113
|
-
and
|
|
168
|
+
- **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
|
|
114
170
|
cross providers, e.g. a local Qwen model on Ollama, then Anthropic Claude Haiku on AWS Bedrock, then Claude Opus:
|
|
115
171
|
|
|
116
172
|
```ruby
|
|
@@ -130,23 +186,41 @@ Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM
|
|
|
130
186
|
{ model: "claude-haiku-4-5", provider: :bedrock }, # small hosted model
|
|
131
187
|
{ model: "claude-opus-5-5", provider: :anthropic } # last resort
|
|
132
188
|
]
|
|
133
|
-
#
|
|
189
|
+
# purpose, output_schema, ...
|
|
134
190
|
end
|
|
135
191
|
```
|
|
192
|
+
- **[Harnesses](docs/harnesses.md)**: choose how a call uses its escalation with `harness:`
|
|
193
|
+
- [`:escalation`](docs/configuration.md#models-and-escalation) (default): try each attempt until one passes
|
|
194
|
+
- [`:squishsum`](docs/harnesses.md#samples): two concurrent samples, accepted only if they agree
|
|
195
|
+
- [`:judged_squishsum`](docs/harnesses.md#the-judge): a chat or Jev judge picks between disagreeing samples
|
|
196
|
+
- [Your own](docs/harnesses.md#custom-harnesses): `Squishling.register_harness` plugs a custom harness into the same
|
|
197
|
+
machinery (samples, judges, `squawk`, `squish_fallback`)
|
|
136
198
|
- **Output contracts**: beyond the strict schema, conditional rules (`given`) and Ruby checks (`squish_validate`)
|
|
137
199
|
reject bad output and trigger the next attempt.
|
|
138
200
|
- **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
|
|
139
201
|
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.
|
|
140
204
|
- **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
|
|
141
|
-
pass to `squish!`, and the source you choose to append are sent to the provider.
|
|
205
|
+
pass to `squish!`, and the source you choose to append are sent to the provider. The one addition: when
|
|
206
|
+
[escalation](docs/configuration.md#models-and-escalation) moves to the next step, that step also sees the previous
|
|
207
|
+
model's rejected output, unless the step sets `forward_rejected: false`.
|
|
142
208
|
|
|
143
209
|
## Documentation
|
|
144
210
|
|
|
211
|
+
- [Explainer site](https://coolhand-labs.github.io/squishling/): interactive walkthrough, spectrum slider, and
|
|
212
|
+
token-cost calculator
|
|
145
213
|
- [Configuration](docs/configuration.md): options, models and escalation, providers, generation params, inheritance
|
|
146
|
-
- [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `
|
|
214
|
+
- [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_to_purpose`, hardening a path,
|
|
147
215
|
what the LLM sees
|
|
148
216
|
- [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty, contracts
|
|
149
217
|
- [Failure handling](docs/failures.md): escalation, `squish_validate`, error classes, fallbacks
|
|
218
|
+
- [Harnesses](docs/harnesses.md): escalation, squishsum, judges (chat or Jev), and custom harnesses
|
|
219
|
+
- [Naming and collisions](docs/naming.md): the methods `include Squishling` adds and what happens when a name is taken
|
|
220
|
+
- [Measuring token spend](docs/measuring-tokens.md): see what each squished path costs, with Coolhand Labs,
|
|
221
|
+
the `squawk` hook, your own observer, or OpenTelemetry
|
|
222
|
+
- [Observers](docs/observers.md): wrap every provider request, and the built-in [Coolhand Labs](docs/coolhand.md) observer
|
|
223
|
+
that files each call under its workload and template
|
|
150
224
|
- [Live examples](examples/README.md): end-to-end tests against Anthropic Claude Haiku and OpenAI GPT-6 Luna
|
|
151
225
|
|
|
152
226
|
## Development
|
|
@@ -157,7 +231,20 @@ bundle exec rake # RSpec (offline; RubyLLM is stubbed) + RuboCop
|
|
|
157
231
|
```
|
|
158
232
|
|
|
159
233
|
See [AGENTS.md](AGENTS.md) for repo conventions, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
|
234
|
+
Have a suggestion to improve Squishling? [Open an issue](https://github.com/Coolhand-Labs/squishling/issues).
|
|
235
|
+
|
|
236
|
+
## Credits
|
|
237
|
+
|
|
238
|
+
Inspired by [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai); see also
|
|
239
|
+
the [talk slides](https://michael.carroll.io/talks/2026/elastic-software/) from XO Ruby NYC 2026.
|
|
160
240
|
|
|
161
241
|
## License
|
|
162
242
|
|
|
163
243
|
Apache-2.0
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
<p align="center">
|
|
248
|
+
This open source project is supported by <a href="https://coolhandlabs.com/">Coolhand Labs</a>.<br><br>
|
|
249
|
+
<a href="https://coolhandlabs.com/"><img src="assets/coolhand-labs.png" alt="Coolhand Labs" width="220"></a>
|
|
250
|
+
</p>
|
data/docs/configuration.md
CHANGED
|
@@ -17,6 +17,7 @@ Squishling.configure do |config|
|
|
|
17
17
|
config.default_model = "claude-sonnet-5-5" # or default_escalation, below
|
|
18
18
|
config.default_provider = nil
|
|
19
19
|
config.default_params = { temperature: 0 }
|
|
20
|
+
config.default_harness = :escalation
|
|
20
21
|
config.logger = Rails.logger
|
|
21
22
|
end
|
|
22
23
|
```
|
|
@@ -27,7 +28,10 @@ end
|
|
|
27
28
|
| `default_escalation` | `nil` | The universal [escalation](#models-and-escalation): models tried in order. Assigning it clears `default_model`, and vice versa. |
|
|
28
29
|
| `default_provider` | `nil` | The provider for `default_model`/`default_escalation` steps that don't name one. Only needed for models missing from RubyLLM's registry (see below). |
|
|
29
30
|
| `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). |
|
|
30
|
-
| `
|
|
31
|
+
| `squawk` | `nil` | A callable run after every LLM attempt 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). |
|
|
32
|
+
| `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). |
|
|
33
|
+
| `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). |
|
|
34
|
+
| `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). |
|
|
31
35
|
|
|
32
36
|
Transport-level retries (rate limits, 5xx, timeouts) are configured on RubyLLM itself
|
|
33
37
|
(`RubyLLM.config.max_retries`, `request_timeout`).
|
|
@@ -59,20 +63,50 @@ Each step is a model name, or a Hash with:
|
|
|
59
63
|
|---|---|---|
|
|
60
64
|
| `model:` | required | The model id. |
|
|
61
65
|
| `attempts:` | `1` | How many attempts this step gets before escalating to the next one. |
|
|
62
|
-
| `
|
|
66
|
+
| `priority:` | position | An Integer; steps run lowest first. Give every step a `priority:` or none. Steps with the same priority are one tier (see [Rotating models](#rotating-models-within-a-priority)). Gaps are fine. `order:` is the deprecated name for it. |
|
|
67
|
+
| `rotate:` | `true` | Set `false` on a step to keep its tier in the order written instead of rotating it. |
|
|
63
68
|
| `provider:` | the level's `provider:` | See [Providers](#providers-and-newly-released-models). |
|
|
69
|
+
| `forward_rejected:` | `true` | Set `false` to start this step from the original input alone, without the previous step's rejected output and its errors (see below). |
|
|
64
70
|
| `params:` | `{}` | [Generation params](#generation-params) for this step, merged over the class and method params key by key. |
|
|
65
71
|
|
|
66
|
-
Without `
|
|
72
|
+
Without `priority:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
|
|
67
73
|
|
|
68
74
|
```ruby
|
|
69
75
|
squishling escalation: [
|
|
70
|
-
{ model: "claude-opus-5-5",
|
|
71
|
-
{ model: "claude-haiku-4-5",
|
|
72
|
-
{ model: "
|
|
76
|
+
{ model: "claude-opus-5-5", priority: 30, params: { thinking: { effort: :high } } },
|
|
77
|
+
{ model: "claude-haiku-4-5", priority: 10, attempts: 2 },
|
|
78
|
+
{ model: "gemma-4b", priority: 10 },
|
|
79
|
+
{ model: "claude-sonnet-5-5", priority: 20 }
|
|
73
80
|
]
|
|
74
81
|
```
|
|
75
82
|
|
|
83
|
+
**Best practice:** number priorities 10, 20, 30, and so on, so a model can be slotted in between later (a `15`)
|
|
84
|
+
without renumbering the rest. Haiku and Gemma share the lowest priority here, so they take turns going first (see below).
|
|
85
|
+
|
|
86
|
+
### Rotating models within a priority
|
|
87
|
+
|
|
88
|
+
Give several models the same `priority:` and they share a tier. Each call rotates which of them goes first, so
|
|
89
|
+
every model gets an even share of the real work over many calls, a fair test of how well each handles it:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
squishling escalation: [
|
|
93
|
+
{ model: "claude-haiku-4-5", priority: 10 },
|
|
94
|
+
{ model: "gemma-4b", priority: 10 },
|
|
95
|
+
{ model: "claude-sonnet-5-5", priority: 20 }
|
|
96
|
+
]
|
|
97
|
+
# call 1: haiku, gemma-4b, sonnet call 2: gemma-4b, haiku, sonnet call 3: haiku, gemma-4b, sonnet
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- If the model that goes first fails (invalid output or a provider failure, after its own `attempts:`), the other
|
|
101
|
+
models in the tier are tried, in rotated order, before the call escalates to the next priority.
|
|
102
|
+
- Each class and method counts its own calls (`AClass#a`, `AClass#b` and `BClass#a` rotate independently), shared across
|
|
103
|
+
threads, but not across processes: each process (a Puma worker, say) starts its own count at the first model, and it
|
|
104
|
+
restarts with the process. A `squish!` whose own escalation has a rotating tier counts against its method too.
|
|
105
|
+
- A tier rotates by its declared order, so ties keep the order they were written in. A tier with a single model
|
|
106
|
+
never rotates, and neither does an escalation without `priority:`. `rotate: false` on any step of a tier keeps the
|
|
107
|
+
whole tier in the order written, whatever its other steps say.
|
|
108
|
+
- Rotation also moves which stages the [`:squishsum` and `:judged_squishsum`](harnesses.md) harnesses sample first.
|
|
109
|
+
|
|
76
110
|
`params:` on a step let one step switch to a reasoning model that rejects sampling params:
|
|
77
111
|
|
|
78
112
|
```ruby
|
|
@@ -105,10 +139,13 @@ end
|
|
|
105
139
|
Passing both `model:` and `escalation:` in one declaration raises `ConfigurationError`, and so does a list passed as
|
|
106
140
|
`model:`. Across separate declarations (a reopened class, or two config assignments), the latest one wins.
|
|
107
141
|
|
|
108
|
-
Consecutive attempts of the same step (same model, provider,
|
|
109
|
-
conversation. Moving to a different step starts a fresh chat with the original input plus the previous
|
|
110
|
-
it was rejected, so no provider-specific history crosses providers.
|
|
111
|
-
next
|
|
142
|
+
Consecutive attempts of the same step (same model, provider, params, and `forward_rejected:`, e.g. `attempts: 2`)
|
|
143
|
+
continue one conversation. Moving to a different step starts a fresh chat with the original input plus the previous
|
|
144
|
+
output and why it was rejected, so no provider-specific history crosses providers. That rejected output is
|
|
145
|
+
model-generated and is sent to the next step's provider, even when it is a different one, truncated to 4,000
|
|
146
|
+
characters. If you don't want a step to see it (say, because the next step is a different provider), set
|
|
147
|
+
`forward_rejected: false` on that step and it starts from the original input only. Configuration errors (bad
|
|
148
|
+
credentials, an unknown model, a request the provider rejects) never move to the next attempt. See [Failure handling](failures.md).
|
|
112
149
|
|
|
113
150
|
## Providers and newly released models
|
|
114
151
|
|
|
@@ -116,14 +153,21 @@ RubyLLM looks models up in its bundled registry. To use a model that isn't there
|
|
|
116
153
|
OpenAI or Anthropic model, name its provider next to it. Squishling then tells RubyLLM to assume the model exists:
|
|
117
154
|
|
|
118
155
|
```ruby
|
|
119
|
-
squishling model: "gpt-
|
|
156
|
+
squishling model: "gpt-7-preview", provider: :openai
|
|
120
157
|
squish :triage, model: "claude-haiku-4-5", provider: :anthropic
|
|
121
|
-
Squishling.configure { |c| c.default_model = "gpt-
|
|
158
|
+
Squishling.configure { |c| c.default_model = "gpt-7-preview"; c.default_provider = :openai }
|
|
122
159
|
```
|
|
123
160
|
|
|
124
161
|
A provider is paired with the model or escalation declared at the same level, and applies to that escalation's steps
|
|
125
|
-
that don't name their own. A
|
|
126
|
-
|
|
162
|
+
that don't name their own. A model never inherits a provider meant for a different model: a per-method model doesn't
|
|
163
|
+
take the class's provider, and a subclass that declares its own `model:` or `escalation:` doesn't take its parent's
|
|
164
|
+
(a subclass that only changes params or the purpose keeps the parent's model and provider together). For models that
|
|
165
|
+
are in the registry, a provider is optional and the normal registry lookup is kept.
|
|
166
|
+
|
|
167
|
+
A `provider:` with no model beside it would apply to nothing, so it raises `ConfigurationError`:
|
|
168
|
+
`squish ..., provider: ...` needs a `model:` or `escalation:` in the same call (as `squish!` always has), and
|
|
169
|
+
`squishling provider: ...` needs one in the call or already declared on that same class (not inherited). Set the
|
|
170
|
+
provider next to the model, or, for the configured default model, use `config.default_provider`.
|
|
127
171
|
|
|
128
172
|
## Generation params
|
|
129
173
|
|
|
@@ -164,22 +208,32 @@ end
|
|
|
164
208
|
|
|
165
209
|
- Keys that Squishling or RubyLLM control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
|
|
166
210
|
`response_format`, `text`, `output_config`, `tools`, `tool_choice`, `schema`, and the camelCase and plural
|
|
167
|
-
spellings other providers use, such as `systemInstruction`, `toolConfig`, `outputConfig`, `inputs`, …)
|
|
168
|
-
`ConfigurationError`, because they would override the model, the conversation, or the strict output format.
|
|
211
|
+
spellings other providers use, such as `systemInstruction`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
|
|
212
|
+
raise `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
|
|
213
|
+
- **Nested containers** such as Gemini's `generationConfig` (and `generation_config` for Gemini Interactions) and
|
|
214
|
+
Mistral Conversations' `completion_args` accept ordinary settings, but reject the keys inside them that carry the
|
|
215
|
+
output format or tools (`responseMimeType`, `responseSchema`, `responseJsonSchema`, `response_format`, `tools`,
|
|
216
|
+
`tool_choice`, `toolConfig`, and their snake_case spellings). Use `output_schema` instead:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
squishling params: { generationConfig: { topK: 5 } } # ok
|
|
220
|
+
squishling params: { generationConfig: { responseMimeType: "text/plain" } } # ConfigurationError
|
|
221
|
+
```
|
|
222
|
+
|
|
169
223
|
- If a provider rejects a param, the call raises `Squishling::ConfigurationError` naming the params. It isn't
|
|
170
224
|
retried or sent to `squish_fallback`. See [Failure handling](failures.md).
|
|
171
225
|
|
|
172
226
|
## Inheritance
|
|
173
227
|
|
|
174
|
-
Subclasses inherit the model or escalation
|
|
228
|
+
Subclasses inherit the model or escalation and its provider (as a pair), harness, generation params (merged key by key), purpose, output schema,
|
|
175
229
|
`squish_when` predicate, `squish_context` names, `squish_validate`, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
|
|
176
230
|
overridden methods, are routed the same way.
|
|
177
231
|
|
|
178
|
-
`
|
|
179
|
-
`
|
|
232
|
+
`append_to_purpose` sections are added to, not replaced: a subclass's sections follow its parent's, and
|
|
233
|
+
`append_to_purpose false` drops the inherited ones. See [Appending to the purpose](routing.md#appending-to-the-purpose).
|
|
180
234
|
|
|
181
235
|
## Per-call overrides
|
|
182
236
|
|
|
183
|
-
Inside a squished method, `squish!` sends the call to the LLM with its own `
|
|
184
|
-
`
|
|
237
|
+
Inside a squished method, `squish!` sends the call to the LLM with its own `purpose:`,
|
|
238
|
+
`append_to_purpose:`, `context:`, `model:` or `escalation:` (with `provider:`), `params:`, and `harness:`. Each layers over the method and class
|
|
185
239
|
settings the same way they layer over each other. See [Handing off to the LLM](routing.md#handing-off-to-the-llm-with-squish).
|