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
|
@@ -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
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptBuilder
|
|
4
|
+
module Streaming
|
|
5
|
+
# Incremental parser for a Server-Sent Events (text/event-stream) body.
|
|
6
|
+
#
|
|
7
|
+
# Chunks can split the body anywhere, including in the middle of a line, a
|
|
8
|
+
# CRLF pair, or a multi-byte UTF-8 character; only the current incomplete
|
|
9
|
+
# line and the current event's fields are kept between calls. Lines may end
|
|
10
|
+
# with LF, CRLF, or CR. Comment lines (starting with +:+) and unknown fields
|
|
11
|
+
# are ignored, multiple +data+ lines are joined with newlines, and a leading
|
|
12
|
+
# byte order mark is skipped. Unlike a browser EventSource, a final event
|
|
13
|
+
# that is not followed by a blank line is still dispatched by #finish, since
|
|
14
|
+
# some servers close the stream right after the last event.
|
|
15
|
+
class SSEParser
|
|
16
|
+
# A dispatched event.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] event
|
|
19
|
+
# @return [String, nil] the +event+ field, or nil when the event has none
|
|
20
|
+
# or an empty one (both mean the default +message+ event type)
|
|
21
|
+
# @!attribute [r] data
|
|
22
|
+
# @return [String] the +data+ lines joined with newlines (UTF-8)
|
|
23
|
+
# @!attribute [r] id
|
|
24
|
+
# @return [String, nil] the most recent +id+ field seen in the stream
|
|
25
|
+
Event = Struct.new(:event, :data, :id)
|
|
26
|
+
|
|
27
|
+
BOM = "\xEF\xBB\xBF".b.freeze
|
|
28
|
+
private_constant :BOM
|
|
29
|
+
|
|
30
|
+
LINE_BREAK = /[\r\n]/n
|
|
31
|
+
private_constant :LINE_BREAK
|
|
32
|
+
|
|
33
|
+
CR = 13
|
|
34
|
+
private_constant :CR
|
|
35
|
+
|
|
36
|
+
LF = 10
|
|
37
|
+
private_constant :LF
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@buffer = +"".b
|
|
41
|
+
# Where to resume searching for a line break, so an incomplete line
|
|
42
|
+
# held across chunks is not rescanned from the start on every chunk.
|
|
43
|
+
@scan_from = 0
|
|
44
|
+
@bom_checked = false
|
|
45
|
+
@event_type = nil
|
|
46
|
+
@data_lines = []
|
|
47
|
+
@last_event_id = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse a chunk of the body, yielding each event it completes.
|
|
51
|
+
#
|
|
52
|
+
# @param chunk [String] the next chunk of the response body
|
|
53
|
+
# @yieldparam event [Event] a completed event
|
|
54
|
+
# @return [self]
|
|
55
|
+
def feed(chunk, &block)
|
|
56
|
+
@buffer << chunk.b
|
|
57
|
+
return self unless skip_bom
|
|
58
|
+
|
|
59
|
+
events = []
|
|
60
|
+
pos = 0
|
|
61
|
+
while (index = @buffer.index(LINE_BREAK, [pos, @scan_from].max))
|
|
62
|
+
next_pos = index + 1
|
|
63
|
+
if @buffer.getbyte(index) == CR
|
|
64
|
+
# A CR at the end of the buffer may be the first half of a CRLF
|
|
65
|
+
# split across chunks; wait for the next chunk to decide.
|
|
66
|
+
break if next_pos == @buffer.bytesize
|
|
67
|
+
next_pos += 1 if @buffer.getbyte(next_pos) == LF
|
|
68
|
+
end
|
|
69
|
+
process_line(@buffer.byteslice(pos, index - pos), events)
|
|
70
|
+
pos = next_pos
|
|
71
|
+
end
|
|
72
|
+
@buffer = @buffer.byteslice(pos, @buffer.bytesize - pos) if pos > 0
|
|
73
|
+
# The rest of the buffer has no line break, except possibly a trailing
|
|
74
|
+
# CR, so the next search only needs to start at the last byte.
|
|
75
|
+
@scan_from = [@buffer.bytesize - 1, 0].max
|
|
76
|
+
|
|
77
|
+
events.each(&block)
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Signal the end of the body, yielding the final event if it was not
|
|
82
|
+
# followed by a blank line.
|
|
83
|
+
#
|
|
84
|
+
# @yieldparam event [Event] the final event
|
|
85
|
+
# @return [self]
|
|
86
|
+
def finish(&block)
|
|
87
|
+
@bom_checked = true
|
|
88
|
+
events = []
|
|
89
|
+
unless @buffer.empty?
|
|
90
|
+
line = @buffer.end_with?("\r") ? @buffer.byteslice(0, @buffer.bytesize - 1) : @buffer
|
|
91
|
+
process_line(line, events)
|
|
92
|
+
@buffer = +"".b
|
|
93
|
+
@scan_from = 0
|
|
94
|
+
end
|
|
95
|
+
dispatch(events)
|
|
96
|
+
|
|
97
|
+
events.each(&block)
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Strip a byte order mark from the start of the stream. Returns false
|
|
104
|
+
# while the buffer is too short to tell whether it starts with one.
|
|
105
|
+
def skip_bom
|
|
106
|
+
return true if @bom_checked
|
|
107
|
+
|
|
108
|
+
if @buffer.bytesize < BOM.bytesize && BOM.start_with?(@buffer)
|
|
109
|
+
return false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@buffer = @buffer.byteslice(BOM.bytesize, @buffer.bytesize) if @buffer.start_with?(BOM)
|
|
113
|
+
@bom_checked = true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def process_line(line, events)
|
|
117
|
+
if line.empty?
|
|
118
|
+
dispatch(events)
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
return if line.start_with?(":")
|
|
122
|
+
|
|
123
|
+
field, value = line.split(":", 2)
|
|
124
|
+
value = value.to_s
|
|
125
|
+
value = value.byteslice(1, value.bytesize) if value.start_with?(" ")
|
|
126
|
+
|
|
127
|
+
case field
|
|
128
|
+
when "event"
|
|
129
|
+
# An empty event field means the default "message" type, the same
|
|
130
|
+
# as no event field at all.
|
|
131
|
+
@event_type = value.empty? ? nil : utf8(value)
|
|
132
|
+
when "data"
|
|
133
|
+
@data_lines << value
|
|
134
|
+
when "id"
|
|
135
|
+
@last_event_id = utf8(value) unless value.include?("\0")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def dispatch(events)
|
|
140
|
+
unless @data_lines.empty?
|
|
141
|
+
events << Event.new(@event_type, utf8(@data_lines.join("\n")), @last_event_id)
|
|
142
|
+
end
|
|
143
|
+
@event_type = nil
|
|
144
|
+
@data_lines = []
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def utf8(value)
|
|
148
|
+
value.dup.force_encoding(Encoding::UTF_8)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PromptBuilder
|
|
4
|
+
# Building blocks for reassembling streamed API responses into the body the
|
|
5
|
+
# non-streaming endpoint would have returned. Each serializer defines a
|
|
6
|
+
# +StreamAccumulator+ subclass of Streaming::Accumulator for its format;
|
|
7
|
+
# use +Serializers::Base.stream_accumulator+, +Serializers::Base.collapse_stream+,
|
|
8
|
+
# or +Response.parse_stream+ rather than these classes directly.
|
|
9
|
+
module Streaming
|
|
10
|
+
autoload :Accumulator, File.expand_path("streaming/accumulator", __dir__)
|
|
11
|
+
autoload :EventStreamDecoder, File.expand_path("streaming/event_stream_decoder", __dir__)
|
|
12
|
+
autoload :SSEParser, File.expand_path("streaming/sse_parser", __dir__)
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/prompt_builder.rb
CHANGED
|
@@ -12,6 +12,7 @@ module PromptBuilder
|
|
|
12
12
|
autoload :ToolRegistry, "prompt_builder/tool_registry"
|
|
13
13
|
autoload :Usage, "prompt_builder/usage"
|
|
14
14
|
autoload :Serializers, "prompt_builder/serializers"
|
|
15
|
+
autoload :Streaming, "prompt_builder/streaming"
|
|
15
16
|
autoload :Tools, "prompt_builder/tools"
|
|
16
17
|
|
|
17
18
|
VERSION = File.read(File.join(__dir__, "../VERSION")).strip
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: prompt_builder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
@@ -46,19 +46,28 @@ files:
|
|
|
46
46
|
- lib/prompt_builder/serializers/chat_completion.rb
|
|
47
47
|
- lib/prompt_builder/serializers/chat_completion/request.rb
|
|
48
48
|
- lib/prompt_builder/serializers/chat_completion/response.rb
|
|
49
|
+
- lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb
|
|
49
50
|
- lib/prompt_builder/serializers/converse.rb
|
|
50
51
|
- lib/prompt_builder/serializers/converse/request.rb
|
|
51
52
|
- lib/prompt_builder/serializers/converse/response.rb
|
|
53
|
+
- lib/prompt_builder/serializers/converse/stream_accumulator.rb
|
|
52
54
|
- lib/prompt_builder/serializers/gemini.rb
|
|
53
55
|
- lib/prompt_builder/serializers/gemini/request.rb
|
|
54
56
|
- lib/prompt_builder/serializers/gemini/response.rb
|
|
57
|
+
- lib/prompt_builder/serializers/gemini/stream_accumulator.rb
|
|
55
58
|
- lib/prompt_builder/serializers/messages.rb
|
|
56
59
|
- lib/prompt_builder/serializers/messages/request.rb
|
|
57
60
|
- lib/prompt_builder/serializers/messages/response.rb
|
|
61
|
+
- lib/prompt_builder/serializers/messages/stream_accumulator.rb
|
|
58
62
|
- lib/prompt_builder/serializers/open_responses.rb
|
|
59
63
|
- lib/prompt_builder/serializers/open_responses/request.rb
|
|
60
64
|
- lib/prompt_builder/serializers/open_responses/response.rb
|
|
65
|
+
- lib/prompt_builder/serializers/open_responses/stream_accumulator.rb
|
|
61
66
|
- lib/prompt_builder/session.rb
|
|
67
|
+
- lib/prompt_builder/streaming.rb
|
|
68
|
+
- lib/prompt_builder/streaming/accumulator.rb
|
|
69
|
+
- lib/prompt_builder/streaming/event_stream_decoder.rb
|
|
70
|
+
- lib/prompt_builder/streaming/sse_parser.rb
|
|
62
71
|
- lib/prompt_builder/tool_registry.rb
|
|
63
72
|
- lib/prompt_builder/tools.rb
|
|
64
73
|
- lib/prompt_builder/tools/definition.rb
|