squishling 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 76e867f7a93f81c06fb80016fcbeb1e1db39a064f1b8bb1e7cd98194bbf6ef6e
4
+ data.tar.gz: 2c2ea6a4994f4fb764337c9f194c26eb264cea7a7ecae3e881fa3a4d75303e83
5
+ SHA512:
6
+ metadata.gz: ac0484e5a496beb3c3e27a51d13765c8b62c243d2253eacd12c1b532017e141780f723df5164bcaa7548e37b28f27fa901ff493fb343a34426ffafe120d96bde
7
+ data.tar.gz: 483a5344615d1bc0b1cbb9f3608e4f48df8891eb05dfcb7ad9ac31f22fb3574392f3621d5227037f0a28b48e5389f6e375ef336181ccfd9b3c8836969856cae6
data/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-08
11
+
12
+ Initial release.
13
+
14
+ ### Added
15
+
16
+ - `include Squishling` DSL that makes a class's methods *elastic*: each call either runs the method's Ruby
17
+ implementation or sends its inputs through an LLM via [RubyLLM](https://rubyllm.com) 2.x, and both paths return
18
+ the same strict-schema-validated, typed result. `squishling`, `instructions`, `output_schema`, `squish_when`,
19
+ `squish_context`, `squish`, `squish_fallback`, and `result` (alias `squishling_result`). (#1)
20
+ - Per-input routing: a `squish_when` predicate chooses Ruby or the LLM per call, and a method that is missing or
21
+ raises `NotImplementedError` goes to the LLM automatically. `squish :name, ...` squishes methods other than
22
+ `call`, with per-method `instructions`, `output_schema`, `model`, `provider`, `params`, `when`, and
23
+ `fallback`. (#1)
24
+ - Strict output schemas only, from a Schematist DSL block, a `Schematist::Schema` subclass, or a raw JSON Schema
25
+ Hash. Object schemas become `Data` result classes with `squished?`, `to_h`, and `[]`; `optional` expresses "not
26
+ provided" while keeping every key required. `strict: false` raises `ConfigurationError`. (#1)
27
+ - Validation of everything that comes back, using `json_schemer`: LLM output, deterministic returns, and fallback
28
+ returns. Invalid LLM output is re-asked with the validation errors, up to `max_retries` (default 1). (#1)
29
+ - Failure handling through the `Squishling::Error` taxonomy: `ConfigurationError` (never retried or passed to a
30
+ fallback), `InvalidOutputError` (with `errors`, `raw`, and `attempts`), and `LLMError` (original exception as
31
+ `cause`). `squish_fallback` decides what to return when the LLM can't deliver. Exceptions raised by your own
32
+ Ruby code are never wrapped. (#1)
33
+ - `Squishling.configure` with `default_model`, `default_provider`, `default_params`, `max_retries`, and `logger`.
34
+ The model resolves per call, per method, per class, then universally, then RubyLLM's default. Naming a
35
+ `provider:` next to a model lets you use models missing from RubyLLM's registry. (#1)
36
+ - Layered generation params (`temperature`, `max_output_tokens`, `thinking`, and any provider-specific key) that
37
+ merge key by key across universal, class, and method levels. Keys that Squishling owns (model, messages,
38
+ structured-output format, tools, streaming) are rejected. (#1)
39
+ - Opt-in context: the LLM sees only method arguments and the `squish_context` values you name, never instance
40
+ variables wholesale. Fiber-local routing state and mutex-guarded caches keep it thread- and fiber-safe. (#1)
41
+ - `squish!`, which hands the current call to the LLM from inside the Ruby implementation, typically from a
42
+ `rescue`. Optional per-call `context:`, `append_instructions:`, `instructions:`, `model:`, `provider:`, and
43
+ `params:`; the output schema can't be overridden, and a declared `squish_fallback` still applies. Exceptions
44
+ passed in `context:` are sent as their class and message only. (#3)
45
+ - `append_instructions`, which adds sections to the system prompt at the class, subclass, `squish` method, or
46
+ per-call level. A section can be a String, a Proc, or a class, module, or method rendered as its Ruby source
47
+ (read with Prism, a Ruby default gem, so there is no new runtime dependency). `false` drops the sections
48
+ declared above it. Appended source is sent to your provider. (#3)
49
+ - Live end-to-end examples in `examples/` for Anthropic Claude and OpenAI, including `squish!` scenarios. They need
50
+ real API keys and are not part of the packaged gem. (#1, #3)
51
+ - Documentation: configuration, routing, output schemas, and failure handling guides under `docs/`. (#1, #3)
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Squishling
2
+
3
+ [![CI](https://github.com/Coolhand-Labs/squishling/actions/workflows/ci.yml/badge.svg)](https://github.com/Coolhand-Labs/squishling/actions/workflows/ci.yml)
4
+
5
+ Elastic Ruby classes. A squished method either runs its Ruby implementation or sends its inputs through an LLM
6
+ (via [RubyLLM](https://rubyllm.com)), and either way returns the same strict-schema-validated, typed result.
7
+ Callers can't tell the difference.
8
+
9
+ Inspired by [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai):
10
+ start flexible with AI, then harden high-volume paths into code as the economics justify it.
11
+
12
+ ## Use cases
13
+
14
+ ### Instant integration
15
+
16
+ Accept a new data source today, before anyone writes a parser. Declare what you want back and leave the
17
+ method unimplemented: every call goes to the LLM, and its output is validated against your schema.
18
+
19
+ ```ruby
20
+ class PaymentWebhook
21
+ include Squishling
22
+
23
+ instructions "Normalize this payment provider's webhook into our payment event."
24
+ output_schema do
25
+ string :event, enum: %w[succeeded failed refunded disputed]
26
+ integer :amount_cents
27
+ string :currency
28
+ string :external_id
29
+ end
30
+ # No `def call` yet, so every webhook goes to the LLM.
31
+ end
32
+
33
+ event = PaymentWebhook.call(provider: "adyen", payload: request.raw_post)
34
+ event.event # => "refunded"
35
+ event.amount_cents # => 4200
36
+ event.squished? # => true
37
+ ```
38
+
39
+ When one provider carries the volume, write `def call` for it and add
40
+ `squish_when { |provider:, **| provider != "stripe" }`. Stripe then runs in Ruby, everything else stays on the
41
+ LLM, and callers don't change. See [Hardening a path](docs/routing.md#hardening-a-path).
42
+
43
+ ### Error recovery
44
+
45
+ Keep the Ruby you have for the inputs it understands, and hand the rest to the LLM instead of failing. When the
46
+ parser raises, `squish!` sends this call to the LLM with the error and the parser's own source as context.
47
+
48
+ ```ruby
49
+ class InvoiceParser
50
+ include Squishling
51
+
52
+ instructions "Extract the invoice fields from the vendor's document."
53
+ append_instructions "The Ruby parser that handles well-formed invoices:", self # this class's source
54
+ output_schema do
55
+ string :invoice_number
56
+ number :total
57
+ end
58
+
59
+ def call(vendor:, document:)
60
+ invoice = VendorFormats.fetch(vendor).parse(document)
61
+ result(invoice_number: invoice.number, total: invoice.total)
62
+ rescue VendorFormats::ParseError => e
63
+ squish!(append_instructions: "The parser failed on this document; the error is in the context.",
64
+ context: { parse_error: e })
65
+ end
66
+ end
67
+
68
+ InvoiceParser.call(vendor: "acme", document: pdf_text).squished? # => false (Ruby parsed it)
69
+ InvoiceParser.call(vendor: "acme", document: scanned_text).squished? # => true (recovered by the LLM)
70
+ ```
71
+
72
+ Both calls return the same result class. If the LLM can't deliver either, `squish_fallback` decides what to
73
+ return, or the error is raised with the original `ParseError` as its cause. See
74
+ [Escalating from Ruby](docs/routing.md#escalating-from-ruby-with-squish).
75
+
76
+ ## Installation
77
+
78
+ ```ruby
79
+ gem "squishling"
80
+ ```
81
+
82
+ Requires Ruby 3.3+ and RubyLLM 2.x. Configure your provider API keys in RubyLLM as usual, then optionally set a
83
+ universal model:
84
+
85
+ ```ruby
86
+ Squishling.configure do |config|
87
+ config.default_model = "claude-sonnet-5-5" # falls back to RubyLLM's default when nil
88
+ end
89
+ ```
90
+
91
+ ## How it works
92
+
93
+ A squishling class needs **instructions** (the system prompt) and an **output schema** (the shape of the result,
94
+ validated on both paths). A squished call goes to the LLM when:
95
+
96
+ - its `squish_when` predicate is truthy for these inputs,
97
+ - the method has no implementation (it isn't defined, or raises `NotImplementedError`), or
98
+ - the Ruby implementation calls `squish!`.
99
+
100
+ Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM output.
101
+
102
+ ## Features
103
+
104
+ - **One contract, two paths**: Ruby returns and LLM output are validated against the same strict schema and
105
+ returned as the same typed `Data` objects. `squished?` tells you which path served a call.
106
+ - **Your code as context**: `append_instructions` adds sections to the prompt, including a class's or method's
107
+ own Ruby source.
108
+ - **Any RubyLLM provider and model**: OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more.
109
+ Set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
110
+ reasoning effort, top_p, …).
111
+ - **Defined failure behavior**: invalid output is re-asked with the validation errors, provider errors become
112
+ `Squishling::LLMError`, and `squish_fallback` decides what to return when the LLM can't deliver.
113
+ - **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
114
+ pass to `squish!`, and the source you choose to append are sent to the provider.
115
+
116
+ ## Documentation
117
+
118
+ - [Configuration](docs/configuration.md): options, model and provider resolution, generation params, inheritance
119
+ - [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_instructions`, hardening a path,
120
+ what the LLM sees
121
+ - [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty
122
+ - [Failure handling](docs/failures.md): retries, error classes, fallbacks
123
+ - [Live examples](examples/README.md): end-to-end tests against Anthropic Claude Haiku and OpenAI GPT-6 Luna
124
+
125
+ ## Development
126
+
127
+ ```sh
128
+ bin/setup
129
+ bundle exec rake # RSpec (offline; RubyLLM is stubbed) + RuboCop
130
+ ```
131
+
132
+ See [AGENTS.md](AGENTS.md) for repo conventions, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
133
+
134
+ ## License
135
+
136
+ Apache-2.0
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/SECURITY.md ADDED
@@ -0,0 +1,20 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Only the latest published version of `squishling` on RubyGems is supported with security fixes.
6
+ Please upgrade to the latest version before reporting an issue.
7
+
8
+ ## Reporting a Vulnerability
9
+
10
+ Please do **not** open a public GitHub issue for security vulnerabilities.
11
+
12
+ Instead, report vulnerabilities privately using one of the following:
13
+
14
+ - [GitHub Security Advisories](https://github.com/Coolhand-Labs/squishling/security/advisories/new)
15
+ for this repository (preferred)
16
+ - Email team@coolhandlabs.com
17
+
18
+ Please include a description of the vulnerability, steps to reproduce, and the impact you believe
19
+ it has. We aim to acknowledge reports within 3 business days and to provide a fix or mitigation
20
+ plan within 30 days, depending on severity.
@@ -0,0 +1,128 @@
1
+ # Configuration
2
+
3
+ Squishling sends requests through [RubyLLM](https://rubyllm.com), so configure provider API keys there first
4
+ (OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and any other provider RubyLLM supports):
5
+
6
+ ```ruby
7
+ RubyLLM.configure do |config|
8
+ config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
9
+ config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
10
+ end
11
+ ```
12
+
13
+ Then configure Squishling:
14
+
15
+ ```ruby
16
+ Squishling.configure do |config|
17
+ config.default_model = "claude-sonnet-5-5"
18
+ config.default_provider = nil
19
+ config.default_params = { temperature: 0 }
20
+ config.max_retries = 1
21
+ config.logger = Rails.logger
22
+ end
23
+ ```
24
+
25
+ | Option | Default | Description |
26
+ |---|---|---|
27
+ | `default_model` | `nil` | The universal model for every squishling class that doesn't declare one. `nil` uses RubyLLM's `default_model`. |
28
+ | `default_provider` | `nil` | The provider for `default_model`. Only needed for models missing from RubyLLM's registry (see below). |
29
+ | `default_params` | `{}` | Generation params for every call (temperature, reasoning effort, top_p, …), overridable per class and per method. See [Generation params](#generation-params). |
30
+ | `max_retries` | `1` | How many times to re-ask the LLM after schema-invalid output before raising `InvalidOutputError`. `0` means a single attempt. See [Failure handling](failures.md). |
31
+ | `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings when a fallback is used. |
32
+
33
+ Transport-level retries (rate limits, 5xx, timeouts) are configured on RubyLLM itself
34
+ (`RubyLLM.config.max_retries`, `request_timeout`).
35
+
36
+ ## Model resolution
37
+
38
+ The model for a squished call comes from the first level that declares one:
39
+
40
+ 1. per call: `squish!(model: "...")` (see [Per-call overrides](#per-call-overrides))
41
+ 2. per method: `squish :name, model: "..."`
42
+ 3. per class: `squishling model: "..."` (inherited by subclasses)
43
+ 4. universal: `Squishling.config.default_model`
44
+ 5. RubyLLM's `default_model`
45
+
46
+ ```ruby
47
+ class InvoiceParser
48
+ include Squishling
49
+ squishling model: "claude-sonnet-5-5" # class default
50
+
51
+ squish :classify, model: "claude-haiku-4-5" do # cheaper model for one method
52
+ string :category
53
+ end
54
+ end
55
+ ```
56
+
57
+ ## Providers and newly released models
58
+
59
+ RubyLLM looks models up in its bundled registry. To use a model that isn't there yet, such as a newly released
60
+ OpenAI or Anthropic model, name its provider next to it. Squishling then tells RubyLLM to assume the model exists:
61
+
62
+ ```ruby
63
+ squishling model: "gpt-6-luna", provider: :openai
64
+ squish :triage, model: "claude-haiku-4-5", provider: :anthropic
65
+ Squishling.configure { |c| c.default_model = "gpt-6-luna"; c.default_provider = :openai }
66
+ ```
67
+
68
+ A provider is paired with the model declared at the same level, so a per-method model never inherits a
69
+ class-level provider meant for a different model. For models that are in the registry, a provider is optional
70
+ and the normal registry lookup is kept.
71
+
72
+ ## Generation params
73
+
74
+ `params` holds generation settings. Set them at any of three levels; each level overrides the one above it
75
+ **key by key**:
76
+
77
+ ```ruby
78
+ Squishling.configure { |c| c.default_params = { temperature: 0 } } # every call
79
+
80
+ class VisitSummarizer
81
+ include Squishling
82
+ squishling params: { temperature: 0.1, top_p: 0.9 } # this class (and subclasses)
83
+
84
+ squish :brainstorm, params: { temperature: 0.9 } do # one method: temperature 0.9, top_p 0.9
85
+ array :ideas, of: :string
86
+ end
87
+ end
88
+ ```
89
+
90
+ | Key | Sent as |
91
+ |---|---|
92
+ | `temperature` | RubyLLM's `with_temperature` |
93
+ | `max_output_tokens` | RubyLLM's `with_max_output_tokens` (translated to each provider's own field) |
94
+ | `thinking` | RubyLLM's `with_thinking`: `true` (the model's default), `false` (off), or options such as `{ effort: :low }`, `{ budget: 1024 }`, `{ display: :omitted }` |
95
+ | anything else (`top_p`, `seed`, `service_tier`, Gemini's `generationConfig`, …) | merged into the provider request as-is via `with_provider_options`, in that provider's own field names |
96
+
97
+ - **No params means provider defaults.** Squishling sends nothing unless you set it. For structured
98
+ extraction on non-reasoning models (e.g. Anthropic Claude Haiku), a low temperature reduces run-to-run
99
+ variance.
100
+ - **Reasoning models** (e.g. OpenAI GPT-6 Luna) usually reject sampling params like `temperature` and `top_p`;
101
+ tune `thinking: { effort: }` instead. Anthropic requires `temperature` to be 1 (or unset) when `thinking` is on.
102
+ - **Unsetting an inherited key:** set it to `nil` to send nothing for that key, so the provider default applies.
103
+ This is useful when one method switches to a model that rejects a class-level temperature:
104
+
105
+ ```ruby
106
+ squish :triage, model: "gpt-6-luna", provider: :openai, params: { temperature: nil, thinking: { effort: :low } }
107
+ ```
108
+
109
+ - Keys that Squishling or RubyLLM control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
110
+ `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `schema`, …) raise `ConfigurationError`,
111
+ because they would override the model, the conversation, or the strict output format.
112
+ - If a provider rejects a param, the call raises `Squishling::ConfigurationError` naming the params. It isn't
113
+ retried or sent to `squish_fallback`. See [Failure handling](failures.md).
114
+
115
+ ## Inheritance
116
+
117
+ Subclasses inherit the model, provider, generation params (merged key by key), instructions, output schema,
118
+ `squish_when` predicate, `squish_context` names, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
119
+ overridden methods, are routed the same way.
120
+
121
+ `append_instructions` sections are added to, not replaced: a subclass's sections follow its parent's, and
122
+ `append_instructions false` drops the inherited ones. See [Appending to the instructions](routing.md#appending-to-the-instructions).
123
+
124
+ ## Per-call overrides
125
+
126
+ Inside a squished method, `squish!` sends the call to the LLM with its own `instructions:`,
127
+ `append_instructions:`, `context:`, `model:`/`provider:`, and `params:`. Each layers over the method and class
128
+ settings the same way they layer over each other. See [Escalating from Ruby](routing.md#escalating-from-ruby-with-squish).
data/docs/failures.md ADDED
@@ -0,0 +1,57 @@
1
+ # Failure handling
2
+
3
+ ## When the LLM fails
4
+
5
+ | Failure | What Squishling does |
6
+ |---|---|
7
+ | Rate limit, 5xx, overload, timeout, connection error | RubyLLM retries at the HTTP level (`RubyLLM.config.max_retries`, default 3). If it still fails, raises `Squishling::LLMError`; the original exception is its `cause`. |
8
+ | Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never retried, never sent to the fallback. |
9
+ | Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError` with the provider's message and the params in use. Never retried, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
10
+ | Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Re-asks, then raises `Squishling::InvalidOutputError` |
11
+ | Malformed or truncated JSON | Re-asks, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
12
+ | JSON that doesn't match the schema (wrong types, missing or extra keys, root not an object) | Re-asks with the validation errors, then raises `InvalidOutputError` |
13
+
14
+ Squishling validates output itself with [json_schemer](https://github.com/davishmcclurg/json_schemer), because
15
+ RubyLLM doesn't, and some providers don't enforce strict mode. Re-asks happen in the same conversation, so the model
16
+ sees what it got wrong. `Squishling.config.max_retries` (default 1) sets how many follow-ups are allowed.
17
+ `InvalidOutputError` exposes `errors`, `raw` (the last response), and `attempts`.
18
+
19
+ ## Errors
20
+
21
+ | Error | Raised when |
22
+ |---|---|
23
+ | `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
24
+ | `Squishling::ConfigurationError` | Missing instructions or schema, a non-strict schema, invalid or reserved params, an invalid `append_instructions` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
25
+ | `Squishling::InvalidOutputError` | LLM output still invalid after `max_retries`, or a deterministic/fallback return that doesn't match the schema |
26
+ | `Squishling::LLMError` | The provider call failed after RubyLLM's own retries, including context-length errors (`cause` holds the original) |
27
+
28
+ Errors raised by your own Ruby code are not wrapped.
29
+
30
+ ## Fallbacks
31
+
32
+ Use `squish_fallback` to decide what happens when the LLM path fails with `InvalidOutputError` or `LLMError`.
33
+ It receives the error plus the method's inputs as keywords, and runs against the instance:
34
+
35
+ ```ruby
36
+ class TicketTriager
37
+ include Squishling
38
+ # ...
39
+ squish_fallback do |error, ticket_text:, **|
40
+ Rails.logger.warn("triage fell back: #{error.message}")
41
+ { priority: "medium", team: "support" } # validated and typed, like a deterministic return
42
+ end
43
+ end
44
+ ```
45
+
46
+ - The fallback's return value is validated against the schema, like any deterministic return, and its
47
+ `squished?` is `false`. You can also build it with `result(...)`.
48
+ - To propagate the error instead, re-raise it with `raise error`.
49
+ - Per method: `squish :triage, fallback: ->(error, **inputs) { ... }`.
50
+ - Subclasses inherit the class-level fallback.
51
+ - Calls handed to the LLM with `squish!` use the fallback too. A fallback can't call `squish!` itself; that
52
+ raises `Squishling::Error` rather than looping. See [Escalating from Ruby](routing.md#escalating-from-ruby-with-squish).
53
+ - Without a fallback, the error propagates.
54
+
55
+ Routing to Ruby code isn't automatic on failure. The predicate already chose the LLM for this input, so the
56
+ fallback is where you decide whether Ruby code can handle it after all. Calling the method from its fallback
57
+ (`call(**inputs)`) runs its Ruby implementation directly, without routing it back to the LLM.