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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +103 -0
- data/README.md +18 -3
- data/docs/guide/getting_started.md +86 -1
- data/docs/guide/llm_judge.md +14 -0
- data/docs/guide/rails_integration.md +8 -0
- data/docs/guide/relation_to_agent.md +8 -2
- data/lib/ruby_llm/contract/adapters/response.rb +28 -2
- data/lib/ruby_llm/contract/adapters/ruby_llm.rb +136 -28
- data/lib/ruby_llm/contract/adapters/test.rb +6 -0
- data/lib/ruby_llm/contract/concerns/usage_aggregator.rb +8 -5
- data/lib/ruby_llm/contract/configuration.rb +5 -1
- data/lib/ruby_llm/contract/cost_calculator.rb +98 -44
- data/lib/ruby_llm/contract/eval/eval_history.rb +2 -1
- data/lib/ruby_llm/contract/eval/model_comparison.rb +11 -1
- data/lib/ruby_llm/contract/eval/recommender.rb +10 -3
- data/lib/ruby_llm/contract/eval/report_stats.rb +8 -3
- data/lib/ruby_llm/contract/eval/report_storage.rb +8 -2
- data/lib/ruby_llm/contract/pipeline/base.rb +3 -1
- data/lib/ruby_llm/contract/pipeline/runner.rb +11 -1
- data/lib/ruby_llm/contract/pipeline/trace.rb +10 -0
- data/lib/ruby_llm/contract/provider_options.rb +45 -0
- data/lib/ruby_llm/contract/step/adapter_caller.rb +2 -1
- data/lib/ruby_llm/contract/step/base.rb +29 -12
- data/lib/ruby_llm/contract/step/dsl.rb +54 -0
- data/lib/ruby_llm/contract/step/limit_checker.rb +48 -13
- data/lib/ruby_llm/contract/step/result_builder.rb +66 -4
- data/lib/ruby_llm/contract/step/retry_executor.rb +35 -4
- data/lib/ruby_llm/contract/step/retry_policy.rb +31 -5
- data/lib/ruby_llm/contract/step/runner.rb +20 -1
- data/lib/ruby_llm/contract/step/runner_config.rb +6 -3
- data/lib/ruby_llm/contract/step/trace.rb +61 -20
- data/lib/ruby_llm/contract/token_estimator.rb +5 -3
- data/lib/ruby_llm/contract/version.rb +1 -1
- data/lib/ruby_llm/contract/workflow_scope.rb +48 -0
- data/lib/ruby_llm/contract.rb +2 -0
- 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
|
-
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
|
54
|
-
# cost
|
|
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
|
|
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?
|
|
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
|
-
|
|
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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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.
|
|
@@ -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
|
data/lib/ruby_llm/contract.rb
CHANGED
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.
|
|
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:
|