ruby_llm-contract 1.3.1 → 1.4.1
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 +64 -5
- data/README.md +9 -8
- data/docs/guide/eval_first.md +7 -6
- data/docs/guide/getting_started.md +11 -9
- data/docs/guide/llm_judge.md +6 -5
- data/docs/guide/migration.md +6 -0
- data/docs/guide/multimodal_input.md +14 -11
- data/docs/guide/optimizing_retry_policy.md +48 -25
- data/docs/guide/prompt_ast.md +3 -3
- data/docs/guide/rails_integration.md +7 -1
- data/docs/guide/relation_to_agent.md +2 -1
- data/docs/guide/relation_to_tribunal.md +5 -5
- data/docs/guide/testing.md +13 -12
- data/docs/guide/why.md +3 -3
- data/examples/00_basics.rb +9 -3
- data/examples/01_fallback_showcase.rb +1 -1
- data/examples/02_real_llm_minimal.rb +10 -4
- data/examples/06_retry_variants.rb +20 -16
- data/examples/README.md +2 -2
- data/lib/ruby_llm/contract/adapters/ruby_llm.rb +1 -0
- data/lib/ruby_llm/contract/concerns/eval_host.rb +7 -2
- data/lib/ruby_llm/contract/concerns/production_mode_context.rb +13 -4
- data/lib/ruby_llm/contract/eval/candidate_label.rb +1 -1
- data/lib/ruby_llm/contract/eval/evaluator/proc_evaluator.rb +2 -0
- data/lib/ruby_llm/contract/eval/model_comparison.rb +2 -10
- data/lib/ruby_llm/contract/step/base.rb +11 -1
- data/lib/ruby_llm/contract/step/retry_executor.rb +11 -2
- data/lib/ruby_llm/contract/step/retry_policy.rb +5 -0
- data/lib/ruby_llm/contract/version.rb +1 -1
- data/lib/ruby_llm/contract.rb +18 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58ba70123cbc8bc4c7d3c9dc85ce430f1f1492ceb031df35ae0ea3f18f534dd8
|
|
4
|
+
data.tar.gz: 24fed341a96267536d66532926d0e19cb4eb59e2ffd95467be1ca861d9dbe4f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2cc04307951beb543adb0aae5876013767256e90501ed65be68cc9b4918e547568860bd377cf94651777faaa3159b5be47d3b054445223ee5d7f1a86a5aea697
|
|
7
|
+
data.tar.gz: 8c06562bc7093a78d50c3e42294875884e6537d326bf7a474dc69985f7a5798209cedbe2691b6acd83050857ef715d3ef963f5a08358bc3c79c2984e51a76014
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.1 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`compare_models` runs each candidate on a Step with a retry chain.** The
|
|
8
|
+
Step's `retry_policy` chain picked the model of every attempt, so without
|
|
9
|
+
`production_mode:` each candidate ran the chain's first model (and escalated
|
|
10
|
+
through the chain) under its own label: the scores and costs were all the
|
|
11
|
+
same model's, and `best_for` and `recommend` could name a model never run.
|
|
12
|
+
A candidate now runs without the chain, on its own model. This also holds
|
|
13
|
+
for the Steps of a Pipeline compared with `compare_models`. Without
|
|
14
|
+
`production_mode:`, a policy that only sets `attempts` picks no model, so
|
|
15
|
+
it stays: the candidate keeps its same-model retries, as before; and a
|
|
16
|
+
`retry_policy_override` passed in `context:` is still used as given. With
|
|
17
|
+
`production_mode:`, the candidate-and-fallback policy replaces both, as
|
|
18
|
+
before.
|
|
19
|
+
- **An `evaluator:` that returns an `EvaluationResult` keeps its score.** The
|
|
20
|
+
result was taken as a truthy value, so every case passed with 1.0 - also one
|
|
21
|
+
the evaluator scored 0.0 with `passed: false`, as a judge built on another gem
|
|
22
|
+
does (the RubyLLM::Tribunal example in the docs). It is now used as returned.
|
|
23
|
+
- **The `production_mode:` table draws no chain for a candidate run alone.**
|
|
24
|
+
Since 1.4.0 a candidate whose fallback would send the same request runs
|
|
25
|
+
single-shot, but the table still showed `candidate → fallback` with `0%`
|
|
26
|
+
when the two labels differed (`gpt-5-mini (effort: low)` with fallback
|
|
27
|
+
`gpt-5-mini`). It now shows the candidate alone with `—`, as for a fallback
|
|
28
|
+
equal to the candidate.
|
|
29
|
+
|
|
30
|
+
## 1.4.0 (2026-10-11)
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`reasoning_effort: :default` sends no effort for one call, candidate or
|
|
35
|
+
retry-chain element.** A Step with `reasoning_effort "none"` sent that effort to
|
|
36
|
+
every model, also one given in the context, a `compare_models` candidate or a
|
|
37
|
+
retry-chain element without an effort of its own, and a model without reasoning
|
|
38
|
+
rejected the call (OpenAI: "Unsupported parameter: 'reasoning.effort'"). Nothing
|
|
39
|
+
could take the class effort out short of a subclass with `reasoning_effort :default`,
|
|
40
|
+
and `reasoning_effort: nil` was ignored. `:default` - the word the DSL already
|
|
41
|
+
uses - now works in `context:`, in candidates (`{ model: "gpt-4.1-mini",
|
|
42
|
+
reasoning_effort: :default }`), in a `production_mode:` fallback, in `escalate`
|
|
43
|
+
/ `retry_policy models:` elements and as `CANDIDATES=gpt-4.1-mini@default` in
|
|
44
|
+
`rake ruby_llm_contract:optimize`. A `thinking budget:` stays. `nil` still means
|
|
45
|
+
"not set", and the gem does not drop an effort by itself based on the model.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **A retry attempt's `trace.attempts[n][:config]` shows the effort it was sent.**
|
|
50
|
+
It showed the chain element, so an element without an effort, which runs with the
|
|
51
|
+
call's or the class's, looked as if it had none. Such an attempt now has
|
|
52
|
+
`config: { model:, reasoning_effort: }`; an attempt sent no effort has no
|
|
53
|
+
`config`, as before. Code comparing these hashes may need the extra key.
|
|
54
|
+
- **`production_mode:` skips a fallback that would send the candidate's own request.**
|
|
55
|
+
A fallback without an effort runs with the candidate's, so `{ model: "gpt-4.1-mini",
|
|
56
|
+
reasoning_effort: "low" }` with `fallback: "gpt-4.1-mini"` retried the same call.
|
|
57
|
+
It now runs single-shot, as a fallback equal to the candidate already did. The
|
|
58
|
+
comparison counts the effort each would send: inherited from the call or the
|
|
59
|
+
Step, and none for `:default`.
|
|
60
|
+
|
|
3
61
|
## 1.3.1 (2026-10-11)
|
|
4
62
|
|
|
5
63
|
### Fixed
|
|
@@ -8,11 +66,12 @@
|
|
|
8
66
|
comparison.** With `runs > 1` each report is an `AggregatedReport`: its results
|
|
9
67
|
hold the cases of all N runs, while `total_cost` is the mean cost of one run. The
|
|
10
68
|
cost per call divided one run's cost by the cases of every run, so with `runs: 3`
|
|
11
|
-
a $0.002 call showed as $0.0007.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
69
|
+
a $0.002 call showed as $0.0007. Every candidate of one `compare_models` call runs
|
|
70
|
+
the same number of times, so the ranking held: `cost_per_call`, the rationale and
|
|
71
|
+
`savings` were wrong, `best` and the retry chain were not. Only a comparison put
|
|
72
|
+
together from reports with different run counts could pick the wrong model, since
|
|
73
|
+
a candidate measured over more runs looked cheaper. Cases are now counted per run.
|
|
74
|
+
`Step.recommend` always compares with one run and was not affected.
|
|
16
75
|
|
|
17
76
|
## 1.3.0 (2026-10-11)
|
|
18
77
|
|
data/README.md
CHANGED
|
@@ -15,12 +15,13 @@ gem "ruby_llm-contract"
|
|
|
15
15
|
```ruby
|
|
16
16
|
RubyLLM.configure do |c|
|
|
17
17
|
c.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
18
|
-
c.default_model = "gpt-4.1-mini" # used when a Step has no explicit model
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
# Required: boots the gem so `Step.run` knows how to talk to your LLM.
|
|
22
|
-
#
|
|
23
|
-
RubyLLM::Contract.configure
|
|
21
|
+
# An empty block is fine; options such as `c.logger` go here too.
|
|
22
|
+
RubyLLM::Contract.configure do |c|
|
|
23
|
+
c.default_model = "gpt-4.1-mini" # used when a Step has no explicit model
|
|
24
|
+
end
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Works with any `ruby_llm` provider (OpenAI, Anthropic, Gemini, etc). Requires `ruby_llm ~> 2.0` and Ruby ≥ 3.2.
|
|
@@ -60,7 +61,7 @@ end
|
|
|
60
61
|
Three things to notice in the snippet above:
|
|
61
62
|
|
|
62
63
|
- **`{input}` is a gem-specific prompt placeholder** — at call time `SummarizeArticle.run(article_text)` interpolates `article_text` into that slot. For `input_type Hash`, each top-level key becomes its own placeholder (e.g. `{title}`, `{body}`). Plain text in, plain text out — no ERB, no `String#%`.
|
|
63
|
-
- **`validate("...") { |o, _| ... }`** — each block receives `(parsed_output,
|
|
64
|
+
- **`validate("...") { |o, _| ... }`** — each block receives `(parsed_output, input)`; the convention `|o, _|` reads "output, ignore input". Use `o[:key]` to assert on any field your schema declared. Return false → status `:validation_failed`, retry fires.
|
|
64
65
|
- **`retry_policy do escalate(...) end`** — block form for mixed configs (strings + hashes with per-attempt options). For uniform model lists you can use the shorthand `retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1]` (it's the same DSL — `models` is an alias for `escalate`).
|
|
65
66
|
|
|
66
67
|
```ruby
|
|
@@ -68,7 +69,7 @@ result = SummarizeArticle.run(article_text)
|
|
|
68
69
|
result.status # => :ok (or :validation_failed if all steps fail)
|
|
69
70
|
result.parsed_output # => { tldr: "...", takeaways: [...], tone: "..." }
|
|
70
71
|
result.trace[:model] # => "gpt-4.1-mini" (winning step)
|
|
71
|
-
result.trace[:cost] # => 0.
|
|
72
|
+
result.trace[:cost] # => 0.000309 (total across all attempts)
|
|
72
73
|
|
|
73
74
|
result.trace[:attempts]
|
|
74
75
|
# => [
|
|
@@ -78,7 +79,7 @@ result.trace[:attempts]
|
|
|
78
79
|
# status: :validation_failed,
|
|
79
80
|
# usage: { input_tokens: 256, output_tokens: 84 },
|
|
80
81
|
# latency_ms: 45,
|
|
81
|
-
# cost: 0.
|
|
82
|
+
# cost: 0.000059
|
|
82
83
|
# },
|
|
83
84
|
# {
|
|
84
85
|
# attempt: 2,
|
|
@@ -86,7 +87,7 @@ result.trace[:attempts]
|
|
|
86
87
|
# status: :ok,
|
|
87
88
|
# usage: { input_tokens: 256, output_tokens: 92 },
|
|
88
89
|
# latency_ms: 92,
|
|
89
|
-
# cost: 0.
|
|
90
|
+
# cost: 0.000250
|
|
90
91
|
# }
|
|
91
92
|
# ]
|
|
92
93
|
```
|
|
@@ -114,7 +115,7 @@ end
|
|
|
114
115
|
expect(SummarizeArticle).to pass_eval("regression").without_regressions
|
|
115
116
|
```
|
|
116
117
|
|
|
117
|
-
A bad prompt edit or model swap that drops accuracy on the frozen dataset → red CI, blocked merge.
|
|
118
|
+
A bad prompt edit or model swap that drops accuracy on the frozen dataset → red CI, blocked merge. Save a baseline once (`report.save_baseline!`, or `t.save_baseline = true` in the rake task); until one exists, `without_regressions` has nothing to compare against and passes. Every production miss should become the next `add_case`. See [Prevent silent prompt regressions](docs/guide/eval_first.md) for the full flywheel.
|
|
118
119
|
|
|
119
120
|
## Do I need this?
|
|
120
121
|
|
data/docs/guide/eval_first.md
CHANGED
|
@@ -84,7 +84,8 @@ Compare two prompt versions on the same eval:
|
|
|
84
84
|
diff = SummarizeArticleV2.compare_with(
|
|
85
85
|
SummarizeArticleV1,
|
|
86
86
|
eval: "regression",
|
|
87
|
-
model: "gpt-4.1-mini"
|
|
87
|
+
model: "gpt-4.1-mini",
|
|
88
|
+
context: { retry_policy_override: nil } # run mini, not the step's retry chain
|
|
88
89
|
)
|
|
89
90
|
|
|
90
91
|
diff.safe_to_switch? # => true if no cases regressed
|
|
@@ -103,7 +104,7 @@ end
|
|
|
103
104
|
|
|
104
105
|
# Prompt iteration happens afterward
|
|
105
106
|
diff = SummarizeArticleV2.compare_with(
|
|
106
|
-
SummarizeArticleV1, eval: "regression", model: "gpt-4.1-mini"
|
|
107
|
+
SummarizeArticleV1, eval: "regression", model: "gpt-4.1-mini", context: { retry_policy_override: nil }
|
|
107
108
|
)
|
|
108
109
|
```
|
|
109
110
|
|
|
@@ -124,7 +125,7 @@ Good for: offline smoke tests, local development, testing evaluator wiring, chec
|
|
|
124
125
|
|
|
125
126
|
Not enough for real prompt decisions. For those:
|
|
126
127
|
|
|
127
|
-
- `run_eval(..., context: { model: "..." })` with a real model
|
|
128
|
+
- `run_eval(..., context: { adapter: RubyLLM::Contract::Adapters::RubyLLM.new, model: "..." })` with a real model. An *adapter* here is the layer that actually executes the LLM call — `RubyLLM::Contract::Adapters::Test` returns canned data, the RubyLLM adapter hits the real provider. `model:` chooses the model name; `adapter:` chooses whether the call goes live or stays canned: an eval with `sample_response` stays canned until you pass an adapter, whatever `model:` says. On a step with a `retry_policy` chain, the chain picks each attempt's model, so add `retry_policy_override: nil` to measure the one you name.
|
|
128
129
|
- `compare_with(...)` for prompt A/B.
|
|
129
130
|
|
|
130
131
|
`compare_with` intentionally ignores `sample_response` — canned data would make both sides look the same.
|
|
@@ -135,11 +136,11 @@ For larger datasets, `run_eval` accepts a `concurrency:` argument — cases run
|
|
|
135
136
|
|
|
136
137
|
```ruby
|
|
137
138
|
report = SummarizeArticle.run_eval("regression",
|
|
138
|
-
context: { model: "gpt-4.1-mini" },
|
|
139
|
+
context: { model: "gpt-4.1-mini", retry_policy_override: nil },
|
|
139
140
|
concurrency: 8)
|
|
140
141
|
```
|
|
141
142
|
|
|
142
|
-
|
|
143
|
+
`compare_models` and `optimize_retry_policy` do not take `concurrency:`; they run cases one at a time. Thread count is a ceiling — dataset order of results is preserved. Keep it low enough to respect the provider's rate limits.
|
|
143
144
|
|
|
144
145
|
## Budgeting an eval before you run it
|
|
145
146
|
|
|
@@ -148,7 +149,7 @@ Same accepted by `compare_models` and `optimize_retry_policy`. Thread count is a
|
|
|
148
149
|
```ruby
|
|
149
150
|
SummarizeArticle.estimate_eval_cost("regression",
|
|
150
151
|
models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1])
|
|
151
|
-
# => { "gpt-4.1-nano" => 0.
|
|
152
|
+
# => { "gpt-4.1-nano" => 0.003207, "gpt-4.1-mini" => 0.012829, "gpt-4.1" => 0.064142 }
|
|
152
153
|
```
|
|
153
154
|
|
|
154
155
|
Use it in CI to decide which models are worth running regression on, or to cap worst-case spend per build.
|
|
@@ -37,11 +37,11 @@ class SummarizeArticle < RubyLLM::Contract::Step::Base
|
|
|
37
37
|
# 5. Refuse before calling the LLM if input is too large or estimated cost exceeds the cap
|
|
38
38
|
max_input 2_000
|
|
39
39
|
max_output 4_000
|
|
40
|
-
max_cost 0.
|
|
40
|
+
max_cost 0.05
|
|
41
41
|
end
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Each `validate` block receives `(parsed_output,
|
|
44
|
+
Each `validate` block receives `(parsed_output, input)` — the convention `|o, _|` reads "output, ignore input". Use `o[:key]` to assert on any field your `output_schema` declared. The block returns truthy/falsy: `false` → status `:validation_failed`, retry fires if `retry_policy` was set.
|
|
45
45
|
|
|
46
46
|
## Validation and retry behavior
|
|
47
47
|
|
|
@@ -53,13 +53,13 @@ result = SummarizeArticle.run(article_text)
|
|
|
53
53
|
result.status # => :ok
|
|
54
54
|
result.parsed_output # => { tldr: "...", takeaways: [...], tone: "analytical" }
|
|
55
55
|
result.trace[:model] # => "gpt-4.1-mini" (first model that passed)
|
|
56
|
-
result.trace[:cost] # => 0.
|
|
56
|
+
result.trace[:cost] # => 0.000309 (sum of all attempts)
|
|
57
57
|
result.trace[:attempts]
|
|
58
58
|
# => [
|
|
59
59
|
# { attempt: 1, model: "gpt-4.1-nano", status: :validation_failed,
|
|
60
|
-
# cost: 0.
|
|
60
|
+
# cost: 0.000059, latency_ms: 45, ... },
|
|
61
61
|
# { attempt: 2, model: "gpt-4.1-mini", status: :ok,
|
|
62
|
-
# cost: 0.
|
|
62
|
+
# cost: 0.000250, latency_ms: 92, ... }
|
|
63
63
|
# ]
|
|
64
64
|
```
|
|
65
65
|
|
|
@@ -102,7 +102,9 @@ retry_policy models: [
|
|
|
102
102
|
]
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
`reasoning_effort` is a gpt-5 family feature (gpt-4.1 is not a reasoning model). The per-attempt value is forwarded via `with_thinking` (provider-agnostic — OpenAI `reasoning_effort` and Anthropic extended-thinking budget both supported). Passing it alongside a non-reasoning model is forwarded unchanged to the provider, which will either ignore it or reject the request — the gem does not guard against this.
|
|
105
|
+
`reasoning_effort` is a gpt-5 family feature (gpt-4.1 is not a reasoning model). The per-attempt value is forwarded via `with_thinking` (provider-agnostic — OpenAI `reasoning_effort` and Anthropic extended-thinking budget both supported). Passing it alongside a non-reasoning model is forwarded unchanged to the provider, which will either ignore it or reject the request — the gem does not guard against this. An element without `reasoning_effort` inherits the call's or the step's; `{ model: "gpt-4.1-mini", reasoning_effort: :default }` sends none (see [thinking DSL note](optimizing_retry_policy.md#thinking-dsl-note)).
|
|
106
|
+
|
|
107
|
+
On a step with a `retry_policy` chain, the chain picks the model of every attempt, so a `model:` in the context (of `run`, `run_eval`, `compare_with` or `pass_eval(...).with_context`) is not used. To measure or run one model, add `retry_policy_override: nil` to the context. `compare_models` does this for each candidate.
|
|
106
108
|
|
|
107
109
|
## Evals and CI gates
|
|
108
110
|
|
|
@@ -178,7 +180,7 @@ expect(SummarizeArticle).to pass_eval("regression").without_regressions
|
|
|
178
180
|
`max_input`, `max_output`, and `max_cost` are preflight checks — the LLM is never called if an estimate exceeds the limit. Zero tokens spent, zero cost.
|
|
179
181
|
|
|
180
182
|
```ruby
|
|
181
|
-
result = SummarizeArticle.run(
|
|
183
|
+
result = SummarizeArticle.run(forty_page_report) # ~128k characters
|
|
182
184
|
result.status # => :limit_exceeded
|
|
183
185
|
result.validation_errors
|
|
184
186
|
# => ["Input token limit exceeded: estimated 32000 tokens (heuristic ±30%), max 2000"]
|
|
@@ -251,13 +253,13 @@ SummarizeArticle.estimate_cost(input: article_text)
|
|
|
251
253
|
# => {
|
|
252
254
|
# model: "gpt-4.1-mini",
|
|
253
255
|
# input_tokens: 812, output_tokens_estimate: 4000,
|
|
254
|
-
# estimated_cost: 0.
|
|
256
|
+
# estimated_cost: 0.00672
|
|
255
257
|
# }
|
|
256
258
|
|
|
257
259
|
# Estimate what a full eval would cost across candidate models
|
|
258
260
|
SummarizeArticle.estimate_eval_cost("regression",
|
|
259
261
|
models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1])
|
|
260
|
-
# => { "gpt-4.1-nano" => 0.
|
|
262
|
+
# => { "gpt-4.1-nano" => 0.003207, "gpt-4.1-mini" => 0.012829, "gpt-4.1" => 0.064142 }
|
|
261
263
|
```
|
|
262
264
|
|
|
263
265
|
`estimate_cost` returns `nil` when pricing isn't registered. `estimate_eval_cost` silently treats unknown-pricing cases as `$0.00` and sums the rest — it does **not** fail closed the way `max_cost` does. Treat its output as a floor, not a guarantee; register pricing via `CostCalculator.register_model` before relying on it for budget decisions.
|
data/docs/guide/llm_judge.md
CHANGED
|
@@ -34,7 +34,7 @@ The judge is just another Step. The benefit of using `ruby_llm-contract`: you ge
|
|
|
34
34
|
|
|
35
35
|
The judge takes **two** inputs in a single Hash: the SOURCE you want the answer to be faithful to (here: the article), and the ANSWER under review (here: the summary). Both are required — without the source, the judge has nothing to verify against.
|
|
36
36
|
|
|
37
|
-
The `prompt do` DSL uses a small template syntax: `{key}` placeholders are interpolated from the input. With `input_type Hash`, every top-level key of the hash becomes available as `{key}` — here `{article}` and `{summary}` map to `input[:article]` and `input[:summary]` when you later call `AccuracyJudge.run(article: ..., summary: ...)
|
|
37
|
+
The `prompt do` DSL uses a small template syntax: `{key}` placeholders are interpolated from the input. With `input_type Hash`, every top-level key of the hash becomes available as `{key}` — here `{article}` and `{summary}` map to `input[:article]` and `input[:summary]` when you later call `AccuracyJudge.run({ article: ..., summary: ... })` (one Hash argument, so the braces are needed). With `input_type String`, you use the special `{input}` placeholder (one input, one slot). It is the gem's own syntax — not ERB, not `String#%`, not string interpolation — and it is what makes the judge inputs visible inside the prompt.
|
|
38
38
|
|
|
39
39
|
```ruby
|
|
40
40
|
# A cheap model reads the summary against its source and decides if the
|
|
@@ -66,11 +66,12 @@ class AccuracyJudge < RubyLLM::Contract::Step::Base
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
retry_policy models: %w[gpt-5-nano gpt-5-mini]
|
|
69
|
-
|
|
69
|
+
max_output 2_000 # gpt-5 reasoning tokens count as output
|
|
70
|
+
max_cost 0.01
|
|
70
71
|
end
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
The `retry_policy` escalates to `gpt-5-mini` if the cheaper `gpt-5-nano` fails schema/validate (e.g., returns malformed JSON) — the judge stays reliable without paying for the bigger model on every call. `max_cost 0.
|
|
74
|
+
The `retry_policy` escalates to `gpt-5-mini` if the cheaper `gpt-5-nano` fails schema/validate (e.g., returns malformed JSON) — the judge stays reliable without paying for the bigger model on every call. `max_cost 0.01` bounds each attempt (with `max_output 2_000`, up to roughly 24k input tokens on `gpt-5-mini`), so a case that falls back from nano to mini costs at most about $0.012: the judge runs N× per eval suite (one call per case), so capping per-call cost prevents calibration runs from blowing the budget.
|
|
74
75
|
|
|
75
76
|
This is small on purpose: one boolean, one reason. Easy to read, easy to disagree with.
|
|
76
77
|
|
|
@@ -148,7 +149,7 @@ Only now, after the judge has earned trust on real data, do you use it as the or
|
|
|
148
149
|
|
|
149
150
|
The lambda must return a `Float` in `[0.0, 1.0]` — the eval framework averages per-case scores into the suite score. Map `true → 1.0`, `false → 0.0`, or use weighted partial scores if your judge returns gradations.
|
|
150
151
|
|
|
151
|
-
> ⚠️ **Heads-up before you copy the snippet below.** Smoke evals on `SummarizeArticle` (per [Getting Started](getting_started.md)) typically run with `RubyLLM::Contract::Adapters::Test` for determinism — canned summaries, no API calls. **The `AccuracyJudge.run(...)` inside the evaluator does NOT inherit that Test adapter.** Each Step picks its adapter independently (from `
|
|
152
|
+
> ⚠️ **Heads-up before you copy the snippet below.** Smoke evals on `SummarizeArticle` (per [Getting Started](getting_started.md)) typically run with `RubyLLM::Contract::Adapters::Test` for determinism — canned summaries, no API calls. **The `AccuracyJudge.run(...)` inside the evaluator does NOT inherit that Test adapter.** Each Step picks its adapter independently (from `context: { adapter: }`, or the `default_adapter` set in `RubyLLM::Contract.configure`). Run accuracy-regression evals live (an explicit `context:` or a configured default adapter), and rely on `max_cost` on the judge to bound the bill.
|
|
152
153
|
>
|
|
153
154
|
> This is intentional: the judge IS the oracle, so faking its verdicts would defeat the purpose. Keep the Test-adapter smoke evals for schema/wiring checks only.
|
|
154
155
|
|
|
@@ -161,7 +162,7 @@ SummarizeArticle.define_eval("accuracy_regression") do
|
|
|
161
162
|
summary: output[:tldr],
|
|
162
163
|
article: input
|
|
163
164
|
})
|
|
164
|
-
verdict.parsed_output[:accurate] ? 1.0 : 0.0
|
|
165
|
+
verdict.ok? && verdict.parsed_output[:accurate] ? 1.0 : 0.0
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
# ... one case per real production article you want to defend
|
data/docs/guide/migration.md
CHANGED
|
@@ -153,6 +153,12 @@ The gem handles single calls. You handle parallelism:
|
|
|
153
153
|
|
|
154
154
|
```ruby
|
|
155
155
|
class SummarizeBatch < RubyLLM::Contract::Step::Base
|
|
156
|
+
prompt <<~PROMPT
|
|
157
|
+
Give a one-line TL;DR for each article below, keyed by its id.
|
|
158
|
+
|
|
159
|
+
{input}
|
|
160
|
+
PROMPT
|
|
161
|
+
|
|
156
162
|
output_schema do
|
|
157
163
|
array :summaries do
|
|
158
164
|
object do
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
```ruby
|
|
10
10
|
# app/contracts/extract_invoice_data.rb
|
|
11
11
|
class ExtractInvoiceData < RubyLLM::Contract::Step::Base
|
|
12
|
-
prompt "Extract invoice fields from the attached PDF. Return JSON."
|
|
12
|
+
prompt "Extract invoice fields from the attached PDF. Return JSON. {input}"
|
|
13
13
|
|
|
14
14
|
output_schema do
|
|
15
15
|
string :vendor
|
|
@@ -24,7 +24,9 @@ class ExtractInvoiceData < RubyLLM::Contract::Step::Base
|
|
|
24
24
|
# Conservative estimate of attachment input tokens (provider/model-specific).
|
|
25
25
|
attachment_token_estimate 15_000 # ~12 PDF pages at ~1250 tokens/page
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
max_output 4_000 # room for gpt-5's reasoning tokens, which count as output;
|
|
28
|
+
# without it the cost check assumes an output as long as the input
|
|
29
|
+
max_cost 0.10
|
|
28
30
|
|
|
29
31
|
retry_policy do
|
|
30
32
|
escalate "gpt-4.1-mini",
|
|
@@ -64,7 +66,7 @@ end
|
|
|
64
66
|
The same estimate applies to:
|
|
65
67
|
|
|
66
68
|
- **Runtime** (`limit_checker`) — adds the estimate to `input_tokens` before checking `max_cost`/`max_input`. Refuses pre-flight if budget exceeded.
|
|
67
|
-
- **Pre-flight** (`estimate_cost`) — accepts `attachment:` kwarg; same accounting.
|
|
69
|
+
- **Pre-flight** (`estimate_cost`) — accepts `attachment:` kwarg; same input accounting. The output side matches only when `max_output` is set: without it `estimate_cost` assumes 256 output tokens, while the runtime check assumes an output as long as the input.
|
|
68
70
|
|
|
69
71
|
### Fail-closed without estimate
|
|
70
72
|
|
|
@@ -78,7 +80,7 @@ end
|
|
|
78
80
|
|
|
79
81
|
result = MyContract.run("text", context: { attachment: "doc.pdf" })
|
|
80
82
|
result.status # => :limit_exceeded
|
|
81
|
-
result.validation_errors # => ["attachment present but attachment_token_estimate not
|
|
83
|
+
result.validation_errors # => ["attachment present but attachment_token_estimate not declared; cost cannot be bounded. ..."]
|
|
82
84
|
```
|
|
83
85
|
|
|
84
86
|
### Opting out per-step
|
|
@@ -104,12 +106,12 @@ ExtractInvoiceData.estimate_cost(
|
|
|
104
106
|
attachment: "tmp/invoice.pdf"
|
|
105
107
|
)
|
|
106
108
|
# => { model: "gpt-4.1-mini",
|
|
107
|
-
# input_tokens:
|
|
108
|
-
# output_tokens_estimate:
|
|
109
|
-
# estimated_cost: 0.
|
|
109
|
+
# input_tokens: 15_022,
|
|
110
|
+
# output_tokens_estimate: 4000,
|
|
111
|
+
# estimated_cost: 0.012409 }
|
|
110
112
|
```
|
|
111
113
|
|
|
112
|
-
The `input_tokens` figure includes both the text estimate (chars/4 heuristic) AND the declared `attachment_token_estimate`. Pre-flight refusal mirrors runtime: if `attachment` is passed and `attachment_token_estimate` is not declared, `estimate_cost` returns nil and
|
|
114
|
+
The `input_tokens` figure includes both the text estimate (chars/4 heuristic) AND the declared `attachment_token_estimate`. Pre-flight refusal mirrors runtime: if `attachment` is passed and `attachment_token_estimate` is not declared, `estimate_cost` returns nil (with `on_unknown_attachment_size :warn` it warns and estimates without the attachment instead).
|
|
113
115
|
|
|
114
116
|
**Note on output tokens.** `attachment_token_estimate` adds to `input_tokens` only — not to `output_tokens_estimate`. Vision-heavy responses (long image descriptions, transcribed paragraphs) may exceed the conservative `output_tokens_estimate` default. Treat `estimated_cost` as a floor for budget planning, not a precise predictor; inflate `max_output` or `max_cost` accordingly if your prompt routinely produces verbose descriptions.
|
|
115
117
|
|
|
@@ -130,7 +132,7 @@ Pick a value at or above the provider's worst-case. The estimate is a **floor fo
|
|
|
130
132
|
|
|
131
133
|
## Multi-turn caveat
|
|
132
134
|
|
|
133
|
-
|
|
135
|
+
A Step is single-turn: each `run` sends its prompt with the attachment and keeps no conversation history. A follow-up question on the same document is another `run` that passes the attachment again.
|
|
134
136
|
|
|
135
137
|
## Provider notes
|
|
136
138
|
|
|
@@ -149,8 +151,9 @@ RSpec.describe ExtractInvoiceData do
|
|
|
149
151
|
it "forwards attachment to chat.ask" do
|
|
150
152
|
expect_any_instance_of(RubyLLM::Chat).to receive(:ask)
|
|
151
153
|
.with(anything, with: "fixtures/invoice.pdf")
|
|
152
|
-
.and_return(
|
|
153
|
-
|
|
154
|
+
.and_return(RubyLLM::Message.new(role: :assistant, content: '{"vendor":"X","invoice_number":"1","total_amount":10,"currency":"EUR"}',
|
|
155
|
+
model_id: "gpt-4.1-mini",
|
|
156
|
+
input_tokens: 200, output_tokens: 50))
|
|
154
157
|
|
|
155
158
|
result = described_class.run("extract", context: { attachment: "fixtures/invoice.pdf" })
|
|
156
159
|
expect(result.status).to eq(:ok)
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> Read this when you have 2+ evals and want to know empirically — not by guessing — which models belong in `retry_policy` and in what order.
|
|
4
4
|
|
|
5
|
-
You defined `SummarizeArticle` in
|
|
5
|
+
You defined `SummarizeArticle` in [Getting Started](getting_started.md) with `retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1]`. That list was a guess. `optimize_retry_policy` tells you which models your evals actually need, so you stop paying for the strong model when `nano` was enough — or stop shipping `nano` when the hardest eval proves it isn't.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- **`SummarizeArticle` already has `retry_policy`.** If your step has none, add one first ([getting started](getting_started.md)).
|
|
10
10
|
- **2–3 evals per step.** One eval optimizes for one scenario; with only `smoke`, you get a recommendation that passes smoke but may miss production edge cases. See [eval-first](eval_first.md).
|
|
11
|
-
- **Rake task `ruby_llm_contract:optimize` is registered when `lib/ruby_llm/contract/rake_task.rb` is loaded.** In Rails apps
|
|
11
|
+
- **Rake task `ruby_llm_contract:optimize` is registered when `lib/ruby_llm/contract/rake_task.rb` is loaded.** In Rails apps the railtie auto-loads it lazily during `rake` invocation — no manual setup needed. In non-Rails projects, add `require "ruby_llm/contract/rake_task"` to your `Rakefile` (or any file under `lib/tasks/`) and set `EVAL_DIRS=...` on the command line.
|
|
12
12
|
|
|
13
13
|
> **Two orthogonal dimensions to a retry chain.** A chain element is `{ model:, reasoning_effort: }` — model identity AND thinking budget. `optimize_retry_policy` explores both. You can pin the default thinking config at class level too — see the `thinking` DSL note at the end.
|
|
14
14
|
|
|
@@ -27,10 +27,12 @@ Run once offline to verify the wiring:
|
|
|
27
27
|
```bash
|
|
28
28
|
rake ruby_llm_contract:optimize \
|
|
29
29
|
STEP=SummarizeArticle \
|
|
30
|
-
CANDIDATES=gpt-
|
|
30
|
+
CANDIDATES=gpt-5-nano,gpt-5-mini@low,gpt-5-mini,gpt-5
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The `@low` (or `@medium`, `@high`) suffix on a candidate is CLI shorthand for `{ model: "gpt-
|
|
33
|
+
The `@low` (or `@medium`, `@high`) suffix on a candidate is CLI shorthand for `{ model: "gpt-5-mini", reasoning_effort: "low" }` — saves you constructing hash strings on the command line. Plain model names without `@` use the Step's default thinking config (or none). `@default` runs the candidate with no effort at all, which is what a model without reasoning needs when the Step sets one: `CANDIDATES=gpt-6-luna@none,gpt-4.1-mini@default`.
|
|
34
|
+
|
|
35
|
+
List candidates from the cheapest to the most expensive. The optimizer takes that order as the cost order: the suggested chain tries them in it, and the first candidate that passes every eval is the safe fallback. It does not sort by price.
|
|
34
36
|
|
|
35
37
|
Offline uses each eval's `sample_response` — zero API calls. **Every candidate gets the same score** because they all receive the canned response. That's fine for a smoke test (verifying evals load, candidates parse, output renders) but it doesn't compare model quality. For real optimization, go live.
|
|
36
38
|
|
|
@@ -39,7 +41,7 @@ Offline uses each eval's `sample_response` — zero API calls. **Every candidate
|
|
|
39
41
|
```bash
|
|
40
42
|
LIVE=1 RUNS=3 rake ruby_llm_contract:optimize \
|
|
41
43
|
STEP=SummarizeArticle \
|
|
42
|
-
CANDIDATES=gpt-
|
|
44
|
+
CANDIDATES=gpt-5-nano,gpt-5-mini@low,gpt-5-mini,gpt-5
|
|
43
45
|
```
|
|
44
46
|
|
|
45
47
|
`LIVE=1` makes real API calls. `RUNS=3` averages each `(candidate, eval)` pair over three runs — necessary because OpenAI forces `temperature=1.0` on gpt-5 / o-series and the same pair can score `0.00` on one run and `1.00` on the next.
|
|
@@ -49,40 +51,47 @@ Output (illustrative):
|
|
|
49
51
|
```
|
|
50
52
|
SummarizeArticle — fallback list optimization
|
|
51
53
|
|
|
52
|
-
eval
|
|
53
|
-
|
|
54
|
-
smoke
|
|
55
|
-
dense_article
|
|
56
|
-
critical_tone
|
|
54
|
+
eval nano mini@low mini gpt-5
|
|
55
|
+
-----------------------------------------------------
|
|
56
|
+
smoke 1.00 1.00 1.00 1.00
|
|
57
|
+
dense_article 0.67 1.00 1.00 1.00
|
|
58
|
+
critical_tone 0.50 ← 0.67 ← 1.00 1.00
|
|
57
59
|
|
|
58
60
|
Hardest eval: critical_tone
|
|
59
61
|
|
|
60
62
|
Suggested fallback list:
|
|
61
|
-
gpt-
|
|
62
|
-
gpt-
|
|
63
|
+
gpt-5-nano — covers 1 eval(s)
|
|
64
|
+
gpt-5-mini (effort: low) — covers 1 eval(s)
|
|
65
|
+
gpt-5-mini — passes all 3 evals
|
|
63
66
|
|
|
64
67
|
DSL:
|
|
65
|
-
retry_policy
|
|
68
|
+
retry_policy do
|
|
69
|
+
escalate(
|
|
70
|
+
{ model: "gpt-5-nano" },
|
|
71
|
+
{ model: "gpt-5-mini", reasoning_effort: "low" },
|
|
72
|
+
{ model: "gpt-5-mini" }
|
|
73
|
+
)
|
|
74
|
+
end
|
|
66
75
|
```
|
|
67
76
|
|
|
68
77
|
Reading the table:
|
|
69
78
|
- **`←` marks scores below threshold in the hardest eval.** Not a selection hint — just "this candidate fails the row that matters most".
|
|
70
|
-
- **Hardest eval** = the one that forces the strong fallback. Here, `critical_tone` demands `gpt-
|
|
71
|
-
- **Suggested fallback list** = the shortest chain where each step covers more evals, built greedy-cheapest-first. Stops when all evals pass. Order matters: `gpt-
|
|
79
|
+
- **Hardest eval** = the one that forces the strong fallback. Here, `critical_tone` demands `gpt-5-mini` at its default effort.
|
|
80
|
+
- **Suggested fallback list** = the shortest chain where each step covers more evals, built greedy-cheapest-first. Stops when all evals pass. Order matters: `gpt-5-nano` is tried first; on validation failure, the gem falls back to `gpt-5-mini` at low effort, then at its default.
|
|
72
81
|
|
|
73
|
-
Copy the DSL, paste into your step, verify with `rake ruby_llm_contract:eval
|
|
82
|
+
Copy the DSL, paste into your step, verify with `rake ruby_llm_contract:eval` (defined by `RubyLLM::Contract::RakeTask.new`, see [Rails integration](rails_integration.md#7-ci-gate--block-regressions-before-merge)). You just dropped `gpt-5` from the chain — most requests finish on nano, mini handles what nano misses, and the strong model was never needed.
|
|
74
83
|
|
|
75
84
|
## Measure effective cost before shipping
|
|
76
85
|
|
|
77
86
|
`optimize` shows **first-attempt** cost. In production, a candidate whose validator rejects 20% of outputs actually costs `first_try_cost + fallback_cost × 0.20` per successful output. The first-attempt number hides this.
|
|
78
87
|
|
|
79
|
-
`production_mode:` is an optional `compare_models` kwarg. With `{ fallback: "model" }` it runs each candidate with a runtime `[candidate, fallback]` chain and reports **effective cost** (first-try + fallback cost weighted by rejection rate). Without it, you get only first-attempt metrics (the default).
|
|
88
|
+
`production_mode:` is an optional `compare_models` kwarg. With `{ fallback: "model" }` it runs each candidate with a runtime `[candidate, fallback]` chain and reports **effective cost** (first-try + fallback cost weighted by rejection rate). Without it, you get only first-attempt metrics (the default). A fallback without a `reasoning_effort` runs with the candidate's, so a fallback naming the candidate's model sends the same request and is skipped; `{ model: "...", reasoning_effort: :default }` falls back with no effort.
|
|
80
89
|
|
|
81
90
|
```ruby
|
|
82
91
|
SummarizeArticle.compare_models(
|
|
83
92
|
"dense_article",
|
|
84
|
-
candidates: [{ model: "gpt-5-nano" }, { model: "gpt-5-mini", reasoning_effort: "low" }],
|
|
85
|
-
production_mode: { fallback: "gpt-5
|
|
93
|
+
candidates: [{ model: "gpt-5-nano" }, { model: "gpt-5-mini", reasoning_effort: "low" }, { model: "gpt-5" }],
|
|
94
|
+
production_mode: { fallback: "gpt-5" }
|
|
86
95
|
).print_summary
|
|
87
96
|
```
|
|
88
97
|
|
|
@@ -91,17 +100,20 @@ Output (live mode, illustrative):
|
|
|
91
100
|
```
|
|
92
101
|
dense_article — model comparison
|
|
93
102
|
|
|
94
|
-
Chain
|
|
95
|
-
|
|
96
|
-
gpt-5-nano → gpt-5
|
|
97
|
-
gpt-5-mini (effort: low) → gpt-5
|
|
98
|
-
gpt-5
|
|
103
|
+
Chain first-attempt fallback % effective cost latency score
|
|
104
|
+
----------------------------------------------------------------------------------------------
|
|
105
|
+
gpt-5-nano → gpt-5 $0.0010 33% $0.0030 164ms 1.00
|
|
106
|
+
gpt-5-mini (effort: low) → gpt-5 $0.0015 5% $0.0018 210ms 1.00
|
|
107
|
+
gpt-5 $0.0060 — $0.0060 450ms 1.00
|
|
108
|
+
|
|
109
|
+
Best overall: gpt-5-mini (effort: low)
|
|
110
|
+
Cheapest at 100%: gpt-5-mini (effort: low)
|
|
99
111
|
```
|
|
100
112
|
|
|
101
113
|
- **first-attempt** — cost of the first run alone.
|
|
102
114
|
- **fallback %** — fraction of cases where the validator rejected and the fallback ran.
|
|
103
115
|
- **effective cost** — total per successful output including retries.
|
|
104
|
-
- **`—`** — candidate
|
|
116
|
+
- **`—`** — the fallback would send the candidate's own request, so the candidate runs alone and there is no chain to observe.
|
|
105
117
|
|
|
106
118
|
Run this before finalizing: a candidate saving 3× on first-attempt but falling back 60% of the time may save only 1.2× in production.
|
|
107
119
|
|
|
@@ -131,3 +143,14 @@ end
|
|
|
131
143
|
```
|
|
132
144
|
|
|
133
145
|
Forwarded to `Chat#with_thinking(**)` through the adapter — works provider-agnostically (OpenAI `reasoning_effort`, Anthropic extended-thinking budget). A per-call override via `context: { reasoning_effort: :high }` still wins over the class default.
|
|
146
|
+
|
|
147
|
+
`reasoning_effort: :default` - the same word as in the DSL - sends no effort for one call, candidate or chain element, and keeps a `thinking budget:`. Use it to run a Step set up for a reasoning model on one that takes no effort, which a provider otherwise rejects (OpenAI: "Unsupported parameter: 'reasoning.effort'"):
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
SummarizeArticle.run(text, context: { model: "gpt-4.1-mini", reasoning_effort: :default, retry_policy_override: nil })
|
|
151
|
+
SummarizeArticle.compare_models("regression", candidates: [{ model: "gpt-6-luna" },
|
|
152
|
+
{ model: "gpt-4.1-mini", reasoning_effort: :default }])
|
|
153
|
+
retry_policy { escalate({ model: "gpt-6-luna" }, { model: "gpt-4.1-mini", reasoning_effort: :default }) }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`nil` is not `:default`: a nil effort is ignored and the class one is sent. The gem does not drop an effort by itself based on the model. A chain element without an effort runs with the call's (or the class's), and `trace.attempts[n][:config]` shows the effort each attempt was sent.
|
data/docs/guide/prompt_ast.md
CHANGED
|
@@ -29,7 +29,7 @@ The AST is immutable, diffable, and hashable. Useful for snapshot testing and au
|
|
|
29
29
|
|
|
30
30
|
When input is a Hash, each key becomes a template variable. Concrete scenario: a multi-language newsletter product where the same article has to be summarised in Polish for EU subscribers, English for US, with different audiences per tier (Rails developers vs engineering managers). Hash inputs let one step cover all of these without forking the class.
|
|
31
31
|
|
|
32
|
-
The `Types::Hash.schema(...)` form below declares a **typed contract on the input** — `run()`
|
|
32
|
+
The `Types::Hash.schema(...)` form below declares a **typed contract on the input** — `run()` returns `status: :input_error` (with the reason in `validation_errors`, e.g. `":article is missing in Hash input"`) if a required key is missing or its value violates the declared type, and no model is called. Use it when the input shape is part of the Step's interface (multi-key inputs, multi-tenant variants); plain `input_type Hash` accepts any hash and is fine for prototypes or when the schema is enforced upstream.
|
|
33
33
|
|
|
34
34
|
```ruby
|
|
35
35
|
class SummarizeArticle < RubyLLM::Contract::Step::Base
|
|
@@ -54,9 +54,9 @@ class SummarizeArticle < RubyLLM::Contract::Step::Base
|
|
|
54
54
|
end
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Every `{key}` in a prompt node is pulled from the input hash at run time.
|
|
57
|
+
Every `{key}` in a prompt node is pulled from the input hash at run time. A key the input does not have is left in the prompt as literal `{key}` text, so declare the keys in a `Types::Hash.schema(...)` input type when a missing one should stop the call.
|
|
58
58
|
|
|
59
|
-
> **Not the same as `RubyLLM::Agent.inputs`.** `Step.input_type` is a *runtime type check* on the positional argument passed to `run(input)` —
|
|
59
|
+
> **Not the same as `RubyLLM::Agent.inputs`.** `Step.input_type` is a *runtime type check* on the positional argument passed to `run(input)` — a violating input returns `status: :input_error` instead of calling the model. `RubyLLM::Agent.inputs` is a list of *named template locals* injected into ERB instructions. They solve different problems (validation vs templating) and can coexist on the same project.
|
|
60
60
|
|
|
61
61
|
> **Not the same as `RubyLLM::Agent` ERB templates either.** The `prompt do ... end` DSL above builds a multi-role message list (`system` / `user` / `assistant` / `example` nodes) into a node-AST. `Agent.instructions :name` loads a single-string ERB file from `app/prompts/<agent_path>/<name>.txt.erb` for the system prompt only. Different output shape, different scope. Variable interpolation here uses `{key}` substitution; ERB uses full `<%= ruby %>`.
|
|
62
62
|
|
|
@@ -141,16 +141,22 @@ Add to `spec/spec_helper.rb` (or `test_helper.rb`):
|
|
|
141
141
|
require "ruby_llm/contract/rspec" # or ruby_llm/contract/minitest
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
With Minitest, also `include RubyLLM::Contract::MinitestHelpers` in `ActiveSupport::TestCase`.
|
|
145
|
+
|
|
144
146
|
Then in specs:
|
|
145
147
|
|
|
146
148
|
```ruby
|
|
147
149
|
RSpec.describe ArticlesController do
|
|
150
|
+
include ActiveJob::TestHelper
|
|
151
|
+
|
|
148
152
|
it "saves the summary when the step passes" do
|
|
149
153
|
stub_step(SummarizeArticle, response: {
|
|
150
154
|
tldr: "...", takeaways: %w[a b c], tone: "analytical"
|
|
151
155
|
})
|
|
152
156
|
|
|
153
|
-
|
|
157
|
+
# `summarize` enqueues SummarizeArticleJob; run it here, on this thread,
|
|
158
|
+
# where the stub applies (stub_step is thread-local).
|
|
159
|
+
perform_enqueued_jobs { post :summarize, params: { id: article.id } }
|
|
154
160
|
|
|
155
161
|
# NOTE: jsonb/json column round-trips as string keys on reload.
|
|
156
162
|
expect(article.reload.summary["tldr"]).to eq("...")
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
| What you write | Where it lives |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `model`, `temperature`, `schema`, `instructions`, `
|
|
11
|
+
| `model`, `temperature`, `schema`, `instructions`, `thinking` | covered by both — same idea, different DSL surface |
|
|
12
|
+
| `tools` | only in `RubyLLM::Agent` — a Step makes a single call without tools |
|
|
12
13
|
| `validate :rule do ... end` business invariants on output | only in `ruby_llm-contract` |
|
|
13
14
|
| `retry_policy escalate(...)` model escalation on validation failure | only here (different from RubyLLM's network-level retry) |
|
|
14
15
|
| `max_cost` / `max_input` / `max_output` pre-flight refusal | only here |
|
|
@@ -13,7 +13,7 @@ Both gems sit on top of `ruby_llm`. The space they cover overlaps in vocabulary
|
|
|
13
13
|
| Where the output lives at evaluation time | Already in your variable, returned to caller | Still inside the gem's runner, not yet released |
|
|
14
14
|
| What "fail" means | Red test in CI | Trigger retry/escalate on a stronger model, or fail closed |
|
|
15
15
|
| Strongest features | Rich LLM-as-judge (faithful, relevant, hallucination, refusal, bias, toxicity, jailbreak, PII), red-team adversarial prompts, deterministic helpers (`assert_contains`, `assert_levenshtein`, …), RSpec/Minitest matchers, HTML/JUnit/GH reporters | Schema DSL with constraints, `validate` business rules, `retry_policy escalate(...)` model escalation, `max_cost` pre-flight refusal, regression-eval framework (frozen dataset + baseline + min_score gate), pipeline composition |
|
|
16
|
-
| What it does NOT cover | No retry, no model escalation, no pre-flight cost cap, no contract layer between LLM and your code | No 10-judge LLM-as-judge catalog, no red-team generation, no rich deterministic assertion library
|
|
16
|
+
| What it does NOT cover | No retry, no model escalation, no pre-flight cost cap, no contract layer between LLM and your code | No 10-judge LLM-as-judge catalog, no red-team generation, no rich deterministic assertion library; its test-framework matchers (`satisfy_contract`, `pass_eval`) gate contracts and evals, not individual outputs |
|
|
17
17
|
|
|
18
18
|
## Visual: where each gem sits in your call
|
|
19
19
|
|
|
@@ -38,10 +38,10 @@ your code ──► Step.run ──► LLM ──► [schema + validate]
|
|
|
38
38
|
│
|
|
39
39
|
all attempts fail ─┘
|
|
40
40
|
▼
|
|
41
|
-
|
|
41
|
+
status: :validation_failed
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
The output **never reaches your code** until the contract passes. A failed validation either fixes itself (the retry policy escalates to a stronger model) or fails closed with `
|
|
44
|
+
The output **never reaches your code** until the contract passes. A failed validation either fixes itself (the retry policy escalates to a stronger model) or fails closed with `status: :validation_failed` — your call site sees a deterministic failure status, never schema-invalid data.
|
|
45
45
|
|
|
46
46
|
### Both together (Tribunal in CI → then Contract in prod)
|
|
47
47
|
|
|
@@ -85,7 +85,7 @@ If you specifically need an **LLM-as-judge** (a second LLM grading the first one
|
|
|
85
85
|
- **You need to iterate the judge prompt** because it over-flags on your data — Tribunal's prompts are fixed per assertion.
|
|
86
86
|
- **Use both** for the same project even when your check is in Tribunal's catalog: Tribunal's `assert_faithful` for spec-time grade on individual responses, plus a calibrated custom judge wired as `evaluator:` in a regression `define_eval` over a frozen dataset for CI merge-gating. They cover different lifecycle stages.
|
|
87
87
|
|
|
88
|
-
Either way, the **methodology** in [`llm_judge.md`](llm_judge.md) — calibrate the judge against human-labeled production samples before trusting any score, watch for the
|
|
88
|
+
Either way, the **methodology** in [`llm_judge.md`](llm_judge.md) — calibrate the judge against human-labeled production samples before trusting any score, watch for the five anti-patterns, refine the prompt when it over-flags — applies equally to Tribunal's built-ins, Tribunal's custom registered judges, and Contract `Step::Base` judges. Tribunal's `default_threshold = 0.8` is a starting point, not a calibrated bar for your data.
|
|
89
89
|
|
|
90
90
|
## What Tribunal documents — and what it doesn't
|
|
91
91
|
|
|
@@ -111,7 +111,7 @@ These work today without any code changes in either gem — both use plain Ruby
|
|
|
111
111
|
class ChatReply < RubyLLM::Contract::Step::Base
|
|
112
112
|
prompt "Answer this question grounded in the docs:\n{input}"
|
|
113
113
|
|
|
114
|
-
validate("no PII in answer") do |output,
|
|
114
|
+
validate("no PII in answer") do |output, _input|
|
|
115
115
|
test_case = RubyLLM::Tribunal::TestCase.new(actual_output: output[:answer])
|
|
116
116
|
RubyLLM::Tribunal::Assertions.evaluate(:pii, test_case, {}).first == :pass
|
|
117
117
|
end
|
data/docs/guide/testing.md
CHANGED
|
@@ -33,7 +33,7 @@ result = SummarizeArticle.run("article text", context: { adapter: adapter })
|
|
|
33
33
|
result.ok? # => true
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Multi-step pipeline testing with per-step named responses (using `ArticleCardPipeline` from [Pipeline](pipeline.md)). The keys in `responses:` must match the step identifiers declared with `
|
|
36
|
+
Multi-step pipeline testing with per-step named responses (using `ArticleCardPipeline` from [Pipeline](pipeline.md)). The keys in `responses:` must match the step identifiers declared with `step Klass, as: :name` in your pipeline definition — `:summarize`, `:tag`, `:card` here map to the three steps in that order.
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
39
39
|
result = ArticleCardPipeline.test("article text",
|
|
@@ -56,7 +56,7 @@ result.parsed_output[:tldr] # => "..." ✓
|
|
|
56
56
|
result.parsed_output["tldr"] # => nil ✗
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
The gem warns if a `validate` block (Step-level invariant check) or a `verify`
|
|
59
|
+
The gem warns if a `validate` block (Step-level invariant check) or a `verify` proc (eval-case evaluator declared via `verify "name", ->(output) { ... }` inside `define_eval`) returns `nil` — usually a sign of string-key access on symbol-keyed data.
|
|
60
60
|
|
|
61
61
|
## RSpec setup
|
|
62
62
|
|
|
@@ -128,7 +128,7 @@ In RSpec, non-block stubs auto-clean after each example. In Minitest, `teardown`
|
|
|
128
128
|
|
|
129
129
|
## Minitest
|
|
130
130
|
|
|
131
|
-
Require `ruby_llm/contract/minitest` in your `test_helper.rb
|
|
131
|
+
Require `ruby_llm/contract/minitest` in your `test_helper.rb` and `include RubyLLM::Contract::MinitestHelpers` in your test class (or `ActiveSupport::TestCase`). You get `assert_satisfies_contract` / `refute_satisfies_contract` and `assert_eval_passes` (the Minitest counterparts of `satisfy_contract` and `pass_eval`) and the same `stub_step` helper.
|
|
132
132
|
|
|
133
133
|
## RSpec matchers
|
|
134
134
|
|
|
@@ -158,7 +158,7 @@ end
|
|
|
158
158
|
|
|
159
159
|
`pass_eval` supports a matcher chain — full reference lives in [Getting Started](getting_started.md) under Evals and CI gates. Quick summary:
|
|
160
160
|
|
|
161
|
-
- `.with_context(model: "gpt-4.1-mini")` — pick model / pass adapter
|
|
161
|
+
- `.with_context(model: "gpt-4.1-mini")` — pick model / pass adapter (on a step with a `retry_policy` chain, also pass `retry_policy_override: nil`, or the chain picks the model)
|
|
162
162
|
- `.with_minimum_score(0.8)` — gate on average score
|
|
163
163
|
- `.with_maximum_cost(0.01)` — gate on total cost; fails when a case's model has no pricing data unless you add `.on_unknown_pricing(:warn)`
|
|
164
164
|
- `.without_regressions` — block any previously-passing case that now fails (reads the baseline)
|
|
@@ -168,18 +168,19 @@ end
|
|
|
168
168
|
|
|
169
169
|
Evals run in one of two modes depending on how they're defined and what context is passed:
|
|
170
170
|
|
|
171
|
-
| Has `sample_response`? |
|
|
171
|
+
| Has `sample_response`? | `adapter:` in context? | Mode | API calls |
|
|
172
172
|
|---|---|---|---|
|
|
173
|
-
| Yes | No | **Offline** — uses `sample_response` as canned answer | Zero |
|
|
173
|
+
| Yes | No | **Offline** — uses `sample_response` as canned answer (a `model:` alone does not change that) | Zero |
|
|
174
174
|
| Yes | Yes | **Online** — ignores `sample_response`, calls real LLM | Real |
|
|
175
175
|
| No | Yes | **Online** — calls real LLM | Real |
|
|
176
|
-
| No | No | **
|
|
176
|
+
| No | No | **Online** with the default adapter, which `RubyLLM::Contract.configure` sets up; **Skipped** (`:skipped`, excluded from score) only when no adapter is configured | Real / Zero |
|
|
177
177
|
|
|
178
|
-
Default is offline. To force online, pass adapter
|
|
178
|
+
Default is offline. To force online, pass an adapter in context:
|
|
179
179
|
|
|
180
180
|
```ruby
|
|
181
181
|
# Online — real LLM call
|
|
182
|
-
report = SummarizeArticle.run_eval("regression",
|
|
182
|
+
report = SummarizeArticle.run_eval("regression",
|
|
183
|
+
context: { adapter: RubyLLM::Contract::Adapters::RubyLLM.new, model: "gpt-4.1-nano" })
|
|
183
184
|
|
|
184
185
|
# Offline — uses sample_response
|
|
185
186
|
report = SummarizeArticle.run_eval("smoke")
|
|
@@ -232,7 +233,7 @@ result.ok? # => true (observe never fails the contract)
|
|
|
232
233
|
result.observations # => [{ description: "scores should differ", passed: false }]
|
|
233
234
|
```
|
|
234
235
|
|
|
235
|
-
`observe` runs only after validation passes. Failed observations are logged via `RubyLLM::Contract.logger` — useful for "I want to know this happened without blocking the response".
|
|
236
|
+
`observe` runs only after validation passes. Failed observations are logged via the logger set in `RubyLLM::Contract.configure` (`c.logger`) — useful for "I want to know this happened without blocking the response".
|
|
236
237
|
|
|
237
238
|
> **Not the same as `Chat#on_end_message` / `on_tool_call`.** RubyLLM exposes anonymous, global callbacks attached to a chat instance — they receive the raw `Message` and have no inherent pass/fail concept. `Step.observe` is per-step, named with a description, runs against `parsed_output` (already JSON-parsed and schema-validated), and the pass/fail outcome is recorded in `result.observations`. The two can coexist: use Chat callbacks for transport/tool tracing, use `observe` for domain assertions you want captured in the step trace.
|
|
238
239
|
|
|
@@ -269,8 +270,8 @@ Baselines are JSON files in `.eval_baselines/` — commit them to git:
|
|
|
269
270
|
```
|
|
270
271
|
.eval_baselines/
|
|
271
272
|
SummarizeArticle/
|
|
272
|
-
|
|
273
|
-
regression_gpt-4_1-mini.json
|
|
273
|
+
regression.json # report.save_baseline!, the rake task, without_regressions
|
|
274
|
+
regression_gpt-4_1-mini.json # report.save_baseline!(model: "gpt-4.1-mini"), one per model
|
|
274
275
|
```
|
|
275
276
|
|
|
276
277
|
Each file contains dataset name, step name, score, and per-case results. No timestamps — re-saving an identical baseline produces no git diff. Baseline semantics (what counts as a regression, how `compare_with_baseline` works) are covered in [Getting Started](getting_started.md#evals-and-ci-gates) and [Eval-First](eval_first.md).
|
data/docs/guide/why.md
CHANGED
|
@@ -62,18 +62,18 @@ Nano misclassifies the tone on the first attempt → contract rejects → mini g
|
|
|
62
62
|
|
|
63
63
|
**See it in 30 seconds:** `ruby examples/01_fallback_showcase.rb` — zero API keys required. The Test adapter simulates a tone/takeaways mismatch on the first attempt and a consistent sample on the retry, then prints the per-attempt trace.
|
|
64
64
|
|
|
65
|
-
`retry_policy` has three other shapes beyond cross-model escalation — same-model `attempts: 3` (absorbs sampling variance without paying for a stronger tier), `reasoning_effort` escalation (low → medium → high on one model), and cross-provider fallback (
|
|
65
|
+
`retry_policy` has three other shapes beyond cross-model escalation — same-model `attempts: 3` (absorbs sampling variance without paying for a stronger tier), `reasoning_effort` escalation (low → medium → high on one model), and cross-provider fallback (OpenAI → Anthropic — ruby_llm picks the provider from the model name; a local Ollama model needs `provider: :ollama` for the whole call, so it cannot be one tier of such a chain). `examples/06_retry_variants.rb` runs all three through the Test adapter with the trace printed.
|
|
66
66
|
|
|
67
67
|
## Failure 4 — Runaway cost and no fallback policy
|
|
68
68
|
|
|
69
|
-
Someone pastes a 40-page PDF into the endpoint that calls `SummarizeArticle`. The prompt expands to 80k tokens. Your provider bill jumps. Meanwhile, a separate team uses `gpt-4.1` for every single call because "quality matters" — even though 80% of their traffic is trivially handled by `gpt-4.1-nano` at 1/
|
|
69
|
+
Someone pastes a 40-page PDF into the endpoint that calls `SummarizeArticle`. The prompt expands to 80k tokens. Your provider bill jumps. Meanwhile, a separate team uses `gpt-4.1` for every single call because "quality matters" — even though 80% of their traffic is trivially handled by `gpt-4.1-nano` at 1/20th the cost. Neither situation is visible until the invoice arrives.
|
|
70
70
|
|
|
71
71
|
Contracts make cost a first-class concern:
|
|
72
72
|
|
|
73
73
|
```ruby
|
|
74
74
|
max_input 2_000 # refuses before calling the API if tokens exceed budget
|
|
75
75
|
max_output 4_000
|
|
76
|
-
max_cost 0.
|
|
76
|
+
max_cost 0.05 # refuses if estimated cost exceeds cap (checked per attempt)
|
|
77
77
|
retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1] # cheap first, escalate only on failure
|
|
78
78
|
```
|
|
79
79
|
|
data/examples/00_basics.rb
CHANGED
|
@@ -166,9 +166,15 @@ r.trace[:cost] # => sum of per-attempt costs
|
|
|
166
166
|
# adapter = RubyLLM::Contract::Adapters::RubyLLM.new
|
|
167
167
|
# result = SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter })
|
|
168
168
|
#
|
|
169
|
-
# Switch provider per call — ruby_llm resolves the provider from the model name
|
|
170
|
-
#
|
|
171
|
-
#
|
|
169
|
+
# Switch provider per call — ruby_llm resolves the provider from the model name.
|
|
170
|
+
# The retry_policy chain picks the model of every attempt, so turn it off for
|
|
171
|
+
# the call (retry_policy_override: nil) to run a model of your choice:
|
|
172
|
+
# (configure c.anthropic_api_key, or c.ollama_api_base for Ollama, first)
|
|
173
|
+
# SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter, model: "claude-sonnet-4-6",
|
|
174
|
+
# retry_policy_override: nil })
|
|
175
|
+
# SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter, model: "gemma3:4b",
|
|
176
|
+
# retry_policy_override: nil,
|
|
177
|
+
# provider: :ollama, assume_model_exists: true })
|
|
172
178
|
|
|
173
179
|
# =============================================================================
|
|
174
180
|
# Where to go next
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
# tone: "negative"
|
|
51
51
|
#
|
|
52
52
|
# See also: examples/06_retry_variants.rb — same-model retry, reasoning_effort
|
|
53
|
-
# escalation, and cross-provider fallback (
|
|
53
|
+
# escalation, and cross-provider fallback (OpenAI → Anthropic).
|
|
54
54
|
# =============================================================================
|
|
55
55
|
|
|
56
56
|
require_relative "../lib/ruby_llm/contract"
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
# not change — same prompt, schema, validates, retry_policy.
|
|
9
9
|
#
|
|
10
10
|
# Requires: gem install ruby_llm; export OPENAI_API_KEY=sk-...
|
|
11
|
-
#
|
|
11
|
+
# Another provider needs its own key in RubyLLM.configure below (e.g.
|
|
12
|
+
# c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]) and its models in the step.
|
|
12
13
|
#
|
|
13
14
|
# Run: OPENAI_API_KEY=sk-... ruby examples/02_real_llm_minimal.rb
|
|
14
15
|
# =============================================================================
|
|
@@ -40,6 +41,11 @@ puts "Tokens: #{result.trace[:usage]}" # real usage
|
|
|
40
41
|
puts "Cost: $#{result.trace[:cost]}" # sum across retries
|
|
41
42
|
puts "TL;DR: #{result.parsed_output[:tldr]}"
|
|
42
43
|
|
|
43
|
-
# Switch provider per call — ruby_llm resolves the provider from the model name
|
|
44
|
-
#
|
|
45
|
-
#
|
|
44
|
+
# Switch provider per call — ruby_llm resolves the provider from the model name.
|
|
45
|
+
# The step's retry_policy chain picks the model of every attempt, so turn it off
|
|
46
|
+
# for the call (retry_policy_override: nil) to run a model of your choice:
|
|
47
|
+
# (configure c.anthropic_api_key, or c.ollama_api_base for Ollama, first)
|
|
48
|
+
# SummarizeArticle.run(article, context: { adapter: adapter, model: "claude-sonnet-4-6",
|
|
49
|
+
# retry_policy_override: nil })
|
|
50
|
+
# SummarizeArticle.run(article, context: { adapter: adapter, model: "gemma3:4b", retry_policy_override: nil,
|
|
51
|
+
# provider: :ollama, assume_model_exists: true }) # local Ollama
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
# attempt 1 effort=low status=validation_failed
|
|
21
21
|
# attempt 3 effort=high status=ok
|
|
22
22
|
#
|
|
23
|
-
# C — cross-provider
|
|
24
|
-
# attempt 1 model=
|
|
25
|
-
# attempt 3 model=
|
|
23
|
+
# C — cross-provider OpenAI → Anthropic
|
|
24
|
+
# attempt 1 model=gpt-5-nano status=validation_failed
|
|
25
|
+
# attempt 3 model=claude-haiku-4-5 status=ok
|
|
26
26
|
# =============================================================================
|
|
27
27
|
|
|
28
28
|
require_relative "../lib/ruby_llm/contract"
|
|
@@ -108,26 +108,30 @@ adapter = RubyLLM::Contract::Adapters::Test.new(responses: RESPONSES)
|
|
|
108
108
|
print_trace("reasoning escalation", SummarizeArticleReasoningEscalation.run("article", context: { adapter: adapter }))
|
|
109
109
|
|
|
110
110
|
# =============================================================================
|
|
111
|
-
# VARIANT C — cross-provider fallback (
|
|
111
|
+
# VARIANT C — cross-provider fallback (OpenAI → Anthropic)
|
|
112
112
|
#
|
|
113
|
-
# When to use:
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
# provider from the model name.
|
|
113
|
+
# When to use: the cheap tiers live on one provider and the last resort on
|
|
114
|
+
# another. ruby_llm detects the provider from the model name, so a chain of
|
|
115
|
+
# models from its registry can cross providers.
|
|
117
116
|
#
|
|
118
|
-
# To run against real backends: configure ruby_llm for
|
|
119
|
-
# (
|
|
120
|
-
#
|
|
117
|
+
# To run against real backends: configure ruby_llm for both providers
|
|
118
|
+
# (openai_api_key + anthropic_api_key) and swap the Test adapter for
|
|
119
|
+
# Adapters::RubyLLM. The retry_policy itself is unchanged.
|
|
121
120
|
#
|
|
122
|
-
#
|
|
121
|
+
# A chain element carries only `model` and `reasoning_effort`. A local
|
|
122
|
+
# Ollama model needs `provider: :ollama` (and `assume_model_exists: true`)
|
|
123
|
+
# in the context, which applies to every attempt, so it cannot be one tier
|
|
124
|
+
# of a chain that also reaches hosted providers.
|
|
125
|
+
#
|
|
126
|
+
# Order matters: cheapest first; the last tier is the safety net.
|
|
123
127
|
# =============================================================================
|
|
124
128
|
|
|
125
129
|
class SummarizeArticleCrossProvider < SummarizeArticle
|
|
126
|
-
retry_policy models: %w[
|
|
130
|
+
retry_policy models: %w[gpt-5-nano gpt-5-mini claude-haiku-4-5]
|
|
127
131
|
end
|
|
128
132
|
|
|
129
133
|
puts "=" * 70
|
|
130
|
-
puts "C — cross-provider fallback (
|
|
134
|
+
puts "C — cross-provider fallback (OpenAI → Anthropic)"
|
|
131
135
|
puts "=" * 70
|
|
132
136
|
adapter = RubyLLM::Contract::Adapters::Test.new(responses: RESPONSES)
|
|
133
137
|
print_trace("cross-provider", SummarizeArticleCrossProvider.run("article", context: { adapter: adapter }))
|
|
@@ -140,8 +144,8 @@ print_trace("cross-provider", SummarizeArticleCrossProvider.run("article", conte
|
|
|
140
144
|
# 2. `reasoning_effort` escalation is cheaper than model escalation when the
|
|
141
145
|
# model is right but needs more thinking, not a stronger backbone.
|
|
142
146
|
# 3. Cross-provider retry uses the same DSL — ruby_llm resolves the provider
|
|
143
|
-
# from the model name. Start cheapest
|
|
144
|
-
#
|
|
147
|
+
# from the model name. Start cheapest, end on the most accurate model,
|
|
148
|
+
# whichever provider hosts it.
|
|
145
149
|
# 4. The per-attempt trace (model, config, status, cost) is identical across
|
|
146
150
|
# variants — your logging does not care which retry shape you picked.
|
|
147
151
|
# =============================================================================
|
data/examples/README.md
CHANGED
|
@@ -8,11 +8,11 @@ Pedagogical order: hook → activation → evolution → composition → quality
|
|
|
8
8
|
|---|------|---------|
|
|
9
9
|
| 00 | `00_basics.rb` | **"How do I start?"** — seven incremental layers: plain prompt → output_schema → validate → structured prompt → Hash input → cross-input validate → retry_policy → trace inspection, plus real-LLM swap pointer. |
|
|
10
10
|
| 01 | `01_fallback_showcase.rb` | **"Show me the gem in 30 seconds."** — Part A: schema-only ships a flaky sample. Part B: full contract rejects it and retry_policy escalates to the next model. Per-attempt trace printed inline. |
|
|
11
|
-
| 02 | `02_real_llm_minimal.rb` | **"How do I plug in a real LLM?"** — ~30 lines. `Adapters::RubyLLM.new` in context, same step. Also shows per-call
|
|
11
|
+
| 02 | `02_real_llm_minimal.rb` | **"How do I plug in a real LLM?"** — ~30 lines. `Adapters::RubyLLM.new` in context, same step. Also shows a per-call model switch (Anthropic, or a local Ollama model) with the step's retry chain turned off for that call. |
|
|
12
12
|
| 03 | `03_summarize_with_keywords.rb` | **"How does the contract evolve when the product grows?"** — marketing wants a "topic pills" row, so `SummarizeArticle` gains a keywords field with probability and cross-validation. Prompt, schema, and validates stay in lockstep. |
|
|
13
13
|
| 04 | `04_summarize_and_translate.rb` | **"How do steps compose into a pipeline?"** — 3 steps threaded by `Pipeline::Base`: English summary → translate to French → quality review. Fail-fast: a rejected summary means translate and review never run. |
|
|
14
14
|
| 05 | `05_eval_dataset.rb` | **"How do I stop silent prompt regressions?"** — define_eval with real cases, baseline vs regressed adapter, regression detection signal, inline eval_case. |
|
|
15
|
-
| 06 | `06_retry_variants.rb` | **"What retry shapes exist beyond cross-model?"** — `attempts: 3` (variance absorption), `reasoning_effort` escalation (low→medium→high), cross-provider fallback (
|
|
15
|
+
| 06 | `06_retry_variants.rb` | **"What retry shapes exist beyond cross-model?"** — `attempts: 3` (variance absorption), `reasoning_effort` escalation (low→medium→high), cross-provider fallback (OpenAI → Anthropic). |
|
|
16
16
|
|
|
17
17
|
Four of the seven carry an "Expected output" section in the file header (01, 03, 05, 06), so you can read what they print without running them. The other three differ: `00_basics.rb` prints nothing and annotates each result inline with `# =>`; `02_real_llm_minimal.rb` needs a live provider key, so its output is not deterministic; `04_summarize_and_translate.rb` prints but has no header block.
|
|
18
18
|
|
|
@@ -129,6 +129,7 @@ module RubyLLM
|
|
|
129
129
|
# overrides any inherited `options[:thinking][:effort]`; budget is
|
|
130
130
|
# taken from `options[:thinking][:budget]` only.
|
|
131
131
|
def resolve_thinking_config(options)
|
|
132
|
+
options = ::RubyLLM::Contract.without_default_effort(options)
|
|
132
133
|
base = options[:thinking].is_a?(Hash) ? options[:thinking].dup : {}
|
|
133
134
|
base[:effort] = options[:reasoning_effort] if options[:reasoning_effort]
|
|
134
135
|
base.empty? ? nil : base
|
|
@@ -114,12 +114,17 @@ module RubyLLM
|
|
|
114
114
|
"call compare_models with production_mode: on individual Step classes instead."
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# A Step's retry chain would pick every attempt's model, so a candidate
|
|
118
|
+
# runs without it (same-model retries stay), unless production_mode or
|
|
119
|
+
# the caller passes a retry policy for the comparison.
|
|
117
120
|
def build_candidate_context(context, config, fallback_config)
|
|
118
|
-
model_context =
|
|
121
|
+
model_context = { retry_policy_override: Step::RetryPolicy::WITHOUT_MODEL_CHAIN }
|
|
122
|
+
.merge(isolate_context(context), model: config[:model])
|
|
119
123
|
model_context[:reasoning_effort] = config[:reasoning_effort] if config[:reasoning_effort]
|
|
120
124
|
return model_context unless fallback_config
|
|
121
125
|
|
|
122
|
-
|
|
126
|
+
inherited_effort = context[:reasoning_effort] || (thinking[:effort] if respond_to?(:thinking) && thinking)
|
|
127
|
+
model_context[:retry_policy_override] = production_mode_override(config, fallback_config, inherited_effort)
|
|
123
128
|
model_context
|
|
124
129
|
end
|
|
125
130
|
|
|
@@ -20,14 +20,23 @@ module RubyLLM
|
|
|
20
20
|
RubyLLM::Contract.normalize_candidate_config(production_mode[:fallback])
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def production_mode_override(config, fallback_config)
|
|
24
|
-
return nil if
|
|
23
|
+
def production_mode_override(config, fallback_config, inherited_effort = nil)
|
|
24
|
+
return nil if same_request?(config, fallback_config, inherited_effort)
|
|
25
25
|
|
|
26
26
|
Step::RetryPolicy.new(models: [config, fallback_config])
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# Compared by what each would send: a candidate without an effort runs
|
|
30
|
+
# with the call's or the Step's, a fallback without one with the
|
|
31
|
+
# candidate's, and :default sends none.
|
|
32
|
+
def same_request?(candidate, fallback, inherited_effort)
|
|
33
|
+
candidate_effort = sent_effort(candidate[:reasoning_effort] || inherited_effort)
|
|
34
|
+
fallback_effort = fallback[:reasoning_effort] ? sent_effort(fallback[:reasoning_effort]) : candidate_effort
|
|
35
|
+
candidate[:model] == fallback[:model] && candidate_effort == fallback_effort
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sent_effort(effort)
|
|
39
|
+
RubyLLM::Contract.normalize_effort(effort) == :default ? nil : effort
|
|
31
40
|
end
|
|
32
41
|
end
|
|
33
42
|
end
|
|
@@ -23,7 +23,7 @@ module RubyLLM
|
|
|
23
23
|
match = PATTERN.match(label)
|
|
24
24
|
return { model: label } unless match
|
|
25
25
|
|
|
26
|
-
{ model: match.pre_match.strip, reasoning_effort: match[1] }
|
|
26
|
+
{ model: match.pre_match.strip, reasoning_effort: RubyLLM::Contract.normalize_effort(match[1]) }
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -74,8 +74,8 @@ module RubyLLM
|
|
|
74
74
|
def production_mode_table
|
|
75
75
|
fallback_label = self.class.candidate_label(@fallback)
|
|
76
76
|
rows = @reports.map do |label, report|
|
|
77
|
-
|
|
78
|
-
{ chain:
|
|
77
|
+
same = !report.production_mode?
|
|
78
|
+
{ chain: same ? label : "#{label} → #{fallback_label}", report: report, same: same }
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
chain_width = [rows.map { |r| r[:chain].length }.max || 0, 20].max
|
|
@@ -92,14 +92,6 @@ module RubyLLM
|
|
|
92
92
|
|
|
93
93
|
private
|
|
94
94
|
|
|
95
|
-
def chain_label(label, fallback_label)
|
|
96
|
-
label == fallback_label ? label : "#{label} → #{fallback_label}"
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def chain_same_as_fallback?(label, fallback_label)
|
|
100
|
-
label == fallback_label
|
|
101
|
-
end
|
|
102
|
-
|
|
103
95
|
def format_production_row(row, chain_width)
|
|
104
96
|
report = row[:report]
|
|
105
97
|
format(" %-#{chain_width}s %-13s %-10s %-14s %-9s %6.2f",
|
|
@@ -195,8 +195,17 @@ module RubyLLM
|
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
+
def runtime_policy(context)
|
|
199
|
+
return retry_policy unless context.key?(:retry_policy_override)
|
|
200
|
+
|
|
201
|
+
override = context[:retry_policy_override]
|
|
202
|
+
return override unless override == RetryPolicy::WITHOUT_MODEL_CHAIN
|
|
203
|
+
|
|
204
|
+
retry_policy if retry_policy&.model_list&.empty?
|
|
205
|
+
end
|
|
206
|
+
|
|
198
207
|
def runtime_settings(context)
|
|
199
|
-
policy = context
|
|
208
|
+
policy = runtime_policy(context)
|
|
200
209
|
extra = context.slice(*ADAPTER_CONTEXT_KEYS).merge(merged_provider_options(context))
|
|
201
210
|
|
|
202
211
|
# Always pass the class-level `thinking` config to the adapter when
|
|
@@ -261,6 +270,7 @@ module RubyLLM
|
|
|
261
270
|
# programmer bugs (NoMethodError, adapter-code ArgumentError) must propagate
|
|
262
271
|
# instead of being silently masked as :input_error.
|
|
263
272
|
def run_once(input, adapter:, model:, context_temperature: nil, extra_options: {})
|
|
273
|
+
extra_options = RubyLLM::Contract.without_default_effort(extra_options)
|
|
264
274
|
runner =
|
|
265
275
|
begin
|
|
266
276
|
Runner.new(config: build_runner_config(adapter, model, context_temperature, extra_options))
|
|
@@ -18,11 +18,12 @@ module RubyLLM
|
|
|
18
18
|
policy.max_attempts.times do |attempt_index|
|
|
19
19
|
config = policy.config_for_attempt(attempt_index, default_config)
|
|
20
20
|
model = config[:model]
|
|
21
|
-
attempt_extra = extra_options.merge(config.except(:model))
|
|
21
|
+
attempt_extra = RubyLLM::Contract.without_default_effort(extra_options.merge(config.except(:model)))
|
|
22
22
|
|
|
23
23
|
result = run_once(input, adapter: adapter, model: model,
|
|
24
24
|
context_temperature: context_temperature, extra_options: attempt_extra)
|
|
25
|
-
all_attempts << { attempt: attempt_index + 1, model: model, config:
|
|
25
|
+
all_attempts << { attempt: attempt_index + 1, model: model, config: sent_config(model, attempt_extra),
|
|
26
|
+
result: result }
|
|
26
27
|
break unless policy.retryable?(result)
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -49,6 +50,14 @@ module RubyLLM
|
|
|
49
50
|
)
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# What the attempt was sent, not the chain element it came from: an
|
|
54
|
+
# element without an effort still gets the class or context one
|
|
55
|
+
# (runtime_settings copies the class effort into :reasoning_effort).
|
|
56
|
+
def sent_config(model, options)
|
|
57
|
+
effort = options[:reasoning_effort]
|
|
58
|
+
effort ? { model: model, reasoning_effort: effort } : { model: model }
|
|
59
|
+
end
|
|
60
|
+
|
|
52
61
|
def build_attempt_entry(attempt)
|
|
53
62
|
trace = attempt[:result].trace
|
|
54
63
|
entry = { attempt: attempt[:attempt], model: attempt[:model], status: attempt[:result].status }
|
|
@@ -15,6 +15,11 @@ module RubyLLM
|
|
|
15
15
|
# transport retry could not).
|
|
16
16
|
DEFAULT_RETRY_ON = %i[validation_failed parse_error].freeze
|
|
17
17
|
|
|
18
|
+
# Internal, not a documented override value: keeps a Step's own policy
|
|
19
|
+
# unless it picks models, so compare_models runs a candidate on its
|
|
20
|
+
# own model with the same-model retries the Step would give it.
|
|
21
|
+
WITHOUT_MODEL_CHAIN = :without_model_chain
|
|
22
|
+
|
|
18
23
|
def initialize(models: nil, attempts: nil, retry_on: nil, &block)
|
|
19
24
|
@configs = []
|
|
20
25
|
@retryable_statuses = DEFAULT_RETRY_ON.dup
|
data/lib/ruby_llm/contract.rb
CHANGED
|
@@ -91,6 +91,23 @@ module RubyLLM
|
|
|
91
91
|
Thread.current[RELOADING_KEY] ? true : false
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# `reasoning_effort: :default`, the DSL's word for "no effort", leaves the
|
|
95
|
+
# class-level effort out of one call, candidate or retry-chain element.
|
|
96
|
+
# Rake and JSON give it as a String.
|
|
97
|
+
def normalize_effort(effort)
|
|
98
|
+
effort.to_s == "default" ? :default : effort
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The options as an adapter takes them: `reasoning_effort: :default` goes,
|
|
102
|
+
# and with it the effort in `thinking`; a thinking budget stays.
|
|
103
|
+
def without_default_effort(options)
|
|
104
|
+
return options unless normalize_effort(options[:reasoning_effort]) == :default
|
|
105
|
+
|
|
106
|
+
thinking = options[:thinking].is_a?(Hash) ? options[:thinking].except(:effort) : {}
|
|
107
|
+
resolved = options.except(:reasoning_effort, :thinking)
|
|
108
|
+
thinking.empty? ? resolved : resolved.merge(thinking: thinking)
|
|
109
|
+
end
|
|
110
|
+
|
|
94
111
|
def normalize_candidate_config(entry)
|
|
95
112
|
case entry
|
|
96
113
|
when String
|
|
@@ -104,7 +121,7 @@ module RubyLLM
|
|
|
104
121
|
end
|
|
105
122
|
|
|
106
123
|
normalized = { model: model.strip }
|
|
107
|
-
effort = entry[:reasoning_effort] || entry["reasoning_effort"]
|
|
124
|
+
effort = normalize_effort(entry[:reasoning_effort] || entry["reasoning_effort"])
|
|
108
125
|
normalized[:reasoning_effort] = effort if effort
|
|
109
126
|
normalized
|
|
110
127
|
else
|