prompt_builder 0.3.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +86 -17
  4. data/VERSION +1 -1
  5. data/lib/prompt_builder/errors.rb +9 -1
  6. data/lib/prompt_builder/response.rb +34 -2
  7. data/lib/prompt_builder/serializers/base.rb +55 -1
  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 -8
  12. data/lib/prompt_builder/serializers/converse/request.rb +195 -23
  13. data/lib/prompt_builder/serializers/converse/response.rb +35 -2
  14. data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
  15. data/lib/prompt_builder/serializers/converse.rb +1 -8
  16. data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
  17. data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
  18. data/lib/prompt_builder/serializers/gemini.rb +1 -8
  19. data/lib/prompt_builder/serializers/messages/request.rb +95 -33
  20. data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
  21. data/lib/prompt_builder/serializers/messages.rb +1 -8
  22. data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
  23. data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
  24. data/lib/prompt_builder/serializers/open_responses.rb +1 -8
  25. data/lib/prompt_builder/session.rb +49 -31
  26. data/lib/prompt_builder/streaming/accumulator.rb +169 -0
  27. data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
  28. data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
  29. data/lib/prompt_builder/streaming.rb +14 -0
  30. data/lib/prompt_builder.rb +1 -0
  31. metadata +10 -1
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class ChatCompletion < Base
6
+ # Reassembles an OpenAI Chat Completions SSE stream of
7
+ # +chat.completion.chunk+ objects into the +chat.completion+ object the
8
+ # non-streaming endpoint would have returned.
9
+ #
10
+ # Choice deltas are merged by +choices[].index+ into a +message+:
11
+ # +content+, +refusal+, and other string fields are concatenated, and
12
+ # +tool_calls+ fragments are merged by their own +index+, with +id+,
13
+ # +type+, and +function.name+ taken as they appear and
14
+ # +function.arguments+ concatenated. +finish_reason+ comes from the chunk
15
+ # that carries it, and +logprobs+ arrays are concatenated. Usage arrives
16
+ # in a final chunk with empty +choices+, and only when the request set
17
+ # +stream_options.include_usage+.
18
+ #
19
+ # The stream must end with +data: [DONE]+; a chunk with an +error+ raises
20
+ # an ErrorResponseError.
21
+ class StreamAccumulator < Streaming::Accumulator
22
+ DONE = "[DONE]"
23
+ private_constant :DONE
24
+
25
+ def initialize
26
+ super
27
+ @response = {}
28
+ @choices = {}
29
+ @done = false
30
+ end
31
+
32
+ private
33
+
34
+ def handle_event(event)
35
+ if event.data.strip == DONE
36
+ @done = true
37
+ return
38
+ end
39
+
40
+ chunk = parse_json(event.data)
41
+ return unless chunk.is_a?(Hash)
42
+
43
+ if chunk["error"]
44
+ error = chunk["error"]
45
+ message = error.is_a?(Hash) ? [error["code"] || error["type"], error["message"]].compact.join(": ") : error.to_s
46
+ raise_error_event!(message)
47
+ end
48
+
49
+ chunk.each do |key, value|
50
+ next if value.nil?
51
+
52
+ if key == "choices"
53
+ value.each_with_index { |choice, position| merge_choice(choice, position) if choice.is_a?(Hash) }
54
+ else
55
+ @response[key] = value
56
+ end
57
+ end
58
+ end
59
+
60
+ def merge_choice(choice, position)
61
+ index = choice["index"] || position
62
+ merged = @choices[index] ||= {"index" => index, "message" => {"role" => "assistant", "content" => nil}, "tool_calls" => {}}
63
+
64
+ merge_delta(merged, choice["delta"]) if choice["delta"].is_a?(Hash)
65
+ merge_logprobs(merged, choice["logprobs"]) if choice["logprobs"].is_a?(Hash)
66
+ merged["finish_reason"] = choice["finish_reason"] if choice["finish_reason"]
67
+ end
68
+
69
+ def merge_delta(merged, delta)
70
+ message = merged["message"]
71
+ delta.each do |key, value|
72
+ next if value.nil?
73
+
74
+ case key
75
+ when "role"
76
+ message["role"] = value
77
+ when "tool_calls"
78
+ value.each_with_index { |call, position| merge_tool_call(merged["tool_calls"], call, position) if call.is_a?(Hash) }
79
+ when "annotations"
80
+ (message["annotations"] ||= []).concat(value) if value.is_a?(Array)
81
+ else
82
+ if value.is_a?(String)
83
+ message[key] = message[key] ? +message[key] : +""
84
+ message[key] << value
85
+ else
86
+ message[key] = value
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ def merge_tool_call(tool_calls, delta, position)
93
+ call = tool_calls[delta["index"] || position] ||= {"function" => {}}
94
+ call["id"] = delta["id"] if delta["id"]
95
+ call["type"] = delta["type"] if delta["type"]
96
+
97
+ function = delta["function"]
98
+ return unless function.is_a?(Hash)
99
+
100
+ call["function"]["name"] = function["name"] if function["name"]
101
+ if function["arguments"]
102
+ arguments = call["function"]["arguments"]
103
+ call["function"]["arguments"] = arguments ? +arguments : +""
104
+ call["function"]["arguments"] << function["arguments"]
105
+ end
106
+ end
107
+
108
+ def merge_logprobs(merged, logprobs)
109
+ target = merged["logprobs"] ||= {}
110
+ logprobs.each do |key, value|
111
+ if value.is_a?(Array)
112
+ (target[key] ||= []).concat(value)
113
+ elsif !value.nil?
114
+ target[key] = value
115
+ end
116
+ end
117
+ end
118
+
119
+ def build_result
120
+ incomplete!("Chat Completions stream ended without data: [DONE]") unless @done
121
+
122
+ choices = @choices.sort_by { |index, _| index.to_i }.map { |_, choice| build_choice(choice) }
123
+ @response.merge("object" => "chat.completion", "choices" => choices)
124
+ end
125
+
126
+ def build_choice(choice)
127
+ message = choice["message"]
128
+ tool_calls = choice["tool_calls"].sort_by { |index, _| index.to_i }.map do |_, call|
129
+ {"type" => "function"}.merge(call)
130
+ end
131
+ message = message.merge("tool_calls" => tool_calls) unless tool_calls.empty?
132
+
133
+ {
134
+ "index" => choice["index"],
135
+ "message" => message,
136
+ "logprobs" => choice["logprobs"],
137
+ "finish_reason" => choice["finish_reason"]
138
+ }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -7,6 +7,7 @@ module PromptBuilder
7
7
  class ChatCompletion < Base
