prompt_builder 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +79 -17
  4. data/VERSION +1 -1
  5. data/lib/prompt_builder/errors.rb +9 -1
  6. data/lib/prompt_builder/response.rb +24 -0
  7. data/lib/prompt_builder/serializers/base.rb +46 -0
  8. data/lib/prompt_builder/serializers/chat_completion/request.rb +20 -6
  9. data/lib/prompt_builder/serializers/chat_completion/response.rb +3 -1
  10. data/lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb +143 -0
  11. data/lib/prompt_builder/serializers/chat_completion.rb +1 -0
  12. data/lib/prompt_builder/serializers/converse/request.rb +195 -23
  13. data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
  14. data/lib/prompt_builder/serializers/converse.rb +1 -0
  15. data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
  16. data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
  17. data/lib/prompt_builder/serializers/gemini.rb +1 -0
  18. data/lib/prompt_builder/serializers/messages/request.rb +95 -33
  19. data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
  20. data/lib/prompt_builder/serializers/messages.rb +1 -0
  21. data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
  22. data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
  23. data/lib/prompt_builder/serializers/open_responses.rb +1 -0
  24. data/lib/prompt_builder/session.rb +49 -31
  25. data/lib/prompt_builder/streaming/accumulator.rb +169 -0
  26. data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
  27. data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
  28. data/lib/prompt_builder/streaming.rb +14 -0
  29. data/lib/prompt_builder.rb +1 -0
  30. metadata +10 -1
@@ -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
- # - Converse supports neither and warns once at serialization time
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
- # Pass either +effort+ or +budget_tokens+, not both — providers reject
471
- # requests that set both controls. Direct +session.reasoning = {...}+
472
- # assignment keeps working for provider-specific keys.
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 clear the reasoning configuration
475
- # @param effort [String, Symbol, nil] a portable effort level; one of
476
- # +minimal+, +low+, +medium+, +high+, +xhigh+, +max+ (unsupported levels
477
- # are omitted by serializers whose target API does not accept them)
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, nil] the resulting +reasoning+ configuration
480
- # @raise [ArgumentError] if neither or both of +effort+ and +budget_tokens+
481
- # are given when enabling, or the values are invalid
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) # disable
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 effort or budget_tokens" if effort || budget_tokens
510
+ raise ArgumentError, "cannot combine think(false) with budget_tokens" if budget_tokens
489
511
 
490
- return self.reasoning = nil
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
- effort = effort.to_s
501
- unless THINK_EFFORT_LEVELS.include?(effort)
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
@@ -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.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