prompt_builder 0.4.0 → 0.5.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +79 -17
  4. data/VERSION +1 -1
  5. data/lib/prompt_builder/errors.rb +9 -1
  6. data/lib/prompt_builder/response.rb +24 -0
  7. data/lib/prompt_builder/serializers/base.rb +46 -0
  8. data/lib/prompt_builder/serializers/chat_completion/request.rb +20 -6
  9. data/lib/prompt_builder/serializers/chat_completion/response.rb +3 -1
  10. data/lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb +143 -0
  11. data/lib/prompt_builder/serializers/chat_completion.rb +1 -0
  12. data/lib/prompt_builder/serializers/converse/request.rb +195 -23
  13. data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
  14. data/lib/prompt_builder/serializers/converse.rb +1 -0
  15. data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
  16. data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
  17. data/lib/prompt_builder/serializers/gemini.rb +1 -0
  18. data/lib/prompt_builder/serializers/messages/request.rb +95 -33
  19. data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
  20. data/lib/prompt_builder/serializers/messages.rb +1 -0
  21. data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
  22. data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
  23. data/lib/prompt_builder/serializers/open_responses.rb +1 -0
  24. data/lib/prompt_builder/session.rb +49 -31
  25. data/lib/prompt_builder/streaming/accumulator.rb +169 -0
  26. data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
  27. data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
  28. data/lib/prompt_builder/streaming.rb +14 -0
  29. data/lib/prompt_builder.rb +1 -0
  30. metadata +10 -1
@@ -22,11 +22,11 @@ module PromptBuilder
22
22
  # - +parallel_tool_calls+ — parallel tool call control is not supported
23
23
  # - +presence_penalty+ — not supported by the Converse API
24
24
  # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
25
- # - +reasoning+ — extended thinking is not supported on the Converse endpoint;
26
- # a warning is issued (once per process) when +session.reasoning+ is set
27
25
  # - +safety_identifier+ — no equivalent user-safety field on the Converse endpoint
28
26
  # - +store+ — server-side response storage is not supported
29
- # - +stream+ — SSE streaming is handled outside the Converse request payload
27
+ # - +stream+ — streaming is selected by endpoint (+ConverseStream+), not a
28
+ # request body field; Converse::StreamAccumulator (or
29
+ # Response.parse_stream) reassembles the streamed response
30
30
  # - +stream_options+ — stream event options are not supported
31
31
  # - +top_logprobs+ — log probability output is not supported
32
32
  # - +truncation+ — server-side context truncation is not supported
@@ -35,6 +35,42 @@ module PromptBuilder
35
35
  # - +metadata+ — only scalar values are forwarded to +requestMetadata+; Hash/Array values are omitted
36
36
  # - +text+ — only +format.type == "json_schema"+ is supported
37
37
  #
38
+ # === Reasoning
39
+ #
40
+ # The Converse API has no reasoning parameter of its own; each model
41
+ # family reads its native reasoning fields from
42
+ # +additionalModelRequestFields+. The family is detected from the model
43
+ # id (an optional cross-region prefix such as +us.+ or +global.+ and an
44
+ # ARN resource path are allowed):
45
+ # - Anthropic (+anthropic.*+) — the Messages API fields: +type+,
46
+ # +budget_tokens+, and +display+ go to +thinking+ and +effort+ to
47
+ # +output_config.effort+, with +thinking.type+ inferred as in the
48
+ # Messages serializer (+enabled+ for +budget_tokens+; +adaptive+ for
49
+ # +display+, a bare Session#think, or an +effort+ level unless
50
+ # +temperature+, a +top_p+ below 0.95, or a forced +tool_choice+ is set)
51
+ # - OpenAI (+openai.*+) — the Responses API +reasoning+ object, without
52
+ # the Anthropic-only +budget_tokens+, +type+, and +display+ keys
53
+ # - OpenAI gpt-oss (+openai.gpt-oss-*+) — +effort+ to the flat
54
+ # +reasoning_effort+ field, the only reasoning field these
55
+ # open-weight models read
56
+ # - Any other model raises an UnsupportedFormatError when
57
+ # +session.reasoning+ is set to a non-empty configuration (a bare
58
+ # Session#think and Session#think(false) send nothing); pass the
59
+ # model's native fields through the +additional_model_request_fields+
60
+ # extra instead
61
+ #
62
+ # +reasoning.type: "disabled"+ (Session#think(false)) turns reasoning
63
+ # off: Anthropic models get +thinking.type: "disabled"+ (with any
64
+ # +effort+ still sent as +output_config.effort+), and OpenAI models get
65
+ # effort +none+ in place of any effort, which models that cannot turn
66
+ # reasoning off reject.
67
+ #
68
+ # Values are forwarded without validation; the API rejects values the
69
+ # model does not support. The reasoning fields are deep-merged into the
70
+ # +additional_model_request_fields+ extra, with the extra's values
71
+ # taking precedence on conflicts, except that a +thinking+ object in the
72
+ # extra replaces the inferred one as a whole.
73
+ #
38
74
  # Tool choice restrictions:
39
75
  # - +tool_choice: "none"+ has no Converse representation and is omitted
40
76
  #
@@ -43,7 +79,13 @@ module PromptBuilder
43
79
  # into the top-level +system+ parameter, with +instructions+ merged last
44
80
  # (instructions apply to the current request, matching Open Responses
45
81
  # semantics)
46
- # - +Reasoning+ items are silently skipped
82
+ # - +Reasoning+ items are replayed as +reasoningContent+ blocks on the
83
+ # assistant turn, which Anthropic models require ahead of a +toolUse+
84
+ # block when thinking is on. Only signed +thinking+ blocks (as
85
+ # +reasoningText+ with its +signature+) and +redacted_thinking+ blocks
86
+ # (as +redactedContent+) are replayed; unsigned reasoning (e.g. parsed
87
+ # from another provider's response) is dropped, since Bedrock rejects
88
+ # reasoning without a valid signature. Summary blocks are dropped
47
89
  # - +RefusalContent+ is dropped silently (a parsed response refusal can
48
90
  # stay in session history without breaking subsequent request_payload calls)
49
91
  # - +OutputText.annotations+ and +OutputText.logprobs+ are dropped silently
@@ -120,14 +162,10 @@ module PromptBuilder
120
162
  "3gp" => "three_gp"
121
163
  }.freeze
122
164
 
123
- class << self
124
- # Reset the once-per-process reasoning warning. Primarily used in tests.
125
- #
126
- # @return [void]
127
- def reset_reasoning_warning!
128
- @reasoning_warning_issued = false
129
- end
165
+ # Model id provider segments whose reasoning fields can be mapped.
166
+ REASONING_PROVIDERS = ["anthropic", "openai"].freeze
130
167
 
168
+ class << self
131
169
  private
132
170
 
133
171
  def serialize_request(session)
@@ -138,8 +176,6 @@ module PromptBuilder
138
176
  h = {}
139
177
  raise UnsupportedFormatError, "Converse format requires session.model" unless session.model
140
178
 
141
- warn_reasoning_unsupported! if session.reasoning
142
-
143
179
  h["modelId"] = session.model
144
180
 
145
181
  system = build_system(session)
@@ -174,17 +210,120 @@ module PromptBuilder
174
210
  extra = session.extra
175
211
  apply_session_extra!(h, extra) unless extra.empty?
176
212
 
213
+ reasoning_fields = build_reasoning_fields(session)
214
+ unless reasoning_fields.empty?
215
+ h["additionalModelRequestFields"] = merge_reasoning_fields(reasoning_fields, h["additionalModelRequestFields"] || {})
216
+ end
217
+
177
218
  h
178
219
  end
179
220
 
