ruby_llm-contract 0.6.3 → 0.7.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: 75884386ae53ddf1985760afa2c94f64ce15274b0cb0829ea40e6711506e60cf
4
- data.tar.gz: 203229c4ce8ab0b1ab9e6209871668b0713b114e412eba03e79e9964dc9a43bb
3
+ metadata.gz: 5decfb7456338baa05d0e6bb79287bb3fb3af0e0cc2c3f001e09122fa76ac298
4
+ data.tar.gz: be3b46ff015fd651e885c4c078dbdaf06623865f4e4b80b864b66e8dd9e16c34
5
5
  SHA512:
6
- metadata.gz: 0c586ce70e71d8e77c262e0ae21bab2e29ef6ccdb16df4a0e56271aeb98efba59027618db24d4b59470fd3a9d340051201584038f8c67143f7ca1bec0e5e365b
7
- data.tar.gz: 6a988c62f6b36a4da860b736c5523abcc568ed1384c1f20cd8ff9659124c12eeeddb36100d5b8542af07b363cef7ade8a1aca01250929ec2c23172d4f3faec5f
6
+ metadata.gz: fd3882613ac2b500c46dc6b1084d8f298db96800bf01932e3bc2638a7a3d2a8588610c1de8a1f3754a8e15fb7b1ef29c0c0a7bddd11709cd95bbf12fcd48e83e
7
+ data.tar.gz: 4cb584323a5575de4131b0eb82cdb1426743ca6651030708673d17264c9fec60619bb7d5d54f62eee4c1fa357b4022f57bf08b95d81f153eccf8e625ce3ef5e7
data/.rubocop.yml CHANGED
@@ -42,14 +42,17 @@ AllCops:
42
42
  - 'internal/**/*'
43
43
 
44
44
  Metrics/ClassLength:
45
- Max: 130
45
+ Max: 140
46
+
47
+ Metrics/ModuleLength:
48
+ Max: 150
46
49
 
47
50
  Metrics/AbcSize:
48
51
  Max: 30
49
52
 
50
53
  Metrics/ParameterLists:
51
- Max: 11
52
- MaxOptionalParameters: 9
54
+ Max: 12
55
+ MaxOptionalParameters: 10
53
56
 
54
57
  Style/FormatStringToken:
