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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14b40817f3f49682f1ed311aa5c05007dfc7f55957c9cf89683fd26fea962aa7
4
- data.tar.gz: 32b93a239eaa8f5994675a24fca6598e5e0a9edeb8d77efa97ebcf9783db6aa4
3
+ metadata.gz: 1d16d5c74c4eb8fee390ba827002dee27418f0cc66694d1fc2762893b1c027fb
4
+ data.tar.gz: 385bad5ba43f729d35260e7362955c35d5ffe7b4db731d1d6c0abe285e62d569
5
5
  SHA512:
6
- metadata.gz: 184955d83ece437fbf86d7f93c2103a1c61302d8df9b70f7430b49b2896c7aab42e04d1f7503203979ff1087ed8e05b67baeb767c9283c43f4bafa82c720abd8
7
- data.tar.gz: 351cdbe7b06b587e2c236ff75012fae0bf2fa526613a2d79e6b6c37aac643547bc22cfc74f73cb2e965dc31dd5f0234a7addf034fce8b1de5ea8dd74dab529d3
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 `:output_tokens`
187
- (a response with none never exhausts a budget).
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
@@ -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.
@@ -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, output_tokens (nil if unreported), and model; never the content
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` (nil when the
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:`, and `params:` (generation params; see [Configuration](configuration.md))
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 |
@@ -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
- Request = Data.define(:label, :model, :provider, :instructions, :messages, :schema, :params)
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, :output_tokens, and
23
- # whatever else the provider reports, such as :cache_read_tokens and :thinking_tokens), empty when unknown.
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: detail(usage, "prompt_tokens_details", "cached_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) { chat.ask(last[:content]) }
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)
@@ -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] += count(usage.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
- @squishling_params = Params.normalize(params, "#{self} params") if params
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, harness: nil, when: nil, fallback: nil, validate: nil, squawk: 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, harness: nil, predicate: nil, fallback: nil, validator: nil, squawk: 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, harness: nil, max_total_output_tokens: nil, max_total_input_tokens: 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
- unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
44
- raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
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), []]
@@ -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
- params = judge[:type] == :judgment ? {} : @definition.params
123
- return ModelPath.steps([judge], provider: nil, params:)
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, and the temperature its retries in the same chat use, if any.
21
- Step = Data.define(:model, :provider, :params, :forward_rejected, :retry_temperature) do
22
- def initialize(model:, provider:, params:, forward_rejected:, retry_temperature: nil)
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. turn is this call's place in the method's rotation
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
- normalized = { model:, provider: step[:provider], params:, attempts:, forward_rejected: }
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
 
@@ -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
- Usage = Data.define(:input_tokens, :output_tokens, :model)
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
 
@@ -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:, reply: outcome)
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, system_prompt: request.instructions, output_schema: request.schema["schema"],
154
- messages: request.messages, conversation:, payload: Request.payload_of(request.messages), reply: outcome)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
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. max_total_output_tokens/max_total_input_tokens cap what the call may spend across all its
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.5.1
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