180
- # The Converse endpoint has no reasoning/extended thinking parameter,
181
- # so the reasoning config is dropped. Warn once per process instead of
182
- # dropping it silently.
183
- def warn_reasoning_unsupported!
184
- return if @reasoning_warning_issued
221
+ # Map session.reasoning to the native reasoning fields of the model's
222
+ # family, which Converse passes through additionalModelRequestFields.
223
+ def build_reasoning_fields(session)
224
+ reasoning = session.reasoning
225
+ return {} unless reasoning
226
+
227
+ case reasoning_provider(session.model)
228
+ when "anthropic"
229
+ anthropic_reasoning_fields(session, reasoning)
230
+ when "openai"
231
+ gpt_oss_model?(session.model) ? gpt_oss_reasoning_fields(reasoning) : openai_reasoning_fields(reasoning)
232
+ else
233
+ # A bare Session#think leaves reasoning to the model's defaults
234
+ # and Session#think(false) asks for no reasoning, which a model
235
+ # without a mapped reasoning switch is assumed to do already.
236
+ return {} if reasoning.empty? || reasoning_disabled?(reasoning)
237
+
238
+ raise UnsupportedFormatError,
239
+ "Converse format does not support session.reasoning for model #{session.model}; " \
240
+ "set the model's native reasoning fields with the additional_model_request_fields extra instead"
241
+ end
242
+ end
243
+
244
+ # Bedrock model ids may carry a cross-region inference prefix
245
+ # (us.anthropic.claude-...) and may be given as an ARN whose resource
246
+ # path ends in the id. Application inference profile ARNs carry no
247
+ # provider and are not recognized.
248
+ def reasoning_provider(model)
249
+ segments = model.to_s.split("/").last.to_s.split(".")
250
+ segments.first(2).find { |segment| REASONING_PROVIDERS.include?(segment) }
251
+ end
252
+
253
+ # Mirrors the Messages serializer's thinking inference, without its
254
+ # validation: the API rejects configurations the model doesn't accept.
255
+ def anthropic_reasoning_fields(session, reasoning)
256
+ fields = {}
257
+
258
+ type = reasoning["type"]
259
+ type ||= "enabled" unless reasoning["budget_tokens"].nil?
260
+ type ||= "adaptive" if reasoning.empty? || reasoning["display"]
261
+ type ||= "adaptive" if reasoning["effort"] && !anthropic_thinking_conflicts?(session)
262
+ if type
263
+ thinking = {"type" => type}
264
+ thinking["budget_tokens"] = reasoning["budget_tokens"] unless reasoning["budget_tokens"].nil?
265
+ thinking["display"] = reasoning["display"] if reasoning["display"]
266
+ fields["thinking"] = thinking
267
+ end
268
+
269
+ fields["output_config"] = {"effort" => reasoning["effort"]} if reasoning["effort"]
270
+
271
+ fields
272
+ end
273
+
274
+ # Whether the payload sets anything Anthropic thinking is
275
+ # incompatible with, in which case effort is sent on its own.
276
+ def anthropic_thinking_conflicts?(session)
277
+ return true if session.temperature
278
+ return true if session.top_p && session.top_p < 0.95
279
+ return false unless session.tool_choice
185
280
 
186
- @reasoning_warning_issued = true
187
- warn "PromptBuilder: session.reasoning is not supported by the Converse format and was omitted from the request payload"
281
+ tool_choice = serialize_tool_choice(session.tool_choice, session.tool_definitions.empty?)
282
+ tool_choice&.key?("any") || tool_choice&.key?("tool") || false
283
+ end
284
+
285
+ # The OpenAI reasoning object takes effort-based keys only. Effort is
286
+ # its only control, so turning reasoning off overrides any effort.
287
+ def openai_reasoning_fields(reasoning)
288
+ fields = reasoning.except("budget_tokens", "display", "type")
289
+ fields["effort"] = "none" if reasoning_disabled?(reasoning)
290
+ fields.empty? ? {} : {"reasoning" => fields}
291
+ end
292
+
293
+ # The open-weight gpt-oss models take the Chat Completions request
294
+ # body and ignore the nested reasoning object.
295
+ def gpt_oss_model?(model)
296
+ model.to_s.split("/").last.to_s.include?("openai.gpt-oss")
297
+ end
298
+
299
+ # gpt-oss reads only a flat reasoning_effort field.
300
+ def gpt_oss_reasoning_fields(reasoning)
301
+ effort = reasoning_disabled?(reasoning) ? "none" : reasoning["effort"]
302
+ effort ? {"reasoning_effort" => effort} : {}
303
+ end
304
+
305
+ # The extra's values win. A thinking configuration in the extra
306
+ # replaces the inferred one as a whole, since which of its keys are
307
+ # valid depends on its type; other objects (output_config,
308
+ # reasoning) are deep-merged so the extra can add keys to them.
309
+ def merge_reasoning_fields(reasoning_fields, extra_fields)
310
+ reasoning_fields.merge(extra_fields) do |key, value, override|
311
+ if key != "thinking" && value.is_a?(Hash) && override.is_a?(Hash)
312
+ deep_merge(value, override)
313
+ else
314
+ override
315
+ end
316
+ end
317
+ end
318
+
319
+ def deep_merge(base, override)
320
+ base.merge(override) do |_key, base_value, override_value|
321
+ if base_value.is_a?(Hash) && override_value.is_a?(Hash)
322
+ deep_merge(base_value, override_value)
323
+ else
324
+ override_value
325
+ end
326
+ end
188
327
  end
189
328
 
190
329
  def apply_session_extra!(h, extra)
@@ -268,9 +407,14 @@ module PromptBuilder
268
407
  content << {"cachePoint" => {"type" => "default"}}
269
408
  end
270
409
  raw_messages << {"role" => "user", "content" => content}
271
- when Items::Reasoning, Items::Compaction, Items::ItemReference
272
- # Reasoning, Compaction, and ItemReference items are not supported
273
- # in the request, so ignore them rather than raising an error.
410
+ when Items::Reasoning
411
+ # Summary blocks come from the Responses API and have no
412
+ # Converse representation; they are silently skipped.
413
+ content = item.content.filter_map { |block| serialize_reasoning_block(block) }
414
+ raw_messages << {"role" => "assistant", "content" => content} unless content.empty?
415
+ when Items::Compaction, Items::ItemReference
416
+ # Compaction and ItemReference items are not supported in the
417
+ # request, so ignore them rather than raising an error.
274
418
  next
275
419
  end
276
420
  end
@@ -278,6 +422,34 @@ module PromptBuilder
278
422
  merge_consecutive_messages(raw_messages)
279
423
  end
280
424
 
425
+ # Bedrock validates replayed reasoning against its signature, so only
426
+ # signed thinking and redacted thinking blocks can be sent back.
427
+ def serialize_reasoning_block(block)
428
+ case block["type"]
429
+ when "thinking"
430
+ # Unsigned thinking from cross-provider history is dropped
431
+ # rather than rejected.
432
+ return nil unless block["signature"]
433
+
434
+ {
435
+ "reasoningContent" => {
436
+ "reasoningText" => {
437
+ "text" => block.fetch("thinking", ""),
438
+ "signature" => block["signature"]
439
+ }
440
+ }
441
+ }
442
+ when "redacted_thinking"
443
+ unless block["data"]
444
+ raise UnsupportedFormatError,
445
+ "Converse format requires reasoning.data for redacted_thinking blocks"
446
+ end
447
+
448
+ {"reasoningContent" => {"redactedContent" => block["data"]}}
449
+ end
450
+ # Unsupported reasoning block types are silently omitted.
451
+ end
452
+
281
453
  def serialize_system_content(content)
282
454
  case content
