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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3d6985e56b8e410f796207f23b52e910b925f1bf4263353da65ca3f540c9535
4
- data.tar.gz: 4cb5f7471dc5d97f3d3bd78fe41110cb6a9f28139e90029872fa612ed4ef3e9e
3
+ metadata.gz: b836138600866650ba9df05fce0a0ce02d148dc1819b9b215e5e47aa124e93cb
4
+ data.tar.gz: 237df37acef3aba9320acc723156dfdf33a928729c58b6e79ad54ea1ac33cf68
5
5
  SHA512:
6
- metadata.gz: 62e15223ae05cfa64fd64959eb62858ace305a6f24e23e2c3f466219681eae4a1cd4c08addaf9d7d309f554c4ead57e1051d9b4b1eb55bcea4542ce8df23d789
7
- data.tar.gz: e4c07a0a8b4706cbc062ca713ab9c8a2e87906b2fa4126a3b6d10f72ef30625ce6c8c5ea0a29ac30fef0b5251a06fd451bd1462c45289a0ab1cc939cdc25b989
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. That skewed `best`, the retry chain, the rationale
12
- and `savings`, and a candidate measured over more runs looked cheaper than one
13
- measured once. Cases are now counted per run. `Step.recommend` always compares
14
- with one run, so only a `Recommender` built by hand on a multi-run comparison
15
- was affected.
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
- model_context[:retry_policy_override] = production_mode_override(config, fallback_config)
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 same_candidate?(config, fallback_config)
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
- def same_candidate?(first, second)
30
- first[:model] == second[:model] && first[:reasoning_effort] == second[:reasoning_effort]
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: config, result: result }
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 }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.3.1"
5
+ VERSION = "1.4.0"
6
6
  end
7
7
  end
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-contract
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna