ruby_llm-llm_judge 0.1.2 → 0.1.4

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: 71f6bd771e7f6bec3a9dcdf7036ee1238110c63ee530b15d32c6dadcfb0d0eaa
4
- data.tar.gz: e54b13efadda477c3d3713c66184dc4ce01061b740e06ab52e95df0b672ae7e9
3
+ metadata.gz: fc81f9575b6468ad1a7ea81b19d777ea169eca0619cf1995c59c0aab01128254
4
+ data.tar.gz: 6052e01d0b02b576c25fe5baa0545a8f89e9060e12d42ae65f3fa99e8e14f5f7
5
5
  SHA512:
6
- metadata.gz: 4e29ff7f1b47222dfc71f6f096cd058615d2517b7e16b29f9c2a68f094f2af95be8a99232d5ba72cc1d6b9a932fa5f9bf290e701a9744ee1a241fc6a0c710834
7
- data.tar.gz: 942f4a7da0c27d1741d3e0e2b80ae719335123d6ddf276b84b0e4d200ed315d5f839cfc1ea998e708b6b54f83d7b7e97c718f6d4a070e17d2bfe0be23e9b3541
6
+ metadata.gz: ba27da7ce65adbb3d39d6447decaa6b2454d2bbee7086577f81798cb213ccf573efd81c250d46a5bc29d7b58d811892a73918b7f89cddd1dec778b50620d8406
7
+ data.tar.gz: 411fd92bb9f7700b6075559ed5644cbdad5485a555fc6601249ebfc1c28d657dbc221255bf308f4358c1730cd3c506b08c553a7e2359ddb0e449a2c0e5aedaf7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 (2026-10-03)
4
+
5
+ - `structured_output: true` sends a strict JSON Schema with one-call requests, requiring every question and option.
6
+ - `strategy: :committed` asks for one option per question and returns it with probability 1.
7
+ - The provider accepts RubyLLM main's `with:` attachments argument and raises `UnsupportedAttachmentError` when images are passed. Judgments on RubyLLM main failed with `ArgumentError` before.
8
+ - The README explains when to enable reasoning: off suits classification, while rule-following questions need it. See [the decision-mode benchmarks](benchmarks/decision-modes.md).
9
+
10
+ ## 0.1.3 (2026-09-29)
11
+
12
+ - Setting a `chat_provider_options` key to `nil` removes that field, including the Luna defaults and, before RubyLLM 2.1, the output limit field. A nested hash emptied this way is removed too.
13
+ - One-call answers given as a list of single-question objects are accepted when each question appears once. Claude Haiku 4.5 returns this shape on its first attempt, which previously cost a corrective retry on nearly every judgment.
14
+ - The one-call instructions now say that `answers` is an object keyed by question ID. Haiku no longer needs corrective retries (59 of 96 benchmark cases before), and Luna scored at least as well in a back-to-back comparison. See [the Haiku benchmark](benchmarks/haiku.md).
15
+ - Luna defaults apply to every OpenAI Luna model ID (`gpt-6-luna`, `gpt-5.6-luna`, `gpt-luna-latest`) and to OpenRouter's `openai/` Luna IDs, not only `gpt-6-luna` on OpenAI. On OpenRouter they send temperature zero and `reasoning: { enabled: false }`. With reasoning on, Luna's ratings often returned no digit. Pro variants and prefixed gateway IDs, such as `us.openai.gpt-5.6-luna` through `:openai`, are excluded.
16
+
3
17
  ## 0.1.2 (2026-09-28)
4
18
 
5
19
  - When a rating call fails, no new calls start and the error is raised once calls in flight finish. Previously the remaining calls kept running, and were billed, after `judge` raised.
data/README.md CHANGED
@@ -84,9 +84,11 @@ result = RubyLLM::LLMJudge.judge(
84
84
  )
85
85
  ```
86
86
 
87
- Both strategies return valid judgments with this model. Its one-call answers arrive in a Markdown code fence, which the gem removes before parsing.
87
+ Both strategies return valid judgments with this model. Its one-call answers arrive in a Markdown code fence, which the gem removes before parsing. On the benchmark cases it scored 56–57/64 on AG News and 29–30/32 on SST-2 at about 1.1 s median, a little below Luna. [Haiku results](benchmarks/haiku.md).
88
88
 
89
- The default Luna rating request uses Chat Completions, temperature zero, reasoning disabled, `store: false`, and a four-token output limit. Tune requests with `scoring_protocol`, `temperature`, `max_output_tokens`, and `chat_provider_options`. `chat_provider_options` is merged over those defaults, so setting one key, such as `service_tier: 'flex'`, keeps reasoning disabled; set a key explicitly to change a default.
89
+ The default Luna rating request uses Chat Completions, temperature zero, reasoning disabled, `store: false`, and a four-token output limit. Tune requests with `scoring_protocol`, `temperature`, `max_output_tokens`, and `chat_provider_options`. `chat_provider_options` is merged over those defaults, so setting one key, such as `service_tier: 'flex'`, keeps reasoning disabled; set a key explicitly to change a default. Set a key to `nil` to remove that field from the request, whether it is a default or, before RubyLLM 2.1, the output limit field. For example, an app that routes RubyLLM 1.x OpenAI chats through the Responses API can send `chat_provider_options: { max_completion_tokens: nil, max_output_tokens: 256 }`.
90
+
91
+ Luna defaults apply to each provider's own Luna model IDs: `gpt-6-luna`, `gpt-5.6-luna`, or `gpt-luna-latest` on `:openai`, which sends temperature zero, `reasoning_effort: 'none'`, and `store: false`; and `openai/gpt-6-luna` or `openai/gpt-5.6-luna` on `:openrouter`, which sends temperature zero and `reasoning: { enabled: false }`. Pro variants, other providers such as `:bedrock`, and prefixed IDs such as `us.openai.gpt-5.6-luna` sent through `:openai` to an OpenAI-compatible gateway get no Luna defaults, because their request format may differ; set `temperature:` and disable reasoning in that endpoint's format yourself. With reasoning left on, the ratings strategy's four-token output limit is spent on reasoning: on eight AG News cases, `gpt-6-luna` through OpenRouter returned no digit for all eight, and `gpt-5.6-luna` for four. RubyLLM before 2.1 sends temperature 1.0 for any OpenAI model whose ID starts with `gpt-5`, so `gpt-5.6-luna` on `:openai` runs at 1.0 there unless you pass `chat_provider_options: { temperature: 0 }`.
90
92
 
91
93
  ## One-call typed decisions
92
94
 
@@ -140,6 +142,31 @@ result = RubyLLM::LLMJudge.judge(
140
142
 
141
143
  OpenRouter's [latency sorting](https://openrouter.ai/docs/guides/routing/provider-selection) chooses among its available providers using their observed response times. It sped up OpenRouter in our test, while direct OpenAI was faster still. The measured speed and answer-quality tradeoffs are below.
142
144
 
145
+ ### Strict response schemas
146
+
147
+ Set `structured_output: true` to send a strict JSON Schema with the one-call request, through RubyLLM's `with_schema`. The schema requires every question and every option, and nothing else, so a provider that supports structured outputs cannot return the wrong shape. Without it, the gem validates the reply and makes its corrective retry. In our runs it had little effect on accuracy, cut GPT-6 Luna's corrective retries from 3.3 to 0.7 per 96 judgments, and added 40–350 ms. Models and providers without structured-output support reject the request, so leave it off for them.
148
+
149
+ ### Committed answers
150
+
151
+ Set `strategy: :committed` to ask for one option per question instead of a distribution. The reply is shorter, and each answer comes back with probability 1 for the chosen option and 0 for the rest, so `confidence` is always 1.0 and the probabilities carry no calibration. Use it when you only need the decision; use `:single_request` or `:ratings` when you threshold on probabilities. It accepts `structured_output: true`, which limits each answer to that question's option IDs. On classification it was 100–300 ms faster than probabilities and as accurate or up to three cases in 64 less; on rule-following questions it was the most accurate mode with reasoning off.
152
+
153
+ ### Reasoning
154
+
155
+ The Luna defaults disable reasoning, which suits classification: on the benchmarks below, Luna is as accurate with reasoning off and much faster. Questions that apply explicit rules to a state are different. On [StreamDecisionBench](https://github.com/JacobLinCool/StreamDecisionBench), whose questions carry decision policies, GPT-5.6 Luna through LLM Judge answered 47% of states correctly with reasoning off and 90% with reasoning effort low, at about twice the latency. The ratings strategy cannot apply a policy across a state and should not be used for these questions. [Classification and rule-following results by mode](benchmarks/decision-modes.md).
156
+
157
+ To enable reasoning, override the default and leave room for it in the output limit:
158
+
159
+ ```ruby
160
+ provider_options: {
161
+ strategy: :single_request,
162
+ max_output_tokens: 4096,
163
+ chat_provider_options: { reasoning_effort: 'low' } # OpenAI
164
+ # chat_provider_options: { reasoning: { effort: 'low' } } # OpenRouter
165
+ }
166
+ ```
167
+
168
+ Reasoning does not fit the `:ratings` strategy, whose four-token output limit is meant for a single digit.
169
+
143
170
  ## How scoring works
144
171
 
145
172
  The default `:ratings` strategy asks the model to rate every declared answer from 0 to 9. It makes one call per answer, with up to six calls running at once (`max_workers:` changes this), then applies softmax to the ratings. If a call fails after its retry, no new calls start, calls in flight finish, and the error is raised. It retries a malformed digit once. When the highest ratings tie on a Choice question, it makes a one-call JSON judgment for that question and uses its distribution. If that call also ties, it raises an error instead of choosing whichever option came first. `result.raw[:tie_breaks]` records each resolution, and token usage includes the extra call. Set `tie_breaker: :first` to use the original first-option rule, or `tie_break_max_output_tokens:` to change the tie-break response limit (default 8192).
@@ -6,24 +6,38 @@ module RubyLLM
6
6
  module LLMJudge
7
7
  class Engine
8
8
  MAX_WORKERS = 6
9
+ # Luna model IDs by provider, which get temperature zero and reasoning disabled in that provider's
10
+ # format. Only each provider's own IDs match: an OpenAI-compatible gateway such as Bedrock, reached
11
+ # through :openai with an ID like "us.openai.gpt-5.6-luna", may use another request format. Pro
12
+ # variants are excluded: they exist to reason more.
13
+ LUNA_IDS = {
14
+ openai: /\Agpt-(?:\d+(?:\.\d+)?-)?luna(?:-latest)?\z/,
15
+ openrouter: %r{\A~?openai/gpt-(?:\d+(?:\.\d+)?-)?luna(?:-latest)?(?::\w+)?\z}
16
+ }.freeze
9
17
  SYSTEM_INSTRUCTIONS = 'Return exactly one ASCII digit 0-9. 9 means very likely to be the correct answer; 0 means very unlikely. No explanation.'
10
18
  SINGLE_REQUEST_INSTRUCTIONS = 'Answer all questions using only a JSON object with an "answers" field. ' \
11
19
  'For each question, return an object mapping every supplied option ID to a ' \
12
20
  'probability between 0 and 1. Include every option exactly once and make ' \
13
21
  'each question\'s probabilities sum to 1. Evaluate each question independently ' \
14
- 'against the same state. Return no explanations or markdown.'
22
+ 'against the same state. Return no explanations or markdown. The "answers" value ' \
23
+ 'must be an object whose keys are the question IDs.'
24
+ COMMITTED_INSTRUCTIONS = 'Answer all questions using only a JSON object with an "answers" field whose keys are ' \
25
+ 'the question IDs. For each question, give the ID of the one option that best applies. ' \
26
+ 'Evaluate each question independently against the same state. Return no explanations ' \
27
+ 'or markdown.'
28
+ STRATEGIES = %i[ratings single_request committed].freeze
15
29
 
16
30
  def initialize(config:, model: DEFAULT_MODEL, provider_options: {}, scorer: nil, responder: nil)
17
31
  @config = config
18
32
  options = provider_options.transform_keys(&:to_sym)
19
33
  allowed = %i[scoring_provider scoring_protocol temperature max_output_tokens chat_provider_options
20
34
  max_arms max_input_bytes strategy malformed_retries tie_breaker tie_break_max_output_tokens
21
- max_workers]
35
+ max_workers structured_output]
22
36
  unknown = options.keys - allowed
23
37
  raise ArgumentError, "Unknown LLMJudge provider options: #{unknown.join(', ')}" unless unknown.empty?
24
38
 
25
39
  @strategy = options.fetch(:strategy, :ratings).to_sym
26
- raise ArgumentError, 'strategy must be :ratings or :single_request' unless %i[ratings single_request].include?(@strategy)
40
+ raise ArgumentError, 'strategy must be :ratings, :single_request, or :committed' unless STRATEGIES.include?(@strategy)
27
41
  @tie_breaker = options.fetch(:tie_breaker, :single_request).to_sym
28
42
  raise ArgumentError, 'tie_breaker must be :single_request or :first' unless %i[single_request first].include?(@tie_breaker)
29
43
 
@@ -31,14 +45,15 @@ module RubyLLM
31
45
  @scoring_model = model
32
46
  raise ArgumentError, 'A model is required' unless @scoring_model.is_a?(String) && !@scoring_model.empty?
33
47
 
34
- luna_defaults = @scoring_provider == :openai && @scoring_model == DEFAULT_MODEL
35
- @scoring_protocol = options.fetch(:scoring_protocol, luna_defaults ? :chat_completions : nil)
48
+ luna_defaults = LUNA_IDS.fetch(@scoring_provider, nil)&.match?(@scoring_model) || false
49
+ @scoring_protocol = options.fetch(:scoring_protocol,
50
+ luna_defaults && @scoring_provider == :openai ? :chat_completions : nil)
36
51
  # Before 2.1, RubyLLM chats use each provider's one chat API (Chat Completions for OpenAI).
37
52
  if !NATIVE && ![nil, :chat_completions].include?(@scoring_protocol&.to_sym)
38
53
  raise ArgumentError, 'scoring_protocol requires RubyLLM 2.1'
39
54
  end
40
55
  @temperature = options.fetch(:temperature, luna_defaults ? 0 : nil)
41
- @max_output_tokens = options.fetch(:max_output_tokens, @strategy == :single_request ? 8192 : 4)
56
+ @max_output_tokens = options.fetch(:max_output_tokens, @strategy == :ratings ? 4 : 8192)
42
57
  @tie_break_max_output_tokens = options.fetch(:tie_break_max_output_tokens, 8192)
43
58
  chat_provider_options = options.fetch(:chat_provider_options, {})
44
59
  raise ArgumentError, 'chat_provider_options must be a Hash' unless chat_provider_options.is_a?(Hash)
@@ -49,6 +64,8 @@ module RubyLLM
49
64
  @max_input_bytes = options[:max_input_bytes]
50
65
  @malformed_retries = options.fetch(:malformed_retries, 1)
51
66
  @max_workers = options.fetch(:max_workers, MAX_WORKERS)
67
+ @structured_output = options.fetch(:structured_output, false)
68
+ raise ArgumentError, 'structured_output must be true or false' unless [true, false].include?(@structured_output)
52
69
  raise ArgumentError, 'max_workers must be a positive integer' unless @max_workers.is_a?(Integer) && @max_workers.positive?
53
70
  raise ArgumentError, 'max_output_tokens must be positive' unless @max_output_tokens.is_a?(Integer) && @max_output_tokens.positive?
54
71
  unless @tie_break_max_output_tokens.is_a?(Integer) && @tie_break_max_output_tokens.positive?
@@ -62,11 +79,12 @@ module RubyLLM
62
79
  end
63
80
 
64
81
  @scorer = scorer || method(:score_with_model)
65
- @responder = responder || method(:respond_with_model)
82
+ @responder = responder
66
83
  end
67
84
 
68
85
  def judge(input, questions:, model:)
69
- return judge_single_request(input, questions, model) if @strategy == :single_request
86
+ return judge_one_call(input, questions, model, :probabilities) if @strategy == :single_request
87
+ return judge_one_call(input, questions, model, :committed) if @strategy == :committed
70
88
 
71
89
  jobs = build_jobs(input, questions)
72
90
  results = run(jobs) { |job| @scorer.call(job[:prompt]) }
@@ -78,7 +96,7 @@ module RubyLLM
78
96
  chosen = answer(question, softmax(ratings))
79
97
  if question.type == :choice && ratings.count(ratings.max) > 1 && @tie_breaker == :single_request
80
98
  # Raises if the one-call judgment also ties, after its corrective retry.
81
- judgment = judge_single_request(input, { question.name => question }, model)
99
+ judgment = judge_one_call(input, { question.name => question }, model, :probabilities)
82
100
  chosen = judgment.answers.fetch(question.name)
83
101
  tie_breaks << { question: question.name, ratings:, judgment: }
84
102
  end
@@ -103,44 +121,98 @@ module RubyLLM
103
121
 
104
122
  private
105
123
 
106
- def judge_single_request(input, questions, model)
124
+ # One request for every question. :probabilities asks for a distribution per question;
125
+ # :committed asks for one option per question and returns it with probability 1.
126
+ def judge_one_call(input, questions, model, mode)
107
127
  specs = validated_options(input, questions)
108
- original_prompt = single_request_prompt(input, specs)
128
+ original_prompt = one_call_prompt(input, specs)
109
129
  prompt = original_prompt
110
130
  attempts = []
111
131
  loop do
112
- result = @responder.call(prompt)
132
+ result = respond(prompt, mode, specs)
113
133
  attempts << result
114
134
  begin
115
- answers, reported, normalization = parse_single_request(result.fetch(:content), specs)
135
+ answers, reported, normalization = mode == :committed ? parse_committed(result.fetch(:content), specs)
136
+ : parse_single_request(result.fetch(:content), specs)
137
+ raw = { method: mode == :committed ? 'committed_answers' : 'single_request_probabilities',
138
+ scoring_provider: @scoring_provider, scoring_model: @scoring_model,
139
+ reported_probabilities: reported, attempts: attempts.size }
140
+ raw[:reported_totals] = normalization if normalization
116
141
  return Types::Judgment.new(
117
- answers:, model: model.id,
118
- tokens: aggregate_tokens(attempts.map { |attempt| attempt.fetch(:tokens) }),
119
- raw: { method: 'single_request_probabilities', scoring_provider: @scoring_provider,
120
- scoring_model: @scoring_model, reported_probabilities: reported,
121
- reported_totals: normalization, attempts: attempts.size }
142
+ answers:, model: model.id, raw:,
143
+ tokens: aggregate_tokens(attempts.map { |attempt| attempt.fetch(:tokens) })
122
144
  )
123
145
  rescue RubyLLM::Error => error
124
146
  raise if attempts.size > @malformed_retries
125
147
 
126
148
  prompt = "#{original_prompt}\n\nThe previous answer was invalid: #{error.message}. " \
127
149
  "Previous answer: #{result.fetch(:content).to_s[0, 4000]}\n" \
128
- 'Return a complete corrected JSON object with every question and option.'
150
+ 'Return a complete corrected JSON object with every question.'
129
151
  end
130
152
  end
131
153
  end
132
154
 
133
- def parse_single_request(content, specs)
155
+ def respond(prompt, mode, specs)
156
+ return @responder.call(prompt) if @responder
157
+
158
+ respond_with_model(prompt, mode:, schema: @structured_output ? answer_schema(mode, specs) : nil)
159
+ end
160
+
161
+ # The answers object keyed by question ID, accepting a fenced reply or a list of one-question objects.
162
+ def reported_answers(content, specs)
134
163
  parsed = JSON.parse(strip_code_fence(content))
135
164
  raise Error, 'Scoring model returned a non-object response' unless parsed.is_a?(Hash)
136
165
 
137
166
  reported = parsed.fetch('answers')
167
+ # Some models mirror the prompt's question list: [{"id": {...}}, ...].
168
+ if reported.is_a?(Array) && reported.all? { |item| item.is_a?(Hash) }
169
+ ids = reported.flat_map(&:keys)
170
+ reported = reported.reduce({}, :merge) if ids.uniq.size == ids.size
171
+ end
138
172
  expected_questions = specs.map { |question, _| question.name.to_s }
139
173
  unless reported.is_a?(Hash) && reported.keys.sort == expected_questions.sort
140
174
  actual = reported.is_a?(Hash) ? reported.keys : reported.class.name
141
175
  raise Error, "Question IDs must be #{expected_questions.inspect}; got #{actual.inspect}"
142
176
  end
143
177
 
178
+ reported
179
+ end
180
+
181
+ def parse_committed(content, specs)
182
+ reported = reported_answers(content, specs)
183
+ answers = specs.to_h do |question, options|
184
+ keys = options.map { |name, _| name.to_s }
185
+ value = reported.fetch(question.name.to_s)
186
+ value = { 'yes' => 'true', 'no' => 'false' }.fetch(value.to_s.downcase, value.to_s) if question.type == :probability
187
+ index = keys.index(value.to_s) unless value.is_a?(Hash) || value.is_a?(Array)
188
+ raise Error, "The answer for #{question.name} must be one of #{keys.inspect}; got #{value.inspect}" unless index
189
+
190
+ [question.name, answer(question, keys.each_index.map { |position| position == index ? 1.0 : 0.0 })]
191
+ end
192
+ [answers, reported, nil]
193
+ rescue JSON::ParserError, KeyError, TypeError => error
194
+ raise Error, "Scoring model returned invalid JSON answers: #{error.message}"
195
+ end
196
+
197
+ # A strict JSON Schema that admits only complete answers to these questions.
198
+ def answer_schema(mode, specs)
199
+ properties = specs.to_h do |question, options|
200
+ keys = options.map { |name, _| name.to_s }
201
+ schema = if mode == :committed
202
+ { type: 'string', enum: keys }
203
+ else
204
+ { type: 'object', properties: keys.to_h { |key| [key, { type: 'number' }] },
205
+ required: keys, additionalProperties: false }
206
+ end
207
+ [question.name.to_s, schema]
208
+ end
209
+ answers = { type: 'object', properties:, required: properties.keys, additionalProperties: false }
210
+ { name: 'judgment', strict: true,
211
+ schema: { type: 'object', properties: { answers: }, required: ['answers'], additionalProperties: false } }
212
+ end
213
+
214
+ def parse_single_request(content, specs)
215
+ reported = reported_answers(content, specs)
144
216
  normalization = {}
145
217
  answers = specs.to_h do |question, options|
146
218
  distribution = reported.fetch(question.name.to_s)
@@ -172,7 +244,9 @@ module RubyLLM
172
244
  raise Error, "Scoring model returned invalid JSON probabilities: #{error.message}"
173
245
  end
174
246
 
175
- def single_request_prompt(input, specs)
247
+ # The state comes first: listing the questions first made Luna 6 retry more and answer less
248
+ # accurately on the benchmark cases.
249
+ def one_call_prompt(input, specs)
176
250
  request = {
177
251
  state: input,
178
252
  questions: specs.map do |question, options|
@@ -280,10 +354,13 @@ module RubyLLM
280
354
  end
281
355
  end
282
356
 
357
+ # Luna rates poorly with reasoning on: the output limit is spent before the answer.
283
358
  def default_chat_options(luna_defaults)
284
- return {} unless @scoring_provider == :openai
285
-
286
- luna_defaults ? { store: false, reasoning_effort: 'none' } : { store: false }
359
+ case @scoring_provider
360
+ when :openai then luna_defaults ? { store: false, reasoning_effort: 'none' } : { store: false }
361
+ when :openrouter then luna_defaults ? { reasoning: { enabled: false } } : {}
362
+ else {}
363
+ end
287
364
  end
288
365
 
289
366
  def score_with_model(prompt)
@@ -300,13 +377,16 @@ module RubyLLM
300
377
  end
301
378
  end
302
379
 
303
- def respond_with_model(prompt)
380
+ def respond_with_model(prompt, mode:, schema: nil)
304
381
  limit = @strategy == :ratings ? @tie_break_max_output_tokens : @max_output_tokens
305
- response = scoring_chat(instructions: SINGLE_REQUEST_INSTRUCTIONS, max_output_tokens: limit).ask(prompt)
306
- { content: response.content.to_s, tokens: response.tokens }
382
+ instructions = mode == :committed ? COMMITTED_INSTRUCTIONS : SINGLE_REQUEST_INSTRUCTIONS
383
+ response = scoring_chat(instructions:, max_output_tokens: limit, schema:).ask(prompt)
384
+ # With a schema, RubyLLM parses the JSON reply into a Hash.
385
+ content = response.content
386
+ { content: content.is_a?(String) ? content : JSON.generate(content), tokens: response.tokens }
307
387
  end
308
388
 
309
- def scoring_chat(instructions: SYSTEM_INSTRUCTIONS, max_output_tokens: @max_output_tokens)
389
+ def scoring_chat(instructions: SYSTEM_INSTRUCTIONS, max_output_tokens: @max_output_tokens, schema: nil)
310
390
  context = RubyLLM::Context.new(@config)
311
391
  chat = if NATIVE
312
392
  context.chat(model: @scoring_model, provider: @scoring_provider,
@@ -316,15 +396,30 @@ module RubyLLM
316
396
  end
317
397
  chat.with_instructions(instructions)
318
398
  chat.with_temperature(@temperature) unless @temperature.nil?
399
+ chat.with_schema(schema) if schema
319
400
  if NATIVE
320
401
  chat.with_max_output_tokens(max_output_tokens)
321
- chat.with_provider_options(@chat_provider_options)
402
+ chat.with_provider_options(deep_compact(@chat_provider_options))
322
403
  else
323
- chat.with_params(**deep_merge(max_output_tokens_param(max_output_tokens), @chat_provider_options))
404
+ chat.with_params(**deep_compact(deep_merge(max_output_tokens_param(max_output_tokens), @chat_provider_options)))
324
405
  end
325
406
  chat
326
407
  end
327
408
 
409
+ # A nil in chat_provider_options removes that field: a default, or before 2.1
410
+ # the output limit field. A hash emptied by removal is removed too.
411
+ def deep_compact(hash)
412
+ hash.each_with_object({}) do |(key, value), compacted|
413
+ next if value.nil?
414
+
415
+ if value.is_a?(Hash) && !value.empty?
416
+ value = deep_compact(value)
417
+ next if value.empty?
418
+ end
419
+ compacted[key] = value
420
+ end
421
+ end
422
+
328
423
  # The request field each provider reads for the output limit, as RubyLLM 2.1 renders it.
329
424
  def max_output_tokens_param(limit)
330
425
  case @scoring_provider
@@ -7,7 +7,10 @@ module RubyLLM
7
7
  'http://127.0.0.1'
8
8
  end
9
9
 
10
- def judge(input, questions:, model:, provider_options: {})
10
+ # RubyLLM passes images as +with:+; they are not supported yet.
11
+ def judge(input, questions:, model:, with: [], provider_options: {})
12
+ raise UnsupportedAttachmentError, Array(with).first.mime_type unless Array(with).empty?
13
+
11
14
  Engine.new(config:, model: model.id, provider_options:).judge(input, questions:, model:)
12
15
  end
13
16
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module LLMJudge
5
- VERSION = '0.1.2'
5
+ VERSION = '0.1.4'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-llm_judge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara