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
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class Gemini < Base
6
+ # Reassembles a Gemini +streamGenerateContent+ stream into the
7
+ # +GenerateContentResponse+ the non-streaming +generateContent+ endpoint
8
+ # would have returned.
9
+ #
10
+ # With +alt=sse+ each event is a complete +GenerateContentResponse+
11
+ # chunk. Candidates are merged by +index+: their parts are concatenated
12
+ # in order, and a text part that starts a chunk continues the text part
13
+ # that ended the previous chunk when both are the same kind (thought or
14
+ # not), keeping the +thoughtSignature+; two parts that both carry a
15
+ # signature stay separate. Parts within one chunk are distinct parts and
16
+ # are never merged with each other. A part with only a
17
+ # +thoughtSignature+ and no text is the signature for the text before it,
18
+ # so it is attached to that text part. +functionCall+ and other parts
19
+ # arrive whole. +citationMetadata.citationSources+ and the
20
+ # +logprobsResult+ token lists are concatenated (with +logProbabilitySum+
21
+ # recomputed from the chosen tokens), and every other field
22
+ # (+finishReason+, +usageMetadata+, +modelVersion+, +responseId+, ...) is
23
+ # taken from the last chunk that has it.
24
+ #
25
+ # Without +alt=sse+ the endpoint returns a JSON array of the same chunks;
26
+ # it is buffered whole and merged the same way.
27
+ #
28
+ # Gemini sends no terminal event, so a stream whose candidates have no
29
+ # +finishReason+ (and that was not blocked by +promptFeedback.blockReason+)
30
+ # is incomplete. A chunk with an +error+ raises an ErrorResponseError.
31
+ class StreamAccumulator < Streaming::Accumulator
32
+ TEXT_PART_KEYS = %w[text thought thoughtSignature].freeze
33
+ private_constant :TEXT_PART_KEYS
34
+
35
+ LOGPROBS_TOKEN_LISTS = %w[topCandidates chosenCandidates].freeze
36
+ private_constant :LOGPROBS_TOKEN_LISTS
37
+
38
+ def initialize
39
+ super
40
+ @response = {}
41
+ @candidates = {}
42
+ end
43
+
44
+ private
45
+
46
+ def handle_event(event)
47
+ merge_chunk(parse_json(event.data))
48
+ end
49
+
50
+ def collapse_json(value)
51
+ return value unless value.is_a?(Array)
52
+
53
+ value.each { |chunk| merge_chunk(chunk) }
54
+ build_result
55
+ end
56
+
57
+ def merge_chunk(chunk)
58
+ return unless chunk.is_a?(Hash)
59
+
60
+ if chunk["error"].is_a?(Hash)
61
+ error = chunk["error"]
62
+ raise_error_event!([error["status"] || error["code"], error["message"]].compact.join(": "))
63
+ end
64
+
65
+ chunk.each do |key, value|
66
+ next if value.nil?
67
+
68
+ if key == "candidates"
69
+ value.each_with_index { |candidate, position| merge_candidate(candidate, position) if candidate.is_a?(Hash) }
70
+ else
71
+ @response[key] = value
72
+ end
73
+ end
74
+ end
75
+
76
+ def merge_candidate(candidate, position)
77
+ merged = @candidates[candidate["index"] || position] ||= {}
78
+ candidate.each do |key, value|
79
+ next if value.nil?
80
+
81
+ case key
82
+ when "content"
83
+ merge_content(merged, value) if value.is_a?(Hash)
84
+ when "citationMetadata"
85
+ merge_citation_metadata(merged, value) if value.is_a?(Hash)
86
+ when "logprobsResult"
87
+ merge_logprobs_result(merged, value) if value.is_a?(Hash)
88
+ else
89
+ merged[key] = value
90
+ end
91
+ end
92
+ end
93
+
94
+ def merge_content(merged, content)
95
+ target = merged["content"] ||= {"parts" => []}
96
+ content.each { |key, value| target[key] = value unless key == "parts" || value.nil? }
97
+ parts = (content["parts"] || []).select { |part| part.is_a?(Hash) }
98
+ parts.each_with_index { |part, position| append_part(target["parts"], part, continuation: position.zero?) }
99
+ end
100
+
101
+ # Only the first part of a chunk can continue text from the previous
102
+ # chunk; later parts in the same chunk are separate parts in the
103
+ # response, so they keep their own boundaries and signatures.
104
+ def append_part(parts, part, continuation:)
105
+ last = parts.last
106
+ if signature_only?(part) && text_part?(last) && !last["thoughtSignature"]
107
+ last["thoughtSignature"] = part["thoughtSignature"]
108
+ elsif continuation && mergeable_text?(last, part)
109
+ last["text"] << part["text"]
110
+ last["thoughtSignature"] = part["thoughtSignature"] if part["thoughtSignature"]
111
+ elsif text_part?(part)
112
+ parts << part.merge("text" => +part["text"])
113
+ else
114
+ parts << part
115
+ end
116
+ end
117
+
118
+ def mergeable_text?(last, part)
119
+ return false unless text_part?(last) && text_part?(part)
120
+ return false unless !!last["thought"] == !!part["thought"]
121
+
122
+ !(last["thoughtSignature"] && part["thoughtSignature"])
123
+ end
124
+
125
+ def text_part?(part)
126
+ part.is_a?(Hash) && part["text"].is_a?(String) && (part.keys - TEXT_PART_KEYS).empty?
127
+ end
128
+
129
+ def signature_only?(part)
130
+ part["thoughtSignature"].is_a?(String) && !part.key?("text") && (part.keys - TEXT_PART_KEYS).empty?
131
+ end
132
+
133
+ def merge_citation_metadata(merged, metadata)
134
+ target = merged["citationMetadata"] ||= {}
135
+ metadata.each do |key, value|
136
+ if key == "citationSources" && value.is_a?(Array)
137
+ (target[key] ||= []).concat(value)
138
+ elsif !value.nil?
139
+ target[key] = value
140
+ end
141
+ end
142
+ end
143
+
144
+ # Each chunk reports log probabilities for its own tokens, so the
145
+ # token lists are concatenated in order. The sum is recomputed for the
146
+ # whole candidate once the stream ends (see #recompute_logprobs_sum).
147
+ def merge_logprobs_result(merged, result)
148
+ target = merged["logprobsResult"] ||= {}
149
+ result.each do |key, value|
150
+ if LOGPROBS_TOKEN_LISTS.include?(key) && value.is_a?(Array)
151
+ (target[key] ||= []).concat(value)
152
+ elsif !value.nil?
153
+ target[key] = value
154
+ end
155
+ end
156
+ end
157
+
158
+ def recompute_logprobs_sum(candidate)
159
+ result = candidate["logprobsResult"]
160
+ return unless result&.key?("logProbabilitySum")
161
+
162
+ chosen = result["chosenCandidates"]
163
+ return unless chosen
164
+
165
+ result["logProbabilitySum"] = chosen.sum { |token| token.is_a?(Hash) ? token["logProbability"].to_f : 0.0 }
166
+ end
167
+
168
+ def build_result
169
+ candidates = @candidates.sort_by { |index, _| index.to_i }.map { |_, candidate| candidate }
170
+ blocked = @response.dig("promptFeedback", "blockReason")
171
+ finished = !candidates.empty? && candidates.all? { |candidate| finished_candidate?(candidate) }
172
+ incomplete!("Gemini stream ended without a finishReason") unless blocked || finished
173
+
174
+ candidates.each { |candidate| recompute_logprobs_sum(candidate) }
175
+ @response.merge("candidates" => candidates)
176
+ end
177
+
178
+ # FINISH_REASON_UNSPECIFIED is the API's default value, which the
179
+ # response parser treats as no finish reason, so it does not mark a
180
+ # candidate as finished either.
181
+ def finished_candidate?(candidate)
182
+ reason = candidate["finishReason"]
183
+ !reason.nil? && reason != "FINISH_REASON_UNSPECIFIED"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -7,6 +7,7 @@ module PromptBuilder
7
7
  class Gemini < Base
8
8
  autoload :Request, File.expand_path("gemini/request", __dir__)
9
9
  autoload :Response, File.expand_path("gemini/response", __dir__)
10
+ autoload :StreamAccumulator, File.expand_path("gemini/stream_accumulator", __dir__)
10
11
 
11
12
  class << self
12
13
  # Export a session to Gemini request payload.
@@ -30,10 +30,20 @@ module PromptBuilder
30
30
  # also mapped into +metadata.user_id+ automatically
31
31
  # - +service_tier+ — only +auto+ and +standard_only+ are accepted
32
32
  # - +text+ — +format.type=json_schema+ is mapped to +output_config.format+
33
- # - +reasoning+ — +budget_tokens+, +display+, +effort+, and +type+ are forwarded;
34
- # +temperature+ must be unset, +top_p+ must be >= 0.95, and
35
- # +max_output_tokens+ must be greater than +budget_tokens+ when reasoning
36
- # is enabled
33
+ # - +reasoning+ — +type+, +budget_tokens+, and +display+ are forwarded to
34
+ # +thinking+ and +effort+ to +output_config.effort+. A configuration
35
+ # that asks for thinking emits a +thinking+ parameter, since omitting
36
+ # it leaves the thinking mode to the model instead of turning thinking
37
+ # on: +thinking.type+ defaults to +enabled+ when +budget_tokens+ is set
38
+ # and to +adaptive+ for +display+, an empty configuration (bare
39
+ # Session#think), or an +effort+ level. Adaptive thinking is not
40
+ # inferred from +effort+ alongside +temperature+, a +top_p+ below 0.95,
41
+ # or a forced +tool_choice+; the effort is sent on its own. Effort
42
+ # levels are forwarded as is. +type: "disabled"+ (Session#think(false))
43
+ # is sent as +thinking.type: "disabled"+, alongside any effort, and
44
+ # does not count as thinking. When thinking is on, +temperature+ must
45
+ # be unset, +top_p+ must be >= 0.95, and +max_output_tokens+ must be
46
+ # greater than +budget_tokens+
37
47
  #
38
48
  # Input content restrictions:
39
49
  # - System and developer messages are hoisted out of conversational order
@@ -60,6 +70,23 @@ module PromptBuilder
60
70
  # - +FunctionCallOutput.status+ values +incomplete+, +failed+, and +error+
61
71
  # are mapped to +tool_result.is_error: true+
62
72
  #
73
+ # === Model-dependent parameters
74
+ #
75
+ # Which values the API accepts for these depends on the model, and the
76
+ # serialized payload carries no model capability information, so beyond
77
+ # the thinking constraints above they are forwarded without validation:
78
+ # - +temperature+ and +top_p+ — rejected outright by some models and
79
+ # accepted only at their default values by others
80
+ # - +reasoning.type+ — +adaptive+ is accepted wherever adaptive thinking
81
+ # exists and +enabled+ (with a budget) only by models predating it;
82
+ # +disabled+ is rejected by some models and restricted to the lower
83
+ # effort levels by others; +between_tools+ is only offered by some
84
+ # models as their lowest thinking setting
85
+ # - +reasoning.effort+ — the levels a model accepts, alone or combined
86
+ # with a +reasoning.type+, vary by model
87
+ # - Forced +tool_choice+ (+any+/+tool+ type) — rejected by some models
88
+ # regardless of the thinking configuration
89
+ #
63
90
  # === Features in the Messages API not available through Open Responses
64
91
  #
65
92
  # The following Messages API parameters cannot be set through the Open Responses
@@ -84,8 +111,6 @@ module PromptBuilder
84
111
  # request — set headers in your HTTP client)
85
112
  class Request < Base
86
113
  SUPPORTED_METADATA_KEYS = ["user_id"].freeze
87
- EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"].freeze
88
- SUPPORTED_THINKING_TYPES = ["adaptive", "disabled", "enabled"].freeze
89
114
  SUPPORTED_TOOL_CHOICE_TYPES = ["any", "auto", "none", "tool"].freeze
90
115
 
91
116
  # Citation types accepted by the Messages API on text blocks. Annotations
