ruby_llm-contract 0.8.0 → 0.10.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.
@@ -6,6 +6,37 @@ module RubyLLM
6
6
  # Extracted from Base to reduce class length.
7
7
  # DSL accessor methods for step definition (input_type, output_type, prompt, etc.).
8
8
  module Dsl # rubocop:disable Metrics/ModuleLength
9
+ # Sentinel signalling "explicitly reset" (`some_attr(:default)`).
10
+ # Distinguishes reset (lookup stops at this class, returns nil) from
11
+ # "never set" (lookup falls through to superclass).
12
+ UNSET = Object.new
13
+ def UNSET.inspect = "Step::Dsl::UNSET"
14
+ UNSET.freeze
15
+
16
+ # Walks the inheritance chain for a class-level DSL attribute.
17
+ # Returns the first explicitly-set value found, or nil.
18
+ def inherited_value(name)
19
+ ivar = :"@#{name}"
20
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
21
+
22
+ superclass.public_send(name) if superclass.respond_to?(name)
23
+ end
24
+
25
+ # Like `inherited_value`, but honours the `UNSET` sentinel — when this
26
+ # class has been reset via `some_attr(:default)`, returns nil without
27
+ # falling through to the superclass.
28
+ def inherited_value_with_reset(name)
29
+ ivar = :"@#{name}"
30
+ if instance_variable_defined?(ivar)
31
+ value = instance_variable_get(ivar)
32
+ return value unless value.equal?(UNSET)
33
+
34
+ return nil
35
+ end
36
+
37
+ superclass.public_send(name) if superclass.respond_to?(name)
38
+ end
39
+
9
40
  def input_type(type = nil)
10
41
  return @input_type = type if type
11
42
 
@@ -70,6 +101,11 @@ module RubyLLM
70
101
  end
71
102
 
72
103
  def validate(description, &block)
104
+ # `nil.to_s.empty?` is already true - the explicit nil branch was
105
+ # redundant. `caller` pushes the backtrace to user code instead
106
+ # of DSL internals (per Codex review of 0.10.0).
107
+ raise ArgumentError, "validate description must be a non-empty string", caller if description.to_s.empty?
108
+
73
109
  (@class_validates ||= []) << Invariant.new(description, block)
74
110
  end
75
111
 
@@ -91,131 +127,114 @@ module RubyLLM
91
127
 
92
128
  def max_output(tokens = nil)
93
129
  if tokens
94
- unless tokens.is_a?(Numeric) && tokens.positive?
95
- raise ArgumentError, "max_output must be positive, got #{tokens}"
96
- end
97
-
130
+ validate_positive!("max_output", tokens)
98
131
  return @max_output = tokens
99
132
  end
100
133
 
101
- if defined?(@max_output)
102
- @max_output
103
- elsif superclass.respond_to?(:max_output)
104
- superclass.max_output
105
- end
134
+ inherited_value(:max_output)
106
135
  end
107
136
 
108
137
  def max_input(tokens = nil)
109
138
  if tokens
110
- unless tokens.is_a?(Numeric) && tokens.positive?
111
- raise ArgumentError, "max_input must be positive, got #{tokens}"
112
- end
113
-
139
+ validate_positive!("max_input", tokens)
114
140
  return @max_input = tokens
115
141
  end
116
142
 
117
- if defined?(@max_input)
118
- @max_input
119
- elsif superclass.respond_to?(:max_input)
120
- superclass.max_input
121
- end
143
+ inherited_value(:max_input)
122
144
  end
123
145
 
124
146
  def max_cost(amount = nil, on_unknown_pricing: nil)
125
147
  if amount == :default
126
- @max_cost = nil
127
- @max_cost_explicitly_unset = true
148
+ @max_cost = UNSET
128
149
  @on_unknown_pricing = nil
129
150
  return nil
130
151
  end
131
152
 
132
153
  if amount
133
- unless amount.is_a?(Numeric) && amount.positive?
134
- raise ArgumentError, "max_cost must be positive, got #{amount}"
135
- end
154
+ validate_positive!("max_cost", amount)
136
155
 
137
156
  if on_unknown_pricing && !%i[refuse warn].include?(on_unknown_pricing)
138
157
  raise ArgumentError, "on_unknown_pricing must be :refuse or :warn, got #{on_unknown_pricing.inspect}"
139
158
  end
140
159
 
141
- @max_cost_explicitly_unset = false
142
160
  @max_cost = amount
143
161
  @on_unknown_pricing = on_unknown_pricing || :refuse
144
162
  return @max_cost
145
163
  end
146
164
 
147
- return @max_cost if defined?(@max_cost) && !@max_cost_explicitly_unset
148
- return nil if @max_cost_explicitly_unset
149
-
150
- superclass.max_cost if superclass.respond_to?(:max_cost)
165
+ inherited_value_with_reset(:max_cost)
151
166
  end
152
167
 
153
168
  def on_unknown_pricing
154
- if defined?(@on_unknown_pricing)
155
- @on_unknown_pricing
156
- elsif superclass.respond_to?(:on_unknown_pricing)
157
- superclass.on_unknown_pricing
158
- else
159
- :refuse
169
+ inherited_value(:on_unknown_pricing) || :refuse
170
+ end
171
+
172
+ def attachment_token_estimate(n = nil)
173
+ if n == :default
174
+ @attachment_token_estimate = UNSET
175
+ return nil
176
+ end
177
+
178
+ if n
179
+ validate_positive!("attachment_token_estimate", n)
180
+ return @attachment_token_estimate = n
181
+ end
182
+
183
+ inherited_value_with_reset(:attachment_token_estimate)
184
+ end
185
+
186
+ def on_unknown_attachment_size(mode = nil)
187
+ if mode
188
+ unless %i[refuse warn].include?(mode)
189
+ raise ArgumentError,
190
+ "on_unknown_attachment_size must be :refuse or :warn, got #{mode.inspect}"
191
+ end
192
+
193
+ return @on_unknown_attachment_size = mode
160
194
  end
195
+
196
+ inherited_value(:on_unknown_attachment_size) || :refuse
161
197
  end
162
198
 
163
199
  def model(name = nil)
164
200
  if name == :default
165
- @model = nil
166
- @model_explicitly_unset = true
201
+ @model = UNSET
167
202
  return nil
168
203
  end
169
204
 
170
- if name
171
- @model_explicitly_unset = false
172
- return @model = name
173
- end
174
-
175
- return @model if defined?(@model) && !@model_explicitly_unset
176
- return nil if @model_explicitly_unset
205
+ return @model = name if name
177
206
 
178
- superclass.model if superclass.respond_to?(:model)
207
+ inherited_value_with_reset(:model)
179
208
  end
180
209
 
181
210
  def temperature(value = nil)
182
211
  if value == :default
183
- @temperature = nil
184
- @temperature_explicitly_unset = true
212
+ @temperature = UNSET
185
213
  return nil
186
214
  end
187
215
 
188
- if value
216
+ # NOTE: `value` may be 0 (a legitimate setting); use `nil?` rather
217
+ # than truthiness to distinguish "no arg passed" from "explicit 0".
218
+ unless value.nil?
189
219
  unless value.is_a?(Numeric) && value >= 0 && value <= 2
190
220
  raise ArgumentError, "temperature must be 0.0-2.0, got #{value}"
191
221
  end
192
222
 
193
- @temperature_explicitly_unset = false
194
223
  return @temperature = value
195
224
  end
196
225
 
197
- return @temperature if defined?(@temperature) && !@temperature_explicitly_unset
198
- return nil if @temperature_explicitly_unset
199
-
200
- superclass.temperature if superclass.respond_to?(:temperature)
226
+ inherited_value_with_reset(:temperature)
201
227
  end
202
228
 
203
229
  def thinking(effort: nil, budget: nil)
204
230
  if effort == :default
205
- @thinking = nil
206
- @thinking_explicitly_unset = true
231
+ @thinking = UNSET
207
232
  return nil
208
233
  end
209
234
 
210
- if effort || budget
211
- @thinking_explicitly_unset = false
212
- return @thinking = { effort: effort, budget: budget }.compact
213
- end
214
-
215
- return @thinking if defined?(@thinking) && !@thinking_explicitly_unset
216
- return nil if @thinking_explicitly_unset
235
+ return @thinking = { effort: effort, budget: budget }.compact if effort || budget
217
236
 
218
- superclass.thinking if superclass.respond_to?(:thinking)
237
+ inherited_value_with_reset(:thinking)
219
238
  end
220
239
 
221
240
  def reasoning_effort(value = nil)
@@ -228,7 +247,6 @@ module RubyLLM
228
247
  if value == :default
229
248
  current_budget = thinking && thinking[:budget]
230
249
  if current_budget
231
- @thinking_explicitly_unset = false
232
250
  @thinking = { budget: current_budget }
233
251
  return nil
234
252
  end
@@ -261,6 +279,16 @@ module RubyLLM
261
279
  superclass.retry_policy
262
280
  end
263
281
  end
282
+
283
+ private
284
+
285
+ # Shared positivity guard for `max_input`, `max_output`, `max_cost`,
286
+ # `attachment_token_estimate`. Mirrors `CostCalculator.validate_price!`.
287
+ def validate_positive!(name, value)
288
+ return if value.is_a?(Numeric) && value.positive?
289
+
290
+ raise ArgumentError, "#{name} must be positive, got #{value}"
291
+ end
264
292
  end
265
293
  end
266
294
  end
@@ -11,7 +11,13 @@ module RubyLLM
11
11
  def check_limits(messages)
12
12
  return nil unless max_input || max_cost
13
13
 
14
- estimated = TokenEstimator.estimate(messages)
14
+ text_tokens = TokenEstimator.estimate(messages)
15
+ attachment_tokens, attachment_error = resolve_attachment_tokens
16
+ if attachment_error
17
+ return build_limit_result(messages, text_tokens, [attachment_error])
18
+ end
19
+
20
+ estimated = text_tokens + attachment_tokens
15
21
  errors = collect_limit_errors(estimated)
16
22
 
17
23
  return nil if errors.empty?
@@ -19,6 +25,33 @@ module RubyLLM
19
25
  build_limit_result(messages, estimated, errors)
20
26
  end
21
27
 
28
+ # Fail-closed: when an attachment is passed via context but no
29
+ # attachment_token_estimate is declared, the gem cannot bound vision/
30
+ # PDF cost. Refuses with a clear error unless on_unknown_attachment_size
31
+ # is :warn (per-step opt-out, mirroring on_unknown_pricing).
32
+ def resolve_attachment_tokens
33
+ return [0, nil] unless attachment_present?
34
+
35
+ estimate = attachment_token_estimate
36
+ if estimate.nil?
37
+ if on_unknown_attachment_size == :warn
38
+ warn "[ruby_llm-contract] attachment present but " \
39
+ "attachment_token_estimate not declared — cost limit not enforced " \
40
+ "for the attachment portion"
41
+ return [0, nil]
42
+ end
43
+
44
+ return [0,
45
+ "attachment present but attachment_token_estimate not declared; " \
46
+ "cost cannot be bounded. Declare " \
47
+ "`attachment_token_estimate(n)` on the step class, or set " \
48
+ "`on_unknown_attachment_size :warn` to proceed without attachment " \
49
+ "cost checks."]
50
+ end
51
+
52
+ [estimate, nil]
53
+ end
54
+
22
55
  def collect_limit_errors(estimated)
23
56
  errors = []
24
57
  if max_input && estimated > max_input
@@ -6,6 +6,8 @@ module RubyLLM
6
6
  # Extracted from Base to reduce class length.
7
7
  # Handles retry logic: run_with_retry, build_retry_result, aggregate usage, build attempt entries.
8
8
  module RetryExecutor
9
+ include Concerns::UsageAggregator
10
+
9
11
  private
10
12
 
11
13
  def run_with_retry(input, adapter:, default_model:, policy:, context_temperature: nil, extra_options: {})
@@ -29,7 +31,7 @@ module RubyLLM
29
31
  def build_retry_result(all_attempts)
30
32
  last = all_attempts.last[:result]
31
33
  attempt_log = all_attempts.map { |attempt| build_attempt_entry(attempt) }
32
- aggregated_usage = aggregate_retry_usage(all_attempts)
34
+ aggregated_usage = aggregate_usage(all_attempts.map { |a| a[:result].trace })
33
35
  total_cost = sum_attempt_costs(all_attempts)
34
36
  total_latency = sum_attempt_latency(all_attempts)
35
37
 
@@ -80,18 +82,9 @@ module RubyLLM
80
82
  end
81
83
  end
82
84
 
83
- def aggregate_retry_usage(all_attempts)
84
- totals = { input_tokens: 0, output_tokens: 0 }
85
- all_attempts.each do |attempt|
86
- usage = attempt[:result].trace
87
- usage = usage.respond_to?(:usage) ? usage.usage : nil
88
- next unless usage.is_a?(Hash)
89
-
90
- totals[:input_tokens] += usage[:input_tokens] || 0
91
- totals[:output_tokens] += usage[:output_tokens] || 0
92
- end
93
- totals
94
- end
85
+ # `aggregate_usage(traces)` provided by Concerns::UsageAggregator —
86
+ # replaces the prior `aggregate_retry_usage` private helper which
87
+ # duplicated the same per-trace input/output token sum.
95
88
  end
96
89
  end
97
90
  end
@@ -6,26 +6,15 @@ module RubyLLM
6
6
  class Runner
7
7
  include LimitChecker
8
8
 
9
- def initialize(input_type:, output_type:, prompt_block:, contract_definition:,
10
- adapter:, model:, output_schema: nil, max_output: nil,
11
- max_input: nil, max_cost: nil, on_unknown_pricing: :refuse,
12
- temperature: nil, extra_options: {}, observers: [])
13
- @config = RunnerConfig.new(
14
- input_type: input_type,
15
- output_type: output_type,
16
- prompt_block: prompt_block,
17
- contract_definition: contract_definition,
18
- adapter: adapter,
19
- model: model,
20
- output_schema: output_schema,
21
- max_output: max_output,
22
- max_input: max_input,
23
- max_cost: max_cost,
24
- on_unknown_pricing: on_unknown_pricing,
25
- temperature: temperature,
26
- extra_options: extra_options,
27
- observers: observers
28
- )
9
+ # Two construction forms:
10
+ # Runner.new(config: a_runner_config) # preferred value-object
11
+ # Runner.new(input_type:, output_type:, ...) # legacy kwarg form (still supported)
12
+ #
13
+ # The legacy form delegates to `RunnerConfig.build(**kwargs)`, so the
14
+ # defaults live in one place (`RunnerConfig.build`) and the kwarg
15
+ # surface is no longer duplicated here.
16
+ def initialize(config: nil, **kwargs)
17
+ @config = config || RunnerConfig.build(**kwargs)
29
18
  end
30
19
 
31
20
  def call(input)
@@ -90,6 +79,19 @@ module RubyLLM
90
79
  @config.on_unknown_pricing
91
80
  end
92
81
 
82
+ def attachment_token_estimate
83
+ @config.attachment_token_estimate
84
+ end
85
+
86
+ def on_unknown_attachment_size
87
+ @config.on_unknown_attachment_size
88
+ end
89
+
90
+ def attachment_present?
91
+ opts = @config.extra_options
92
+ !opts.nil? && !opts[:attachment].nil?
93
+ end
94
+
93
95
  def effective_max_output
94
96
  @config.effective_max_output
95
97
  end
@@ -15,10 +15,36 @@ module RubyLLM
15
15
  :max_input,
16
16
  :max_cost,
17
17
  :on_unknown_pricing,
18
+ :attachment_token_estimate,
19
+ :on_unknown_attachment_size,
18
20
  :temperature,
19
21
  :extra_options,
20
22
  :observers
21
23
  ) do
24
+ # Factory with sensible defaults for optional fields. Lets callers
25
+ # (Step::Base#run_once and tests) construct a RunnerConfig without
26
+ # repeating the 11-default boilerplate, and gives `Runner.new(config:)`
27
+ # a clean entry point for the value-object form.
28
+ def self.build(input_type:, output_type:, prompt_block:, contract_definition:,
29
+ adapter:, model:,
30
+ output_schema: nil, max_output: nil,
31
+ max_input: nil, max_cost: nil, on_unknown_pricing: :refuse,
32
+ attachment_token_estimate: nil, on_unknown_attachment_size: :refuse,
33
+ temperature: nil, extra_options: {}, observers: [])
34
+ new(
35
+ input_type: input_type, output_type: output_type,
36
+ prompt_block: prompt_block, contract_definition: contract_definition,
37
+ adapter: adapter, model: model,
38
+ output_schema: output_schema, max_output: max_output,
39
+ max_input: max_input, max_cost: max_cost,
40
+ on_unknown_pricing: on_unknown_pricing,
41
+ attachment_token_estimate: attachment_token_estimate,
42
+ on_unknown_attachment_size: on_unknown_attachment_size,
43
+ temperature: temperature, extra_options: extra_options,
44
+ observers: observers
45
+ )
46
+ end
47
+
22
48
  def effective_max_output
