squishling 0.5.1 → 0.6.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 +41 -0
- data/README.md +1 -1
- data/docs/adapters.md +8 -4
- data/docs/configuration.md +50 -3
- data/docs/failures.md +4 -1
- data/docs/measuring-tokens.md +2 -1
- data/docs/observers.md +2 -1
- data/docs/routing.md +2 -1
- data/docs/testing.md +1 -1
- data/lib/squishling/adapter.rb +11 -4
- data/lib/squishling/adapters/openai_compatible.rb +13 -2
- data/lib/squishling/adapters/ruby_llm.rb +19 -1
- data/lib/squishling/budget.rb +11 -3
- data/lib/squishling/cache.rb +33 -0
- data/lib/squishling/class_methods.rb +21 -6
- data/lib/squishling/configuration.rb +25 -0
- data/lib/squishling/definition.rb +22 -7
- data/lib/squishling/judge.rb +8 -2
- data/lib/squishling/llm_client.rb +3 -2
- data/lib/squishling/model_path.rb +12 -9
- data/lib/squishling/observer.rb +9 -2
- data/lib/squishling/run.rb +1 -1
- data/lib/squishling/testing/adapter.rb +5 -3
- data/lib/squishling/testing/request.rb +7 -2
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +5 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d16d5c74c4eb8fee390ba827002dee27418f0cc66694d1fc2762893b1c027fb
|
|
4
|
+
data.tar.gz: 385bad5ba43f729d35260e7362955c35d5ffe7b4db731d1d6c0abe285e62d569
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2a06a78caf781e12ea7dca4208bca5cc5bb008578cb3a26095b790477d82d2e0ebe5f574599b13acd36ffdfe83e0e269059e89b9f9c8285b61f55376f37e6002
|
|
7
|
+
data.tar.gz: 940b86dfd9a8d612360fdc76e05532320dc65ac5f02bbfcf1f5600a863b399f569de650f373921fdb2ac8665edf1b039ec329b19e4ef6b5b662c135341bbca7b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-11-01
|
|
11
|
+
|
|
12
|
+
Prompt caching, on by default. Read **Changed** before upgrading: requests now ask the provider to cache the stable
|
|
13
|
+
prompt, which can bill a cache write on providers that charge for one.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Prompt caching: a `cache` setting that asks the provider to cache the stable prompt (the purpose and the input note in
|
|
18
|
+
the system prompt), so a repeated call can read it back instead of paying for it again. It is `true`, `false`, or a
|
|
19
|
+
Hash of options such as `{ ttl: "1h" }`, set with `config.default_cache`, `squishling cache:`, `squish :method,
|
|
20
|
+
cache:`, `squish!(cache:)`, or an escalation step's `cache:`; the more specific level wins. There is no class-level
|
|
21
|
+
`cache false` macro, since it would collide with `ActiveRecord::Base.cache`: use `squishling cache: false`. The RubyLLM
|
|
22
|
+
adapter asks for it with `Chat#with_caching` and sends no cache controls when it is off; a System One judgment is never
|
|
23
|
+
cached. See [Prompt caching](docs/configuration.md#prompt-caching). (#79, #80)
|
|
24
|
+
- `Observer::Usage` gains `cache_read_tokens` and `cache_write_tokens`, and `Adapter::Request`, `ModelPath::Step`, and
|
|
25
|
+
`Testing::Request` gain a `cache` member (defaulting to `false`, so existing constructors keep working). Adapters that
|
|
26
|
+
report cache usage put `:cache_read_tokens` and `:cache_write_tokens` in `Adapter::Response#tokens`. (#79, #80)
|
|
27
|
+
- `config.budget_counts_cache_reads` (default `false`, `true` or `false` only): whether the tokens a request reads from the
|
|
28
|
+
provider's prompt cache count toward `max_total_input_tokens`. (#97)
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Behavior change:** prompt caching is on by default (`config.default_cache` is `true`). With the RubyLLM adapter, a
|
|
33
|
+
provider that bills for cache writes (such as Anthropic Claude) may charge a write premium on the first call. Set
|
|
34
|
+
`config.default_cache = false`, or `cache: false` on a class, method, or step, to opt out. A model that can't take
|
|
35
|
+
cache controls may reject the request, which raises `ConfigurationError`: set `cache: false` for that model. The
|
|
36
|
+
OpenAI-compatible adapter ignores the setting. (#79, #80)
|
|
37
|
+
- **Behavior change:** `max_total_input_tokens` now counts the tokens written to the prompt cache, which RubyLLM
|
|
38
|
+
reports apart from `input_tokens`, so a call that writes a large cached prompt can reach the budget sooner than before.
|
|
39
|
+
Tokens read from the cache are left out unless `budget_counts_cache_reads` is `true`. Output tokens are unchanged.
|
|
40
|
+
(#79, #80, #97)
|
|
41
|
+
- **Behavior change:** the OpenAI-compatible adapter reports `input_tokens` net of cached tokens, as every adapter
|
|
42
|
+
does (the cached reads go to `cache_read_tokens`, and a server's cache writes to `cache_write_tokens`). A custom
|
|
43
|
+
adapter's `input_tokens` should likewise exclude cached tokens. Code that read `input_tokens` from `squawk` usage or an
|
|
44
|
+
observer for this adapter will see a smaller number on cache hits. (#79, #80)
|
|
45
|
+
|
|
46
|
+
### Documented
|
|
47
|
+
|
|
48
|
+
- The adapter docs list the separate [`squishling-apple_fm`](https://github.com/Coolhand-Labs/squishling-apple-fm-adapter)
|
|
49
|
+
and [`squishling-claude_code`](https://github.com/Coolhand-Labs/squishling-claude-code-adapter) adapter gems. (#94)
|
|
50
|
+
|
|
10
51
|
## [0.5.1] - 2026-10-31
|
|
11
52
|
|
|
12
53
|
The v0.5.0 red-team follow-ups (#70): data-egress, secret-leakage, and misclassification fixes. Several of them change
|
data/README.md
CHANGED
|
@@ -172,7 +172,7 @@ sees it right where the value goes, and the purpose stays short and about the go
|
|
|
172
172
|
the OpenAI-compatible adapter (OpenAI, Ollama, vLLM, LM Studio, Together, Groq, … over plain HTTP), or write your
|
|
173
173
|
own in one method.
|
|
174
174
|
- **Any provider and model**: set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
|
|
175
|
-
reasoning effort, top_p, …).
|
|
175
|
+
reasoning effort, top_p, …) and [prompt caching](docs/configuration.md#prompt-caching) (on by default, opt out per class, method, or call).
|
|
176
176
|
- **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `priority:` (models tied on priority take turns going first), provider,
|
|
177
177
|
params, `forward_rejected:`, `retry_temperature:`, and `include_when:` (leave a model out of calls where a Proc is falsy). Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
|
|
178
178
|
cross providers, e.g. a local Qwen model on Ollama, then Anthropic Claude Haiku on AWS Bedrock, then Claude Opus:
|
data/docs/adapters.md
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
An **adapter** is how Squishling reaches a model. Squishling builds the request (system prompt, conversation so far,
|
|
4
4
|
strict output schema, generation params), the adapter makes one completion and returns the model's output, and
|
|
5
5
|
Squishling does the rest: validation, `squish_validate`, retries and escalation, harnesses, `squawk`, and fallbacks.
|
|
6
|
-
Two adapters ship, and you can write your own.
|
|
6
|
+
Two adapters ship, separate gems add more (below), and you can write your own.
|
|
7
7
|
|
|
8
8
|
| Adapter | Reaches | Needs |
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| [`:ruby_llm`](#ruby_llm) | OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and every provider [RubyLLM](https://rubyllm.com) supports | `gem "ruby_llm"` |
|
|
11
11
|
| [`:openai_compatible`](#openai-compatible-endpoints) | Any server that speaks OpenAI's Chat Completions API: OpenAI, Ollama, vLLM, LM Studio, llama.cpp, Together, Groq, … | nothing (Ruby's `Net::HTTP`) |
|
|
12
|
+
| [`squishling-apple_fm`](https://github.com/Coolhand-Labs/squishling-apple-fm-adapter) (separate gem) | The on-device model built into macOS (Apple Intelligence): free and offline, for development | `gem "squishling-apple_fm"`, macOS 26+ on Apple Silicon |
|
|
13
|
+
| [`squishling-claude_code`](https://github.com/Coolhand-Labs/squishling-claude-code-adapter) (separate gem) | Your local Claude Code CLI (`claude -p`), on your own Claude subscription instead of API billing, for development | `gem "squishling-claude_code"`, Claude Code installed and logged in |
|
|
12
14
|
| [Your own](#writing-your-own) | Anything | a class with one method |
|
|
13
15
|
|
|
14
16
|
Squishling itself depends on neither RubyLLM nor any provider SDK.
|
|
@@ -176,15 +178,17 @@ Squishling.register_adapter(:acme, AcmeAdapter)
|
|
|
176
178
|
| `messages` | The conversation so far, ending with the user message to answer. After invalid output, Squishling appends your previous answer and a retry message; you never keep history. |
|
|
177
179
|
| `schema` | `{ "name", "description", "schema", "strict" => true }`: the strict structured-output payload, without the conditional keywords providers can't take. |
|
|
178
180
|
| `params` | The resolved [generation params](configuration.md#generation-params): the portable `:temperature`, `:max_output_tokens`, `:thinking`, plus provider-specific keys. Squishling has already rejected the keys it owns. Translate the portable ones for your provider; pass the rest through if it takes them. |
|
|
181
|
+
| `cache` | The [prompt caching](configuration.md#prompt-caching) to ask for: `false` (none), or a Hash of options (`{}` for the provider's default, `{ ttl: "1h" }`). Ignore it if your provider has no prompt caching or caches on its own. |
|
|
179
182
|
|
|
180
183
|
### The response
|
|
181
184
|
|
|
182
185
|
`Squishling::Adapter::Response.new(content:, model:, tokens:, finish_reason:)`. `content` is the model's output as a
|
|
183
186
|
String, or a Hash if you parsed it; `nil` or empty counts as invalid output and is retried. `model` is the id the
|
|
184
|
-
provider reports. `tokens` is a Hash (`:input_tokens`, `:output_tokens`, and anything else you have); `{}` when
|
|
187
|
+
provider reports. `tokens` is a Hash (`:input_tokens`, which leaves out the tokens read from or written to the prompt cache, `:output_tokens`, `:cache_read_tokens`, `:cache_write_tokens`, and anything else you have); `{}` when
|
|
185
188
|
unknown. These feed [`squawk`'s `usage`](failures.md#observing-every-attempt-squawk), the [observer](observers.md)'s `Usage`, and the
|
|
186
|
-
[token budget](failures.md#capping-a-calls-cost-token-budgets), which counts `:input_tokens` and
|
|
187
|
-
(a response with none never exhausts a
|
|
189
|
+
[token budget](failures.md#capping-a-calls-cost-token-budgets), which counts `:input_tokens`, `:cache_write_tokens` and
|
|
190
|
+
`:output_tokens`, and `:cache_read_tokens` only with `budget_counts_cache_reads` (a response with none never exhausts a
|
|
191
|
+
budget).
|
|
188
192
|
`finish_reason` is why the provider stopped, as a Symbol: `:stop`, `:max_tokens` (the output cap cut the reply off),
|
|
189
193
|
`:content_filter`, `:tool_calls`, or the provider's own spelling; leave it out (`nil`) when unknown. Map your
|
|
190
194
|
provider's "length" reason to `:max_tokens`, so Squishling can [tell a cutoff from a wrong
|
data/docs/configuration.md
CHANGED
|
@@ -32,7 +32,9 @@ end
|
|
|
32
32
|
| `default_escalation` | `nil` | The universal [escalation](#models-and-escalation): models tried in order. Assigning it clears `default_model`, and vice versa. |
|
|
33
33
|
| `default_provider` | `nil` | The provider for `default_model`/`default_escalation` steps that don't name one. What it means depends on the adapter: RubyLLM needs it only for models missing from its registry (see below); the OpenAI-compatible adapter ignores it. |
|
|
34
34
|
| `default_params` | `{}` | Generation params for every call (temperature, reasoning effort, top_p, …), overridable per class, per method, and per escalation step. See [Generation params](#generation-params). |
|
|
35
|
+
| `default_cache` | `true` | Asks the provider to cache the stable prompt on every request: `true`, `false`, or a Hash of options such as `{ ttl: "1h" }`. Overridable per class, per method, per escalation step, and per `squish!` call. See [Prompt caching](#prompt-caching). |
|
|
35
36
|
| `default_max_total_output_tokens`, `default_max_total_input_tokens` | `nil` (no limit) | The most output (or input) tokens one call may use across all its requests; once reached, the call raises `BudgetExhaustedError` instead of making another attempt. Overridable per class (`squishling max_total_output_tokens: ...`), per method, and per `squish!` call. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
|
|
37
|
+
| `budget_counts_cache_reads` | `false` | Whether the input tokens a request reads from the provider's prompt cache count toward `max_total_input_tokens`. Off by default, since reads are cheap; tokens written to the cache always count. Global only. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
|
|
36
38
|
| `squawk` | `nil` | A callable run after every LLM attempt (see `squawk_on`), and once more when a token budget or a disagreement stops the call, with the raw output, for sending it to an error tracker or tracing tool. Also settable per class and per method. See [Observing every attempt](failures.md#observing-every-attempt-squawk). |
|
|
37
39
|
| `squawk_on` | `:all` | Which attempts reach the `squawk` hook: `:all`, `:rejections` (errors only), `:retries` (errors plus any accepted attempt after the first), `:final` (the last attempt the escalation allows), or a list of them (any one holding runs the hook). A call [stopped](failures.md#a-call-that-is-stopped) by a token budget or a disagreement is always reported. Also settable per class and per method. |
|
|
38
40
|
| `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). |
|
|
@@ -76,6 +78,7 @@ Each step is a model name, or a Hash with:
|
|
|
76
78
|
| `retry_temperature:` | none | A Numeric `temperature` for this step's retries in the same chat (not its first attempt), to nudge a model that keeps sending the same rejected reply. Don't use it on a step that sets `thinking`, since reasoning models reject `temperature`. |
|
|
77
79
|
| `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). |
|
|
78
80
|
| `params:` | `{}` | [Generation params](#generation-params) for this step, merged over the class and method params key by key. |
|
|
81
|
+
| `cache:` | the level's `cache:` | [Prompt caching](#prompt-caching) for this step: `false`, `true`, or a Hash of options. It replaces the level's value, since caching behaves differently per model and provider. |
|
|
79
82
|
|
|
80
83
|
Without `priority:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
|
|
81
84
|
|
|
@@ -166,7 +169,7 @@ end
|
|
|
166
169
|
Passing both `model:` and `escalation:` in one declaration raises `ConfigurationError`, and so does a list passed as
|
|
167
170
|
`model:`. Across separate declarations (a reopened class, or two config assignments), the latest one wins.
|
|
168
171
|
|
|
169
|
-
Consecutive attempts of the same step (same model, provider, params, `forward_rejected:`, and `retry_temperature:`, e.g. `attempts: 2`)
|
|
172
|
+
Consecutive attempts of the same step (same model, provider, params, `cache:`, `forward_rejected:`, and `retry_temperature:`, e.g. `attempts: 2`)
|
|
170
173
|
continue one conversation. Moving to a different step starts a fresh chat with the original input plus the previous
|
|
171
174
|
output and why it was rejected, so no provider-specific history crosses providers. That rejected output is
|
|
172
175
|
model-generated and is sent to the next step's provider, even when it is a different one, truncated to 4,000
|
|
@@ -257,9 +260,53 @@ end
|
|
|
257
260
|
- If a provider rejects a param, the call raises `Squishling::ConfigurationError` naming the params. It isn't
|
|
258
261
|
retried or sent to `squish_fallback`. See [Failure handling](failures.md).
|
|
259
262
|
|
|
263
|
+
## Prompt caching
|
|
264
|
+
|
|
265
|
+
Squishling puts the stable text (the purpose and the note on the inputs) in the system prompt and the
|
|
266
|
+
per-call data in the user message, which is the order provider prompt caches want. With the RubyLLM adapter, caching is
|
|
267
|
+
on by default: each request goes out with RubyLLM's `with_caching`, so Anthropic Claude (whose caching is opt-in) caches
|
|
268
|
+
the prompt, and a call's retries within one escalation step reuse it. The cache point RubyLLM sets covers the request up
|
|
269
|
+
to its last message, so the per-call data is written to the cache too, and only a later request that shares that prefix
|
|
270
|
+
reads it back. The OpenAI-compatible adapter ignores the setting, and providers that cache on their own (OpenAI, Google
|
|
271
|
+
Gemini) still do.
|
|
272
|
+
|
|
273
|
+
`cache` is `true`, `false`, or a Hash of options for the provider (`{ ttl: "1h" }`). Each level overrides the one above
|
|
274
|
+
it, and `nil` inherits; a Hash replaces the level above's rather than merging into it:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
Squishling.configure { |c| c.default_cache = false } # every call (default: true)
|
|
278
|
+
|
|
279
|
+
class Triager
|
|
280
|
+
include Squishling
|
|
281
|
+
squishling cache: false # this class (and subclasses)
|
|
282
|
+
|
|
283
|
+
squish :triage, cache: { ttl: "1h" } do # one method
|
|
284
|
+
string :priority
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
squish!(cache: false, escalation: ["claude-opus-5-5"]) # one hand-off
|
|
289
|
+
# escalation: [{ model: "claude-haiku-4-5", cache: false }, "claude-sonnet-5-5"] # one step
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- **Writing a cache entry costs extra.** Anthropic charges a premium for the write (about 1.25x the input price for the
|
|
293
|
+
5-minute lifetime, more for `ttl: "1h"`) and pays it back only when a later request reads the entry. A class with a
|
|
294
|
+
long prompt that is called once and never again costs slightly more with caching on; set `cache: false` for it.
|
|
295
|
+
- **Not every model takes cache controls.** A model that can't cache may reject them (a provider 400, which is a
|
|
296
|
+
`ConfigurationError`, never retried); set `cache: false` for it, per class, method, or escalation step.
|
|
297
|
+
- **A prefix below the model's minimum is not cached.** Providers ignore caching under a model-specific minimum, so a
|
|
298
|
+
short prompt is a harmless no-op, not an error.
|
|
299
|
+
- **An option the provider doesn't take raises `ConfigurationError`** when the request is made (Anthropic accepts `ttl`,
|
|
300
|
+
for instance). Explicit Gemini content caches (`id:`) are not supported.
|
|
301
|
+
- **Judgments** (`type: :judgment` judges) don't use a chat, so they are never cached.
|
|
302
|
+
- **Token budgets** count the tokens written to the cache but not those read back, so a hit doesn't spend
|
|
303
|
+
`max_total_input_tokens`; `budget_counts_cache_reads = true` counts reads too.
|
|
304
|
+
- **See whether it hits** in `cache_read_tokens` and `cache_write_tokens`: they are in `squawk`'s `usage` and on the
|
|
305
|
+
[observer](observers.md#what-yield-returns)'s `Usage` (nil when the provider reports none).
|
|
306
|
+
|
|
260
307
|
## Inheritance
|
|
261
308
|
|
|
262
|
-
Subclasses inherit the model or escalation and its provider (as a pair), harness, generation params (merged key by key), token budgets (limit by limit), purpose, output schema,
|
|
309
|
+
Subclasses inherit the model or escalation and its provider (as a pair), harness, generation params (merged key by key), prompt caching, token budgets (limit by limit), purpose, output schema,
|
|
263
310
|
`squish_when` predicate, `squish_context` names, `squish_validate`, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
|
|
264
311
|
overridden methods, are routed the same way.
|
|
265
312
|
|
|
@@ -269,6 +316,6 @@ overridden methods, are routed the same way.
|
|
|
269
316
|
## Per-call overrides
|
|
270
317
|
|
|
271
318
|
Inside a squished method, `squish!` sends the call to the LLM with its own `purpose:`,
|
|
272
|
-
`append_to_purpose:`, `context:`, `model:` or `escalation:` (with `provider:`), `params:`, `harness:`, and
|
|
319
|
+
`append_to_purpose:`, `context:`, `model:` or `escalation:` (with `provider:`), `params:`, `cache:`, `harness:`, and
|
|
273
320
|
`max_total_output_tokens:`/`max_total_input_tokens:`. Each layers over the method and class
|
|
274
321
|
settings the same way they layer over each other. See [Handing off to the LLM](routing.md#handing-off-to-the-llm-with-squish).
|
data/docs/failures.md
CHANGED
|
@@ -155,7 +155,10 @@ Things to know:
|
|
|
155
155
|
limit by one request (for the squishsum harnesses, one round of samples).
|
|
156
156
|
- Tokens are the counts the [adapter](adapters.md) reports (`output_tokens` includes a reasoning model's thinking
|
|
157
157
|
tokens for providers that count them that way). A request that failed reports none, and a provider that reports no
|
|
158
|
-
usage never exhausts a budget.
|
|
158
|
+
usage never exhausts a budget. The input total includes the tokens written to the prompt cache
|
|
159
|
+
(`cache_write_tokens`) but not those read from it (`cache_read_tokens`), which adapters report apart from
|
|
160
|
+
`input_tokens`: with [prompt caching](configuration.md#prompt-caching) on, a hit on a long cached prompt doesn't spend
|
|
161
|
+
the budget. Set `config.budget_counts_cache_reads = true` in `Squishling.configure` to count reads too.
|
|
159
162
|
- The limits are positive Integers. Set them with `Squishling.configure` (`default_max_total_output_tokens`,
|
|
160
163
|
`default_max_total_input_tokens`), `squishling`, `squish :method, ...`, or a [`squish!`](routing.md#handing-off-to-the-llm-with-squish) call.
|
|
161
164
|
The more specific level wins, limit by limit, and `false` lifts a limit set at a wider level.
|
data/docs/measuring-tokens.md
CHANGED
|
@@ -93,7 +93,8 @@ can meter spend knowing which method, escalation level, sample, or judge made th
|
|
|
93
93
|
```ruby
|
|
94
94
|
class SpendObserver
|
|
95
95
|
def around(call)
|
|
96
|
-
usage = yield # input_tokens
|
|
96
|
+
usage = yield # input_tokens (not counting cached ones), output_tokens, cache_read_tokens, cache_write_tokens
|
|
97
|
+
# (each nil if unreported), and model; never the content
|
|
97
98
|
tags = ["label:#{call.label}", "role:#{call.role || 'squish'}", "model:#{usage.model}"]
|
|
98
99
|
Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags:)
|
|
99
100
|
Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags:)
|
data/docs/observers.md
CHANGED
|
@@ -26,7 +26,8 @@ raises `ConfigurationError`.
|
|
|
26
26
|
|
|
27
27
|
## What `yield` returns
|
|
28
28
|
|
|
29
|
-
The block returns a `Squishling::Observer::Usage`: the request's `input_tokens` and `output_tokens
|
|
29
|
+
The block returns a `Squishling::Observer::Usage`: the request's `input_tokens` and `output_tokens`, the
|
|
30
|
+
`cache_read_tokens` and `cache_write_tokens` of [prompt caching](configuration.md#prompt-caching) (each nil when the
|
|
30
31
|
provider didn't report them) and the `model` that answered. That is enough to meter spend:
|
|
31
32
|
|
|
32
33
|
```ruby
|
data/docs/routing.md
CHANGED
|
@@ -98,7 +98,7 @@ end
|
|
|
98
98
|
- `purpose:` (replaces the class's)
|
|
99
99
|
- `append_to_purpose:` (added to the class's, see [Appending to the purpose](#appending-to-the-purpose))
|
|
100
100
|
- `output_schema:` (or a schema block)
|
|
101
|
-
- `model:` or `escalation:`, `provider:`,
|
|
101
|
+
- `model:` or `escalation:`, `provider:`, `params:` (generation params), and `cache:` (prompt caching); see [Configuration](configuration.md)
|
|
102
102
|
- `harness:` (see [Harnesses](harnesses.md), including your own)
|
|
103
103
|
- `max_total_output_tokens:` and `max_total_input_tokens:` (see [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets))
|
|
104
104
|
- `when:`, a predicate proc
|
|
@@ -143,6 +143,7 @@ end
|
|
|
143
143
|
| `append_to_purpose:` | Added to the declared sections; `false` (alone or first in an Array) drops them for this call |
|
|
144
144
|
| `purpose:` | Replaces the purpose |
|
|
145
145
|
| `model:` or `escalation:`, `provider:`, `params:` | E.g. send this call to a stronger model, or a whole [escalation](configuration.md#models-and-escalation), when Ruby fails. A `provider:` needs a `model:` or `escalation:`; `params:` merge key by key over the declared ones. |
|
|
146
|
+
| `cache:` | Replaces the declared [prompt caching](configuration.md#prompt-caching) setting; `false` turns it off for this call |
|
|
146
147
|
| `harness:` | Replaces the declared [harness](harnesses.md), e.g. `harness: :judged_squishsum` to double-check a hand-off |
|
|
147
148
|
| `max_total_output_tokens:`, `max_total_input_tokens:` | Cap what this call may spend, e.g. a tighter budget for a hand-off; `false` lifts a declared cap. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
|
|
148
149
|
|
data/docs/testing.md
CHANGED
|
@@ -92,7 +92,7 @@ and `#verdict` are the real ones; rescue them as you would in production.
|
|
|
92
92
|
|---|---|
|
|
93
93
|
| `arguments`, `context` | the method's arguments and the `squish_context` / `squish!` context the model was sent |
|
|
94
94
|
| `system_prompt`, `output_schema` | the purpose (with `append_to_purpose` sections) and the strict schema |
|
|
95
|
-
| `model`, `provider`, `params` | the step it went to |
|
|
95
|
+
| `model`, `provider`, `params`, `cache` | the step it went to (`cache` is `false` when [prompt caching](configuration.md#prompt-caching) was off for it) |
|
|
96
96
|
| `role`, `sample`, `attempt`, `attempts` | `nil`/`:sample`/`:judge`, `"a"`, and where it is in the escalation |
|
|
97
97
|
| `conversation` | the chat it belongs to |
|
|
98
98
|
| `errors` | the validation messages a retry or escalation carried back to the model |
|
data/lib/squishling/adapter.rb
CHANGED
|
@@ -12,15 +12,22 @@ module Squishling
|
|
|
12
12
|
# structured-output payload ({ "name", "description", "schema", "strict" => true }). params is the resolved
|
|
13
13
|
# generation params: the portable :temperature, :max_output_tokens, and :thinking, plus any provider-specific keys
|
|
14
14
|
# (see Params). label ("Class#method") prefixes the adapter's error messages. model and provider are the step's
|
|
15
|
-
# (nil when it names none).
|
|
16
|
-
|
|
15
|
+
# (nil when it names none). cache is the prompt caching to ask the provider for: false (none), or a Hash of options
|
|
16
|
+
# ({} for the provider's default, { ttl: "1h" }), see Cache. An adapter whose provider has no prompt caching, or
|
|
17
|
+
# caches on its own, ignores it.
|
|
18
|
+
Request = Data.define(:label, :model, :provider, :instructions, :messages, :schema, :params, :cache) do
|
|
19
|
+
def initialize(label:, model:, provider:, instructions:, messages:, schema:, params:, cache: false)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
17
23
|
|
|
18
24
|
# Stands in for an empty reply when the conversation is replayed: some servers reject an empty assistant message.
|
|
19
25
|
EMPTY_REPLY = "(no output)"
|
|
20
26
|
|
|
21
27
|
# What a completion returns. content is the model's output: the raw String, or a Hash the adapter already parsed.
|
|
22
|
-
# model is the model id the provider reports. tokens is a Hash of counts (:input_tokens,
|
|
23
|
-
# whatever else the provider reports, such as :cache_read_tokens
|
|
28
|
+
# model is the model id the provider reports. tokens is a Hash of counts (:input_tokens, the input not read from or
|
|
29
|
+
# written to the prompt cache, :output_tokens, and whatever else the provider reports, such as :cache_read_tokens,
|
|
30
|
+
# :cache_write_tokens and :thinking_tokens), empty when unknown.
|
|
24
31
|
# finish_reason is why the provider stopped, as a Symbol: :stop, :max_tokens (the output cap cut the reply off),
|
|
25
32
|
# :content_filter, :tool_calls, or the provider's own spelling; nil when it reported none.
|
|
26
33
|
Response = Data.define(:content, :model, :tokens, :finish_reason) do
|
|
@@ -292,14 +292,25 @@ module Squishling
|
|
|
292
292
|
def tokens_of(usage)
|
|
293
293
|
return {} unless usage.is_a?(Hash)
|
|
294
294
|
|
|
295
|
+
cached = detail(usage, "prompt_tokens_details", "cached_tokens")
|
|
296
|
+
written = detail(usage, "prompt_tokens_details", "cache_write_tokens")
|
|
295
297
|
{
|
|
296
|
-
input_tokens: usage["prompt_tokens"],
|
|
298
|
+
input_tokens: uncached(usage["prompt_tokens"], cached, written),
|
|
297
299
|
output_tokens: usage["completion_tokens"],
|
|
298
|
-
cache_read_tokens:
|
|
300
|
+
cache_read_tokens: cached,
|
|
301
|
+
cache_write_tokens: written,
|
|
299
302
|
thinking_tokens: detail(usage, "completion_tokens_details", "reasoning_tokens")
|
|
300
303
|
}.select { |_, count| count.is_a?(Integer) }
|
|
301
304
|
end
|
|
302
305
|
|
|
306
|
+
# prompt_tokens counts the tokens read from and written to the cache; input_tokens is the rest, as every adapter
|
|
307
|
+
# reports it (see Adapter::Response), so they aren't counted twice.
|
|
308
|
+
def uncached(prompt_tokens, *cached)
|
|
309
|
+
return prompt_tokens unless prompt_tokens.is_a?(Integer)
|
|
310
|
+
|
|
311
|
+
[prompt_tokens - cached.grep(Integer).sum, 0].max
|
|
312
|
+
end
|
|
313
|
+
|
|
303
314
|
# A count inside a usage sub-object, which a server might send in any shape.
|
|
304
315
|
def detail(usage, group, key)
|
|
305
316
|
usage[group][key] if usage[group].is_a?(Hash)
|
|
@@ -38,7 +38,7 @@ module Squishling
|
|
|
38
38
|
chat = build_chat(request)
|
|
39
39
|
*history, last = request.messages
|
|
40
40
|
history.each { |message| chat.add_message(role: message[:role], content: message[:content]) }
|
|
41
|
-
response = guarded(request) {
|
|
41
|
+
response = guarded(request) { ask(chat, request, last[:content]) }
|
|
42
42
|
Adapter::Response.new(content: response.content, model: response.model, tokens: tokens_of(response),
|
|
43
43
|
finish_reason: response.finish_reason)
|
|
44
44
|
end
|
|
@@ -82,6 +82,17 @@ module Squishling
|
|
|
82
82
|
|
|
83
83
|
private
|
|
84
84
|
|
|
85
|
+
# RubyLLM checks a caching option against the provider when it renders the request, and raises ArgumentError for
|
|
86
|
+
# one it can't use. That is the developer's setting, so it is a setup mistake rather than a failed request.
|
|
87
|
+
def ask(chat, request, content)
|
|
88
|
+
chat.ask(content)
|
|
89
|
+
rescue ArgumentError => e
|
|
90
|
+
raise unless request.cache && e.message.match?(/caching/i) && raised_by_ruby_llm?(e)
|
|
91
|
+
|
|
92
|
+
raise configuration_error(request,
|
|
93
|
+
"RubyLLM rejected the request, cache #{request.cache.inspect} (#{e.message})")
|
|
94
|
+
end
|
|
95
|
+
|
|
85
96
|
def tokens_of(response)
|
|
86
97
|
response.tokens ? response.tokens.to_h : {}
|
|
87
98
|
end
|
|
@@ -106,6 +117,7 @@ module Squishling
|
|
|
106
117
|
chat.with_instructions(request.instructions)
|
|
107
118
|
chat.with_schema(request.schema)
|
|
108
119
|
apply_params(chat, request)
|
|
120
|
+
apply_cache(chat, request)
|
|
109
121
|
chat
|
|
110
122
|
rescue ::RubyLLM::ModelNotFoundError, ::RubyLLM::ConfigurationError => e
|
|
111
123
|
raise configuration_error(request, e.message)
|
|
@@ -124,6 +136,12 @@ module Squishling
|
|
|
124
136
|
raise configuration_error(request, "invalid params #{params.keys.inspect} (#{e.message})")
|
|
125
137
|
end
|
|
126
138
|
|
|
139
|
+
# Prompt caching is only asked for when the request carries it: off sends no cache controls. RubyLLM checks the
|
|
140
|
+
# options when it renders the request, so an unusable one is reported by #ask.
|
|
141
|
+
def apply_cache(chat, request)
|
|
142
|
+
chat.with_caching(request.cache) if request.cache
|
|
143
|
+
end
|
|
144
|
+
|
|
127
145
|
# true: the model's default thinking; false: off; a Hash: { effort:, budget:, display: }.
|
|
128
146
|
def apply_thinking(chat, thinking)
|
|
129
147
|
thinking.is_a?(Hash) ? chat.with_thinking(**thinking) : chat.with_thinking(thinking)
|
data/lib/squishling/budget.rb
CHANGED
|
@@ -33,17 +33,20 @@ module Squishling
|
|
|
33
33
|
# threads, so the totals are taken under a lock. A response without token counts adds nothing, so a provider that
|
|
34
34
|
# reports no usage never exhausts a budget.
|
|
35
35
|
class Tally
|
|
36
|
-
def initialize(budget)
|
|
36
|
+
def initialize(budget, cache_reads: false)
|
|
37
37
|
@budget = budget
|
|
38
|
+
@cache_reads = cache_reads
|
|
38
39
|
@used = { input_tokens: 0, output_tokens: 0 }
|
|
39
40
|
@lock = Mutex.new
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Counts a response's tokens: an Adapter::Response, or a judgment (see Observer.usage).
|
|
43
|
+
# Counts a response's tokens: an Adapter::Response, or a judgment (see Observer.usage). The input total includes
|
|
44
|
+
# the tokens written to the prompt cache, and those read from it only when cache_reads is set; adapters report
|
|
45
|
+
# both apart from input_tokens.
|
|
43
46
|
def add(response)
|
|
44
47
|
usage = Observer.usage(response)
|
|
45
48
|
@lock.synchronize do
|
|
46
|
-
@used[:input_tokens] +=
|
|
49
|
+
@used[:input_tokens] += input_count(usage)
|
|
47
50
|
@used[:output_tokens] += count(usage.output_tokens)
|
|
48
51
|
end
|
|
49
52
|
response
|
|
@@ -63,6 +66,11 @@ module Squishling
|
|
|
63
66
|
|
|
64
67
|
private
|
|
65
68
|
|
|
69
|
+
def input_count(usage)
|
|
70
|
+
total = count(usage.input_tokens) + count(usage.cache_write_tokens)
|
|
71
|
+
@cache_reads ? total + count(usage.cache_read_tokens) : total
|
|
72
|
+
end
|
|
73
|
+
|
|
66
74
|
def count(tokens)
|
|
67
75
|
tokens.is_a?(Integer) && tokens.positive? ? tokens : 0
|
|
68
76
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Prompt caching: whether a request asks the provider to cache the stable prompt (the purpose and the input note in
|
|
5
|
+
# the system prompt), layered config -> class -> method -> squish! like generation params, and per escalation step.
|
|
6
|
+
# A level declares true, false, or a Hash of options (ttl: "1h"), and nil leaves it to the level above. What a
|
|
7
|
+
# request carries is false (no cache controls) or a Hash of options ({} for the provider's default). See
|
|
8
|
+
# docs/configuration.md.
|
|
9
|
+
module Cache
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# One level's value, validated: nil (inherit), true, false, or a frozen Hash with Symbol keys. Explicit provider
|
|
13
|
+
# caches (id:) are not supported: an adapter holds no state between requests.
|
|
14
|
+
def normalize(value, label)
|
|
15
|
+
return value if value.nil? || [true, false].include?(value)
|
|
16
|
+
unless value.is_a?(Hash)
|
|
17
|
+
raise ConfigurationError, "#{label} must be true, false, or a Hash of options, got #{value.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
options = value.transform_keys(&:to_sym)
|
|
21
|
+
raise ConfigurationError, "#{label}: id: (an explicit provider cache) isn't supported" if options.key?(:id)
|
|
22
|
+
|
|
23
|
+
options.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What the request carries: the last level that declares a value wins (a Hash replaces, it doesn't merge), and no
|
|
27
|
+
# level at all means off. true is the provider's default options.
|
|
28
|
+
def resolve(*layers)
|
|
29
|
+
value = layers.reverse.find { |layer| !layer.nil? }
|
|
30
|
+
value == true ? {}.freeze : value || false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -15,6 +15,9 @@ module Squishling
|
|
|
15
15
|
# parent's provider.
|
|
16
16
|
# params: are generation params merged over the configured defaults (see Configuration#default_params):
|
|
17
17
|
# squishling params: { temperature: 0.1, top_p: 0.9 }
|
|
18
|
+
# cache: asks the provider to cache the stable prompt (true by default, see Cache); false opts the class out, and
|
|
19
|
+
# a Hash such as { ttl: "1h" } passes options through:
|
|
20
|
+
# squishling cache: false
|
|
18
21
|
# append_to_purpose: adds sections after the purpose (see #append_to_purpose):
|
|
19
22
|
# squishling append_to_purpose: ["The Ruby that handles well-formed input:", self]
|
|
20
23
|
# harness: chooses how the escalation is used (see Harness):
|
|
@@ -29,7 +32,7 @@ module Squishling
|
|
|
29
32
|
# squawk_on: limits which attempts reach the hook: :all (the default), :rejections, :retries, :final, or a
|
|
30
33
|
# list of them (any one holding runs the hook):
|
|
31
34
|
# squishling squawk: hook, squawk_on: %i[rejections final]
|
|
32
|
-
def squishling(model: nil, escalation: nil, provider: nil, params: nil, harness: nil, purpose: nil,
|
|
35
|
+
def squishling(model: nil, escalation: nil, provider: nil, params: nil, cache: nil, harness: nil, purpose: nil,
|
|
33
36
|
append_to_purpose: nil, output_schema: nil, squawk: nil, squawk_on: nil, max_total_output_tokens: nil,
|
|
34
37
|
max_total_input_tokens: nil)
|
|
35
38
|
path = ModelPath.declare(model, escalation, to_s)
|
|
@@ -44,8 +47,7 @@ module Squishling
|
|
|
44
47
|
# provider meant for its parent's.
|
|
45
48
|
@squishling_model_path = path if path
|
|
46
49
|
@squishling_provider = provider if provider || path
|
|
47
|
-
|
|
48
|
-
@squishling_harness = Harness.normalize(harness, to_s) unless harness.nil?
|
|
50
|
+
declare_generation(params, cache, harness)
|
|
49
51
|
self.purpose(purpose) if purpose
|
|
50
52
|
self.append_to_purpose(append_to_purpose) unless append_to_purpose.nil?
|
|
51
53
|
self.output_schema(output_schema) if output_schema
|
|
@@ -76,6 +78,11 @@ module Squishling
|
|
|
76
78
|
squishling_lookup(:@squishling_provider)
|
|
77
79
|
end
|
|
78
80
|
|
|
81
|
+
# The class's (or nearest ancestor's) cache setting (see Cache), nil when none declared it.
|
|
82
|
+
def squishling_cache
|
|
83
|
+
squishling_lookup(:@squishling_cache)
|
|
84
|
+
end
|
|
85
|
+
|
|
79
86
|
def squishling_harness
|
|
80
87
|
squishling_lookup(:@squishling_harness)
|
|
81
88
|
end
|
|
@@ -171,13 +178,14 @@ module Squishling
|
|
|
171
178
|
# string :priority
|
|
172
179
|
# end
|
|
173
180
|
def squish(*names, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil, escalation: nil,
|
|
174
|
-
provider: nil, params: nil,
|
|
175
|
-
squawk_on: nil, max_total_output_tokens: nil, max_total_input_tokens: nil, &schema_block)
|
|
181
|
+
provider: nil, params: nil, cache: nil, harness: nil, when: nil, fallback: nil, validate: nil,
|
|
182
|
+
squawk: nil, squawk_on: nil, max_total_output_tokens: nil, max_total_input_tokens: nil, &schema_block)
|
|
176
183
|
schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
|
|
177
184
|
model = ModelPath.declare(model, escalation, "#{self} squish")
|
|
178
185
|
raise ConfigurationError, "#{self} squish: provider: needs a model: or escalation:" if provider && !model
|
|
179
186
|
|
|
180
187
|
params &&= Params.normalize(params, "#{self} squish params")
|
|
188
|
+
cache = Cache.normalize(cache, "#{self} squish cache")
|
|
181
189
|
harness = Harness.normalize(harness, "#{self} squish") unless harness.nil?
|
|
182
190
|
unless append_to_purpose.nil?
|
|
183
191
|
append_to_purpose = Appendices.normalize(append_to_purpose, "#{self} squish append_to_purpose")
|
|
@@ -185,7 +193,7 @@ module Squishling
|
|
|
185
193
|
squawk = Squawk.validate(squawk, "#{self} squish")
|
|
186
194
|
squawk_on = Squawk.validate_on(squawk_on, "#{self} squish")
|
|
187
195
|
budget = Budget.build("#{self} squish", output: max_total_output_tokens, input: max_total_input_tokens)
|
|
188
|
-
options = { purpose:, append_to_purpose:, output_schema: schema, model:, provider:, params:, harness:,
|
|
196
|
+
options = { purpose:, append_to_purpose:, output_schema: schema, model:, provider:, params:, cache:, harness:,
|
|
189
197
|
predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk:,
|
|
190
198
|
squawk_on:, budget: }.compact
|
|
191
199
|
|
|
@@ -216,6 +224,13 @@ module Squishling
|
|
|
216
224
|
|
|
217
225
|
private
|
|
218
226
|
|
|
227
|
+
# nil leaves a level unset (so the parent's applies); false for cache: is stored, to opt the class out.
|
|
228
|
+
def declare_generation(params, cache, harness)
|
|
229
|
+
@squishling_params = Params.normalize(params, "#{self} params") if params
|
|
230
|
+
@squishling_cache = Cache.normalize(cache, "#{self} cache") unless cache.nil?
|
|
231
|
+
@squishling_harness = Harness.normalize(harness, to_s) unless harness.nil?
|
|
232
|
+
end
|
|
233
|
+
|
|
219
234
|
# nil leaves a level unset (so the parent's applies); false for squawk: is stored, to silence it.
|
|
220
235
|
def declare_squawk(squawk, squawk_on)
|
|
221
236
|
@squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
|
|
@@ -24,6 +24,14 @@ module Squishling
|
|
|
24
24
|
# adapter for its provider; any other key (top_p, seed, ...) is passed to the provider as-is.
|
|
25
25
|
attr_reader :default_params
|
|
26
26
|
|
|
27
|
+
# Whether requests ask the provider to cache the stable prompt: true (the default), false, or a Hash of options
|
|
28
|
+
# such as { ttl: "1h" } (see Cache). Overridable per class, per method, per escalation step, and per squish! call.
|
|
29
|
+
attr_reader :default_cache
|
|
30
|
+
|
|
31
|
+
# Whether the tokens a request reads from the provider's prompt cache count toward max_total_input_tokens: false
|
|
32
|
+
# (the default) leaves them out, true counts them. Tokens written to the cache always count (see Budget).
|
|
33
|
+
attr_reader :budget_counts_cache_reads
|
|
34
|
+
|
|
27
35
|
# How every squishling class that doesn't declare its own harness uses its escalation (see Harness):
|
|
28
36
|
# :escalation (the default), :squishsum, :judged_squishsum, a harness you registered, or a Hash with type: and
|
|
29
37
|
# its options.
|
|
@@ -67,6 +75,8 @@ module Squishling
|
|
|
67
75
|
@default_model_path = nil
|
|
68
76
|
@default_provider = nil
|
|
69
77
|
@default_params = {}
|
|
78
|
+
@default_cache = true
|
|
79
|
+
@budget_counts_cache_reads = false
|
|
70
80
|
@default_harness = nil
|
|
71
81
|
@logger = nil
|
|
72
82
|
@squawk = nil
|
|
@@ -131,6 +141,21 @@ module Squishling
|
|
|
131
141
|
@default_params = Params.normalize(params, "default_params")
|
|
132
142
|
end
|
|
133
143
|
|
|
144
|
+
# nil restores the default (true).
|
|
145
|
+
def default_cache=(cache)
|
|
146
|
+
@default_cache = cache.nil? || Cache.normalize(cache, "default_cache")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# true or false; unlike default_cache=, nil is not accepted.
|
|
150
|
+
def budget_counts_cache_reads=(counts)
|
|
151
|
+
unless [true, false].include?(counts)
|
|
152
|
+
raise ConfigurationError,
|
|
153
|
+
"Squishling.configure: budget_counts_cache_reads must be true or false, got #{counts.inspect}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@budget_counts_cache_reads = counts
|
|
157
|
+
end
|
|
158
|
+
|
|
134
159
|
def default_harness=(harness)
|
|
135
160
|
@default_harness = harness.nil? ? nil : Harness.normalize(harness, "default_harness")
|
|
136
161
|
end
|
|
@@ -13,8 +13,8 @@ module Squishling
|
|
|
13
13
|
attr_reader :klass, :name, :call_context, :handed_off
|
|
14
14
|
|
|
15
15
|
def initialize(klass:, name:, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil,
|
|
16
|
-
provider: nil, params: nil,
|
|
17
|
-
squawk_on: nil, budget: nil, call_context: {}, handed_off: false)
|
|
16
|
+
provider: nil, params: nil, cache: nil, harness: nil, predicate: nil, fallback: nil, validator: nil,
|
|
17
|
+
squawk: nil, squawk_on: nil, budget: nil, call_context: {}, handed_off: false)
|
|
18
18
|
@klass = klass
|
|
19
19
|
@name = name
|
|
20
20
|
@purpose = purpose
|
|
@@ -23,6 +23,7 @@ module Squishling
|
|
|
23
23
|
@model = model
|
|
24
24
|
@provider = provider
|
|
25
25
|
@params = params
|
|
26
|
+
@cache = cache
|
|
26
27
|
@harness = harness
|
|
27
28
|
@predicate = predicate
|
|
28
29
|
@fallback = fallback
|
|
@@ -37,15 +38,16 @@ module Squishling
|
|
|
37
38
|
# This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
|
|
38
39
|
# can't be overridden: the call must still return the method's result type.
|
|
39
40
|
def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
|
|
40
|
-
provider: nil, params: nil,
|
|
41
|
+
provider: nil, params: nil, cache: nil, harness: nil, max_total_output_tokens: nil,
|
|
42
|
+
max_total_input_tokens: nil)
|
|
41
43
|
call_path = ModelPath.declare(model, escalation, "#{label} squish!")
|
|
42
44
|
raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
45
|
+
|
|
46
|
+
check_context!(context)
|
|
46
47
|
|
|
47
48
|
appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
|
|
48
49
|
call_params = params && Params.normalize(params, "#{label} squish! params")
|
|
50
|
+
call_cache = Cache.normalize(cache, "#{label} squish! cache")
|
|
49
51
|
call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
|
|
50
52
|
call_budget = Budget.build("#{label} squish!", output: max_total_output_tokens, input: max_total_input_tokens)
|
|
51
53
|
self.class.new(
|
|
@@ -56,6 +58,7 @@ module Squishling
|
|
|
56
58
|
model: call_path || @model, provider: call_path ? provider : @provider,
|
|
57
59
|
# merge, not Params.resolve: a nil at the method level must still unset the class's key.
|
|
58
60
|
params: call_params ? (@params || {}).merge(call_params) : @params,
|
|
61
|
+
cache: [call_cache, @cache].find { |level| !level.nil? },
|
|
59
62
|
harness: call_harness || @harness, budget: @budget.merge(call_budget),
|
|
60
63
|
call_context: @call_context.merge((context || {}).transform_keys(&:to_sym)),
|
|
61
64
|
handed_off: true # only squish! builds a definition this way; it is not part of a declared one
|
|
@@ -101,7 +104,7 @@ module Squishling
|
|
|
101
104
|
else
|
|
102
105
|
0
|
|
103
106
|
end
|
|
104
|
-
ModelPath.steps(eligible, provider:, params:, turn:)
|
|
107
|
+
ModelPath.steps(eligible, provider:, params:, cache:, turn:)
|
|
105
108
|
end
|
|
106
109
|
|
|
107
110
|
# The steps and provider of the first level (method, class, or config) that declares a model or escalation, or
|
|
@@ -139,6 +142,12 @@ module Squishling
|
|
|
139
142
|
Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
|
|
140
143
|
end
|
|
141
144
|
|
|
145
|
+
# Prompt caching: false, or the options to ask the provider for (see Cache). The config default, overridden by the
|
|
146
|
+
# class, then the method (and a squish! call).
|
|
147
|
+
def cache
|
|
148
|
+
Cache.resolve(Squishling.config.default_cache, klass.squishling_cache, @cache)
|
|
149
|
+
end
|
|
150
|
+
|
|
142
151
|
# The token limits of one call (see Budget): config defaults, overridden limit by limit by the class, then by the
|
|
143
152
|
# method (and a squish! call).
|
|
144
153
|
def budget
|
|
@@ -247,6 +256,12 @@ module Squishling
|
|
|
247
256
|
|
|
248
257
|
private
|
|
249
258
|
|
|
259
|
+
def check_context!(context)
|
|
260
|
+
return if context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
|
|
261
|
+
|
|
262
|
+
raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
|
|
263
|
+
end
|
|
264
|
+
|
|
250
265
|
# Values JSON can't represent (NaN, Infinity, cycles) are invalid output, not a raw JSON error.
|
|
251
266
|
def jsonify_return(attrs)
|
|
252
267
|
[Schema.jsonify(attrs), []]
|
data/lib/squishling/judge.rb
CHANGED
|
@@ -111,6 +111,11 @@ module Squishling
|
|
|
111
111
|
value.is_a?(String) && !value.strip.empty?
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# A judgment is not a chat, so it has nothing to cache whatever its step says.
|
|
115
|
+
def judgment_steps(judge)
|
|
116
|
+
ModelPath.steps([judge], provider: nil, params: {}).map { |step| step.with(cache: false) }
|
|
117
|
+
end
|
|
118
|
+
|
|
114
119
|
def judgment?
|
|
115
120
|
@judge&.fetch(:type) == :judgment
|
|
116
121
|
end
|
|
@@ -119,8 +124,9 @@ module Squishling
|
|
|
119
124
|
# under its own, like any escalation step; a judgment judge sends only its own params, as provider options.
|
|
120
125
|
def resolve_steps(path)
|
|
121
126
|
if (judge = @judge)
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
return judgment_steps(judge) if judge[:type] == :judgment
|
|
128
|
+
|
|
129
|
+
return ModelPath.steps([judge], provider: nil, params: @definition.params, cache: @definition.cache)
|
|
124
130
|
end
|
|
125
131
|
|
|
126
132
|
rest = path.drop_while { |step| step == path.first }
|
|
@@ -16,7 +16,7 @@ module Squishling
|
|
|
16
16
|
@harness = harness
|
|
17
17
|
@handed_off = handed_off
|
|
18
18
|
@adapter = Squishling.config.resolved_adapter
|
|
19
|
-
@tally = Budget::Tally.new(budget)
|
|
19
|
+
@tally = Budget::Tally.new(budget, cache_reads: Squishling.config.budget_counts_cache_reads)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# The budget limit this client's requests have reached ({ limit:, max:, used: }), or nil. Checked before each
|
|
@@ -89,7 +89,8 @@ module Squishling
|
|
|
89
89
|
def complete(chat, message, step, reask)
|
|
90
90
|
messages = chat.messages + [{ role: :user, content: message }]
|
|
91
91
|
request = Adapter::Request.new(label: @label, model: step.model, provider: step.provider,
|
|
92
|
-
instructions: chat.instructions, messages:, schema: chat.schema, params: step.request_params(reask)
|
|
92
|
+
instructions: chat.instructions, messages:, schema: chat.schema, params: step.request_params(reask),
|
|
93
|
+
cache: step.cache)
|
|
93
94
|
response = @adapter.complete(request)
|
|
94
95
|
unless response.is_a?(Adapter::Response)
|
|
95
96
|
raise ConfigurationError, "#{@label}: the #{@adapter.class} adapter must return a " \
|
|
@@ -17,9 +17,10 @@ module Squishling
|
|
|
17
17
|
# retry_temperature: (a Numeric) is the temperature for the step's same-chat retries after the first attempt.
|
|
18
18
|
module ModelPath
|
|
19
19
|
# One attempt: the model, its provider, the fully resolved generation params, whether the previous
|
|
20
|
-
# step's rejected output is shown to it,
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
# step's rejected output is shown to it, the temperature its retries in the same chat use, if any, and the
|
|
21
|
+
# prompt caching options to request (false for none, see Cache).
|
|
22
|
+
Step = Data.define(:model, :provider, :params, :forward_rejected, :retry_temperature, :cache) do
|
|
23
|
+
def initialize(model:, provider:, params:, forward_rejected:, retry_temperature: nil, cache: false)
|
|
23
24
|
super
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -35,7 +36,7 @@ module Squishling
|
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
STEP_KEYS = %i[model provider params attempts priority order rotate forward_rejected include_when
|
|
39
|
+
STEP_KEYS = %i[model provider params cache attempts priority order rotate forward_rejected include_when
|
|
39
40
|
retry_temperature].freeze
|
|
40
41
|
|
|
41
42
|
module_function
|
|
@@ -72,13 +73,14 @@ module Squishling
|
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
# One Step per attempt. The level's provider applies to steps that don't name their own, and each
|
|
75
|
-
# step's params are merged over the level-resolved params
|
|
76
|
+
# step's params are merged over the level-resolved params, and its cache: replaces the level-resolved one (a Hash
|
|
77
|
+
# or false, see Cache). turn is this call's place in the method's rotation
|
|
76
78
|
# (see Rotation): each tier of tied priorities starts turn steps further along.
|
|
77
|
-
def steps(path, provider:, params:, turn: 0)
|
|
79
|
+
def steps(path, provider:, params:, cache: false, turn: 0)
|
|
78
80
|
rotated(path, turn).flat_map do |step|
|
|
79
81
|
attempt = Step.new(model: step[:model], provider: step[:provider] || provider,
|
|
80
82
|
params: Params.resolve(params, step[:params]), forward_rejected: step[:forward_rejected],
|
|
81
|
-
retry_temperature: step[:retry_temperature])
|
|
83
|
+
retry_temperature: step[:retry_temperature], cache: Cache.resolve(cache, step[:cache]))
|
|
82
84
|
[attempt] * step[:attempts]
|
|
83
85
|
end
|
|
84
86
|
end
|
|
@@ -131,7 +133,7 @@ module Squishling
|
|
|
131
133
|
def normalize_step(step, label, extras: [])
|
|
132
134
|
case step
|
|
133
135
|
when String, Symbol
|
|
134
|
-
{ model: model_name(step, label), provider: nil, params: nil, attempts: 1, forward_rejected: true }
|
|
136
|
+
{ model: model_name(step, label), provider: nil, params: nil, cache: nil, attempts: 1, forward_rejected: true }
|
|
135
137
|
when Hash then normalize_hash(step, label, extras)
|
|
136
138
|
else
|
|
137
139
|
raise ConfigurationError, "#{label}: each step must be a model name or a Hash with model:, got #{step.inspect}"
|
|
@@ -169,7 +171,8 @@ module Squishling
|
|
|
169
171
|
end
|
|
170
172
|
|
|
171
173
|
params = step[:params] && Params.normalize(step[:params], "#{label} (#{model}) params")
|
|
172
|
-
|
|
174
|
+
cache = Cache.normalize(step[:cache], "#{label} (#{model}) cache")
|
|
175
|
+
normalized = { model:, provider: step[:provider], params:, cache:, attempts:, forward_rejected: }
|
|
173
176
|
normalized.merge(step.slice(:priority, :rotate, :include_when, :retry_temperature, *extras))
|
|
174
177
|
end
|
|
175
178
|
|
data/lib/squishling/observer.rb
CHANGED
|
@@ -17,8 +17,13 @@ module Squishling
|
|
|
17
17
|
:request)
|
|
18
18
|
|
|
19
19
|
# What an observer's block returns: the request's token usage and the model that answered, never the content.
|
|
20
|
-
# A count the provider didn't report is nil.
|
|
21
|
-
|
|
20
|
+
# A count the provider didn't report is nil. cache_read_tokens and cache_write_tokens are the input tokens served
|
|
21
|
+
# from, and written to, the provider's prompt cache (see Cache), so a hit is visible.
|
|
22
|
+
Usage = Data.define(:input_tokens, :output_tokens, :model, :cache_read_tokens, :cache_write_tokens) do
|
|
23
|
+
def initialize(input_tokens:, output_tokens:, model:, cache_read_tokens: nil, cache_write_tokens: nil)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
22
27
|
|
|
23
28
|
BUILT_IN = %i[coolhand].freeze
|
|
24
29
|
|
|
@@ -96,6 +101,8 @@ module Squishling
|
|
|
96
101
|
model = response.respond_to?(:model) ? response.model : nil
|
|
97
102
|
Usage.new(input_tokens: token_count(tokens, :input_tokens, :input),
|
|
98
103
|
output_tokens: token_count(tokens, :output_tokens, :output),
|
|
104
|
+
cache_read_tokens: token_count(tokens, :cache_read_tokens, :cache_read),
|
|
105
|
+
cache_write_tokens: token_count(tokens, :cache_write_tokens, :cache_write),
|
|
99
106
|
model: model.respond_to?(:id) ? model.id : model)
|
|
100
107
|
end
|
|
101
108
|
|
data/lib/squishling/run.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Squishling
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
step = Harness::OptionTypes.check(:step, declaration, label, :"harness step")
|
|
76
|
-
ModelPath.steps([step], provider: nil, params: @definition.params).freeze
|
|
76
|
+
ModelPath.steps([step], provider: nil, params: @definition.params, cache: @definition.cache).freeze
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
# Makes each attempt of the stages in order (the escalation's steps when none are given) until an output
|
|
@@ -82,7 +82,8 @@ module Squishling
|
|
|
82
82
|
payload = Schema.jsonify(input)
|
|
83
83
|
@requests << Request.new(label:, type: :judgment, role: :judge, sample: nil, attempt: call[:attempt],
|
|
84
84
|
attempts: call[:attempts], model: step.model, provider: step.provider, params: step.params,
|
|
85
|
-
system_prompt: nil, output_schema: nil, messages: [], conversation: nil, payload:,
|
|
85
|
+
cache: step.cache, system_prompt: nil, output_schema: nil, messages: [], conversation: nil, payload:,
|
|
86
|
+
reply: outcome)
|
|
86
87
|
outcome
|
|
87
88
|
end
|
|
88
89
|
|
|
@@ -150,8 +151,9 @@ module Squishling
|
|
|
150
151
|
def record(request, call, outcome, conversation)
|
|
151
152
|
@requests << Request.new(label: request.label, type: :chat, role: call[:role], sample: call[:sample]&.to_s,
|
|
152
153
|
attempt: call[:attempt], attempts: call[:attempts], model: request.model, provider: request.provider,
|
|
153
|
-
params: request.params,
|
|
154
|
-
|
|
154
|
+
params: request.params, cache: request.cache, system_prompt: request.instructions,
|
|
155
|
+
output_schema: request.schema["schema"], messages: request.messages, conversation:,
|
|
156
|
+
payload: Request.payload_of(request.messages), reply: outcome)
|
|
155
157
|
end
|
|
156
158
|
end
|
|
157
159
|
|
|
@@ -12,9 +12,14 @@ module Squishling
|
|
|
12
12
|
# sent. For a judge they are those of the call being judged, and candidates holds the two samples. messages is
|
|
13
13
|
# the conversation as sent (empty for a System One judgment, whose payload is the input). conversation is the
|
|
14
14
|
# index of the chat in Adapter#conversations (nil for a judgment): a retry is the same conversation, an
|
|
15
|
-
# escalation a new one. reply is the Adapter::Response, or the error raised instead.
|
|
15
|
+
# escalation a new one. reply is the Adapter::Response, or the error raised instead. cache is the prompt caching
|
|
16
|
+
# asked for: false, or a Hash of options.
|
|
16
17
|
Request = Data.define(:label, :type, :role, :sample, :attempt, :attempts, :model, :provider, :params,
|
|
17
|
-
:system_prompt, :output_schema, :messages, :conversation, :payload, :reply) do
|
|
18
|
+
:system_prompt, :output_schema, :messages, :conversation, :payload, :reply, :cache) do
|
|
19
|
+
def initialize(reply:, cache: false, **attributes)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
18
23
|
def arguments
|
|
19
24
|
input["arguments"]
|
|
20
25
|
end
|
data/lib/squishling/version.rb
CHANGED
data/lib/squishling.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "squishling/version"
|
|
|
8
8
|
require_relative "squishling/errors"
|
|
9
9
|
require_relative "squishling/configuration"
|
|
10
10
|
require_relative "squishling/params"
|
|
11
|
+
require_relative "squishling/cache"
|
|
11
12
|
require_relative "squishling/named_registry"
|
|
12
13
|
require_relative "squishling/secret"
|
|
13
14
|
require_relative "squishling/adapter/error_body"
|
|
@@ -163,11 +164,12 @@ module Squishling
|
|
|
163
164
|
# it); return it from the method. The overrides apply to this call only: append_to_purpose adds to (or,
|
|
164
165
|
# with false, replaces) the declared sections, context is sent alongside the declared squish_context,
|
|
165
166
|
# model/escalation (one or the other)/provider/purpose/harness replace the declared ones, and params merge
|
|
166
|
-
# key by key over them.
|
|
167
|
+
# key by key over them. cache: replaces the declared prompt caching setting (false turns it off, see Cache).
|
|
168
|
+
# max_total_output_tokens/max_total_input_tokens cap what the call may spend across all its
|
|
167
169
|
# requests (false lifts a declared cap).
|
|
168
170
|
def squish!(append_to_purpose: nil, context: nil, purpose: nil, model: nil, escalation: nil, provider: nil,
|
|
169
|
-
params: nil, harness: nil, max_total_output_tokens: nil, max_total_input_tokens: nil)
|
|
171
|
+
params: nil, cache: nil, harness: nil, max_total_output_tokens: nil, max_total_input_tokens: nil)
|
|
170
172
|
Router.hand_off(self, { append_to_purpose:, context:, purpose:, model:, escalation:, provider:, params:,
|
|
171
|
-
harness:, max_total_output_tokens:, max_total_input_tokens: })
|
|
173
|
+
cache:, harness:, max_total_output_tokens:, max_total_input_tokens: })
|
|
172
174
|
end
|
|
173
175
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: squishling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
@@ -78,6 +78,7 @@ files:
|
|
|
78
78
|
- lib/squishling/adapters/ruby_llm.rb
|
|
79
79
|
- lib/squishling/appendices.rb
|
|
80
80
|
- lib/squishling/budget.rb
|
|
81
|
+
- lib/squishling/cache.rb
|
|
81
82
|
- lib/squishling/class_methods.rb
|
|
82
83
|
- lib/squishling/class_registry.rb
|
|
83
84
|
- lib/squishling/collisions.rb
|