283
455
  when Content::InputText, Content::Text
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class Converse < Base
6
+ # Reassembles a Bedrock +ConverseStream+ response (AWS event-stream
7
+ # binary frames) into the +ConverseResponse+ the non-streaming +Converse+
8
+ # endpoint would have returned: +output.message+, +stopReason+, +usage+,
9
+ # and +metrics+.
10
+ #
11
+ # +messageStart+ provides the role. Content blocks are tracked by
12
+ # +contentBlockIndex+: +contentBlockStart+ provides a tool use block's
13
+ # +toolUseId+ and +name+, and +contentBlockDelta+ carries +text+,
14
+ # +toolUse.input+ string fragments (joined and parsed as JSON when the
15
+ # block stops), and +reasoningContent+ text, signature, and redacted
16
+ # content. +messageStop+ provides +stopReason+ and
17
+ # +additionalModelResponseFields+, and +metadata+ provides +usage+,
18
+ # +metrics+, and any other response fields (e.g. +trace+).
19
+ #
20
+ # The stream must contain +messageStop+. Messages whose +:message-type+
21
+ # is +exception+ or +error+ raise an ErrorResponseError. Unknown events
22
+ # and delta types are ignored.
23
+ #
24
+ # The response id is not part of the stream; pass the HTTP headers to
25
+ # +Response.parse+ (or +Response.parse_stream+) to populate it.
26
+ class StreamAccumulator < Streaming::Accumulator
27
+ # Bedrock adds a random-length "p" field to event payloads to mask
28
+ # their size; it is not part of the response.
29
+ PADDING_KEY = "p"
30
+ private_constant :PADDING_KEY
31
+
32
+ def initialize
33
+ super
34
+ @role = nil
35
+ @blocks = {}
36
+ @tool_inputs = {}
37
+ @redacted = {}
38
+ @stop = nil
39
+ @metadata = nil
40
+ end
41
+
42
+ private
43
+
44
+ def new_decoder
45
+ Streaming::EventStreamDecoder.new
46
+ end
47
+
48
+ def handle_event(message)
49
+ headers = message.headers
50
+ case headers[":message-type"]
51
+ when "event"
52
+ data = parse_json(message.payload)
53
+ handle_stream_event(headers[":event-type"], data) if data.is_a?(Hash)
54
+ when "exception"
55
+ raise_error_event!([headers[":exception-type"], exception_message(message.payload)].compact.join(": "))
56
+ when "error"
57
+ raise_error_event!([headers[":error-code"], headers[":error-message"]].compact.join(": "))
58
+ end
59
+ end
60
+
61
+ def exception_message(payload)
62
+ text = payload.dup.force_encoding(Encoding::UTF_8)
63
+ data = JSON.parse(text)
64
+ data.is_a?(Hash) ? (data["message"] || data["Message"]) : text
65
+ rescue JSON::ParserError
66
+ text.empty? ? nil : text
67
+ end
68
+
69
+ def handle_stream_event(type, data)
70
+ case type
71
+ when "messageStart"
72
+ @role = data["role"]
73
+ when "contentBlockStart"
74
+ start_block(data["contentBlockIndex"], data["start"] || {})
75
+ when "contentBlockDelta"
76
+ apply_delta(data["contentBlockIndex"], data["delta"] || {})
77
+ when "contentBlockStop"
78
+ finish_block(data["contentBlockIndex"])
79
+ when "messageStop"
80
+ @stop = data.except(PADDING_KEY)
81
+ when "metadata"
82
+ @metadata = data.except(PADDING_KEY)
83
+ end
84
+ end
85
+
86
+ def start_block(index, start)
87
+ return unless start["toolUse"].is_a?(Hash)
88
+
89
+ @blocks[index] = {"toolUse" => start["toolUse"].dup}
90
+ @tool_inputs[index] = +""
91
+ end
92
+
93
+ def apply_delta(index, delta)
94
+ if delta["text"]
95
+ block = @blocks[index] ||= {}
96
+ block["text"] = block["text"] ? +block["text"] : +""
97
+ block["text"] << delta["text"]
98
+ elsif delta["toolUse"].is_a?(Hash)
99
+ @blocks[index] ||= {"toolUse" => {}}
100
+ (@tool_inputs[index] ||= +"") << delta["toolUse"]["input"].to_s
101
+ elsif delta["reasoningContent"].is_a?(Hash)
102
+ apply_reasoning_delta(index, delta["reasoningContent"])
103
+ end
104
+ end
105
+
106
+ def apply_reasoning_delta(index, delta)
107
+ block = @blocks[index] ||= {}
108
+ reasoning = block["reasoningContent"] ||= {}
109
+
110
+ if delta["text"] || delta["signature"]
111
+ text_block = reasoning["reasoningText"] ||= {"text" => +""}
112
+ text_block["text"] << delta["text"] if delta["text"]
113
+ if delta["signature"]
114
+ text_block["signature"] = text_block["signature"] ? +text_block["signature"] : +""
115
+ text_block["signature"] << delta["signature"]
116
+ end
117
+ end
118
+
119
+ if delta["redactedContent"]
120
+ # Blobs are base64 encoded per event, so decode each fragment and
121
+ # re-encode the joined bytes when the result is built.
122
+ (@redacted[index] ||= +"".b) << delta["redactedContent"].unpack1("m")
123
+ end
124
+ end
125
+
126
+ def finish_block(index)
127
+ json = @tool_inputs.delete(index)
128
+ return unless json
129
+
130
+ tool_use = (@blocks[index] ||= {})["toolUse"] ||= {}
131
+ tool_use["input"] = json.empty? ? {} : parse_json(json)
132
+ end
133
+
134
+ def build_result
135
+ incomplete!("Converse stream ended without a messageStop event") unless @stop
136
+
137
+ @tool_inputs.keys.each { |index| finish_block(index) }
138
+ @redacted.each do |index, bytes|
139
+ @blocks[index]["reasoningContent"]["redactedContent"] = [bytes].pack("m0")
140
+ end
141
+ content = @blocks.sort_by { |index, _| index.to_i }.map { |_, block| block }
142
+
143
+ result = {"output" => {"message" => {"role" => @role || "assistant", "content" => content}}}
144
+ @stop.each { |key, value| result[key] = value unless value.nil? }
145
+ @metadata&.each { |key, value| result[key] = value unless value.nil? }
146
+ result
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -7,6 +7,7 @@ module PromptBuilder
7
7
  class Converse < Base