23
49
  extra_options[:max_tokens] || max_output
24
50
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.1"
6
6
  end
7
7
  end
@@ -158,6 +158,7 @@ require_relative "contract/concerns/production_mode_context"
158
158
  require_relative "contract/concerns/eval_host"
159
159
  require_relative "contract/concerns/trace_equality"
160
160
  require_relative "contract/concerns/usage_aggregator"
161
+ require_relative "contract/concerns/stub_helpers"
161
162
  require_relative "contract/configuration"
162
163
  require_relative "contract/prompt/node"
163
164
  require_relative "contract/prompt/nodes"
@@ -23,9 +23,13 @@ Gem::Specification.new do |spec|
23
23
  spec.metadata["rubygems_mfa_required"] = "true"
24
24
 
25
25
  spec.files = Dir.chdir(__dir__) do
26
+ # Internal trackers + dev configs excluded so the published gem
27
+ # contains only what adopters actually need at runtime.
28
+ excluded_files = %w[TODO.md .rspec .rubycritic.yml .simplecov]
26
29
  `git ls-files -z`.split("\x0").reject do |f|
27
30
  (File.expand_path(f) == __FILE__) ||
28
- f.start_with?("spec/", "docs/", "doc/", ".ai/", ".claude/", ".git")
31
+ f.start_with?("spec/", "docs/", "doc/", ".ai/", ".claude/", ".git", ".revive/") ||
32
+ excluded_files.include?(f)
29
33
  end
30
34
  end
31
35
  spec.require_paths = ["lib"]
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.8.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna
@@ -59,10 +59,7 @@ executables: []
59
59
  extensions: []
60
60
  extra_rdoc_files: []
61
61
  files:
62
- - ".rspec"
63
62
  - ".rubocop.yml"
64
- - ".rubycritic.yml"
65
- - ".simplecov"
66
63
  - CHANGELOG.md
67
64
  - Gemfile
68
65
  - Gemfile.lock
@@ -88,6 +85,7 @@ files:
88
85
  - lib/ruby_llm/contract/concerns/deep_symbolize.rb
89
86
  - lib/ruby_llm/contract/concerns/eval_host.rb
90
87
  - lib/ruby_llm/contract/concerns/production_mode_context.rb
88
+ - lib/ruby_llm/contract/concerns/stub_helpers.rb
91
89
  - lib/ruby_llm/contract/concerns/trace_equality.rb
92
90
  - lib/ruby_llm/contract/concerns/usage_aggregator.rb
93
91
  - lib/ruby_llm/contract/configuration.rb
@@ -159,6 +157,7 @@ files:
159
157
  - lib/ruby_llm/contract/prompt/renderer.rb
160
158
  - lib/ruby_llm/contract/railtie.rb
161
159
  - lib/ruby_llm/contract/rake_task.rb
160
+ - lib/ruby_llm/contract/rake_task/suite_gate.rb
162
161
  - lib/ruby_llm/contract/rspec.rb
163
162
  - lib/ruby_llm/contract/rspec/helpers.rb
164
163
  - lib/ruby_llm/contract/rspec/pass_eval.rb
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubycritic.yml DELETED
@@ -1,8 +0,0 @@
1
- paths:
2
- - lib
3
-
4
- formats:
5
- - console
6
-
7
- minimum_score: 80
8
- no_browser: true
data/.simplecov DELETED
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "simplecov"
4
-
5
- SimpleCov.start do
6
- enable_coverage :branch
7
- primary_coverage :branch
8
-
9
- add_filter "/spec/"
10
- add_filter "/examples/"
11
- add_filter "/internal/"
12
- add_filter "/tmp/"
13
-
14
- track_files "lib/**/*.rb"
15
-
16
- if ENV["CI"] == "true" || ENV["SIMPLECOV_STRICT"] == "1"
17
- minimum_coverage line: 89
18
- minimum_coverage branch: 75
19
- end
20
-
21
- command_name "RSpec"
22
- end