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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +86 -17
- data/VERSION +1 -1
- data/lib/prompt_builder/errors.rb +9 -1
- data/lib/prompt_builder/response.rb +34 -2
- data/lib/prompt_builder/serializers/base.rb +55 -1
- 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 -8
- data/lib/prompt_builder/serializers/converse/request.rb +195 -23
- data/lib/prompt_builder/serializers/converse/response.rb +35 -2
- data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
- data/lib/prompt_builder/serializers/converse.rb +1 -8
- 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 -8
- 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 -8
- 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 -8
- 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
|
@@ -7,6 +7,14 @@ module PromptBuilder
|
|
|
7
7
|
#
|
|
8
8
|
# Required session fields (an UnsupportedFormatError is raised when missing):
|
|
9
9
|
# - +model+
|
|
10
|
+
#
|
|
11
|
+
# Partially supported session fields:
|
|
12
|
+
# - +reasoning+ — forwarded as is, except that the +budget_tokens+,
|
|
13
|
+
# +display+, and +type+ keys used by other serializers are omitted
|
|
14
|
+
# since they are not part of the Open Responses schema.
|
|
15
|
+
# +type: "disabled"+ (Session#think(false)) is sent as
|
|
16
|
+
# +effort: "none"+ in place of any effort, and models that cannot
|
|
17
|
+
# turn reasoning off reject it
|
|
10
18
|
class Request < Base
|
|
11
19
|
# Content extra keys that are part of the Open Responses API schema and
|
|
12
20
|
# must survive extra-stripping (the canonical content classes have no
|
|
@@ -17,6 +25,11 @@ module PromptBuilder
|
|
|
17
25
|
}.freeze
|
|
18
26
|
private_constant :CANONICAL_EXTRA_KEYS
|
|
19
27
|
|
|
28
|
+
# Reasoning keys that only other serializers understand. The Open
|
|
29
|
+
# Responses API rejects unknown reasoning parameters.
|
|
30
|
+
NON_CANONICAL_REASONING_KEYS = ["budget_tokens", "display", "type"].freeze
|
|
31
|
+
private_constant :NON_CANONICAL_REASONING_KEYS
|
|
32
|
+
|
|
20
33
|
class << self
|
|
21
34
|
# Export a session to Open Responses API request payload.
|
|
22
35
|
#
|
|
@@ -33,6 +46,7 @@ module PromptBuilder
|
|
|
33
46
|
normalize_content_urls!(payload)
|
|
34
47
|
strip_non_replayable_reasoning!(payload)
|
|
35
48
|
strip_output_only_fields!(payload)
|
|
49
|
+
strip_non_canonical_reasoning_keys!(payload)
|
|
36
50
|
normalize_and_validate_input_images!(payload)
|
|
37
51
|
normalize_text_format!(payload)
|
|
38
52
|
payload
|
|
@@ -251,6 +265,17 @@ module PromptBuilder
|
|
|
251
265
|
end
|
|
252
266
|
end
|
|
253
267
|
|
|
268
|
+
def strip_non_canonical_reasoning_keys!(payload)
|
|
269
|
+
reasoning = payload["reasoning"]
|
|
270
|
+
return unless reasoning.is_a?(Hash)
|
|
271
|
+
|
|
272
|
+
canonical = reasoning.except(*NON_CANONICAL_REASONING_KEYS)
|
|
273
|
+
# Effort is the only reasoning control, so turning reasoning off
|
|
274
|
+
# overrides any effort set alongside it.
|
|
275
|
+
canonical["effort"] = "none" if reasoning_disabled?(reasoning)
|
|
276
|
+
payload["reasoning"] = canonical
|
|
277
|
+
end
|
|
278
|
+
|
|
254
279
|
def normalize_optional_string(value)
|
|
255
280
|
return value unless value.is_a?(String)
|
|
256
281
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptBuilder
|
|
4
|
+
module Serializers
|
|
5
|
+
class OpenResponses < Base
|
|
6
|
+
# Reassembles an Open Responses SSE stream into the response object the
|
|
7
|
+
# non-streaming endpoint would have returned.
|
|
8
|
+
#
|
|
9
|
+
# The terminal events +response.completed+, +response.incomplete+, and
|
|
10
|
+
# +response.failed+ each carry the full response object, so the result is
|
|
11
|
+
# taken from that event rather than rebuilt from the deltas, and the
|
|
12
|
+
# other events are ignored. An +error+ event raises an
|
|
13
|
+
# ErrorResponseError.
|
|
14
|
+
class StreamAccumulator < Streaming::Accumulator
|
|
15
|
+
TERMINAL_EVENTS = %w[response.completed response.incomplete response.failed].freeze
|
|
16
|
+
private_constant :TERMINAL_EVENTS
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
super
|
|
20
|
+
@response = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def handle_event(event)
|
|
26
|
+
type = event.event
|
|
27
|
+
# Only the terminal and error events need parsing. Events without a
|
|
28
|
+
# specific SSE event name are parsed to read the type from the data.
|
|
29
|
+
return unless type.nil? || type == "message" || type == "error" || TERMINAL_EVENTS.include?(type)
|
|
30
|
+
|
|
31
|
+
data = parse_json(event.data)
|
|
32
|
+
return unless data.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
type = data["type"] || type
|
|
35
|
+
if TERMINAL_EVENTS.include?(type)
|
|
36
|
+
@response = data["response"]
|
|
37
|
+
elsif type == "error"
|
|
38
|
+
# The error fields are either on the event itself or nested under
|
|
39
|
+
# an "error" object.
|
|
40
|
+
error = data["error"].is_a?(Hash) ? data["error"] : data.except("type")
|
|
41
|
+
raise_error_event!([error["code"] || error["type"], error["message"]].compact.join(": "))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_result
|
|
46
|
+
unless @response.is_a?(Hash)
|
|
47
|
+
incomplete!("Open Responses stream ended without a response.completed, response.incomplete, or response.failed event")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@response
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -7,6 +7,7 @@ module PromptBuilder
|
|
|
7
7
|
class OpenResponses < Base
|
|
8
8
|
autoload :Request, File.expand_path("open_responses/request", __dir__)
|
|
9
9
|
autoload :Response, File.expand_path("open_responses/response", __dir__)
|
|
10
|
+
autoload :StreamAccumulator, File.expand_path("open_responses/stream_accumulator", __dir__)
|
|
10
11
|
|
|
11
12
|
class << self
|
|
12
13
|
# Export a session to Open Responses API 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 an Open Responses response into an PromptBuilder::Response.
|
|
21
|
-
#
|
|
22
|
-
# @param hash [Hash] the response hash in Open Responses 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
|
|
@@ -27,12 +27,6 @@ module PromptBuilder
|
|
|
27
27
|
JSONIFY_FIELDS = %i[include tool_choice metadata text stream_options reasoning].freeze
|
|
28
28
|
private_constant :JSONIFY_FIELDS
|
|
29
29
|
|
|
30
|
-
# Effort levels accepted by +think+. This is the union of the levels
|
|
31
|
-
# recognized across serializers; each serializer omits levels its target
|
|
32
|
-
# API does not support.
|
|
33
|
-
THINK_EFFORT_LEVELS = %w[minimal low medium high xhigh max].freeze
|
|
34
|
-
private_constant :THINK_EFFORT_LEVELS
|
|
35
|
-
|
|
36
30
|
# All keyword options accepted by +initialize+.
|
|
37
31
|
#
|
|
38
32
|
# This constant is public API — unlike the private field-group constants
|
|
@@ -465,48 +459,72 @@ module PromptBuilder
|
|
|
465
459
|
# Open Responses (+reasoning.effort+)
|
|
466
460
|
# - +budget_tokens+ — Messages (+thinking.budget_tokens+) and Gemini
|
|
467
461
|
# (+thinkingConfig.thinkingBudget+); ignored by effort-based APIs
|
|
468
|
-
#
|
|
462
|
+
# (Open Responses omits it from +reasoning+)
|
|
463
|
+
# - Converse sends both in +additionalModelRequestFields+ in the model
|
|
464
|
+
# family's native format (Messages for Anthropic models, +reasoning+
|
|
465
|
+
# for OpenAI models) and raises for other models
|
|
466
|
+
#
|
|
467
|
+
# Called with no depth control, it stores an empty configuration that
|
|
468
|
+
# leaves both whether and how much to reason to the target API's
|
|
469
|
+
# defaults. Only Messages has a switch for it, which it maps to adaptive
|
|
470
|
+
# thinking; Chat Completions and Gemini send no reasoning parameter, Open
|
|
471
|
+
# Responses sends an empty +reasoning+ object, and models that do not
|
|
472
|
+
# reason by default will not reason. Pass either
|
|
473
|
+
# +effort+ or +budget_tokens+, not both — providers reject requests that
|
|
474
|
+
# set both controls. An explicit token budget is only accepted by some
|
|
475
|
+
# models; +effort+ is the portable control. Direct
|
|
476
|
+
# +session.reasoning = {...}+ assignment keeps working for
|
|
477
|
+
# provider-specific keys.
|
|
469
478
|
#
|
|
470
|
-
#
|
|
471
|
-
#
|
|
472
|
-
#
|
|
479
|
+
# +think(false)+ turns reasoning off by storing
|
|
480
|
+
# <tt>{"type" => "disabled"}</tt>, which each serializer maps to its API's
|
|
481
|
+
# off switch: +thinking.type: "disabled"+ on Messages and Anthropic models
|
|
482
|
+
# on Converse, effort +none+ on Chat Completions, Open Responses, and
|
|
483
|
+
# OpenAI models on Converse, and +thinkingBudget: 0+ on Gemini. Models
|
|
484
|
+
# that cannot turn reasoning off reject these values. This differs from
|
|
485
|
+
# +session.reasoning = nil+, which sends no reasoning parameter and
|
|
486
|
+
# leaves the API's default in place; models that reason by default keep
|
|
487
|
+
# reasoning. An +effort+ can accompany +think(false)+ for the Messages
|
|
488
|
+
# API, where it limits overall token spend without thinking; APIs whose
|
|
489
|
+
# only reasoning control is effort ignore it so reasoning stays off.
|
|
473
490
|
#
|
|
474
|
-
# @param enabled [Boolean] pass +false+ to
|
|
475
|
-
# @param effort [String, Symbol, nil]
|
|
476
|
-
# +
|
|
477
|
-
#
|
|
491
|
+
# @param enabled [Boolean] pass +false+ to turn reasoning off
|
|
492
|
+
# @param effort [String, Symbol, nil] an effort level such as +minimal+,
|
|
493
|
+
# +low+, +medium+, +high+, +xhigh+, or +max+. Any level is accepted, since
|
|
494
|
+
# providers add new levels with new models; serializers forward it as is
|
|
495
|
+
# and the target API rejects levels the model does not support
|
|
478
496
|
# @param budget_tokens [Integer, nil] an explicit thinking token budget
|
|
479
|
-
# @return [Hash
|
|
480
|
-
# @raise [ArgumentError] if
|
|
481
|
-
#
|
|
497
|
+
# @return [Hash] the resulting +reasoning+ configuration
|
|
498
|
+
# @raise [ArgumentError] if both +effort+ and +budget_tokens+ are given
|
|
499
|
+
# when enabling, +budget_tokens+ is given when disabling, or
|
|
500
|
+
# +budget_tokens+ is not a positive integer
|
|
482
501
|
# @example
|
|
502
|
+
# session.think # API defaults (adaptive thinking on Messages)
|
|
483
503
|
# session.think(effort: :medium) # portable across serializers
|
|
484
504
|
# session.think(budget_tokens: 8_000) # explicit where supported
|
|
485
|
-
# session.think(false) #
|
|
505
|
+
# session.think(false) # turn reasoning off
|
|
506
|
+
# session.think(false, effort: :low) # off, with a Messages effort level
|
|
507
|
+
# session.reasoning = nil # API defaults, no reasoning parameter
|
|
486
508
|
def think(enabled = true, effort: nil, budget_tokens: nil)
|
|
487
509
|
unless enabled
|
|
488
|
-
raise ArgumentError, "cannot combine think(false) with
|
|
510
|
+
raise ArgumentError, "cannot combine think(false) with budget_tokens" if budget_tokens
|
|
489
511
|
|
|
490
|
-
|
|
512
|
+
disabled = {"type" => "disabled"}
|
|
513
|
+
disabled["effort"] = effort.to_s if effort
|
|
514
|
+
return self.reasoning = disabled
|
|
491
515
|
end
|
|
492
516
|
|
|
493
|
-
if effort && budget_tokens
|
|
494
|
-
raise ArgumentError, "pass either effort or budget_tokens, not both"
|
|
495
|
-
elsif effort.nil? && budget_tokens.nil?
|
|
496
|
-
raise ArgumentError, "think requires effort: or budget_tokens:"
|
|
497
|
-
end
|
|
517
|
+
raise ArgumentError, "pass either effort or budget_tokens, not both" if effort && budget_tokens
|
|
498
518
|
|
|
499
519
|
if effort
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
raise ArgumentError, "effort must be one of: #{THINK_EFFORT_LEVELS.join(", ")}"
|
|
503
|
-
end
|
|
504
|
-
self.reasoning = {"effort" => effort}
|
|
505
|
-
else
|
|
520
|
+
self.reasoning = {"effort" => effort.to_s}
|
|
521
|
+
elsif budget_tokens
|
|
506
522
|
budget_tokens = Integer(budget_tokens)
|
|
507
523
|
raise ArgumentError, "budget_tokens must be positive" unless budget_tokens.positive?
|
|
508
524
|
|
|
509
525
|
self.reasoning = {"budget_tokens" => budget_tokens}
|
|
526
|
+
else
|
|
527
|
+
self.reasoning = {}
|
|
510
528
|
end
|
|
511
529
|
end
|
|
512
530
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module PromptBuilder
|
|
6
|
+
module Streaming
|
|
7
|
+
# Base class for the per-format accumulators that reassemble a streamed
|
|
8
|
+
# response body into the Hash the non-streaming endpoint would have
|
|
9
|
+
# returned, so it can be parsed with +Response.parse+.
|
|
10
|
+
#
|
|
11
|
+
# Feed the raw body in chunks of any size with #feed (or #<<), then call
|
|
12
|
+
# #result. Only reassembly state is kept, not the raw body.
|
|
13
|
+
#
|
|
14
|
+
# A body that starts with +{+ or +[+ is not a stream (e.g. a JSON error
|
|
15
|
+
# envelope returned with a 4xx status, or a server that ignored the stream
|
|
16
|
+
# flag); it is buffered and returned parsed from #result, so +Response.parse+
|
|
17
|
+
# handles it as it would a non-streamed body.
|
|
18
|
+
#
|
|
19
|
+
# Once #feed or #result raises, the accumulator is spent: later calls raise
|
|
20
|
+
# the same error.
|
|
21
|
+
#
|
|
22
|
+
# Subclasses implement the private hooks +handle_event+ and +build_result+,
|
|
23
|
+
# and may override +new_decoder+ (an SSEParser by default) and
|
|
24
|
+
# +collapse_json+.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# accumulator = PromptBuilder::Serializers::Messages.stream_accumulator
|
|
28
|
+
# http_response.read_body { |chunk| accumulator << chunk }
|
|
29
|
+
# PromptBuilder::Response.parse(accumulator.result, :messages)
|
|
30
|
+
class Accumulator
|
|
31
|
+
JSON_START = /[^\t\n\r ]/n
|
|
32
|
+
private_constant :JSON_START
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@leading = +"".b
|
|
36
|
+
@mode = nil
|
|
37
|
+
@decoder = nil
|
|
38
|
+
@json_body = nil
|
|
39
|
+
@finished = false
|
|
40
|
+
@result = nil
|
|
41
|
+
@error = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Add the next chunk of the raw response body.
|
|
45
|
+
#
|
|
46
|
+
# @param chunk [String] the next chunk; may split events, lines, UTF-8
|
|
47
|
+
# characters, or binary frames anywhere
|
|
48
|
+
# @return [self]
|
|
49
|
+
# @raise [ErrorResponseError] if the stream contains an error event
|
|
50
|
+
# @raise [ParseError] if the stream is malformed
|
|
51
|
+
# @raise [InvalidStateError] if called after #result
|
|
52
|
+
def feed(chunk)
|
|
53
|
+
raise @error if @error
|
|
54
|
+
raise InvalidStateError, "cannot feed a stream accumulator after calling result" if @finished
|
|
55
|
+
return self if chunk.nil? || chunk.empty?
|
|
56
|
+
|
|
57
|
+
capture_error do
|
|
58
|
+
chunk = chunk.b
|
|
59
|
+
case @mode
|
|
60
|
+
when :stream then decode(chunk)
|
|
61
|
+
when :json then @json_body << chunk
|
|
62
|
+
else detect_mode(chunk)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
alias_method :<<, :feed
|
|
68
|
+
|
|
69
|
+
# Finish the stream and return the reassembled response. The result is
|
|
70
|
+
# computed once; later calls return the same Hash.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash, Array] the response the non-streaming endpoint would have
|
|
73
|
+
# returned (or the parsed body if it was not a stream)
|
|
74
|
+
# @raise [IncompleteStreamError] if the stream ended before the format's
|
|
75
|
+
# terminal event
|
|
76
|
+
# @raise [ErrorResponseError] if the stream contains an error event
|
|
77
|
+
# @raise [ParseError] if the stream is malformed
|
|
78
|
+
def result
|
|
79
|
+
raise @error if @error
|
|
80
|
+
return @result if @finished
|
|
81
|
+
|
|
82
|
+
@finished = true
|
|
83
|
+
capture_error { @result = finish_stream }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Create the decoder for the stream framing. It must respond to
|
|
89
|
+
# +feed(chunk) { |event| }+ and +finish { |event| }+.
|
|
90
|
+
def new_decoder
|
|
91
|
+
SSEParser.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Process one decoded event (an SSEParser::Event or an
|
|
95
|
+
# EventStreamDecoder::Message).
|
|
96
|
+
def handle_event(_event)
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Build the non-streaming response Hash once the stream has ended.
|
|
101
|
+
def build_result
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Convert a body that was not a stream into the result.
|
|
106
|
+
def collapse_json(value)
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Parse a JSON event payload.
|
|
111
|
+
def parse_json(data)
|
|
112
|
+
JSON.parse(data.dup.force_encoding(Encoding::UTF_8))
|
|
113
|
+
rescue JSON::ParserError => e
|
|
114
|
+
raise ParseError, "streamed event is not valid JSON (#{e.message}): #{data.to_s[0, 200]}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def raise_error_event!(message)
|
|
118
|
+
message = "unknown error" if message.nil? || message.empty?
|
|
119
|
+
raise ErrorResponseError, "the API returned an error: #{message}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def incomplete!(message)
|
|
123
|
+
raise IncompleteStreamError, message
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Decide from the first non-whitespace byte whether the body is a stream
|
|
127
|
+
# or a plain JSON document. Leading whitespace is held until then.
|
|
128
|
+
def detect_mode(chunk)
|
|
129
|
+
@leading << chunk
|
|
130
|
+
index = @leading.index(JSON_START)
|
|
131
|
+
return unless index
|
|
132
|
+
|
|
133
|
+
body = @leading
|
|
134
|
+
@leading = nil
|
|
135
|
+
if body.getbyte(index) == 0x7B || body.getbyte(index) == 0x5B # "{" or "["
|
|
136
|
+
@mode = :json
|
|
137
|
+
@json_body = body
|
|
138
|
+
else
|
|
139
|
+
@mode = :stream
|
|
140
|
+
@decoder = new_decoder
|
|
141
|
+
decode(body)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def decode(chunk)
|
|
146
|
+
@decoder.feed(chunk) { |event| handle_event(event) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def finish_stream
|
|
150
|
+
case @mode
|
|
151
|
+
when :stream
|
|
152
|
+
@decoder.finish { |event| handle_event(event) }
|
|
153
|
+
build_result
|
|
154
|
+
when :json
|
|
155
|
+
collapse_json(parse_json(@json_body))
|
|
156
|
+
else
|
|
157
|
+
incomplete!("the streamed response body was empty")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def capture_error
|
|
162
|
+
yield
|
|
163
|
+
rescue => e
|
|
164
|
+
@error = e
|
|
165
|
+
raise
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module PromptBuilder
|
|
6
|
+
module Streaming
|
|
7
|
+
# Incremental decoder for the AWS event-stream binary framing
|
|
8
|
+
# (+application/vnd.amazon.eventstream+) used by Bedrock streaming APIs.
|
|
9
|
+
#
|
|
10
|
+
# Each message is a 12-byte prelude (total length, headers length, and a
|
|
11
|
+
# CRC32 of those 8 bytes), the typed headers, the payload, and a CRC32 of
|
|
12
|
+
# everything before it. Both checksums are verified. Chunks can split
|
|
13
|
+
# messages anywhere; only the bytes of the current incomplete message are
|
|
14
|
+
# kept between calls.
|
|
15
|
+
class EventStreamDecoder
|
|
16
|
+
# A decoded message.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] headers
|
|
19
|
+
# @return [Hash{String => Object}] the message headers (e.g.
|
|
20
|
+
# +":message-type"+, +":event-type"+, +":exception-type"+,
|
|
21
|
+
# +":content-type"+) keyed by name
|
|
22
|
+
# @!attribute [r] payload
|
|
23
|
+
# @return [String] the raw payload bytes (binary encoding)
|
|
24
|
+
Message = Struct.new(:headers, :payload)
|
|
25
|
+
|
|
26
|
+
PRELUDE_LENGTH = 12
|
|
27
|
+
private_constant :PRELUDE_LENGTH
|
|
28
|
+
|
|
29
|
+
CRC_LENGTH = 4
|
|
30
|
+
private_constant :CRC_LENGTH
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@buffer = +"".b
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Decode a chunk of the body, yielding each message it completes.
|
|
37
|
+
#
|
|
38
|
+
# @param chunk [String] the next chunk of the response body
|
|
39
|
+
# @yieldparam message [Message] a decoded message
|
|
40
|
+
# @return [self]
|
|
41
|
+
# @raise [ParseError] if a checksum does not match or a message is malformed
|
|
42
|
+
def feed(chunk, &block)
|
|
43
|
+
@buffer << chunk.b
|
|
44
|
+
|
|
45
|
+
messages = []
|
|
46
|
+
pos = 0
|
|
47
|
+
while @buffer.bytesize - pos >= PRELUDE_LENGTH
|
|
48
|
+
total_length, headers_length = read_prelude(pos)
|
|
49
|
+
break if @buffer.bytesize - pos < total_length
|
|
50
|
+
|
|
51
|
+
messages << decode_message(@buffer.byteslice(pos, total_length), headers_length)
|
|
52
|
+
pos += total_length
|
|
53
|
+
end
|
|
54
|
+
@buffer = @buffer.byteslice(pos, @buffer.bytesize - pos) if pos > 0
|
|
55
|
+
|
|
56
|
+
messages.each(&block)
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Signal the end of the body.
|
|
61
|
+
#
|
|
62
|
+
# @return [self]
|
|
63
|
+
# @raise [IncompleteStreamError] if the body ended in the middle of a message
|
|
64
|
+
def finish
|
|
65
|
+
unless @buffer.empty?
|
|
66
|
+
raise IncompleteStreamError,
|
|
67
|
+
"event stream ended in the middle of a message (#{@buffer.bytesize} bytes left over)"
|
|
68
|
+
end
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def read_prelude(pos)
|
|
75
|
+
total_length, headers_length, prelude_crc = @buffer.byteslice(pos, PRELUDE_LENGTH).unpack("NNN")
|
|
76
|
+
actual_crc = Zlib.crc32(@buffer.byteslice(pos, 8))
|
|
77
|
+
unless actual_crc == prelude_crc
|
|
78
|
+
raise ParseError, "event stream prelude checksum mismatch (expected #{prelude_crc}, got #{actual_crc})"
|
|
79
|
+
end
|
|
80
|
+
if total_length < PRELUDE_LENGTH + CRC_LENGTH + headers_length
|
|
81
|
+
raise ParseError, "event stream message length #{total_length} is too short for #{headers_length} bytes of headers"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
[total_length, headers_length]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def decode_message(bytes, headers_length)
|
|
88
|
+
expected_crc = bytes.byteslice(-CRC_LENGTH, CRC_LENGTH).unpack1("N")
|
|
89
|
+
actual_crc = Zlib.crc32(bytes.byteslice(0, bytes.bytesize - CRC_LENGTH))
|
|
90
|
+
unless actual_crc == expected_crc
|
|
91
|
+
raise ParseError, "event stream message checksum mismatch (expected #{expected_crc}, got #{actual_crc})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
payload_length = bytes.bytesize - PRELUDE_LENGTH - headers_length - CRC_LENGTH
|
|
95
|
+
Message.new(
|
|
96
|
+
decode_headers(bytes.byteslice(PRELUDE_LENGTH, headers_length)),
|
|
97
|
+
bytes.byteslice(PRELUDE_LENGTH + headers_length, payload_length)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def decode_headers(bytes)
|
|
102
|
+
headers = {}
|
|
103
|
+
pos = 0
|
|
104
|
+
take = lambda do |length|
|
|
105
|
+
raise ParseError, "event stream message headers are truncated" if pos + length > bytes.bytesize
|
|
106
|
+
|
|
107
|
+
value = bytes.byteslice(pos, length)
|
|
108
|
+
pos += length
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
while pos < bytes.bytesize
|
|
113
|
+
name = take.call(take.call(1).unpack1("C")).force_encoding(Encoding::UTF_8)
|
|
114
|
+
headers[name] = decode_header_value(take.call(1).unpack1("C"), take)
|
|
115
|
+
end
|
|
116
|
+
headers
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def decode_header_value(type, take)
|
|
120
|
+
case type
|
|
121
|
+
when 0 then true
|
|
122
|
+
when 1 then false
|
|
123
|
+
when 2 then take.call(1).unpack1("c")
|
|
124
|
+
when 3 then take.call(2).unpack1("s>")
|
|
125
|
+
when 4 then take.call(4).unpack1("l>")
|
|
126
|
+
when 5 then take.call(8).unpack1("q>")
|
|
127
|
+
when 6 then take.call(take.call(2).unpack1("n"))
|
|
128
|
+
when 7 then take.call(take.call(2).unpack1("n")).force_encoding(Encoding::UTF_8)
|
|
129
|
+
when 8 then Time.at(0, take.call(8).unpack1("q>"), :millisecond).utc
|
|
130
|
+
when 9 then take.call(16).unpack1("H*").sub(/\A(.{8})(.{4})(.{4})(.{4})/, '\1-\2-\3-\4-')
|
|
131
|
+
else
|
|
132
|
+
raise ParseError, "unknown event stream header value type #{type}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|