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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +79 -17
- data/VERSION +1 -1
- data/lib/prompt_builder/errors.rb +9 -1
- data/lib/prompt_builder/response.rb +24 -0
- data/lib/prompt_builder/serializers/base.rb +46 -0
- data/lib/prompt_builder/serializers/chat_completion/request.rb +20 -6
- data/lib/prompt_builder/serializers/chat_completion/response.rb +3 -1
- data/lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb +143 -0
- data/lib/prompt_builder/serializers/chat_completion.rb +1 -0
- data/lib/prompt_builder/serializers/converse/request.rb +195 -23
- data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
- data/lib/prompt_builder/serializers/converse.rb +1 -0
- data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
- data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
- data/lib/prompt_builder/serializers/gemini.rb +1 -0
- data/lib/prompt_builder/serializers/messages/request.rb +95 -33
- data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
- data/lib/prompt_builder/serializers/messages.rb +1 -0
- data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
- data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
- data/lib/prompt_builder/serializers/open_responses.rb +1 -0
- data/lib/prompt_builder/session.rb +49 -31
- data/lib/prompt_builder/streaming/accumulator.rb +169 -0
- data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
- data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
- data/lib/prompt_builder/streaming.rb +14 -0
- data/lib/prompt_builder.rb +1 -0
- 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+ —
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
272
|
-
#
|
|
273
|
-
#
|
|
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
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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"]
|