prompt_builder 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +86 -17
  4. data/VERSION +1 -1
  5. data/lib/prompt_builder/errors.rb +9 -1
  6. data/lib/prompt_builder/response.rb +34 -2
  7. data/lib/prompt_builder/serializers/base.rb +55 -1
  8. data/lib/prompt_builder/serializers/chat_completion/request.rb +20 -6
  9. data/lib/prompt_builder/serializers/chat_completion/response.rb +3 -1
  10. data/lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb +143 -0
  11. data/lib/prompt_builder/serializers/chat_completion.rb +1 -8
  12. data/lib/prompt_builder/serializers/converse/request.rb +195 -23
  13. data/lib/prompt_builder/serializers/converse/response.rb +35 -2
  14. data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
  15. data/lib/prompt_builder/serializers/converse.rb +1 -8
  16. data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
  17. data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
  18. data/lib/prompt_builder/serializers/gemini.rb +1 -8
  19. data/lib/prompt_builder/serializers/messages/request.rb +95 -33
  20. data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
  21. data/lib/prompt_builder/serializers/messages.rb +1 -8
  22. data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
  23. data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
  24. data/lib/prompt_builder/serializers/open_responses.rb +1 -8
  25. data/lib/prompt_builder/session.rb +49 -31
  26. data/lib/prompt_builder/streaming/accumulator.rb +169 -0
  27. data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
  28. data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
  29. data/lib/prompt_builder/streaming.rb +14 -0
  30. data/lib/prompt_builder.rb +1 -0
  31. metadata +10 -1
@@ -7,6 +7,7 @@ module PromptBuilder
7
7
  class Converse < Base
8
8
  autoload :Request, File.expand_path("converse/request", __dir__)
9
9
  autoload :Response, File.expand_path("converse/response", __dir__)
10
+ autoload :StreamAccumulator, File.expand_path("converse/stream_accumulator", __dir__)
10
11
 
11
12
  class << self
12
13
  # Export a session to Converse request payload.
@@ -16,14 +17,6 @@ module PromptBuilder
16
17
  def request_payload(session)
17
18
  Request.request_payload(session)
18
19
  end
19
-
20
- # Parse a Converse response into a PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Converse 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
@@ -23,6 +23,9 @@ module PromptBuilder
23
23
  # - +parallel_tool_calls+ — parallel tool call control is not supported
24
24
  # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
25
25
  # - +safety_identifier+ — no equivalent user-safety field on the generate endpoint
26
+ # - +stream+ — streaming is selected by endpoint (+streamGenerateContent+),
27
+ # not a request body field; Gemini::StreamAccumulator (or
28
+ # Response.parse_stream) reassembles the streamed response
26
29
  # - +stream_options+ — stream event options are not supported
27
30
  # - +truncation+ — server-side context truncation is not supported
28
31
  #
@@ -52,8 +55,13 @@ module PromptBuilder
52
55
  #
53
56
  # The following Gemini parameters cannot be set through the Open Responses
54
57
  # canonical format:
55
- # - +thinkingConfig.thinkingBudget+ — use +reasoning.budget_tokens+ instead
58
+ # - +thinkingConfig.thinkingBudget+ — use +reasoning.budget_tokens+ instead;
59
+ # +reasoning.type: "disabled"+ (Session#think(false)) is sent as
60
+ # +thinkingBudget: 0+ in place of any budget or effort, and models
61
+ # that cannot turn thinking off reject it
56
62
  # - +thinkingConfig.thinkingLevel+ — use +reasoning.effort+ instead
63
+ # (levels are upcased and forwarded as is; combining it with
64
+ # +reasoning.budget_tokens+ raises, since the API rejects both together)
57
65
  # - +thinkingConfig.includeThoughts+ — use +reasoning.summary = "auto"+ instead
58
66
  # - +topK+ — top-K sampling parameter
59
67
  # - +seed+ — for reproducible outputs (model-dependent)
@@ -77,7 +85,6 @@ module PromptBuilder
77
85
  # - +cachedContent+ — referencing a CachedContent resource by name
78
86
  # - Top-level +labels+ (Vertex flavor only)
79
87
  class Request < Base
80
- SUPPORTED_REASONING_EFFORTS = %w[minimal low medium high].freeze
81
88
  SUPPORTED_REASONING_SUMMARIES = %w[auto].freeze
82
89
  SUPPORTED_SERVICE_TIERS = %w[unspecified standard flex priority].freeze
83
90
 
@@ -493,14 +500,25 @@ module PromptBuilder
493
500
  # Unsupported reasoning.* keys are silently omitted.
494
501
  thinking_config = {}
495
502
 
496
- if session.reasoning["budget_tokens"]
497
- thinking_config["thinkingBudget"] = session.reasoning["budget_tokens"]
503
+ if reasoning_disabled?(session.reasoning)
504
+ # A zero budget turns thinking off. It replaces any budget or
505
+ # effort set alongside it, since the API rejects a budget
506
+ # together with a level. Models that cannot turn thinking off
507
+ # reject it.
508
+ thinking_config["thinkingBudget"] = 0
509
+ else
510
+ if session.reasoning["budget_tokens"]
511
+ thinking_config["thinkingBudget"] = session.reasoning["budget_tokens"]
512
+ end
513
+
514
+ effort = session.reasoning["effort"]
515
+ # Effort levels are forwarded as is; the levels a model accepts vary.
516
+ thinking_config["thinkingLevel"] = effort.to_s.upcase if effort
498
517
  end
499
518
 
500
- effort = session.reasoning["effort"]
501
- # Unsupported effort levels are silently omitted.
502
- if effort && SUPPORTED_REASONING_EFFORTS.include?(effort)
503
- thinking_config["thinkingLevel"] = effort.upcase
519
+ if thinking_config.key?("thinkingBudget") && thinking_config.key?("thinkingLevel")
520
+ raise UnsupportedFormatError,
521
+ "Gemini format does not support reasoning.budget_tokens together with reasoning.effort"
504
522
  end
505
523
 
506
524
  summary = session.reasoning["summary"]
@@ -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.
@@ -16,14 +17,6 @@ module PromptBuilder
16
17
  def request_payload(session)
17
18
  Request.request_payload(session)
18
19
  end
19
-
20
- # Parse a Gemini response into a PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Gemini 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
@@ -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.
@@ -16,14 +17,6 @@ module PromptBuilder
16
17
  def request_payload(session)
17
18
  Request.request_payload(session)
18
19
  end
19
-
20
- # Parse a Messages response into an PromptBuilder::Response.
21
- #
22
- # @param hash [Hash] the response hash in Messages 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