8
8
  autoload :Request, File.expand_path("converse/request", __dir__)
9
9
  autoload :Response, File.expand_path("converse/response", __dir__)
10
+ autoload :StreamAccumulator, File.expand_path("converse/stream_accumulator", __dir__)
10
11
 
11
12
  class << self
12
13
  # Export a session to Converse request payload.
@@ -23,6 +23,9 @@ module PromptBuilder
23
23
  # - +parallel_tool_calls+ — parallel tool call control is not supported
24
24
  # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
25
25
  # - +safety_identifier+ — no equivalent user-safety field on the generate endpoint
26
+ # - +stream+ — streaming is selected by endpoint (+streamGenerateContent+),
27
+ # not a request body field; Gemini::StreamAccumulator (or
28
+ # Response.parse_stream) reassembles the streamed response
26
29
  # - +stream_options+ — stream event options are not supported
27
30
  # - +truncation+ — server-side context truncation is not supported
28
31
  #
@@ -52,8 +55,13 @@ module PromptBuilder
52
55
  #
53
56
  # The following Gemini parameters cannot be set through the Open Responses
54
57
  # canonical format:
55
- # - +thinkingConfig.thinkingBudget+ — use +reasoning.budget_tokens+ instead
58
+ # - +thinkingConfig.thinkingBudget+ — use +reasoning.budget_tokens+ instead;
59
+ # +reasoning.type: "disabled"+ (Session#think(false)) is sent as
60
+ # +thinkingBudget: 0+ in place of any budget or effort, and models
61
+ # that cannot turn thinking off reject it
56
62
  # - +thinkingConfig.thinkingLevel+ — use +reasoning.effort+ instead
63
+ # (levels are upcased and forwarded as is; combining it with
64
+ # +reasoning.budget_tokens+ raises, since the API rejects both together)
57
65
  # - +thinkingConfig.includeThoughts+ — use +reasoning.summary = "auto"+ instead
58
66
  # - +topK+ — top-K sampling parameter
59
67
  # - +seed+ — for reproducible outputs (model-dependent)
@@ -77,7 +85,6 @@ module PromptBuilder
77
85
  # - +cachedContent+ — referencing a CachedContent resource by name
78
86
  # - Top-level +labels+ (Vertex flavor only)
79
87
  class Request < Base
80
- SUPPORTED_REASONING_EFFORTS = %w[minimal low medium high].freeze
81
88
  SUPPORTED_REASONING_SUMMARIES = %w[auto].freeze
82
89
  SUPPORTED_SERVICE_TIERS = %w[unspecified standard flex priority].freeze
83
90
 
@@ -493,14 +500,25 @@ module PromptBuilder
493
500
  # Unsupported reasoning.* keys are silently omitted.
494
501
  thinking_config = {}
495
502
 
496
- if session.reasoning["budget_tokens"]
497
- thinking_config["thinkingBudget"] = session.reasoning["budget_tokens"]
503
+ if reasoning_disabled?(session.reasoning)
504
+ # A zero budget turns thinking off. It replaces any budget or
505
+ # effort set alongside it, since the API rejects a budget
506
+ # together with a level. Models that cannot turn thinking off
507
+ # reject it.
508
+ thinking_config["thinkingBudget"] = 0
509
+ else
510
+ if session.reasoning["budget_tokens"]
511
+ thinking_config["thinkingBudget"] = session.reasoning["budget_tokens"]
512
+ end
513
+
514
+ effort = session.reasoning["effort"]
515
+ # Effort levels are forwarded as is; the levels a model accepts vary.
516
+ thinking_config["thinkingLevel"] = effort.to_s.upcase if effort
498
517
  end
499
518
 
500
- effort = session.reasoning["effort"]
501
- # Unsupported effort levels are silently omitted.
502
- if effort && SUPPORTED_REASONING_EFFORTS.include?(effort)
503
- thinking_config["thinkingLevel"] = effort.upcase
519
+ if thinking_config.key?("thinkingBudget") && thinking_config.key?("thinkingLevel")
520
+ raise UnsupportedFormatError,
521
+ "Gemini format does not support reasoning.budget_tokens together with reasoning.effort"
504
522
  end
505
523
 
506
524
  summary = session.reasoning["summary"]