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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +86 -17
- data/VERSION +1 -1
- data/lib/prompt_builder/errors.rb +9 -1
- data/lib/prompt_builder/response.rb +34 -2
- data/lib/prompt_builder/serializers/base.rb +55 -1
- 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 -8
- data/lib/prompt_builder/serializers/converse/request.rb +195 -23
- data/lib/prompt_builder/serializers/converse/response.rb +35 -2
- data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
- data/lib/prompt_builder/serializers/converse.rb +1 -8
- 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 -8
- 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 -8
- 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 -8
- 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
|
@@ -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
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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+ — +
|
|
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.
|
|
@@ -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
|