55
58
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-04-21)
4
+
5
+ ### Breaking changes
6
+
7
+ - **`:adapter_error` removed from `DEFAULT_RETRY_ON`.** New default: `[:validation_failed, :parse_error]`. `ruby_llm` already retries transport errors (`RateLimitError`, `ServerError`, `ServiceUnavailableError`, `OverloadedError`, timeouts) at the Faraday layer, so the previous default re-ran the same model on errors the HTTP middleware already retried with backoff. To restore pre-0.7 behavior: `retry_on :validation_failed, :parse_error, :adapter_error`. Recommended pattern: pair `:adapter_error` with `escalate "model_a", "model_b"` — a different model/provider can bypass what transport retry could not.
8
+ - **`AdapterCaller` narrows `rescue` from `StandardError` to `RubyLLM::Error` + `Faraday::Error`.** Provider errors and transport errors that escape ruby_llm's Faraday retry middleware (`Faraday::TimeoutError`, `Faraday::ConnectionFailed`) still produce `:adapter_error` as before. Programmer errors that are neither (`NoMethodError`, adapter code bugs) now propagate instead of being silently converted to `:adapter_error` and retried. **Known limitation:** adapter code raising `ArgumentError` is still coerced into `:input_error` by `Step::Base#run_once` (which rescues `ArgumentError` for input-type validation). Disambiguating adapter-ArgumentError vs input-validation-ArgumentError requires a `run_once` refactor and is tracked as a follow-up.
9
+
10
+ ### Migration
11
+
12
+ If you rely on the old behavior, opt in explicitly:
13
+
14
+ ```ruby
15
+ retry_policy do
16
+ attempts 3
17
+ retry_on :validation_failed, :parse_error, :adapter_error
18
+ end
19
+ ```
20
+
21
+ Or better, with a model fallback chain:
22
+
23
+ ```ruby
24
+ retry_policy do
25
+ escalate "gpt-4.1-nano", "gpt-4.1-mini"
26
+ retry_on :validation_failed, :parse_error, :adapter_error
27
+ end
28
+ ```
29
+
30
+ ## 0.6.4 (2026-04-20)
31
+
32
+ ### Features
33
+
34
+ - **`production_mode:` on `compare_models` and `optimize_retry_policy`** — measures retry-aware, end-to-end cost per successful output. Pass `production_mode: { fallback: "gpt-5-mini" }` and each candidate runs with a runtime-injected `[candidate, fallback]` retry chain. The report exposes `escalation_rate`, `single_shot_cost`, and `effective_cost` so "the cheaper candidate" decision matches production cost rather than first-attempt cost.
35
+ - **New Report metrics** — `escalation_rate`, `single_shot_cost`, `effective_cost`, `single_shot_latency_ms`, `effective_latency_ms`, `latency_percentiles` (p50/p95/max). `AggregatedReport` averages all of them across `runs:`.
36
+ - **Extended `ModelComparison#table`** — when `production_mode:` is set, renders a `Chain` column (`candidate → fallback`) with `single-shot`, `escalation`, `effective cost`, `latency`, `score`. Edge case `candidate == fallback` renders as a single model and `—` in the escalation column, with retry injection skipped entirely so `effective == single-shot` by construction, not by coincidence.
37
+ - **`context[:retry_policy_override]`** — new context key that nullifies or replaces class-level `retry_policy` for a single call. Used internally by production-mode injection; safe to use directly when you need a transient override that doesn't mutate the step class.
38
+
39
+ ### Scope
40
+
41
+ - Single-fallback (2-tier) chains only. Multi-tier chains can be inspected post-hoc via `trace.attempts` but aren't summarized in the optimize table.
42
+ - Costs with `runs: 3 + production_mode: { fallback: "gpt-5-mini" }` are ≈3× a single-shot eval plus the actual retry attempts — not 6×. Production-mode metrics come from a single pass.
43
+ - **Step-only.** Calling `compare_models` with `production_mode:` on a `Pipeline::Base` subclass raises `ArgumentError` — retry injection is Step-level and pipeline-wide fallback semantics aren't defined yet. Benchmark individual steps.
44
+
45
+ ### Documentation
46
+
47
+ - **Guide: [Production-mode cost measurement](docs/guide/optimizing_retry_policy.md#production-mode-cost-measurement)** — API, metric interpretation, 2-tier scope note.
48
+
3
49
  ## 0.6.3 (2026-04-20)
4
50
 
5
51
  ### Features
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ruby_llm-contract (0.6.3)
4
+ ruby_llm-contract (0.7.0)
5
5
  dry-types (~> 1.7)
6
6
  ruby_llm (~> 1.0)
7
7
  ruby_llm-schema (~> 0.3)
@@ -258,7 +258,7 @@ CHECKSUMS
258
258
  rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
259
259
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
260
260
  ruby_llm (1.14.0) sha256=57c6f7034fc4a44504ea137d70f853b07824f1c1cdbe774ab3ab3522e7098deb
261
- ruby_llm-contract (0.6.3)
261
+ ruby_llm-contract (0.7.0)
262
262
  ruby_llm-schema (0.3.0) sha256=a591edc5ca1b7f0304f0e2261de61ba4b3bea17be09f5cf7558153adfda3dec6
263
263
  ruby_parser (3.22.0) sha256=1eb4937cd9eb220aa2d194e352a24dba90aef00751e24c8dfffdb14000f15d23
264
264
  rubycritic (4.12.0) sha256=024fed90fe656fa939f6ea80aab17569699ac3863d0b52fd72cb99892247abc8
data/README.md CHANGED
@@ -160,6 +160,8 @@ Nano fails on edge cases. Mini and full both score 100% — but mini is **5x che
160
160
 
161
161
  Running live against gpt-5 / o-series? Pass `runs: 3` to average out sampling variance (OpenAI forces `temperature=1.0` server-side, so one unlucky run can misclassify a viable candidate). See [Reducing variance with `runs:`](docs/guide/optimizing_retry_policy.md#reducing-variance-with-runs).
162
162
 
163
+ Want the *effective* cost — first-attempt plus retries — rather than the single-shot headline number? Pass `production_mode: { fallback: "gpt-5-mini" }` and the table gains `escalation`, `effective cost`, and a `Chain` column. See [Production-mode cost measurement](docs/guide/optimizing_retry_policy.md#production-mode-cost-measurement).
164
+
163
165
  ## Let the gem tell you what to do
164
166
 
165
167
  Don't read tables — get a recommendation. Supports `model + reasoning_effort` combinations:
@@ -5,6 +5,7 @@ module RubyLLM
5
5
  module Concerns
6
6
  module EvalHost
7
7
  include ContextHelpers
8
+ include ProductionModeContext
8
9
 
9
10
  SAMPLE_RESPONSE_COMPARE_WARNING = "[ruby_llm-contract] compare_with ignores sample_response. " \
10
11
  "Without model: or context: { adapter: ... }, both sides will be skipped " \
@@ -70,26 +71,29 @@ module RubyLLM
70
71
  Eval::PromptDiff.new(candidate: my_report, baseline: other_report)
71
72
  end
72
73
 
73
- def compare_models(eval_name, models: [], candidates: [], context: {}, runs: 1)
74
+ def compare_models(eval_name, models: [], candidates: [], context: {}, runs: 1, production_mode: nil)
74
75
  raise ArgumentError, "Pass either models: or candidates:, not both" if models.any? && candidates.any?
75
76
 
76
77
  runs = coerce_runs(runs)
77
78
 
78
79
  context = safe_context(context)
79
80
  candidate_configs = normalize_candidates(models, candidates)
81
+ reject_production_mode_on_pipeline!(production_mode)
82
+ fallback_config = normalize_production_mode(production_mode)
80
83
 
81
84
  reports = {}
82
85
  configs = {}
83
86
  candidate_configs.each do |config|
84
87
  label = Eval::ModelComparison.candidate_label(config)
85
- model_context = isolate_context(context).merge(model: config[:model])
86
- model_context[:reasoning_effort] = config[:reasoning_effort] if config[:reasoning_effort]
88
+ model_context = build_candidate_context(context, config, fallback_config)
87
89
  per_run = Array.new(runs) { run_single_eval(eval_name, model_context) }
88
90
  reports[label] = runs == 1 ? per_run.first : Eval::AggregatedReport.new(per_run)
89
91
  configs[label] = config
90
92
  end
91
93
 
92
- Eval::ModelComparison.new(eval_name: eval_name, reports: reports, configs: configs)
94
+ Eval::ModelComparison.new(
95
+ eval_name: eval_name, reports: reports, configs: configs, fallback: fallback_config
96
+ )
93
97
  end
94
98
 
95
99
  private
@@ -101,6 +105,24 @@ module RubyLLM
101
105
  runs
102
106
  end
103
107
 
108
+ def reject_production_mode_on_pipeline!(production_mode)
109
+ return if production_mode.nil? || production_mode == false
110
+ return unless defined?(Pipeline::Base) && self < Pipeline::Base
111
+
112
+ raise ArgumentError,
113
+ "production_mode: is not supported on Pipeline (#{self}). Retry injection happens at Step level; " \
114
+ "call compare_models with production_mode: on individual Step classes instead."
115
+ end
116
+
117
+ def build_candidate_context(context, config, fallback_config)
118
+ model_context = isolate_context(context).merge(model: config[:model])
119
+ model_context[:reasoning_effort] = config[:reasoning_effort] if config[:reasoning_effort]
120
+ return model_context unless fallback_config
121
+
122
+ model_context[:retry_policy_override] = production_mode_override(config, fallback_config)
123
+ model_context
124
+ end
125
+
104
126
  def normalize_candidates(models, candidates)
105
127
  if candidates.any?
106
128
  candidates.map { |c| RubyLLM::Contract.normalize_candidate_config(c) }.uniq
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Contract
5
+ module Concerns
6
+ # Helpers for injecting a retry_policy_override into per-candidate eval
7
+ # context when compare_models runs in production-mode. When candidate
8
+ # == fallback, retry injection is skipped so the row degenerates into
9
+ # a single-shot eval by construction.
10
+ module ProductionModeContext
11
+ private
12
+
13
+ def normalize_production_mode(production_mode)
14
+ return nil if production_mode.nil? || production_mode == false
15
+
16
+ unless production_mode.is_a?(Hash) && production_mode[:fallback]
17
+ raise ArgumentError, "production_mode: must be a Hash with :fallback, got #{production_mode.inspect}"
18
+ end
19
+
20
+ RubyLLM::Contract.normalize_candidate_config(production_mode[:fallback])
21
+ end
22
+
23
+ def production_mode_override(config, fallback_config)
24
+ return nil if same_candidate?(config, fallback_config)
25
+
26
+ Step::RetryPolicy.new(models: [config, fallback_config])
27
+ end
28
+
29
+ def same_candidate?(first, second)
30
+ first[:model] == second[:model] && first[:reasoning_effort] == second[:reasoning_effort]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -86,6 +86,49 @@ module RubyLLM
86
86
  def failures
87
87
  @runs.flat_map(&:failures)
88
88
  end
89
+
90
+ def production_mode?
91
+ @runs.any?(&:production_mode?)
92
+ end
93
+
94
+ def escalation_rate
95
+ values = @runs.filter_map(&:escalation_rate)
96
+ return nil if values.empty?
97
+
98
+ values.sum / values.length.to_f
99
+ end
100
+
101
+ def single_shot_cost
102
+ values = @runs.filter_map(&:single_shot_cost)
103
+ return nil if values.empty?
104
+
105
+ values.sum / values.length.to_f
106
+ end
107
+
108
+ def effective_cost
109
+ total_cost
110
+ end
111
+
112
+ def single_shot_latency_ms
113
+ values = @runs.filter_map(&:single_shot_latency_ms)
114
+ return nil if values.empty?
115
+
116
+ values.sum / values.length.to_f
117
+ end
118
+
119
+ def effective_latency_ms
120
+ avg_latency_ms
121
+ end
122
+
123
+ def latency_percentiles
124
+ per_run = @runs.filter_map(&:latency_percentiles)
125
+ return nil if per_run.empty?
126
+
127
+ %i[p50 p95 max].each_with_object({}) do |key, acc|
128
+ values = per_run.filter_map { |h| h[key] }
129
+ acc[key] = values.empty? ? nil : values.sum / values.length.to_f
130
+ end
131
+ end
89
132
  end
90
133
  end
91
134
  end
@@ -5,10 +5,10 @@ module RubyLLM
5
5
  module Eval
6
6
  class CaseResult
7
7
  attr_reader :name, :input, :output, :expected, :step_status,
8
- :score, :details, :duration_ms, :cost
8
+ :score, :details, :duration_ms, :cost, :attempts
9
9
 
10
10
  def initialize(name:, input:, output:, expected:, step_status:,
11
- score:, passed:, label: nil, details: nil, duration_ms: nil, cost: nil)
11
+ score:, passed:, label: nil, details: nil, duration_ms: nil, cost: nil, attempts: nil)
12
12
  @name = name
13
13
  @input = input
14
14
  @output = output
@@ -20,6 +20,7 @@ module RubyLLM
20
20
  @details = details
21
21
  @duration_ms = duration_ms
22
22
  @cost = cost
23
+ @attempts = attempts
23
24
  freeze
24
25
  end
25
26
 
@@ -58,7 +59,8 @@ module RubyLLM
58
59
  label: label,
59
60
  details: @details,
60
61
  duration_ms: @duration_ms,
61
- cost: @cost
62
+ cost: @cost,
63
+ attempts: @attempts
62
64
  }
63
65
  end
64
66
 
@@ -18,7 +18,8 @@ module RubyLLM
18
18
  label: evaluation.label,
19
19
  details: evaluation.details,
20
20
  duration_ms: trace_metric(trace, :total_latency_ms, :latency_ms),
21
- cost: trace_metric(trace, :total_cost, :cost)
21
+ cost: trace_metric(trace, :total_cost, :cost),
22
+ attempts: trace_attempts(trace)
22
23
  )
