squishling 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +140 -0
- data/README.md +118 -31
- data/docs/configuration.md +75 -21
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +73 -9
- data/docs/harnesses.md +326 -0
- data/docs/measuring-tokens.md +140 -0
- data/docs/naming.md +43 -0
- data/docs/observers.md +80 -0
- data/docs/routing.md +36 -27
- data/docs/schemas.md +24 -5
- data/lib/squishling/appendices.rb +3 -3
- data/lib/squishling/class_methods.rb +60 -29
- data/lib/squishling/collisions.rb +54 -0
- data/lib/squishling/configuration.rb +32 -0
- data/lib/squishling/definition.rb +56 -27
- data/lib/squishling/errors.rb +22 -1
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +93 -0
- data/lib/squishling/harness/registry.rb +69 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +79 -0
- data/lib/squishling/invoker.rb +26 -188
- data/lib/squishling/judge.rb +186 -0
- data/lib/squishling/llm_client.rb +180 -0
- data/lib/squishling/model_path.rb +82 -25
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +109 -0
- data/lib/squishling/params.rb +47 -6
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/router.rb +8 -2
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/source.rb +5 -5
- data/lib/squishling/squawk.rb +52 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +56 -7
- metadata +25 -3
data/docs/coolhand.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Coolhand Labs
|
|
2
|
+
|
|
3
|
+
Squishling ships an [observer](observers.md) for [Coolhand Labs](https://coolhandlabs.com/). With it, every LLM call a
|
|
4
|
+
squished method makes is filed under a named **workload** and **template** in Coolhand. Squishling knows exactly
|
|
5
|
+
where each call sits (which method, which escalation level, which sample, which judge), so it declares that for you.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Add and configure the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem as described in
|
|
10
|
+
[Measuring token spend](measuring-tokens.md#coolhand-labs). **That's all.** Squishling defaults to the Coolhand
|
|
11
|
+
observer whenever Coolhand is already available in your codebase: loaded, with an `api_key`, and not disabled
|
|
12
|
+
(`config.enabled = false`). Nothing is added to Squishling's dependencies, and nothing happens when the gem isn't
|
|
13
|
+
there. It needs a version of the `coolhand` gem that has `Coolhand.with_context` (declared workloads); with an older
|
|
14
|
+
one, the observer stays off.
|
|
15
|
+
|
|
16
|
+
To turn it off, set `observer` to `false`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
Squishling.configure do |config|
|
|
20
|
+
config.observer = false # never declare, even though Coolhand is configured
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The other settings:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
Squishling.configure do |config|
|
|
28
|
+
config.observer = :coolhand # always declare; raises ConfigurationError now if the gem is missing or too old
|
|
29
|
+
config.observer = nil # the default: declare when Coolhand is loaded and configured
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
To report somewhere else, or as well, use any [other observer](observers.md). `config.observer` holds one.
|
|
34
|
+
|
|
35
|
+
## What is declared
|
|
36
|
+
|
|
37
|
+
Each request runs inside `Coolhand.with_context(reporter: "squishling", workload: ..., template: ...)`, so Coolhand's
|
|
38
|
+
Net::HTTP interception tags it with the declaration.
|
|
39
|
+
|
|
40
|
+
| | Key | Name |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Workload | `Class#method` | `Class#method` (its `description` is `Class#method`, or `Class#method (rescue)` when a [`squish!`](routing.md#handing-off-to-the-llm-with-squish) call handed it to the LLM) |
|
|
43
|
+
| Template | `Class#method/<role>` | `Class#method · <role>` |
|
|
44
|
+
|
|
45
|
+
`<role>` is where the request sits:
|
|
46
|
+
|
|
47
|
+
| Role | Request |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `squish:L<n>` | Attempt *n* (1-based) of the [escalation](configuration.md#models-and-escalation), counting each attempt of each step (a [custom harness](harnesses.md#custom-harnesses) that escalates stage by stage restarts at L1 for each) |
|
|
50
|
+
| `sample:a`, `sample:b`, ... | A [squishsum](harnesses.md) sample (which runs on a worker thread; the declaration is made on that thread) |
|
|
51
|
+
| `judge` | A harness's judge, chat or System One (all of a judge's attempts share this one template) |
|
|
52
|
+
|
|
53
|
+
So `Parser#call/squish:L2` is the second attempt of `Parser#call`, e.g. `Parser#call · squish L2` in Coolhand.
|
|
54
|
+
|
|
55
|
+
- The model is deliberately not in the key. It is on the log, and the position is the stable identity.
|
|
56
|
+
- Repeated attempts on the same step count as separate levels, because the level is the attempt's position in the
|
|
57
|
+
expanded escalation.
|
|
58
|
+
- A class name long enough to push a key past Coolhand's 255-character limit is left undeclared rather than
|
|
59
|
+
breaking the call.
|
|
60
|
+
- Squishling's declaration is made inside any `Coolhand.with_context` you wrapped around the call, so for the fields it
|
|
61
|
+
sets, Squishling's wins.
|
|
62
|
+
- The description never contains the purpose text, which can be a proc that differs per call. The workload's key
|
|
63
|
+
and name stay `Class#method` either way, so calls that went through `squish!` and calls that didn't share one
|
|
64
|
+
workload.
|
|
65
|
+
- `observer = :coolhand` loads the gem when it is assigned, so assign it after `coolhand` is available (the default
|
|
66
|
+
needs no ordering).
|
data/docs/failures.md
CHANGED
|
@@ -10,19 +10,82 @@ A failed attempt moves on to the next one; once the last fails, the error is rai
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| Rate limit, 5xx, overload, timeout, connection error | RubyLLM retries at the HTTP level first (`RubyLLM.config.max_retries`, default 3). If it still fails, moves to the next attempt in a fresh chat; on the last attempt, raises `Squishling::LLMError` with the original exception as its `cause`. |
|
|
12
12
|
| Bad API key, unknown model, missing provider config | Raises `Squishling::ConfigurationError`. Never escalated, never sent to the fallback. |
|
|
13
|
-
| Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError`
|
|
13
|
+
| Provider rejects the request (400 Bad Request), e.g. an unsupported `temperature` or a schema it won't accept | Raises `ConfigurationError` naming the error class and HTTP status, and the params in use; the provider's own message is on `cause`. Never escalated, never sent to the fallback, so a setup mistake can't be silently covered up on every call. |
|
|
14
14
|
| Empty or `nil` response (e.g. a refusal or a max-tokens cutoff) | Moves to the next attempt, then raises `Squishling::InvalidOutputError` |
|
|
15
15
|
| Malformed or truncated JSON | Moves to the next attempt, then raises `InvalidOutputError`. JSON wrapped in a markdown code fence is accepted. |
|
|
16
16
|
| JSON that doesn't match the schema (wrong types, missing or extra keys, `null` in a non-`optional` field, a broken [conditional rule](schemas.md#contracts-beyond-the-schema), root not an object) | Moves to the next attempt with the validation errors, then raises `InvalidOutputError` |
|
|
17
17
|
| Schema-valid output that [`squish_validate`](#output-checks-squish_validate) rejects | Moves to the next attempt with your messages, then raises `InvalidOutputError` |
|
|
18
|
+
| With a [squishsum harness](harnesses.md): two valid samples that differ, with no judge or a judge that rejects both | Raises `Squishling::DisagreementError` (an `InvalidOutputError`), carrying both typed `candidates` |
|
|
18
19
|
|
|
19
20
|
Squishling validates output itself with [json_schemer](https://github.com/davishmcclurg/json_schemer), because
|
|
20
21
|
RubyLLM doesn't, and some providers don't enforce strict mode. When the next attempt is on the same step (same
|
|
21
|
-
model, provider, and
|
|
22
|
-
different step gets a fresh chat with the original input plus the rejected output
|
|
23
|
-
sent to the next step's provider, which may not be the one that
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
model, provider, params, and `forward_rejected:`), the re-ask happens in the same conversation, so the model sees
|
|
23
|
+
what it got wrong. A different step gets a fresh chat with the original input plus the rejected output (truncated to
|
|
24
|
+
4,000 characters) and its errors, so that output is sent to the next step's provider, which may not be the one that
|
|
25
|
+
produced it. Set `forward_rejected: false` on a step to leave both out. `InvalidOutputError` exposes `errors`, `raw`
|
|
26
|
+
(the last response), `attempts`, and `models` (the model tried on each attempt); for a
|
|
27
|
+
[`DisagreementError`](harnesses.md#when-the-harness-fails), `raw` holds both candidates, `attempts` is `nil`, and
|
|
28
|
+
`models` has one entry per role (sample a, sample b, then the judge). With a `logger` configured, every
|
|
29
|
+
escalation is logged as a warning.
|
|
30
|
+
|
|
31
|
+
### What ends up in errors and logs
|
|
32
|
+
|
|
33
|
+
Model output can echo sensitive input, so the raw response is kept in one place: `InvalidOutputError#raw` (plus the opt-in `squawk` hook below).
|
|
34
|
+
`InvalidOutputError#message`, `#errors`, and `config.logger` warnings are built from these:
|
|
35
|
+
|
|
36
|
+
- For unparseable JSON, only the position (`response was not valid JSON (at line 1 column 7)`), never the parser's
|
|
37
|
+
snippet of the response.
|
|
38
|
+
- JSON Schema errors, which name the schema path and the rule that failed. They never contain the rejected value, but
|
|
39
|
+
they do name an extra key the model added (`object property at /foo is a disallowed additional property`), and
|
|
40
|
+
that key name is chosen by the model.
|
|
41
|
+
- Your own `squish_validate` messages, verbatim. If you interpolate result values into them, those values reach
|
|
42
|
+
the message, the logger, and the next attempt's prompt.
|
|
43
|
+
- A fixed message when a response contains a value JSON can't represent (`1e400` parses to `Infinity`, or a string
|
|
44
|
+
with invalid UTF-8).
|
|
45
|
+
- For a failed provider request (`LLMError`, and the `ConfigurationError` for a 400, bad credentials, or a forbidden
|
|
46
|
+
key), only the error class and HTTP status. The provider's own message can echo request content or part of an API
|
|
47
|
+
key, so it stays on `cause` (`error.cause.message`) for you to read or log deliberately.
|
|
48
|
+
- At most the first 20 of these errors, plus a count of the rest, so a very large malformed response can't flood
|
|
49
|
+
the retry message or the log.
|
|
50
|
+
- For a [`DisagreementError`](harnesses.md#when-the-harness-fails), only what Squishling wrote: a `:judgment` judge's
|
|
51
|
+
choice and probability. A chat judge's `reason` is model-written, so it is available as `#reason` but never in the
|
|
52
|
+
message or logs.
|
|
53
|
+
|
|
54
|
+
The same applies to anything you log yourself, such as `error.message` in a [fallback](#fallbacks). Use `error.raw`
|
|
55
|
+
only where you are willing to store model output. To send the output of every attempt somewhere on purpose, use
|
|
56
|
+
[`squawk`](#observing-every-attempt-squawk).
|
|
57
|
+
|
|
58
|
+
## Observing every attempt (`squawk`)
|
|
59
|
+
|
|
60
|
+
`squawk` is an opt-in hook for sending what the model returned to an error tracker or tracing tool. It runs after
|
|
61
|
+
every LLM attempt, accepted or rejected, and does nothing unless you set it:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Squishling.configure do |config|
|
|
65
|
+
config.squawk = lambda do |output:, metadata:, error:|
|
|
66
|
+
ObservabilitySolution.record(output, metadata, error) if error
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Keyword | Value |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `output` | The raw response: a Hash or String, or `nil` when the provider call itself failed |
|
|
74
|
+
| `error` | `nil` for an accepted attempt. Otherwise the `InvalidOutputError` (with `errors` and `raw`) or `LLMError` that ended the attempt |
|
|
75
|
+
| `metadata` | `label` (`"Class#method"`), `attempt`, `attempts` (the escalation's length; under a harness, the sample's or judge's own attempts), `final` (the last attempt), `model`, `provider`, `params`, `input` (the JSON sent: arguments and named context only), `usage` (token counts, when RubyLLM reports them), `role` (`nil` for an escalation attempt, `:sample`, or `:judge`), `sample` (`"a"`, `"b"`, ... for a sample's attempt, else `nil`) |
|
|
76
|
+
|
|
77
|
+
- Set it globally with `config.squawk`, per class with `squishling squawk: ...`, or per method with
|
|
78
|
+
`squish :triage, squawk: ...`. The method's hook wins over the class's, which wins over the configured one;
|
|
79
|
+
`squawk: false` silences an inherited hook. Subclasses and `squish!` calls inherit it.
|
|
80
|
+
- Any object that responds to `call` works. The hook receives only the keywords it declares, unless it takes `**`,
|
|
81
|
+
so a lambda that wants just `error:` is fine and new metadata fields won't break it.
|
|
82
|
+
- It runs inline, so keep it quick. Exceptions it raises propagate unchanged and fail the call, like any of your own
|
|
83
|
+
code, so rescue inside the hook if an outage in your tracing tool shouldn't.
|
|
84
|
+
- `output` is the same object the result is built from, so treat it as read-only.
|
|
85
|
+
- Under a [harness](harnesses.md), it runs for every sample and judge attempt too, each with its own `input`, and
|
|
86
|
+
`role`/`sample` say which one it was. A [custom harness](harnesses.md#reporting-to-squawk) reports the same way.
|
|
87
|
+
- It isn't called for a `ConfigurationError`, for deterministic and fallback returns, or for an attempt where your own
|
|
88
|
+
`squish_validate` raises (that exception propagates first).
|
|
26
89
|
|
|
27
90
|
## Output checks (`squish_validate`)
|
|
28
91
|
|
|
@@ -57,16 +120,17 @@ end
|
|
|
57
120
|
| Error | Raised when |
|
|
58
121
|
|---|---|
|
|
59
122
|
| `Squishling::Error` | Base class for everything below. Raised directly for misuse at call time, e.g. `result` or `squish!` called outside a squished method |
|
|
60
|
-
| `Squishling::ConfigurationError` | Missing
|
|
123
|
+
| `Squishling::ConfigurationError` | Missing purpose or schema, a non-strict schema, invalid or reserved params, an invalid `model:`/`escalation:` declaration, an invalid `append_to_purpose` item or unavailable source, bad credentials, an unknown model, a request the provider rejects (400) |
|
|
61
124
|
| `Squishling::InvalidOutputError` | LLM output still invalid (schema or `squish_validate`) after every attempt in the escalation, or a deterministic/fallback return that doesn't match the schema |
|
|
125
|
+
| `Squishling::DisagreementError` | A subclass of `InvalidOutputError`: a [squishsum harness](harnesses.md#when-the-harness-fails)'s samples disagreed and no judge accepted either. `candidates`, `verdict`, and `reason` describe what happened |
|
|
62
126
|
| `Squishling::LLMError` | The provider call failed on the last attempt in the escalation, after RubyLLM's own retries, including context-length errors (`cause` holds the original) |
|
|
63
127
|
|
|
64
128
|
Errors raised by your own Ruby code are not wrapped.
|
|
65
129
|
|
|
66
130
|
## Fallbacks
|
|
67
131
|
|
|
68
|
-
Use `squish_fallback` to decide what happens when
|
|
69
|
-
`LLMError`.
|
|
132
|
+
Use `squish_fallback` to decide what happens when the LLM path fails with `InvalidOutputError` (including a
|
|
133
|
+
`DisagreementError`) or `LLMError`. It's the one fallback for every [harness](harnesses.md).
|
|
70
134
|
It receives the error plus the method's inputs as keywords, and runs against the instance:
|
|
71
135
|
|
|
72
136
|
```ruby
|
data/docs/harnesses.md
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# Harnesses: escalation, squishsum, and your own
|
|
2
|
+
|
|
3
|
+
A squished call's **harness** decides how it uses its [escalation](configuration.md#models-and-escalation):
|
|
4
|
+
|
|
5
|
+
| Harness | What it does | Requests per call |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `:escalation` (default) | Tries each attempt in order until an output passes the schema and `squish_validate`. | 1 or more |
|
|
8
|
+
| `:squishsum` | Asks the escalation's **first step** twice, concurrently. Identical outputs are accepted; different ones raise `Squishling::DisagreementError`. | 2 or more |
|
|
9
|
+
| `:judged_squishsum` | Like `:squishsum`, but when the two outputs differ, a **judge** picks one or rejects both. | 2 or more, plus 1 or more judge requests when they differ |
|
|
10
|
+
|
|
11
|
+
Need a different trade-off, such as two models plus a tiebreaker or a majority vote? Register your own with
|
|
12
|
+
[`Squishling.register_harness`](#custom-harnesses).
|
|
13
|
+
|
|
14
|
+
Use a squishsum harness when a confidently wrong answer costs more than a second request: one sample can make a
|
|
15
|
+
mistake, but two independent samples rarely make the same one.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
class TicketTriager
|
|
19
|
+
include Squishling
|
|
20
|
+
|
|
21
|
+
squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5"],
|
|
22
|
+
harness: :judged_squishsum # two Haiku samples; Sonnet judges when they differ
|
|
23
|
+
purpose "Assign a priority and a team."
|
|
24
|
+
output_schema do
|
|
25
|
+
string :priority, enum: %w[low medium high]
|
|
26
|
+
string :team
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Declaring a harness
|
|
32
|
+
|
|
33
|
+
`harness:` takes a type, or a Hash with `type:` and its options:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
Squishling.configure { |c| c.default_harness = :squishsum } # every class without its own
|
|
37
|
+
|
|
38
|
+
squishling harness: :judged_squishsum # this class (and subclasses)
|
|
39
|
+
squish :triage, harness: { type: :squishsum, # one method
|
|
40
|
+
compare: ->(a, b, **) { a.priority == b.priority } }
|
|
41
|
+
squish!(harness: :escalation) # one call (see routing.md)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The first level that declares one wins: `squish!`, then the method, then the class, then
|
|
45
|
+
`Squishling.config.default_harness`, then `:escalation`. Options aren't merged across levels.
|
|
46
|
+
|
|
47
|
+
| Option | Harnesses | Description |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`, or a [registered](#custom-harnesses) name (required in the Hash form). |
|
|
50
|
+
| `compare:` | squishsum ones | `->(a, b, **inputs) { ... }`, evaluated against the instance with the two typed results and the method's inputs. Truthy means the samples agree. Default: the two outputs are exactly equal. |
|
|
51
|
+
| `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled one. |
|
|
52
|
+
| `judge_instructions:` | judged ones | A String, or a Proc evaluated against the instance, that replaces the [default judge prompt](#the-default-judge-prompt). |
|
|
53
|
+
|
|
54
|
+
## Samples
|
|
55
|
+
|
|
56
|
+
Both samples run on the escalation's first step, with its model, provider, and params:
|
|
57
|
+
|
|
58
|
+
- **They're independent.** Each sample has its own chat, and the two requests are sent concurrently on separate
|
|
59
|
+
threads. Only the provider requests run on those threads. Parsing, schema validation, `squish_validate`,
|
|
60
|
+
`compare:`, and the judge all run on the calling thread, so your Squishling callbacks never run concurrently.
|
|
61
|
+
RubyLLM instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for `chat.ruby_llm`) do see
|
|
62
|
+
each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
|
|
63
|
+
- **Each one is retried like an escalation step.** A sample with invalid output is re-asked in its own chat, up to
|
|
64
|
+
the first step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
|
|
65
|
+
chat. A sample still failing after its attempts fails the call with `InvalidOutputError` or `LLMError`, as
|
|
66
|
+
usual. Later escalation steps are never used for samples (a judge may use the next one, see
|
|
67
|
+
[The judge](#the-judge)).
|
|
68
|
+
- **Agreement is exact by default.** Free-text fields seldom match word for word, so pass `compare:` to decide
|
|
69
|
+
which fields must match:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
squishling harness: { type: :squishsum, compare: ->(a, b, **) { a.priority == b.priority && a.team == b.team } }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When the samples agree, the first one is returned.
|
|
76
|
+
|
|
77
|
+
## The judge
|
|
78
|
+
|
|
79
|
+
With a judged harness, two valid samples that disagree go to a judge. The judge picks candidate `a` or `b`,
|
|
80
|
+
whose typed result is returned (`squished?` is `true`), or it picks `neither`, which raises
|
|
81
|
+
`DisagreementError`.
|
|
82
|
+
|
|
83
|
+
The judge is:
|
|
84
|
+
|
|
85
|
+
1. the `judge:` step, if declared: a model name, or a Hash with `model:`, `provider:`, `params:`, and `attempts:`
|
|
86
|
+
(as in an escalation step, without `priority:` or `rotate:`), plus `type:`; or
|
|
87
|
+
2. the escalation's next step after the sampled one, with its attempts. Steps tied on `priority:` rotate between calls
|
|
88
|
+
(see [Rotating models](configuration.md#rotating-models-within-a-priority)), so which model that is can vary.
|
|
89
|
+
|
|
90
|
+
A judged harness with neither raises `ConfigurationError` before any sample is requested. A `judge:`
|
|
91
|
+
step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
|
|
92
|
+
(a chat judge's params, though, do merge over the method's).
|
|
93
|
+
|
|
94
|
+
### Chat judges
|
|
95
|
+
|
|
96
|
+
By default the judge is a chat model, asked for a strict `{ "verdict": "a" | "b" | "neither", "reason": "..." }`.
|
|
97
|
+
It gets the same context the samples had, plus the samples themselves:
|
|
98
|
+
|
|
99
|
+
- **System prompt:** the judge prompt, then the operation's own purpose (including any `append_to_purpose`
|
|
100
|
+
sections).
|
|
101
|
+
- **User message:** JSON with the samples' `input` (`arguments` and `context`), the `output_schema`, and the
|
|
102
|
+
`candidates` `a` and `b`.
|
|
103
|
+
|
|
104
|
+
A verdict that doesn't match its schema is retried per the judge step's `attempts:`; a judge that never returns a
|
|
105
|
+
valid verdict raises `InvalidOutputError`. A chat judge's params are the method's
|
|
106
|
+
[generation params](configuration.md#generation-params) with its own `params:` merged over them.
|
|
107
|
+
|
|
108
|
+
### System One judgment models (Jev)
|
|
109
|
+
|
|
110
|
+
A judge can also be a System One decision model, such as TypeSafe's Jev, via
|
|
111
|
+
[RubyLLM judgments](https://rubyllm.com/judgments/) (RubyLLM 2.1+). Decision models return calibrated
|
|
112
|
+
probabilities rather than text, so they are much faster and cheaper than a chat judge.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
squishling harness: {
|
|
116
|
+
type: :judged_squishsum,
|
|
117
|
+
judge: { model: "jev-latest", type: :judgment, min_confidence: 0.8 }
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Squishling asks one `choice` question (`a`, `b`, or `neither`), with the judge prompt as its instructions and the
|
|
122
|
+
operation's purpose, input, output schema, and candidates as the judgment input. A pick of `a` or `b` is
|
|
123
|
+
accepted only when its probability is at least `min_confidence:` (default `0.8`). Otherwise, as with `neither`,
|
|
124
|
+
`DisagreementError` is raised with the choice and its probability in its `reason`. A judgment judge sends only its own
|
|
125
|
+
`params:` (as RubyLLM `provider_options:`). `attempts:` retries a failed request. Configure the provider's
|
|
126
|
+
credentials in RubyLLM as usual.
|
|
127
|
+
|
|
128
|
+
### The default judge prompt
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
You are an impartial judge. Two independent attempts at the same operation returned different outputs,
|
|
132
|
+
candidate "a" and candidate "b". Both already match the required output format, so judge only their
|
|
133
|
+
content. You are given the operation's purpose and its input. Decide which candidate correctly and
|
|
134
|
+
faithfully carries out the purpose for this input. If both do (for example, they differ only in
|
|
135
|
+
wording), choose either one, preferring "a". Choose "neither" only when both are wrong or you can't tell
|
|
136
|
+
whether either is correct. Never combine them or invent a third answer.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
It is available as `Squishling::Harness::DEFAULT_JUDGE_INSTRUCTIONS`. Replace it with `judge_instructions:`:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
squishling harness: { type: :judged_squishsum,
|
|
143
|
+
judge_instructions: "Pick the candidate whose priority follows our SLA rules: ..." }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## When the harness fails
|
|
147
|
+
|
|
148
|
+
A disagreement raises `Squishling::DisagreementError`, a subclass of `InvalidOutputError`. It's raised when the
|
|
149
|
+
samples differ with no judge, or when the judge rejects both. Like any failure on the LLM path, it goes to
|
|
150
|
+
[`squish_fallback`](failures.md#fallbacks), which can still return one of the candidates:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
squish_fallback do |error, **|
|
|
154
|
+
raise error unless error.is_a?(Squishling::DisagreementError)
|
|
155
|
+
|
|
156
|
+
error.candidates.first # already typed and validated; squished? is true
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| Attribute | Value |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `candidates` | The two typed results, `[a, b]` |
|
|
163
|
+
| `verdict` | `nil` when there was no judge; `:neither` when the judge rejected both |
|
|
164
|
+
| `reason` | The judge's reason (or, for a judgment model, the choice and its probability). A chat judge's reason is model-written, so it is not part of `message` or the logs; a judgment model's choice and probability are |
|
|
165
|
+
| `raw` | Both candidates as Hashes, `[a.to_h, b.to_h]` |
|
|
166
|
+
| `attempts` | `nil` |
|
|
167
|
+
| `models` | The model behind each role: sample `a`, sample `b`, then the judge if one ran (one entry each, not one per attempt) |
|
|
168
|
+
|
|
169
|
+
Other failures are unchanged. A sample or judge that never produces valid output raises `InvalidOutputError`, a
|
|
170
|
+
failed request on the last attempt raises `LLMError`, and setup mistakes (including a provider 400 on either
|
|
171
|
+
sample) raise `ConfigurationError`, which is never sent to the fallback.
|
|
172
|
+
|
|
173
|
+
## Custom harnesses
|
|
174
|
+
|
|
175
|
+
A harness is a class that decides how to use the escalation: which samples to ask for, how to compare them, who
|
|
176
|
+
breaks a tie. Register one under a name and it works anywhere a built-in type does: `squishling harness: :name`,
|
|
177
|
+
`squish :method, harness: { type: :name, ... }`, `squish!(harness: ...)`, and `config.default_harness`. It gets
|
|
178
|
+
everything the built-ins get: model, provider, and params resolution, schema validation and
|
|
179
|
+
[`squish_validate`](failures.md#output-checks-squish_validate), retries and escalation,
|
|
180
|
+
[`squawk`](failures.md#observing-every-attempt-squawk) reports with token usage, concurrent samples, and the
|
|
181
|
+
[failure behavior](#when-a-custom-harness-fails) (`squish_fallback`, `DisagreementError`).
|
|
182
|
+
|
|
183
|
+
### Worked example: two models and a tiebreaker
|
|
184
|
+
|
|
185
|
+
Ask two cheap models. If they agree, done. If they don't, ask a third:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class Tiebreaker < Squishling::Harness::Base
|
|
189
|
+
option :tiebreaker, :step, required: true # a model name, or a step Hash
|
|
190
|
+
option :compare, :proc # optional, like squishsum's compare:
|
|
191
|
+
|
|
192
|
+
def call(run, tiebreaker:, compare: nil)
|
|
193
|
+
raise Squishling::ConfigurationError, "#{run.label}: needs two models" if run.stages.size < 2
|
|
194
|
+
|
|
195
|
+
first, second = run.samples(run.stages.first(2)) # the escalation's first two steps, asked concurrently
|
|
196
|
+
return first if run.agree?(first, second, compare)
|
|
197
|
+
|
|
198
|
+
run.escalate(tiebreaker) # they differ: a third opinion
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Squishling.register_harness(:tiebreaker, Tiebreaker) # e.g. in an initializer
|
|
203
|
+
|
|
204
|
+
class TicketTriager
|
|
205
|
+
include Squishling
|
|
206
|
+
|
|
207
|
+
squishling escalation: ["claude-haiku-4-5", "claude-sonnet-5-5"],
|
|
208
|
+
harness: { type: :tiebreaker, tiebreaker: "claude-opus-5-5",
|
|
209
|
+
compare: ->(a, b, **) { a.priority == b.priority } }
|
|
210
|
+
purpose "Assign a priority and a team."
|
|
211
|
+
output_schema do
|
|
212
|
+
string :priority, enum: %w[low medium high]
|
|
213
|
+
string :team
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`TicketTriager.call(...)` sends the ticket to Haiku and Sonnet at the same time. A pair that agrees on `priority` returns
|
|
219
|
+
Haiku's result; otherwise Opus decides. Either way you get the same typed, validated result as every other path.
|
|
220
|
+
`run.stages` groups identical neighbouring steps into one stage (they are one model's attempts), so this harness needs
|
|
221
|
+
two different models in the escalation.
|
|
222
|
+
|
|
223
|
+
For small harnesses, pass a block instead of a class. It becomes the body of a `Squishling::Harness::Base` subclass:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
Squishling.register_harness(:best_of_three) do
|
|
227
|
+
def call(run)
|
|
228
|
+
results = run.samples([run.stages.first] * 3)
|
|
229
|
+
results.max_by { |result| results.count { |other| run.agree?(result, other) } } # the most-agreed-with sample
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Registering
|
|
235
|
+
|
|
236
|
+
`Squishling.register_harness(name, klass = nil, &block)` takes a `Squishling::Harness::Base` subclass or a block, and
|
|
237
|
+
returns the class. `Squishling.harnesses` lists every name, built-ins first.
|
|
238
|
+
|
|
239
|
+
- The registry is global, and a harness is looked up when it is **declared**, so an unknown name fails up front.
|
|
240
|
+
Register in an initializer, before the classes that use the name load.
|
|
241
|
+
- A name is lowercase letters, digits, and underscores. Registering a name twice, or one of `:escalation`,
|
|
242
|
+
`:squishsum`, `:judged_squishsum`, raises `ConfigurationError`.
|
|
243
|
+
- A class must define `call` taking the run as its first argument and each declared option as a keyword (a keyword
|
|
244
|
+
for an option that may be left out needs a default), and be creatable without arguments. This is checked at
|
|
245
|
+
registration.
|
|
246
|
+
- Subclass `Squishling::Harness::Base` directly. The built-in harness classes are implementation, not an extension
|
|
247
|
+
point; write against `Run` as they do.
|
|
248
|
+
- `Squishling.reset_harnesses!` forgets the registered harnesses (the built-ins stay). It is meant for specs.
|
|
249
|
+
`Squishling.reset_config!` leaves them, since they're registered once at boot.
|
|
250
|
+
|
|
251
|
+
### Options
|
|
252
|
+
|
|
253
|
+
Declare each option with `option name, type, required: false`, before registering the class. They are checked when
|
|
254
|
+
the harness is declared, before any request is made, and an unknown, missing, or mistyped option raises
|
|
255
|
+
`ConfigurationError`. A `nil` counts as not given, and `call` receives only the options that were given.
|
|
256
|
+
|
|
257
|
+
| Type | Accepts | `call` receives |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| `:step` | A model name, or a Hash with `model:`, `provider:`, `params:`, `attempts:`, `forward_rejected:` | A normalized, frozen step; pass it to `run.escalate`, or in the Array given to `run.samples` |
|
|
260
|
+
| `:proc` | A Proc | The Proc |
|
|
261
|
+
| `:string`, `:symbol`, `:integer`, `:boolean` | A String, a Symbol (or String, converted), an Integer, `true` or `false` | The value |
|
|
262
|
+
|
|
263
|
+
An option with a shape of its own takes a callable as its type instead. It gets the value, the declaration site (a
|
|
264
|
+
label for error messages), and the option's name, and returns the normalized value, or raises `ConfigurationError`. The
|
|
265
|
+
Hashes, Arrays, and Strings in the result are copied and frozen:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
option :word, ->(value, label, name) do
|
|
269
|
+
raise Squishling::ConfigurationError, "#{label}: #{name}: must be a String" unless value.is_a?(String)
|
|
270
|
+
|
|
271
|
+
value.upcase
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
To check options against each other, define `def self.validate_options!(options, label)` and raise
|
|
276
|
+
`Squishling::ConfigurationError` (include the `label`) to reject.
|
|
277
|
+
|
|
278
|
+
### The run
|
|
279
|
+
|
|
280
|
+
`call(run, **options)` gets a `Squishling::Run`: one squished call, with these primitives. Each returns typed
|
|
281
|
+
results, the same type the deterministic path returns.
|
|
282
|
+
|
|
283
|
+
| Method | What it does |
|
|
284
|
+
|---|---|
|
|
285
|
+
| `run.receiver`, `run.inputs`, `run.label` | The instance, the method's inputs, and `"Class#method"` |
|
|
286
|
+
| `run.steps` | The escalation, one step per attempt |
|
|
287
|
+
| `run.stages` | The escalation grouped by model: `[[haiku, haiku], [sonnet]]` for `[{ model: haiku, attempts: 2 }, sonnet]`. A stage is the attempts of one model |
|
|
288
|
+
| `run.stage(declaration)` | A stage from a model name or a step Hash (a single step is a stage of one attempt). It keeps its own provider, and its `params:` merge over the method's, as a [judge](#the-judge) step does |
|
|
289
|
+
| `run.escalate(*stages)` | Makes each attempt in order until an output passes the schema and `squish_validate`, in the same chat after invalid output and in a fresh one (told about the rejected output) on a new step. With no argument, it runs the escalation: exactly what `:escalation` does |
|
|
290
|
+
| `run.samples(stages)` | Takes an Array of stages (so `run.samples([stage, stage])`, not `run.samples(stage)`; a bare step counts as a stage of one attempt): one sample per stage, each in its own chat and retried like an escalation step, with the requests of every round sent concurrently. Returns the outputs in order. Each stage must be one model's attempts: mixed steps raise `ConfigurationError`. Samples are named `"a"`, `"b"`, ... (up to 26) |
|
|
291
|
+
| `run.judge(judge: nil, instructions: nil)` | Resolves a [judge](#the-judge) now (so a missing one raises `ConfigurationError` before any request) and returns it. `judge:` is a model name, or a [judge Hash](#the-judge) (`model:`, `provider:`, `params:`, `attempts:`, `type:`, `min_confidence:`; no `priority:` or `rotate:`), and `instructions:` a non-blank String or a Proc. `judge.verdict(first, second)` returns a `Squishling::Judge::Verdict` with `choice` (`:a`, `:b`, or `:neither`), `reason`, and `detail`, and `judge.step` is its first attempt. A chat judge's `reason` is model-written, so use `detail` (Squishling's own text, `nil` for a chat judge), not `reason`, in messages and logs. Without `judge:`, the judge is the escalation's second stage |
|
|
292
|
+
| `run.agree?(first, second, compare = nil)` | `compare` (evaluated against the instance with both results and the method's inputs) or, without one, exact equality |
|
|
293
|
+
| `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
|
|
294
|
+
|
|
295
|
+
`call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
|
|
296
|
+
result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
|
|
297
|
+
|
|
298
|
+
### When a custom harness fails
|
|
299
|
+
|
|
300
|
+
A harness fails the way the built-ins do, so `squish_fallback` and the error classes behave predictably:
|
|
301
|
+
|
|
302
|
+
- **Attempts run out.** `run.escalate` and `run.samples` raise `InvalidOutputError` (invalid output after the last
|
|
303
|
+
attempt) or `LLMError` (a failed request on the last attempt) themselves, with the same attributes as always.
|
|
304
|
+
- **Your rule rejects.** Raise one of those errors yourself. `Squishling::DisagreementError.new(candidates:,
|
|
305
|
+
models:)` is the one for samples that don't agree, and `Squishling::InvalidOutputError.new(errors: [...], source: "Harness")` for
|
|
306
|
+
anything else. Both go to [`squish_fallback`](failures.md#fallbacks).
|
|
307
|
+
- **Setup mistakes** raise `ConfigurationError`, which is never retried and never sent to the fallback.
|
|
308
|
+
- **Your own exceptions** (from `call`, `compare:`, or `squish_validate`) propagate unchanged. Squishling doesn't wrap or
|
|
309
|
+
swallow them.
|
|
310
|
+
|
|
311
|
+
Keep model output out of the messages you raise and the lines you `run.log`: it can quote the input. The output is
|
|
312
|
+
available to your code as the results and in `InvalidOutputError#raw`, and to `squawk` if you opt in.
|
|
313
|
+
|
|
314
|
+
### Reporting to `squawk`
|
|
315
|
+
|
|
316
|
+
Every attempt a primitive makes is reported to the [`squawk`](failures.md#observing-every-attempt-squawk) hook, with
|
|
317
|
+
usage, so [token measuring](measuring-tokens.md) works unchanged. `metadata[:role]` says what the attempt was for:
|
|
318
|
+
`nil` for an escalation attempt, `:sample` (with `metadata[:sample]` set to `"a"`, `"b"`, ...), or `:judge`. `attempt`
|
|
319
|
+
and `attempts` count within that sample, judge, or escalation.
|
|
320
|
+
|
|
321
|
+
### Threads
|
|
322
|
+
|
|
323
|
+
Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
|
|
324
|
+
`squish_validate`, and `compare:` all run on the calling thread, and the routing state stays fiber-local. Call a run's
|
|
325
|
+
primitives from the thread `call` runs on: from another one they raise `ConfigurationError`. Don't keep unsynchronized
|
|
326
|
+
state at class level in a harness, since one instance is created per call but the class is shared.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Measuring token spend
|
|
2
|
+
|
|
3
|
+
A squished path costs tokens on every call. A Ruby path costs engineer time once, plus upkeep. Measuring the
|
|
4
|
+
first tells you when the second is worth paying.
|
|
5
|
+
|
|
6
|
+
## What Squishling gives you
|
|
7
|
+
|
|
8
|
+
- `result.squished?` says which path served a call, so you can count LLM calls per class.
|
|
9
|
+
- The [`squawk` hook](failures.md#observing-every-attempt-squawk) runs after every LLM attempt and receives the
|
|
10
|
+
method (`label`, e.g. `"ShipmentUpdate#call"`), the model and provider, which attempt it was, and the token usage
|
|
11
|
+
RubyLLM reported. That is enough to attribute spend to a squishling method without any other tooling.
|
|
12
|
+
- Cost in dollars comes from RubyLLM's model pricing. Multiply the reported tokens by your model's price, or read it
|
|
13
|
+
from RubyLLM directly (see [OpenTelemetry](#opentelemetry), or RubyLLM's `config.instrumenter`).
|
|
14
|
+
|
|
15
|
+
## Deciding when to write the code
|
|
16
|
+
|
|
17
|
+
For each squished path, over a month:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
token cost = calls × (input tokens × input price + output tokens × output price)
|
|
21
|
+
code cost = time to write it + upkeep (new cases, format changes, on-call)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Write the Ruby when the token cost keeps exceeding the code cost, and narrow `squish_when` so only the inputs
|
|
25
|
+
Ruby can't handle still go to the LLM. See [Hardening a path](routing.md#hardening-a-path). If the token cost
|
|
26
|
+
stays small, the debt you haven't taken on is cheaper than the code.
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
| Option | Good for | Setup |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| [Coolhand Labs](#coolhand-labs) | Picking up your squishling calls automatically and measuring their cost and accuracy | The `coolhand` gem and an initializer |
|
|
33
|
+
| [Custom: the `squawk` hook](#custom-the-squawk-hook) | Logging or metrics in your own stack, with no new dependency | A lambda and one config line |
|
|
34
|
+
| [Your own observer](#your-own-observer) | Wrapping each request in your tracing or metrics tool, knowing which method, level, and sample it was | An object with `around(call)` |
|
|
35
|
+
| [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | RubyLLM's tracer and an exporter |
|
|
36
|
+
|
|
37
|
+
### Coolhand Labs
|
|
38
|
+
|
|
39
|
+
Add and initialize the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem. It intercepts the LLM
|
|
40
|
+
requests your app makes, so it picks up your squishling calls automatically and measures their cost, and their
|
|
41
|
+
accuracy through Coolhand's feedback tools. No changes to your squishling classes.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# Gemfile
|
|
45
|
+
gem "coolhand"
|
|
46
|
+
|
|
47
|
+
# config/initializers/coolhand.rb
|
|
48
|
+
Coolhand.configure do |config|
|
|
49
|
+
config.api_key = ENV.fetch("COOLHAND_API_KEY")
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Get an API key at [coolhandlabs.com](https://coolhandlabs.com/). To keep the data in your own infrastructure, point
|
|
54
|
+
`config.base_url` at a Coolhand-compatible endpoint of your own. Squishling also tells Coolhand which workload and
|
|
55
|
+
template each call belongs to; see [Coolhand Labs](coolhand.md).
|
|
56
|
+
|
|
57
|
+
### Custom: the `squawk` hook
|
|
58
|
+
|
|
59
|
+
`config.squawk` runs after every LLM attempt, accepted or rejected, and is handed the method's label, the model,
|
|
60
|
+
and the token usage RubyLLM reported. A lambda can take just the keywords it needs:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
Squishling.configure do |config|
|
|
64
|
+
config.squawk = lambda do |metadata:, **|
|
|
65
|
+
usage = metadata[:usage] || {} # nil when the provider call failed; counts it didn't report are absent
|
|
66
|
+
Rails.logger.info("squishling #{metadata[:label]} model=#{metadata[:model]} " \
|
|
67
|
+
"attempt=#{metadata[:attempt]}/#{metadata[:attempts]} " \
|
|
68
|
+
"in=#{usage[:input_tokens]} out=#{usage[:output_tokens]}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each line carries the `"Class#method"` that made the call, so grouping by label gives you tokens per squishling. A call
|
|
74
|
+
that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
|
|
75
|
+
been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
|
|
76
|
+
[Observing every attempt](failures.md#observing-every-attempt-squawk) for every keyword and the rules for exceptions.
|
|
77
|
+
Under a [harness](harnesses.md), including a [custom one](harnesses.md#custom-harnesses), every sample and judge
|
|
78
|
+
request reaches the hook too. `metadata[:role]` (`nil`, `:sample`, or `:judge`) and `metadata[:sample]` (`"a"`, `"b"`, ...)
|
|
79
|
+
split the spend by what each request was for.
|
|
80
|
+
|
|
81
|
+
### Your own observer
|
|
82
|
+
|
|
83
|
+
An [observer](observers.md) wraps each provider request and gets the request's token usage back from `yield`, so it
|
|
84
|
+
can meter spend knowing which method, escalation level, sample, or judge made the request. Any object that responds to
|
|
85
|
+
`around(call)` is one:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class SpendObserver
|
|
89
|
+
def around(call)
|
|
90
|
+
usage = yield # input_tokens, output_tokens (nil if unreported), and model; never the content
|
|
91
|
+
tags = ["label:#{call.label}", "role:#{call.role || 'squish'}", "model:#{usage.model}"]
|
|
92
|
+
Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags:)
|
|
93
|
+
Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags:)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Squishling.configure { |config| config.observer = SpendObserver.new }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
An observer sees usage, never the model's output or your inputs. See [Observers](observers.md) for the `call` fields
|
|
101
|
+
and the rules.
|
|
102
|
+
|
|
103
|
+
### OpenTelemetry
|
|
104
|
+
|
|
105
|
+
RubyLLM 2.1+ ships its own OpenTelemetry tracer. Each request becomes a span with the model, request settings and
|
|
106
|
+
token usage (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`), following the OpenTelemetry GenAI
|
|
107
|
+
conventions. It does not record message text. Your app owns the SDK and exporters, and the spans work with any OTLP
|
|
108
|
+
backend, [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
|
|
109
|
+
included.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
# Gemfile
|
|
113
|
+
gem "opentelemetry-sdk"
|
|
114
|
+
gem "opentelemetry-exporter-otlp" # or your backend's exporter
|
|
115
|
+
|
|
116
|
+
# config/initializers/opentelemetry.rb
|
|
117
|
+
OpenTelemetry::SDK.configure # reads the standard OTEL_* environment variables
|
|
118
|
+
RubyLLM::OpenTelemetry.enable # starts tracing RubyLLM requests
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Spans don't say which squishling made the request. To attribute them, wrap the call in `RubyLLM.workflow("Name")`, or
|
|
122
|
+
use an [observer](observers.md) that adds the label to the current span. RubyLLM's `config.instrumenter` also reports
|
|
123
|
+
a `usage.ruby_llm` event per request, with tokens and cost, if you would rather meter than trace.
|
|
124
|
+
|
|
125
|
+
## Privacy
|
|
126
|
+
|
|
127
|
+
Squishling only sends the model what you name: the method arguments, any `squish_context` values, and any source you
|
|
128
|
+
append. Tools that sit on the request path are a separate route out of your process, and they see both sides:
|
|
129
|
+
|
|
130
|
+
- **Request interceptors, such as `coolhand`,** record the prompts (your purpose text and those inputs) and the
|
|
131
|
+
model's responses. Coolhand sends them to coolhandlabs.com unless you point `config.base_url` at an endpoint of your
|
|
132
|
+
own.
|
|
133
|
+
- **A `squawk` hook that forwards `output:` or `metadata[:input]`** sends model output and your inputs wherever you
|
|
134
|
+
send them. That is the sanctioned route for model output; see
|
|
135
|
+
[Observing every attempt](failures.md#observing-every-attempt-squawk). The examples above log only metadata.
|
|
136
|
+
- **OpenTelemetry** with RubyLLM's tracer records usage and request settings, not message text.
|
|
137
|
+
- **An observer** sees timing, token usage, and the call's metadata (method, model, level), never the model's output or
|
|
138
|
+
your inputs.
|
|
139
|
+
|
|
140
|
+
Check what your tool stores before pointing it at inputs that contain personal data.
|