ruby_llm-contract 1.3.1 → 1.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 +37 -5
- data/docs/guide/getting_started.md +1 -1
- data/docs/guide/optimizing_retry_policy.md +13 -2
- data/lib/ruby_llm/contract/adapters/ruby_llm.rb +1 -0
- data/lib/ruby_llm/contract/concerns/eval_host.rb +2 -1
- 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/step/base.rb +1 -0
- data/lib/ruby_llm/contract/step/retry_executor.rb +11 -2
- 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: b836138600866650ba9df05fce0a0ce02d148dc1819b9b215e5e47aa124e93cb
|
|
4
|
+
data.tar.gz: 237df37acef3aba9320acc723156dfdf33a928729c58b6e79ad54ea1ac33cf68
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79ff71eea63a6ce371b0d183137d8b0c498b3dcb8f21fa2410cbd9793334120be140a15716080688569e96a0d7b543e2083e986ccb2c199153722a17ae0adffb
|
|
7
|
+
data.tar.gz: c0547247d8d84e2e4bfc03fc398ddee89d1b77330924487a68f606f363365df800dc2be0016a255227cf4bb004c2b3510d1bf3f5ee7f19a1d5d00a2896103a28
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`reasoning_effort: :default` sends no effort for one call, candidate or
|
|
8
|
+
retry-chain element.** A Step with `reasoning_effort "none"` sent that effort to
|
|
9
|
+
every model, also one given in the context, a `compare_models` candidate or a
|
|
10
|
+
retry-chain element without an effort of its own, and a model without reasoning
|
|
11
|
+
rejected the call (OpenAI: "Unsupported parameter: 'reasoning.effort'"). Nothing
|
|
12
|
+
could take the class effort out short of a subclass with `reasoning_effort :default`,
|
|
13
|
+
and `reasoning_effort: nil` was ignored. `:default` - the word the DSL already
|
|
14
|
+
uses - now works in `context:`, in candidates (`{ model: "gpt-4.1-mini",
|
|
15
|
+
reasoning_effort: :default }`), in a `production_mode:` fallback, in `escalate`
|
|
16
|
+
/ `retry_policy models:` elements and as `CANDIDATES=gpt-4.1-mini@default` in
|
|
17
|
+
`rake ruby_llm_contract:optimize`. A `thinking budget:` stays. `nil` still means
|
|
18
|
+
"not set", and the gem does not drop an effort by itself based on the model.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **A retry attempt's `trace.attempts[n][:config]` shows the effort it was sent.**
|
|
23
|
+
It showed the chain element, so an element without an effort, which runs with the
|
|
24
|
+
call's or the class's, looked as if it had none. Such an attempt now has
|
|
25
|
+
`config: { model:, reasoning_effort: }`; an attempt sent no effort has no
|
|
26
|
+
`config`, as before. Code comparing these hashes may need the extra key.
|
|
27
|
+
- **`production_mode:` skips a fallback that would send the candidate's own request.**
|
|
28
|
+
A fallback without an effort runs with the candidate's, so `{ model: "gpt-4.1-mini",
|
|
29
|
+
reasoning_effort: "low" }` with `fallback: "gpt-4.1-mini"` retried the same call.
|
|
30
|
+
It now runs single-shot, as a fallback equal to the candidate already did. The
|
|
31
|
+
comparison counts the effort each would send: inherited from the call or the
|
|
32
|
+
Step, and none for `:default`.
|
|
33
|
+
|
|
3
34
|
## 1.3.1 (2026-10-11)
|
|
4
35
|
|
|
5
36
|
### Fixed
|
|
@@ -8,11 +39,12 @@
|
|
|
8
39
|
comparison.** With `runs > 1` each report is an `AggregatedReport`: its results
|
|
9
40
|
hold the cases of all N runs, while `total_cost` is the mean cost of one run. The
|
|
10
41
|
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
|
-
|
|
42
|
+
a $0.002 call showed as $0.0007. Every candidate of one `compare_models` call runs
|
|
43
|
+
the same number of times, so the ranking held: `cost_per_call`, the rationale and
|
|
44
|
+
`savings` were wrong, `best` and the retry chain were not. Only a comparison put
|
|
45
|
+
together from reports with different run counts could pick the wrong model, since
|
|
46
|
+
a candidate measured over more runs looked cheaper. Cases are now counted per run.
|
|
47
|
+
`Step.recommend` always compares with one run and was not affected.
|
|
16
48
|
|
|
17
49
|
## 1.3.0 (2026-10-11)
|
|
18
50
|
|
|
@@ -102,7 +102,7 @@ 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
106
|
|
|
107
107
|
## Evals and CI gates
|
|
108
108
|
|
|
@@ -30,7 +30,7 @@ rake ruby_llm_contract:optimize \
|
|
|
30
30
|
CANDIDATES=gpt-4.1-nano,gpt-4.1-mini@low,gpt-4.1-mini,gpt-4.1
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The `@low` (or `@medium`, `@high`) suffix on a candidate is CLI shorthand for `{ model: "gpt-4.1-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).
|
|
33
|
+
The `@low` (or `@medium`, `@high`) suffix on a candidate is CLI shorthand for `{ model: "gpt-4.1-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-4.1-mini@default,gpt-6-luna@none`.
|
|
34
34
|
|
|
35
35
|
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
36
|
|
|
@@ -76,7 +76,7 @@ Copy the DSL, paste into your step, verify with `rake ruby_llm_contract:eval`. Y
|
|
|
76
76
|
|
|
77
77
|
`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
78
|
|
|
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).
|
|
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). 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
80
|
|
|
81
81
|
```ruby
|
|
82
82
|
SummarizeArticle.compare_models(
|
|
@@ -131,3 +131,14 @@ end
|
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
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.
|
|
134
|
+
|
|
135
|
+
`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'"):
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
SummarizeArticle.run(text, context: { model: "gpt-4.1-mini", reasoning_effort: :default })
|
|
139
|
+
SummarizeArticle.compare_models("regression", candidates: [{ model: "gpt-4.1-mini", reasoning_effort: :default },
|
|
140
|
+
{ model: "gpt-6-luna" }])
|
|
141
|
+
retry_policy { escalate({ model: "gpt-4.1-mini", reasoning_effort: :default }, { model: "gpt-6-luna" }) }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`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.
|
|
@@ -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
|
|
@@ -119,7 +119,8 @@ module RubyLLM
|
|
|
119
119
|
model_context[:reasoning_effort] = config[:reasoning_effort] if config[:reasoning_effort]
|
|
120
120
|
return model_context unless fallback_config
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
inherited_effort = context[:reasoning_effort] || (thinking[:effort] if respond_to?(:thinking) && thinking)
|
|
123
|
+
model_context[:retry_policy_override] = production_mode_override(config, fallback_config, inherited_effort)
|
|
123
124
|
model_context
|
|
124
125
|
end
|
|
125
126
|
|
|
@@ -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
|
|
@@ -261,6 +261,7 @@ module RubyLLM
|
|
|
261
261
|
# programmer bugs (NoMethodError, adapter-code ArgumentError) must propagate
|
|
262
262
|
# instead of being silently masked as :input_error.
|
|
263
263
|
def run_once(input, adapter:, model:, context_temperature: nil, extra_options: {})
|
|
264
|
+
extra_options = RubyLLM::Contract.without_default_effort(extra_options)
|
|
264
265
|
runner =
|
|
265
266
|
begin
|
|
266
267
|
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 }
|
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
|