@@ -127,7 +152,9 @@ module PromptBuilder
127
152
  output_config = build_output_config(session)
128
153
  h["output_config"] = output_config unless output_config.empty?
129
154
 
130
- thinking = serialize_thinking(session.reasoning)
155
+ tools = build_tools(session)
156
+
157
+ thinking = serialize_thinking(session, tools)
131
158
  validate_thinking_compatibility!(session, thinking) if thinking_active?(thinking)
132
159
  h["thinking"] = thinking if thinking
133
160
 
@@ -145,14 +172,13 @@ module PromptBuilder
145
172
  end
146
173
  h["messages"] = messages
147
174
 
148
- tools = build_tools(session)
149
175
  h["tools"] = tools unless tools.empty?
150
176
 
151
177
  tool_choice = serialize_tool_choice(
152
178
  session.tool_choice,
153
179
  tools: tools,
154
180
  parallel_tool_calls: session.parallel_tool_calls,
155
- thinking_enabled: thinking_active?(thinking)
181
+ thinking_active: thinking_active?(thinking)
156
182
  )
157
183
  h["tool_choice"] = tool_choice if tool_choice
158
184
 
@@ -194,37 +220,77 @@ module PromptBuilder
194
220
  service_tier if ["auto", "standard_only"].include?(service_tier)
195
221
  end
196
222
 
197
- def serialize_thinking(reasoning)
223
+ # A reasoning configuration that asks for thinking emits a +thinking+
224
+ # parameter, since omitting it leaves the mode to the model rather
225
+ # than turning thinking on. An explicit +type+ is forwarded as is;
226
+ # otherwise the type is inferred: +enabled+ for a +budget_tokens+
227
+ # budget (the only mode with a fixed budget), and +adaptive+ for
228
+ # +display+, for the empty configuration stored by a bare
229
+ # Session#think, or for an +effort+ level.
230
+ #
231
+ # Effort is also a valid control without thinking, so adaptive
232
+ # thinking is only inferred from effort when the payload can accept
233
+ # it; alongside sampling parameters or a forced tool_choice the effort
234
+ # is sent on its own. Configurations without any of these keys (e.g.
235
+ # one holding only another provider's +summary+ key) emit nothing.
236
+ def serialize_thinking(session, tools)
237
+ reasoning = session.reasoning
198
238
  return nil unless reasoning
199
239
 
200
- thinking = {}
201
- thinking["budget_tokens"] = reasoning["budget_tokens"] if reasoning.key?("budget_tokens")
240
+ type = reasoning["type"]
241
+ type ||= "enabled" unless reasoning["budget_tokens"].nil?
242
+ type ||= "adaptive" if reasoning.empty? || reasoning["display"]
243
+ if type.nil? && reasoning["effort"] && !thinking_conflicts?(session, tools)
244
+ type = "adaptive"
245
+ end
246
+ return nil unless type
247
+
248
+ thinking = {"type" => type}
249
+ thinking["budget_tokens"] = reasoning["budget_tokens"] unless reasoning["budget_tokens"].nil?
202
250
  thinking["display"] = reasoning["display"] if reasoning["display"]
203
- return nil if thinking.empty? && !reasoning["type"]
204
251
 
205
- thinking["type"] = reasoning["type"] || "enabled"
252
+ validate_thinking!(thinking)
206
253
 
207
- # Unsupported reasoning.type values are silently omitted.
208
- return nil unless SUPPORTED_THINKING_TYPES.include?(thinking["type"])
254
+ thinking
255
+ end
209
256
 
210
- if thinking["type"] == "enabled" && !thinking.key?("budget_tokens")
257
+ # Whether the payload sets anything thinking is incompatible with.
258
+ def thinking_conflicts?(session, tools)
259
+ return true if session.temperature
260
+ return true if session.top_p && session.top_p < 0.95
261
+ return false if tools.empty?
262
+
263
+ forced_tool_choice?(normalize_tool_choice(session.tool_choice))
264
+ end
265
+
266
+ def forced_tool_choice?(choice)
267
+ ["any", "tool"].include?(choice&.dig("type"))
268
+ end
269
+
270
+ # Validate the parts of the thinking contract that follow from the
271
+ # payload alone. Which types and effort levels a model accepts is
272
+ # model-dependent and is not validated here.
273
+ def validate_thinking!(thinking)
274
+ type = thinking["type"]
275
+
276
+ if type == "enabled" && !thinking.key?("budget_tokens")
211
277
  raise UnsupportedFormatError,
212
- "Messages format requires reasoning.budget_tokens when thinking is enabled"
278
+ "Messages format requires reasoning.budget_tokens when reasoning.type is enabled"
213
279
  end
214
280
 
215
- if thinking["type"] != "enabled" && thinking.key?("budget_tokens")
281
+ if type != "enabled" && thinking.key?("budget_tokens")
216
282
  raise UnsupportedFormatError,
217
283
  "Messages format only supports reasoning.budget_tokens when reasoning.type is enabled"
218
284
  end
219
285
 
220
- if thinking["type"] == "disabled" && thinking.key?("display")
221
- raise UnsupportedFormatError,
222
- "Messages format does not support reasoning.display when reasoning.type is disabled"
223
- end
286
+ return unless ["between_tools", "disabled"].include?(type) && thinking.key?("display")
224
287
 
225
- thinking
288
+ raise UnsupportedFormatError,
289
+ "Messages format does not support reasoning.display when reasoning.type is #{type}"
226
290
  end
227
291
 
292
+ # Whether the request turns thinking on, which constrains sampling
293
+ # parameters and forced tool choice.
228
294
  def thinking_active?(thinking)
229
295
  thinking && thinking["type"] != "disabled"
230
296
  end
@@ -232,11 +298,9 @@ module PromptBuilder
232
298
  def build_output_config(session)
233
299
  output_config = {}
234
300
 
235
- if session.reasoning && session.reasoning["effort"]
236
- effort = session.reasoning["effort"]
237
- # Unsupported effort levels are silently omitted.
238
- output_config["effort"] = effort if EFFORT_LEVELS.include?(effort)
239
- end
301
+ # Effort levels are forwarded as is; the levels a model accepts vary.
302
+ effort = session.reasoning["effort"] if session.reasoning
303
+ output_config["effort"] = effort if effort
240
304
 
241
305
  if session.text
242
306
  # Unsupported text.* keys are silently omitted; only format is mapped.
@@ -264,8 +328,6 @@ module PromptBuilder
264
328
  end
265
329
 
266
330
  def validate_thinking_compatibility!(session, thinking)
267
- return unless thinking
268
-
269
331
  budget = thinking["budget_tokens"]
270
332
  if budget && session.max_output_tokens && session.max_output_tokens <= budget
271
333
  raise UnsupportedFormatError,
@@ -569,7 +631,7 @@ module PromptBuilder
569
631
  end
570
632
  end
571
633
 
572
- def serialize_tool_choice(choice, tools:, parallel_tool_calls:, thinking_enabled:)
634
+ def serialize_tool_choice(choice, tools:, parallel_tool_calls:, thinking_active:)
573
635
  if choice.nil?
574
636
  return nil if parallel_tool_calls.nil?
575
637
 
@@ -591,7 +653,7 @@ module PromptBuilder
591
653
  # tool_choice cannot be expressed without tools; omit it.
592
654
  return nil if tools.empty? && normalized_choice["type"] != "none"
593
655
 
594
- if thinking_enabled && ["any", "tool"].include?(normalized_choice["type"])
656
+ if thinking_active && forced_tool_choice?(normalized_choice)
595
657
  raise UnsupportedFormatError,
596
658
  "Messages format does not support forced tool_choice when thinking is enabled"
597
659
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class Messages < Base
6
+ # Reassembles an Anthropic Messages SSE stream into the message object
7
+ # the non-streaming endpoint would have returned.
8
+ #
9
+ # +message_start+ provides the base message. Content blocks are tracked
10
+ # by index: +text_delta+ and +citations_delta+ build text blocks,
11
+ # +thinking_delta+ and +signature_delta+ build thinking blocks, and
12
+ # +input_json_delta+ fragments are joined and parsed as the block's
13
+ # +input+ when it stops. Blocks that arrive whole in
14
+ # +content_block_start+ (e.g. +redacted_thinking+ and server tool
15
+ # results) are kept as is. +message_delta+ provides the stop reason and
16
+ # cumulative usage, which is merged into the +message_start+ usage.
17
+ #
18
+ # The stream must end with +message_stop+; an +error+ event raises an
19
+ # ErrorResponseError. +ping+ and unknown events are ignored.
20
+ class StreamAccumulator < Streaming::Accumulator
21
+ def initialize
22
+ super
23
+ @message = {}
24
+ @started = false
25
+ @blocks = {}
26
+ @input_json = {}
27
+ @stopped = false
28
+ end
29
+
30
+ private
31
+
32
+ def handle_event(event)
33
+ data = parse_json(event.data)
34
+ return unless data.is_a?(Hash)
35
+
36
+ case data["type"] || event.event
37
+ when "message_start"
38
+ @message = data["message"] || {}
39
+ @started = true
40
+ when "content_block_start"
41
+ @blocks[data["index"]] = data["content_block"] || {}
42
+ when "content_block_delta"
43
+ apply_delta(data["index"], data["delta"] || {})
44
+ when "content_block_stop"
45
+ finish_block(data["index"])
46
+ when "message_delta"
47
+ apply_message_delta(data)
48
+ when "message_stop"
49
+ @stopped = true
50
+ when "error"
51
+ error = data["error"].is_a?(Hash) ? data["error"] : {}
52
+ raise_error_event!([error["type"], error["message"]].compact.join(": "))
53
+ end
54
+ end
55
+
56
+ def apply_delta(index, delta)
57
+ case delta["type"]
58
+ when "text_delta"
59
+ block = @blocks[index] ||= {"type" => "text"}
60
+ append(block, "text", delta["text"])
61
+ when "citations_delta"
62
+ block = @blocks[index] ||= {"type" => "text"}
63
+ (block["citations"] ||= []) << delta["citation"]
64
+ when "thinking_delta"
65
+ block = @blocks[index] ||= {"type" => "thinking"}
66
+ append(block, "thinking", delta["thinking"])
67
+ when "signature_delta"
68
+ block = @blocks[index] ||= {"type" => "thinking"}
69
+ append(block, "signature", delta["signature"])
70
+ when "input_json_delta"
71
+ (@input_json[index] ||= +"") << delta["partial_json"].to_s
72
+ end
73
+ end
74
+
75
+ def append(block, key, value)
76
+ block[key] = block[key] ? +block[key] : +""
77
+ block[key] << value.to_s
78
+ end
79
+
80
+ def finish_block(index)
81
+ json = @input_json.delete(index)
82
+ return unless json
83
+
84
+ block = @blocks[index] ||= {}
85
+ block["input"] = json.empty? ? {} : parse_json(json)
86
+ end
87
+
88
+ def apply_message_delta(data)
89
+ (data["delta"] || {}).each { |key, value| @message[key] = value }
90
+
91
+ usage = data["usage"]
92
+ return unless usage.is_a?(Hash)
93
+
94
+ merged = (@message["usage"] || {}).dup
95
+ usage.each { |key, value| merged[key] = value unless value.nil? }
96
+ @message["usage"] = merged
97
+ end
98
+
99
+ def build_result
100
+ incomplete!("Messages stream ended without a message_stop event") unless @stopped
101
+ incomplete!("Messages stream ended without a message_start event") unless @started
102
+
103
+ @input_json.keys.each { |index| finish_block(index) }
104
+ content = @blocks.sort_by { |index, _| index.to_i }.map { |_, block| block }
105
+ @message.merge("content" => content)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -7,6 +7,7 @@ module PromptBuilder
7
7
  class Messages < Base
8
8
  autoload :Request, File.expand_path("messages/request", __dir__)
9
9
  autoload :Response, File.expand_path("messages/response", __dir__)
10
+ autoload :StreamAccumulator, File.expand_path("messages/stream_accumulator", __dir__)
10
11
 
11
12
  class << self
12
13
  # Export a session to Messages request payload.
@@ -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.