23
24
  end
24
25
 
@@ -29,6 +30,12 @@ module RubyLLM
29
30
 
30
31
  trace.respond_to?(pipeline_key) ? trace.public_send(pipeline_key) : trace[step_key]
31
32
  end
33
+
34
+ def trace_attempts(trace)
35
+ return nil unless trace
36
+
37
+ trace.respond_to?(:attempts) ? trace.attempts : nil
38
+ end
32
39
  end
33
40
  end
34
41
  end
@@ -4,20 +4,25 @@ module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
6
  class ModelComparison
7
- attr_reader :eval_name, :reports, :configs
7
+ attr_reader :eval_name, :reports, :configs, :fallback
8
8
 
9
9
  def self.candidate_label(config)
10
10
  effort = config[:reasoning_effort]
11
11
  effort ? "#{config[:model]} (effort: #{effort})" : config[:model]
12
12
  end
13
13
 
14
- def initialize(eval_name:, reports:, configs: nil)
14
+ def initialize(eval_name:, reports:, configs: nil, fallback: nil)
15
15
  @eval_name = eval_name
16
16
  @reports = reports.dup.freeze
17
17
  @configs = (configs || default_configs_from_reports).freeze
18
+ @fallback = fallback
18
19
  freeze
19
20
  end
20
21
 
22
+ def production_mode?
23
+ !@fallback.nil?
24
+ end
25
+
21
26
  def models
22
27
  @reports.keys
23
28
  end
@@ -44,6 +49,8 @@ module RubyLLM
44
49
  end
45
50
 
46
51
  def table
52
+ return production_mode_table if production_mode?
53
+
47
54
  max_label = [@reports.keys.map(&:length).max || 0, 25].max
48
55
  lines = [format(" %-#{max_label}s Score Cost Avg Latency", "Candidate")]
49
56
  lines << " #{"-" * (max_label + 36)}"
@@ -57,6 +64,62 @@ module RubyLLM
57
64
  lines.join("\n")
58
65
  end
59
66
 
67
+ def production_mode_table
68
+ fallback_label = self.class.candidate_label(@fallback)
69
+ rows = @reports.map do |label, report|
70
+ chain = chain_label(label, fallback_label)
71
+ { chain: chain, report: report, same: chain_same_as_fallback?(label, fallback_label) }
72
+ end
73
+
74
+ chain_width = [rows.map { |r| r[:chain].length }.max || 0, 20].max
75
+ lines = [format(" %-#{chain_width}s %-11s %-10s %-14s %-9s %s",
76
+ "Chain", "single-shot", "escalation", "effective cost", "latency", "score")]
77
+ lines << " #{"-" * (chain_width + 60)}"
78
+
79
+ rows.each do |row|
80
+ lines << format_production_row(row, chain_width)
81
+ end
82
+
83
+ lines.join("\n")
84
+ end
85
+
86
+ private
87
+
88
+ def chain_label(label, fallback_label)
89
+ label == fallback_label ? label : "#{label} → #{fallback_label}"
90
+ end
91
+
92
+ def chain_same_as_fallback?(label, fallback_label)
93
+ label == fallback_label
94
+ end
95
+
96
+ def format_production_row(row, chain_width)
97
+ report = row[:report]
98
+ format(" %-#{chain_width}s %-11s %-10s %-14s %-9s %6.2f",
99
+ row[:chain],
100
+ format_money(report.single_shot_cost || report.total_cost),
101
+ format_escalation(row, report),
102
+ format_money(report.effective_cost),
103
+ format_latency(report.effective_latency_ms),
104
+ report.score)
105
+ end
106
+
107
+ def format_money(value)
108
+ value&.positive? ? "$#{format("%.4f", value)}" : "n/a"
109
+ end
110
+
111
+ def format_latency(value)
112
+ value ? "#{value.round}ms" : "n/a"
113
+ end
114
+
115
+ def format_escalation(row, report)
116
+ return "—" if row[:same]
117
+
118
+ format("%d%%", ((report.escalation_rate || 0) * 100).round)
119
+ end
120
+
121
+ public
122
+
60
123
  def print_summary(io = $stdout)
