ruby_llm-contract 1.1.1 → 1.2.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +18 -3
  4. data/docs/guide/getting_started.md +86 -1
  5. data/docs/guide/llm_judge.md +14 -0
  6. data/docs/guide/rails_integration.md +8 -0
  7. data/docs/guide/relation_to_agent.md +8 -2
  8. data/lib/ruby_llm/contract/adapters/response.rb +28 -2
  9. data/lib/ruby_llm/contract/adapters/ruby_llm.rb +136 -28
  10. data/lib/ruby_llm/contract/adapters/test.rb +6 -0
  11. data/lib/ruby_llm/contract/concerns/usage_aggregator.rb +8 -5
  12. data/lib/ruby_llm/contract/configuration.rb +5 -1
  13. data/lib/ruby_llm/contract/cost_calculator.rb +98 -44
  14. data/lib/ruby_llm/contract/eval/eval_history.rb +2 -1
  15. data/lib/ruby_llm/contract/eval/model_comparison.rb +11 -1
  16. data/lib/ruby_llm/contract/eval/recommender.rb +10 -3
  17. data/lib/ruby_llm/contract/eval/report_stats.rb +8 -3
  18. data/lib/ruby_llm/contract/eval/report_storage.rb +8 -2
  19. data/lib/ruby_llm/contract/pipeline/base.rb +3 -1
  20. data/lib/ruby_llm/contract/pipeline/runner.rb +11 -1
  21. data/lib/ruby_llm/contract/pipeline/trace.rb +10 -0
  22. data/lib/ruby_llm/contract/provider_options.rb +45 -0
  23. data/lib/ruby_llm/contract/step/adapter_caller.rb +2 -1
  24. data/lib/ruby_llm/contract/step/base.rb +29 -12
  25. data/lib/ruby_llm/contract/step/dsl.rb +54 -0
  26. data/lib/ruby_llm/contract/step/limit_checker.rb +48 -13
  27. data/lib/ruby_llm/contract/step/result_builder.rb +66 -4
  28. data/lib/ruby_llm/contract/step/retry_executor.rb +35 -4
  29. data/lib/ruby_llm/contract/step/retry_policy.rb +31 -5
  30. data/lib/ruby_llm/contract/step/runner.rb +20 -1
  31. data/lib/ruby_llm/contract/step/runner_config.rb +6 -3
  32. data/lib/ruby_llm/contract/step/trace.rb +61 -20
  33. data/lib/ruby_llm/contract/token_estimator.rb +5 -3
  34. data/lib/ruby_llm/contract/version.rb +1 -1
  35. data/lib/ruby_llm/contract/workflow_scope.rb +48 -0
  36. data/lib/ruby_llm/contract.rb +2 -0
  37. metadata +3 -1
@@ -7,19 +7,40 @@ module RubyLLM
7
7
  include Concerns::TraceEquality
8
8
  include Concerns::DeepFreeze
9
9
 
10
- attr_reader :messages, :model, :latency_ms, :usage, :attempts, :cost
11
-
12
- def initialize(messages: nil, model: nil, latency_ms: nil, usage: nil, attempts: nil, cost: nil)
10
+ attr_reader :messages, :model, :latency_ms, :usage, :attempts, :cost,
11
+ :usage_complete, :cost_complete, :finish_reason, :error_class, :error_type
12
+
13
+ # Marks `cost:` as not given, as distinct from an explicit nil. Only an
14
+ # omitted cost is priced from the registry; nil from an adapter that
15
+ # reports its own cost means "unknown" and must stay unknown.
16
+ COST_UNSET = Object.new.freeze
17
+
18
+ # usage_complete / cost_complete are nil for traces that carry no
19
+ # accounting state (Test adapter, older adapters, hand-built traces);
20
+ # those keep the token-count rule in `unpriced?`.
21
+ # error_class names the exception behind an :adapter_error and is part
22
+ # of to_h; error_type is the class itself, kept in memory only so
23
+ # `retry_on SomeError` can match subclasses. A trace rebuilt from a
24
+ # hash has the name but not the class.
25
+ def initialize(messages: nil, model: nil, latency_ms: nil, usage: nil, attempts: nil, cost: COST_UNSET,
26
+ usage_complete: nil, cost_complete: nil, finish_reason: nil, error_class: nil, error_type: nil)
13
27
  @messages = deep_dup_freeze(messages)
14
28
  @model = model.frozen? ? model : model&.dup&.freeze
15
29
  @latency_ms = latency_ms
16
30
  @usage = deep_dup_freeze(usage)
17
31
  @attempts = deep_dup_freeze(attempts)
18
- @cost = cost || CostCalculator.calculate(model_name: model, usage: usage)
32
+ @usage_complete = usage_complete
33
+ @cost_complete = cost_complete
34
+ @finish_reason = finish_reason
35
+ @error_class = error_class
36
+ @error_type = error_type
37
+ @cost = resolve_cost(cost)
38
+ @keep_nil_cost = keep_nil_cost?(cost)
19
39
  freeze
20
40
  end
21
41
 
22
- KNOWN_KEYS = %i[messages model latency_ms usage attempts cost].freeze
42
+ KNOWN_KEYS = %i[messages model latency_ms usage attempts cost
43
+ usage_complete cost_complete finish_reason error_class].freeze
23
44
 
24
45
  def [](key)
25
46
  return nil unless KNOWN_KEYS.include?(key.to_sym)
@@ -39,27 +60,28 @@ module RubyLLM
39
60
  end
40
61
  alias has_key? key?
41
62
 
63
+ # Never re-prices: a retry merges a subtotal and an aggregate usage that
64
+ # no single model's pricing describes.
42
65
  def merge(**overrides)
43
- self.class.new(
44
- messages: overrides.fetch(:messages, @messages),
45
- model: overrides.fetch(:model, @model),
46
- latency_ms: overrides.fetch(:latency_ms, @latency_ms),
47
- usage: overrides.fetch(:usage, @usage),
48
- attempts: overrides.fetch(:attempts, @attempts),
49
- cost: overrides.fetch(:cost, @cost)
50
- )
66
+ self.class.new(**KNOWN_KEYS.to_h { |key| [key, overrides.fetch(key) { public_send(key) }] },
67
+ error_type: overrides.fetch(:error_type, @error_type))
51
68
  end
52
69
 
53
- # True when a call ran on a named model, used tokens, and still got no
54
- # cost, so any total built from this trace undercounts. A zero-token call
70
+ # True when a total built from this trace undercounts: the adapter said
71
+ # its cost does not cover the call, or (no accounting state) a call ran
72
+ # on a named model, used tokens, and still got no cost. A zero-token call
55
73
  # (Test adapter, sample_response) costs 0 whatever the pricing. A retried
56
74
  # step is judged per attempt: a priced subtotal must not hide an unpriced
57
- # attempt, and the merged trace may have re-priced on the last model.
75
+ # attempt.
58
76
  def cost_unknown?
77
+ return true if @cost_complete == false
78
+
59
79
  if @attempts.is_a?(Array) && !@attempts.empty?
60
- @attempts.any? { |attempt| self.class.unpriced?(attempt[:model], attempt[:usage], attempt[:cost]) }
80
+ @attempts.any? do |attempt|
81
+ attempt[:cost_unknown] || self.class.unpriced?(attempt[:model], attempt[:usage], attempt[:cost])
82
+ end
61
83
  else
62
- self.class.unpriced?(@model, @usage, @cost)
84
+ @cost_complete.nil? && self.class.unpriced?(@model, @usage, @cost)
63
85
  end
64
86
  end
65
87
 
@@ -70,8 +92,11 @@ module RubyLLM
70
92
  end
71
93
 
72
94
  def to_h
73
- { messages: @messages, model: @model, latency_ms: @latency_ms,
74
- usage: @usage, attempts: @attempts, cost: @cost }.compact
95
+ hash = { messages: @messages, model: @model, latency_ms: @latency_ms,
96
+ usage: @usage, attempts: @attempts, cost: @cost, usage_complete: @usage_complete,
97
+ cost_complete: @cost_complete, finish_reason: @finish_reason, error_class: @error_class }.compact
98
+ hash[:cost] = nil if @keep_nil_cost
99
+ hash
75
100
  end
76
101
 
77
102
  def to_s
@@ -80,6 +105,22 @@ module RubyLLM
80
105
 
81
106
  private
82
107
 
108
+ # A given nil cost that the registry would price must stay `cost: nil` in
109
+ # to_h, or `Trace.new(**to_h)` would come back priced. Otherwise it is
110
+ # left out, so a merged trace compares equal to the one it came from.
111
+ def keep_nil_cost?(cost)
112
+ return false unless cost.nil? && @cost_complete.nil?
113
+
114
+ !CostCalculator.calculate(model_name: @model, usage: @usage).nil?
115
+ end
116
+
117
+ def resolve_cost(cost)
118
+ return cost unless cost.equal?(COST_UNSET)
119
+ return nil unless @cost_complete.nil?
120
+
121
+ CostCalculator.calculate(model_name: @model, usage: @usage)
122
+ end
123
+
83
124
  def build_summary_parts
84
125
  parts = [@model || "no-model"]
85
126
  parts << "#{@latency_ms}ms" if @latency_ms
@@ -11,9 +11,11 @@ module RubyLLM
11
11
  # - Worse for non-English text, code, structured data, and unusual scripts
12
12
  # - Useless for models with very different tokenizers (e.g. some open-source models)
13
13
  #
14
- # RubyLLM 1.14 ships no pre-flight tokenizer either; once the API call
15
- # returns, `RubyLLM::Tokens` provides accurate counts from provider usage
16
- # data. This estimator is for the *pre-flight refusal* path only — its job
14
+ # RubyLLM 2.x can count tokens exactly before a call (`chat.count_tokens`),
15
+ # but only on some providers and at the price of an extra request, so this
16
+ # local estimate stays the default; once the API call returns,
17
+ # `RubyLLM::Tokens` provides accurate counts from provider usage data.
18
+ # This estimator is for the *pre-flight refusal* path only - its job
17
19
  # is to answer "is this call almost certainly within budget?" with enough
18
20
  # accuracy that runaway prompts get caught, while accepting that the
19
21
  # boundary cases will be wrong.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.1.1"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Contract
5
+ # Groups a step's or pipeline's RubyLLM calls under `RubyLLM.workflow`, so
6
+ # OpenTelemetry spans and instrumentation events carry the step's name.
7
+ # Off unless `RubyLLM::Contract.configure { |c| c.workflow_instrumentation = true }`.
8
+ #
9
+ # A pipeline opens one workflow and runs each step as `workflow.step(alias)`;
10
+ # a step run inside it does not open another. A step run on its own opens a
11
+ # workflow named after its class. A workflow the application opened itself
12
+ # becomes the parent, as RubyLLM links nested workflows.
13
+ module WorkflowScope
14
+ CURRENT_KEY = :ruby_llm_contract_workflow
15
+
16
+ def self.workflow(name, &block)
17
+ return yield unless enabled? && current.nil?
18
+
19
+ ::RubyLLM.workflow(name.to_s) { |workflow| within(workflow, &block) }
20
+ end
21
+
22
+ def self.step(name, &)
23
+ workflow = current
24
+ return yield unless enabled? && workflow
25
+
26
+ workflow.step(name.to_s, &)
27
+ end
28
+
29
+ def self.enabled?
30
+ Contract.configuration.workflow_instrumentation
31
+ end
32
+
33
+ # Fiber-local, so concurrent eval threads each get their own.
34
+ def self.current
35
+ Thread.current[CURRENT_KEY]
36
+ end
37
+
38
+ def self.within(workflow)
39
+ previous = Thread.current[CURRENT_KEY]
40
+ Thread.current[CURRENT_KEY] = workflow
41
+ yield
42
+ ensure
43
+ Thread.current[CURRENT_KEY] = previous
44
+ end
45
+ private_class_method :within
46
+ end
47
+ end
48
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "contract/version"
4
4
  require_relative "contract/errors"
5
5
  require_relative "contract/unknown_policy"
6
+ require_relative "contract/provider_options"
7
+ require_relative "contract/workflow_scope"
6
8
  require_relative "contract/types"
7
9
 
8
10
  module RubyLLM
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.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna
@@ -170,6 +170,7 @@ files:
170
170
  - lib/ruby_llm/contract/prompt/nodes/system_node.rb
171
171
  - lib/ruby_llm/contract/prompt/nodes/user_node.rb
172
172
  - lib/ruby_llm/contract/prompt/renderer.rb
173
+ - lib/ruby_llm/contract/provider_options.rb
173
174
  - lib/ruby_llm/contract/railtie.rb
174
175
  - lib/ruby_llm/contract/rake_task.rb
175
176
  - lib/ruby_llm/contract/rake_task/suite_gate.rb
@@ -195,6 +196,7 @@ files:
195
196
  - lib/ruby_llm/contract/types.rb
196
197
  - lib/ruby_llm/contract/unknown_policy.rb
197
198
  - lib/ruby_llm/contract/version.rb
199
+ - lib/ruby_llm/contract/workflow_scope.rb
198
200
  - ruby_llm-contract.gemspec
199
201
  homepage: https://github.com/justi/ruby_llm-contract
200
202
  licenses: