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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +201 -0
- data/README.md +136 -0
- data/Rakefile +12 -0
- data/SECURITY.md +20 -0
- data/docs/configuration.md +128 -0
- data/docs/failures.md +57 -0
- data/docs/routing.md +210 -0
- data/docs/schemas.md +87 -0
- data/lib/squishling/appendices.rb +59 -0
- data/lib/squishling/class_methods.rb +168 -0
- data/lib/squishling/configuration.rb +36 -0
- data/lib/squishling/definition.rb +156 -0
- data/lib/squishling/errors.rb +26 -0
- data/lib/squishling/invoker.rb +151 -0
- data/lib/squishling/params.rb +66 -0
- data/lib/squishling/result.rb +122 -0
- data/lib/squishling/router.rb +90 -0
- data/lib/squishling/schema.rb +80 -0
- data/lib/squishling/source.rb +194 -0
- data/lib/squishling/version.rb +5 -0
- data/lib/squishling/wrapper.rb +34 -0
- data/lib/squishling.rb +65 -0
- metadata +119 -0
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
|
+
[](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
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.
|