61
124
  io.puts "#{@eval_name} — model comparison"
62
125
  io.puts
@@ -72,7 +135,7 @@ module RubyLLM
72
135
 
73
136
  def to_h
74
137
  @reports.transform_values do |report|
75
- {
138
+ base = {
76
139
  score: report.score,
77
140
  total_cost: report.total_cost,
78
141
  avg_latency_ms: report.avg_latency_ms,
@@ -80,11 +143,25 @@ module RubyLLM
80
143
  pass_rate_ratio: report.pass_rate_ratio,
81
144
  passed: report.passed?
82
145
  }
146
+ production_mode_metrics(report, base)
83
147
  end
84
148
  end
85
149
 
86
150
  private
87
151
 
152
+ def production_mode_metrics(report, base)
153
+ return base unless report.respond_to?(:production_mode?) && report.production_mode?
154
+
155
+ base.merge(
156
+ escalation_rate: report.escalation_rate,
157
+ single_shot_cost: report.single_shot_cost,
158
+ effective_cost: report.effective_cost,
159
+ single_shot_latency_ms: report.single_shot_latency_ms,
160
+ effective_latency_ms: report.effective_latency_ms,
161
+ latency_percentiles: report.latency_percentiles
162
+ )
163
+ end
164
+
88
165
  def resolve_key(candidate)
89
166
  case candidate
90
167
  when String then candidate
@@ -15,7 +15,9 @@ module RubyLLM
15
15
  BASELINE_DIR = ".eval_baselines"
16
16
 
17
17
  def_delegators :@stats, :score, :passed, :failed, :skipped, :failures, :pass_rate, :pass_rate_ratio,
18
- :total_cost, :avg_latency_ms, :passed?
18
+ :total_cost, :avg_latency_ms, :passed?,
19
+ :production_mode?, :escalation_rate, :single_shot_cost, :single_shot_latency_ms,
20
+ :effective_cost, :effective_latency_ms, :latency_percentiles
19
21
  def_delegators :@presenter, :summary, :to_s, :print_summary
20
22
  def_delegators :@storage, :save_history!, :eval_history, :save_baseline!, :compare_with_baseline,
21
23
  :baseline_exists?
@@ -65,6 +65,69 @@ module RubyLLM
65
65
  def evaluated_results_count
66
66
  evaluated_results.length
67
67
  end
68
+
69
+ def production_mode?
70
+ evaluated_results.any? { |r| r.respond_to?(:attempts) && r.attempts }
71
+ end
72
+
73
+ def escalation_rate
74
+ return nil unless production_mode?
75
+ return 0.0 if evaluated_results.empty?
76
+
77
+ escalated = evaluated_results.count { |r| (r.attempts || []).length > 1 }
78
+ escalated.to_f / evaluated_results.length
79
+ end
80
+
81
+ def single_shot_cost
82
+ return nil unless production_mode?
83
+
84
+ evaluated_results.sum { |r| first_attempt_cost(r) || r.cost || 0.0 }
85
+ end
86
+
87
+ def effective_cost
88
+ total_cost
89
+ end
90
+
91
+ def single_shot_latency_ms
92
+ return nil unless production_mode?
93
+
94
+ latencies = evaluated_results.filter_map { |r| first_attempt_latency(r) || r.duration_ms }
95
+ return nil if latencies.empty?
96
+
97
+ latencies.sum.to_f / latencies.length
98
+ end
99
+
100
+ def effective_latency_ms
101
+ avg_latency_ms
102
+ end
103
+
104
+ def latency_percentiles
105
+ return nil unless production_mode?
106
+
107
+ latencies = evaluated_results.filter_map(&:duration_ms).sort
108
+ return nil if latencies.empty?
109
+
110
+ { p50: percentile(latencies, 0.50), p95: percentile(latencies, 0.95), max: latencies.last.to_f }
111
+ end
112
+
113
+ private
114
+
115
+ def first_attempt_cost(result)
116
+ first = (result.attempts || []).first
117
+ first && first[:cost]
118
+ end
119
+
120
+ def first_attempt_latency(result)
121
+ first = (result.attempts || []).first
122
+ first && first[:latency_ms]
123
+ end
124
+
125
+ def percentile(sorted, fraction)
126
+ return nil if sorted.empty?
127
+
128
+ idx = (fraction * (sorted.length - 1)).round
129
+ sorted[idx].to_f
130
+ end
68
131
  end
69
132
  end
70
133
  end
@@ -94,12 +94,13 @@ module RubyLLM
94
94
  end
95
95
  end
96
96
 
97
- def initialize(step:, candidates:, context: {}, min_score: 0.95, runs: 1)
97
+ def initialize(step:, candidates:, context: {}, min_score: 0.95, runs: 1, production_mode: nil)
98
98
  @step = step
99
99
  @candidates = candidates
100
100
  @context = context
101
101
  @min_score = min_score
102
102
  @runs = runs
103
+ @production_mode = production_mode
103
104
  end
104
105
 
105
106
  def call
@@ -109,7 +110,8 @@ module RubyLLM
109
110
  score_matrix = {}
110
111
  evals.each do |eval_name|
111
112
  comparison = with_retry_disabled do
112
- @step.compare_models(eval_name, candidates: @candidates, context: @context, runs: @runs)
113
+ @step.compare_models(eval_name, candidates: @candidates, context: @context,
114
+ runs: @runs, production_mode: @production_mode)
113
115
  end
114
116
  score_matrix[eval_name] = extract_scores(comparison)
115
117
  end
@@ -1,9 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "faraday"
4
+
3
5
  module RubyLLM
4
6
  module Contract
5
7
  module Step
6
8
  class AdapterCaller
9
+ # Exceptions treated as :adapter_error (retryable when explicitly opted in).
10
+ # RubyLLM::Error covers provider-semantic errors (auth, bad request,
11
+ # rate limit, server error, context length). Faraday::Error covers
12
+ # transport failures that escape ruby_llm's Faraday retry middleware
13
+ # after exhaustion (Faraday::TimeoutError, Faraday::ConnectionFailed).
14
+ # Anything else (NoMethodError, programmer ArgumentError from adapter
15
+ # code, etc.) propagates — those are bugs, not retry candidates.
16
+ ADAPTER_ERRORS = [::RubyLLM::Error, ::Faraday::Error].freeze
17
+
7
18
  def initialize(adapter:, adapter_options:)
8
19
  @adapter = adapter
9
20
  @adapter_options = adapter_options
@@ -14,8 +25,14 @@ module RubyLLM
14
25
  response = @adapter.call(messages: messages, **@adapter_options)
15
26
  latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
16
27
  [response, latency_ms]
17
- rescue StandardError => error
18
- [Result.new(status: :adapter_error, raw_output: nil, parsed_output: nil, validation_errors: [error.message]), 0]
28
+ rescue *ADAPTER_ERRORS => e
29
+ result = Result.new(
30
+ status: :adapter_error,
31
+ raw_output: nil,
32
+ parsed_output: nil,
33
+ validation_errors: [e.message]
34
+ )
35
+ [result, 0]
19
36
  end
20
37
  end
21
38
  end
@@ -59,17 +59,19 @@ module RubyLLM
59
59
  ).recommend
60
60
  end
61
61
 
62
- def optimize_retry_policy(candidates:, context: {}, min_score: 0.95, runs: 1)
62
+ def optimize_retry_policy(candidates:, context: {}, min_score: 0.95, runs: 1, production_mode: nil)
63
63
  Eval::RetryOptimizer.new(
64
64
  step: self,
65
65
  candidates: candidates,
66
66
  context: context,
67
67
  min_score: min_score,
68
- runs: runs
68
+ runs: runs,
69
+ production_mode: production_mode
69
70
  ).call
70
71
  end
71
72
 
72
- KNOWN_CONTEXT_KEYS = %i[adapter model temperature max_tokens provider assume_model_exists reasoning_effort].freeze
73
+ KNOWN_CONTEXT_KEYS = %i[adapter model temperature max_tokens provider assume_model_exists
74
+ reasoning_effort retry_policy_override].freeze
73
75
 
74
76
  include Concerns::ContextHelpers
75
77
 
@@ -156,11 +158,12 @@ module RubyLLM
156
158
  end
157
159
 
158
160
  def runtime_settings(context)
161
+ policy = context.key?(:retry_policy_override) ? context[:retry_policy_override] : retry_policy
159
162
  {
160
163
  model: context[:model] || model || RubyLLM::Contract.configuration.default_model,
161
164
  temperature: context[:temperature],
162
165
  extra_options: context.slice(:provider, :assume_model_exists, :max_tokens, :reasoning_effort),
163
- policy: retry_policy
166
+ policy: policy
164
167
  }
165
168
  end
166
169
 
@@ -6,7 +6,14 @@ module RubyLLM
6
6
  class RetryPolicy
7
7
  attr_reader :max_attempts, :retryable_statuses
8
8
 
9
- DEFAULT_RETRY_ON = %i[validation_failed parse_error adapter_error].freeze
9
+ # Breaking (0.7.0): :adapter_error removed from defaults. ruby_llm's Faraday
10
+ # middleware already retries transport errors (RateLimitError, ServerError,
11
+ # ServiceUnavailableError, OverloadedError, timeouts). Retrying on
12
+ # :adapter_error against the same model re-runs what transport already did.
13
+ # Opt in explicitly with `retry_on :adapter_error` — only meaningful paired
14
+ # with `escalate "model_a", "model_b"` (a different model may bypass what
15
+ # transport retry could not).
16
+ DEFAULT_RETRY_ON = %i[validation_failed parse_error].freeze
10
17
 
11
18
  def initialize(models: nil, attempts: nil, retry_on: nil, &block)
12
19
  @configs = []
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "0.6.3"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -154,6 +154,7 @@ end
154
154
  require_relative "contract/concerns/context_helpers"
155
155
  require_relative "contract/concerns/deep_freeze"
156
156
  require_relative "contract/concerns/deep_symbolize"
157
+ require_relative "contract/concerns/production_mode_context"
157
158
  require_relative "contract/concerns/eval_host"
158
159
  require_relative "contract/concerns/trace_equality"
159
160
  require_relative "contract/concerns/usage_aggregator"
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: 0.6.3
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna
@@ -89,6 +89,7 @@ files:
89
89
  - lib/ruby_llm/contract/concerns/deep_freeze.rb
90
90
  - lib/ruby_llm/contract/concerns/deep_symbolize.rb
91
91
  - lib/ruby_llm/contract/concerns/eval_host.rb
92
+ - lib/ruby_llm/contract/concerns/production_mode_context.rb
92
93
  - lib/ruby_llm/contract/concerns/trace_equality.rb
93
94
  - lib/ruby_llm/contract/concerns/usage_aggregator.rb
94
95
  - lib/ruby_llm/contract/configuration.rb