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
|
@@ -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+ — +
|
|
34
|
-
# +
|
|
35
|
-
#
|
|
36
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
252
|
+
validate_thinking!(thinking)
|
|
206
253
|
|
|
207
|
-
|
|
208
|
-
|
|
254
|
+
thinking
|
|
255
|
+
end
|
|
209
256
|
|
|
210
|
-
|
|
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
|
|
278
|
+
"Messages format requires reasoning.budget_tokens when reasoning.type is enabled"
|
|
213
279
|
end
|
|
214
280
|
|
|
215
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:,
|
|
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
|
|
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.
|