8
8
  autoload :Request, File.expand_path("chat_completion/request", __dir__)
9
9
  autoload :Response, File.expand_path("chat_completion/response", __dir__)
10
+ autoload :StreamAccumulator, File.expand_path("chat_completion/stream_accumulator", __dir__)
10
11
 
11
12
  class << self
12
13
  # Export a session to Chat Completions request payload.
@@ -16,14 +17,6 @@ module PromptBuilder
16
17
  def request_payload(session)
17
18
  Request.request_payload(session)
18
19
  end
19
-
20
- # Parse a Chat Completions response into an PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Chat Completions format
23
- # @return [PromptBuilder::Response] the parsed response
24
- def parse_response(hash)
25
- Response.parse_response(hash)
26
- end
27
20
  end
28
21
  end
29
22
  end
@@ -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
@@ -7,7 +7,24 @@ module PromptBuilder
7
7
  class Converse < Base
8
8
  # Response parser for the Amazon Bedrock Converse API format.
9
9
  class Response < Base
10
+ # The response id header set by Bedrock (the AWS request metadata id).
11
+ RESPONSE_ID_HEADER = "x-amzn-requestid"
12
+
10
13
  class << self
14
+ # Parse a Converse response into a PromptBuilder::Response. The
15
+ # Converse API does not include a response id in the body; Bedrock
16
+ # returns it in the request metadata headers, so the id is read
17
+ # from +headers+ when they are given.
18
+ #
19
+ # @param hash [Hash] the response hash in Converse format
20
+ # @param headers [Hash, #each, nil] the HTTP response headers
21
+ # @return [PromptBuilder::Response] the parsed response
22
+ # @raise [ErrorResponseError] if the payload is an API error envelope
23
+ def parse_response(hash, headers: nil)
24
+ check_error_response!(hash)
25
+ deserialize_response(hash, headers)
26
+ end
27
+
11
28
  private
12
29
 
13
30
  # Bedrock exception bodies carry a top-level "message" (the exception
@@ -25,7 +42,7 @@ module PromptBuilder
25
42
  [error_type, message].compact.join(": ")
26
43
  end
27
44
 
28
- def deserialize_response(hash)
45
+ def deserialize_response(hash, headers = nil)
29
46
  require_response_key!(hash, "output")
30
47
  require_response_key!(hash, "stopReason")
31
48
 
@@ -50,7 +67,7 @@ module PromptBuilder
50
67
  content_blocks = message["content"] || []
51
68
 
52
69
  PromptBuilder::Response.new(
53
- id: nil,
70
+ id: response_id_from_headers(headers),
54
71
  object: nil,
55
72
  model: nil,
56
73
  output: build_output_items(content_blocks),
@@ -61,6 +78,22 @@ module PromptBuilder
61
78
  )
62
79
  end
63
80
 
81
+ # Find the response id header with a case-insensitive name match.
82
+ # Header values may be scalars or arrays of values, depending on
83
+ # the HTTP client the headers come from.
84
+ def response_id_from_headers(headers)
85
+ return nil unless headers.respond_to?(:each)
86
+
87
+ headers.each do |name, value|
88
+ next unless name.to_s.casecmp?(RESPONSE_ID_HEADER)
89
+
90
+ value = value.first if value.is_a?(Array)
91
+ value = value.to_s.strip
92
+ return value unless value.empty?
93
+ end
94
+ nil
95
+ end
96
+
64
97
  def map_stop_reason(reason)
65
98
  case reason
66
99
  when "end_turn", "tool_use", "stop_sequence"
@@ -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