prompt_builder 0.1.2 → 0.3.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.
@@ -16,6 +16,18 @@ module PromptBuilder
16
16
  class << self
17
17
  private
18
18
 
19
+ # OpenAI errors arrive as {"error" => {"message" => ..., "type" =>
20
+ # ..., "code" => ...}}; some compatible servers return a bare string
21
+ # under "error".
22
+ def error_response_message(hash)
23
+ error = hash["error"]
24
+ return nil if error.nil? || hash.key?("choices")
25
+
26
+ return error.to_s unless error.is_a?(Hash)
27
+
28
+ [error["code"] || error["type"], error["message"]].compact.join(": ")
29
+ end
30
+
19
31
  def deserialize_response(hash)
20
32
  validate_response!(hash)
21
33
 
@@ -8,6 +8,9 @@ module PromptBuilder
8
8
  class Converse < Base
9
9
  # Request serializer for the Amazon Bedrock Converse API format.
10
10
  #
11
+ # Required session fields (an UnsupportedFormatError is raised when missing):
12
+ # - +model+ — populates the required +modelId+ parameter
13
+ #
11
14
  # === Unsupported Open Responses features
12
15
  #
13
16
  # These session fields are not supported and are silently omitted from the
@@ -19,7 +22,8 @@ module PromptBuilder
19
22
  # - +parallel_tool_calls+ — parallel tool call control is not supported
20
23
  # - +presence_penalty+ — not supported by the Converse API
21
24
  # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
22
- # - +reasoning+ — extended thinking is not supported on the Converse endpoint
25
+ # - +reasoning+ — extended thinking is not supported on the Converse endpoint;
26
+ # a warning is issued (once per process) when +session.reasoning+ is set
23
27
  # - +safety_identifier+ — no equivalent user-safety field on the Converse endpoint
24
28
  # - +store+ — server-side response storage is not supported
25
29
  # - +stream+ — SSE streaming is handled outside the Converse request payload
@@ -35,6 +39,10 @@ module PromptBuilder
35
39
  # - +tool_choice: "none"+ has no Converse representation and is omitted
36
40
  #
37
41
  # Input content restrictions:
42
+ # - System and developer messages are hoisted out of conversational order
43
+ # into the top-level +system+ parameter, with +instructions+ merged last
44
+ # (instructions apply to the current request, matching Open Responses
45
+ # semantics)
38
46
  # - +Reasoning+ items are silently skipped
39
47
  # - +RefusalContent+ is dropped silently (a parsed response refusal can
40
48
  # stay in session history without breaking subsequent request_payload calls)
@@ -64,8 +72,11 @@ module PromptBuilder
64
72
  # - Prompt management variables (+prompt_variables+)
65
73
  #
66
74
  # Prompt caching markers (+cachePoint+) are emitted via the +cache_point+
67
- # content extra. Cross-region routing via inference profiles is selected
68
- # through the model id and needs no request field.
75
+ # extra on system/message content, tool definitions (appended to the
76
+ # +toolConfig.tools+ array), and +FunctionCallOutput+ items (appended to
77
+ # the message content after the +toolResult+ block). Cross-region routing
78
+ # via inference profiles is selected through the model id and needs no
79
+ # request field.
69
80
  class Request < Base
70
81
  IMAGE_MEDIA_TYPE_FORMATS = {
71
82
  "image/jpeg" => "jpeg",
@@ -110,6 +121,13 @@ module PromptBuilder
110
121
  }.freeze
111
122
 
112
123
  class << self
124
+ # Reset the once-per-process reasoning warning. Primarily used in tests.
125
+ #
126
+ # @return [void]
127
+ def reset_reasoning_warning!
128
+ @reasoning_warning_issued = false
129
+ end
130
+
113
131
  private
114
132
 
115
133
  def serialize_request(session)
@@ -118,7 +136,11 @@ module PromptBuilder
118
136
  ctx = {document_name_counts: Hash.new(0)}
119
137
 
120
138
  h = {}
121
- h["modelId"] = session.model if session.model
139
+ raise UnsupportedFormatError, "Converse format requires session.model" unless session.model
140
+
141
+ warn_reasoning_unsupported! if session.reasoning
142
+
143
+ h["modelId"] = session.model
122
144
 
123
145
  system = build_system(session)
124
146
  h["system"] = system unless system.empty?
@@ -149,11 +171,22 @@ module PromptBuilder
149
171
  h["toolConfig"] = tool_config if tool_config
150
172
 
151
173
  # Session extra: recognized keys for Converse API
152
- apply_session_extra!(h, session.extra) if session.extra
174
+ extra = session.extra
175
+ apply_session_extra!(h, extra) unless extra.empty?
153
176
 
154
177
  h
155
178
  end
156
179
 
180
+ # The Converse endpoint has no reasoning/extended thinking parameter,
181
+ # so the reasoning config is dropped. Warn once per process instead of
182
+ # dropping it silently.
183
+ def warn_reasoning_unsupported!
184
+ return if @reasoning_warning_issued
185
+
186
+ @reasoning_warning_issued = true
187
+ warn "PromptBuilder: session.reasoning is not supported by the Converse format and was omitted from the request payload"
188
+ end
189
+
157
190
  def apply_session_extra!(h, extra)
158
191
  if extra.key?("stop_sequences")
159
192
  inference_config = h["inferenceConfig"] ||= {}
@@ -169,8 +202,6 @@ module PromptBuilder
169
202
  def build_system(session)
170
203
  parts = []
171
204
 
172
- parts << {"text" => session.instructions} if session.instructions
173
-
174
205
  session.items.each do |item|
175
206
  next unless item.is_a?(Items::Message)
176
207
  next unless item.role == "system" || item.role == "developer"
@@ -186,6 +217,11 @@ module PromptBuilder
186
217
  end
187
218
  end
188
219
 
220
+ # Instructions come last: in the Open Responses API they apply to
221
+ # the current request, so they must follow any system messages
222
+ # accumulated in the conversation history.
223
+ parts << {"text" => session.instructions} if session.instructions
224
+
189
225
  parts
190
226
  end
191
227
 
@@ -224,10 +260,14 @@ module PromptBuilder
224
260
  }]
225
261
  }
226
262
  when Items::FunctionCallOutput
227
- raw_messages << {
228
- "role" => "user",
229
- "content" => [serialize_tool_result(item, ctx)]
230
- }
263
+ # The Converse ToolResultContentBlock union has no cachePoint
264
+ # member; the marker must be a sibling content block after the
265
+ # toolResult block.
266
+ content = [serialize_tool_result(item, ctx)]
267
+ if item.extra && item.extra["cache_point"]
268
+ content << {"cachePoint" => {"type" => "default"}}
269
+ end
270
+ raw_messages << {"role" => "user", "content" => content}
231
271
  when Items::Reasoning, Items::Compaction, Items::ItemReference
232
272
  # Reasoning, Compaction, and ItemReference items are not supported
233
273
  # in the request, so ignore them rather than raising an error.
@@ -240,7 +280,7 @@ module PromptBuilder
240
280
 
241
281
  def serialize_system_content(content)
242
282
  case content
243
- when Content::InputText
283
+ when Content::InputText, Content::Text
244
284
  {"text" => content.text}
245
285
  when Content::OutputText
246
286
  validate_output_text!(content)
@@ -268,7 +308,7 @@ module PromptBuilder
268
308
 
269
309
  def serialize_content(content, role:, ctx: nil)
270
310
  case content
271
- when Content::InputText, Content::OutputText
311
+ when Content::InputText, Content::OutputText, Content::Text
272
312
  validate_output_text!(content) if content.is_a?(Content::OutputText)
273
313
  {"text" => content.text}
274
314
  when Content::InputImage
@@ -487,7 +527,7 @@ module PromptBuilder
487
527
 
488
528
  def serialize_tool_result_content(content, ctx)
489
529
  case content
490
- when Content::InputText, Content::OutputText
530
+ when Content::InputText, Content::OutputText, Content::Text
491
531
  validate_output_text!(content) if content.is_a?(Content::OutputText)
492
532
  {"text" => content.text}
493
533
  when Content::InputImage
@@ -585,13 +625,17 @@ module PromptBuilder
585
625
  end
586
626
 
587
627
  def build_tools(session)
588
- session.tool_definitions.map do |definition|
628
+ session.tool_definitions.flat_map do |definition|
589
629
  tool_spec = {"name" => definition.name}
590
630
  tool_spec["description"] = definition.description if definition.description
591
631
  tool_spec["inputSchema"] = {
592
632
  "json" => definition.parameters || {"type" => "object", "properties" => {}}
593
633
  }
594
- {"toolSpec" => tool_spec}
634
+ tools = [{"toolSpec" => tool_spec}]
635
+ # The Converse Tool union type has a cachePoint member, so the
636
+ # marker is its own entry in the tools array.
637
+ tools << {"cachePoint" => {"type" => "default"}} if definition.extra["cache_point"]
638
+ tools
595
639
  end
596
640
  end
597
641
 
@@ -10,6 +10,21 @@ module PromptBuilder
10
10
  class << self
11
11
  private
12
12
 
13
+ # Bedrock exception bodies carry a top-level "message" (the exception
14
+ # class travels in the x-amzn-ErrorType header) or a top-level
15
+ # "__type", while misrouted requests get a Coral service envelope
16
+ # like {"Output" => {"__type" => "..."}, "Version" => "1.0"}.
17
+ def error_response_message(hash)
18
+ return nil if hash.key?("output")
19
+
20
+ coral_output = hash["Output"].is_a?(Hash) ? hash["Output"] : {}
21
+ error_type = hash["__type"] || coral_output["__type"]
22
+ message = hash["message"] || hash["Message"] || coral_output["message"] || coral_output["Message"]
23
+ return nil unless error_type || message
24
+
25
+ [error_type, message].compact.join(": ")
26
+ end
27
+
13
28
  def deserialize_response(hash)
14
29
  require_response_key!(hash, "output")
15
30
  require_response_key!(hash, "stopReason")
@@ -7,6 +7,11 @@ module PromptBuilder
7
7
  class Gemini < Base
8
8
  # Request serializer for the Google Gemini API format.
9
9
  #
10
+ # Required session fields (an UnsupportedFormatError is raised when missing):
11
+ # - +model+ — not part of the payload (it belongs in the request URL) but
12
+ # validated so an incomplete session fails at serialization time
13
+ # - at least one user/model message (the +contents+ array must not be empty)
14
+ #
10
15
  # === Unsupported Open Responses features
11
16
  #
12
17
  # These session fields are not supported and are silently omitted from the
@@ -22,6 +27,12 @@ module PromptBuilder
22
27
  # - +truncation+ — server-side context truncation is not supported
23
28
  #
24
29
  # Input content restrictions:
30
+ # - System and developer messages are hoisted out of conversational order
31
+ # into the top-level +systemInstruction+ field, with +instructions+ merged
32
+ # last (instructions apply to the current request, matching Open Responses
33
+ # semantics)
34
+ # - Only text content (+InputText+/+OutputText+/+Text+) survives in system
35
+ # and developer messages; other content is silently omitted
25
36
  # - +InputImage+ content is only supported in user messages (assistant images are omitted)
26
37
  # - +InputImage+ with +image_url+ requires either base64 +data+ or a URL;
27
38
  # content without +image_url+ or +file_id+ raises
@@ -115,7 +126,11 @@ module PromptBuilder
115
126
  system_instruction = build_system_instruction(session)
116
127
  h["systemInstruction"] = system_instruction if system_instruction
117
128
 
118
- h["contents"] = build_contents(session)
129
+ contents = build_contents(session)
130
+ if contents.empty?
131
+ raise UnsupportedFormatError, "Gemini format requires at least one user/model message"
132
+ end
133
+ h["contents"] = contents
119
134
 
120
135
  generation_config = build_generation_config(session)
121
136
  h["generationConfig"] = generation_config unless generation_config.empty?
@@ -127,7 +142,8 @@ module PromptBuilder
127
142
  h["toolConfig"] = tool_config if tool_config
128
143
 
129
144
  # Session extra: recognized keys for Gemini API
130
- apply_session_extra!(h, session.extra) if session.extra
145
+ extra = session.extra
146
+ apply_session_extra!(h, extra) unless extra.empty?
131
147
 
132
148
  # Gemini selects streaming via endpoint (:streamGenerateContent)
133
149
  # rather than a request body field, so session.stream is a no-op
@@ -153,19 +169,24 @@ module PromptBuilder
153
169
  def build_system_instruction(session)
154
170
  parts = []
155
171
 
156
- if session.instructions
157
- parts << {"text" => session.instructions}
158
- end
159
-
160
172
  session.items.each do |item|
161
173
  next unless item.is_a?(Items::Message)
162
174
  next unless item.role == "system" || item.role == "developer"
163
175
 
164
176
  item.content.each do |content|
165
- parts << {"text" => content.text} if content.is_a?(Content::InputText)
177
+ if content.is_a?(Content::InputText) || content.is_a?(Content::OutputText) || content.is_a?(Content::Text)
178
+ parts << {"text" => content.text}
179
+ end
166
180
  end
167
181
  end
168
182
 
183
+ # Instructions come last: in the Open Responses API they apply to
184
+ # the current request, so they must follow any system messages
185
+ # accumulated in the conversation history.
186
+ if session.instructions
187
+ parts << {"text" => session.instructions}
188
+ end
189
+
169
190
  return nil if parts.empty?
170
191
 
171
192
  {"parts" => parts}
@@ -265,7 +286,7 @@ module PromptBuilder
265
286
  # types are silently omitted.
266
287
  text = output.filter_map do |content|
267
288
  case content
268
- when Content::InputText, Content::OutputText
289
+ when Content::InputText, Content::OutputText, Content::Text
269
290
  content.text
270
291
  end
271
292
  end.join("\n")
@@ -298,7 +319,7 @@ module PromptBuilder
298
319
 
299
320
  def serialize_content(content, role:)
300
321
  case content
301
- when Content::InputText, Content::OutputText
322
+ when Content::InputText, Content::OutputText, Content::Text
302
323
  part = {"text" => content.text}
303
324
  if content.extra && content.extra["thought_signature"]
304
325
  part["thoughtSignature"] = content.extra["thought_signature"]
@@ -63,6 +63,15 @@ module PromptBuilder
63
63
  class << self
64
64
  private
65
65
 
66
+ # Gemini errors arrive as
67
+ # {"error" => {"code" => 400, "message" => ..., "status" => ...}}.
68
+ def error_response_message(hash)
69
+ error = hash["error"]
70
+ return nil unless error.is_a?(Hash) && !hash.key?("candidates")
71
+
72
+ [error["status"] || error["code"], error["message"]].compact.join(": ")
73
+ end
74
+
66
75
  def deserialize_response(hash)
67
76
  require_response_key!(hash, "candidates")
68
77
  require_response_key!(hash, "modelVersion")
@@ -5,6 +5,10 @@ module PromptBuilder
5
5
  class Messages < Base
6
6
  # Request serializer for the Anthropic Messages API format.
7
7
  #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
10
+ # - +max_output_tokens+ — populates the required +max_tokens+ parameter
11
+ #
8
12
  # === Unsupported Open Responses features
9
13
  #
10
14
  # These session fields are not supported and are silently omitted from the
@@ -27,9 +31,17 @@ module PromptBuilder
27
31
  # - +service_tier+ — only +auto+ and +standard_only+ are accepted
28
32
  # - +text+ — +format.type=json_schema+ is mapped to +output_config.format+
29
33
  # - +reasoning+ — +budget_tokens+, +display+, +effort+, and +type+ are forwarded;
30
- # +temperature+ must be unset and +top_p+ must be >= 0.95 when reasoning is enabled
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
31
37
  #
32
38
  # Input content restrictions:
39
+ # - System and developer messages are hoisted out of conversational order
40
+ # into the top-level +system+ parameter, with +instructions+ merged last
41
+ # (instructions apply to the current request, matching Open Responses
42
+ # semantics)
43
+ # - Only text content (+InputText+/+OutputText+/+Text+) survives in system
44
+ # and developer messages; other content is silently omitted
33
45
  # - +InputVideo+ content is not supported and is omitted
34
46
  # - +RefusalContent+ is dropped silently (a parsed refusal can stay in
35
47
  # session history without breaking subsequent request_payload calls)
@@ -95,7 +107,11 @@ module PromptBuilder
95
107
  raise UnsupportedFormatError, "Messages format requires session.model" unless session.model
96
108
 
97
109
  h["model"] = session.model
98
- h["max_tokens"] = session.max_output_tokens if session.max_output_tokens
110
+ unless session.max_output_tokens
111
+ raise UnsupportedFormatError,
112
+ "Messages format requires session.max_output_tokens to populate the required max_tokens field"
113
+ end
114
+ h["max_tokens"] = session.max_output_tokens
99
115
  h["temperature"] = session.temperature if session.temperature
100
116
  h["top_p"] = session.top_p if session.top_p
101
117
  effective_metadata = build_effective_metadata(session)
@@ -105,7 +121,8 @@ module PromptBuilder
105
121
  h["stream"] = session.stream unless session.stream.nil?
106
122
 
107
123
  # Session extra: recognized keys for Messages API
108
- apply_session_extra!(h, session.extra) if session.extra
124
+ extra = session.extra
125
+ apply_session_extra!(h, extra) unless extra.empty?
109
126
 
110
127
  output_config = build_output_config(session)
111
128
  h["output_config"] = output_config unless output_config.empty?
@@ -249,6 +266,12 @@ module PromptBuilder
249
266
  def validate_thinking_compatibility!(session, thinking)
250
267
  return unless thinking
251
268
 
269
+ budget = thinking["budget_tokens"]
270
+ if budget && session.max_output_tokens && session.max_output_tokens <= budget
271
+ raise UnsupportedFormatError,
272
+ "Messages format requires max_output_tokens (max_tokens) to be greater than reasoning.budget_tokens"
273
+ end
274
+
252
275
  if session.temperature
253
276
  raise UnsupportedFormatError,
254
277
  "Messages format does not support temperature when thinking is enabled"
@@ -263,16 +286,12 @@ module PromptBuilder
263
286
  def build_system(session)
264
287
  parts = []
265
288
 
266
- if session.instructions
267
- parts << {"type" => "text", "text" => session.instructions}
268
- end
269
-
270
289
  session.items.each do |item|
271
290
  next unless item.is_a?(Items::Message)
272
291
  next unless item.role == "system" || item.role == "developer"
273
292
 
274
293
  item.content.each do |content|
275
- if content.is_a?(Content::InputText)
294
+ if content.is_a?(Content::InputText) || content.is_a?(Content::OutputText) || content.is_a?(Content::Text)
276
295
  part = {"type" => "text", "text" => content.text}
277
296
  if content.extra && content.extra["cache_control"]
278
297
  part["cache_control"] = content.extra["cache_control"]
@@ -282,6 +301,13 @@ module PromptBuilder
282
301
  end
283
302
  end
284
303
 
304
+ # Instructions come last: in the Open Responses API they apply to
305
+ # the current request, so they must follow any system messages
306
+ # accumulated in the conversation history.
307
+ if session.instructions
308
+ parts << {"type" => "text", "text" => session.instructions}
309
+ end
310
+
285
311
  parts
286
312
  end
287
313
 
@@ -347,7 +373,7 @@ module PromptBuilder
347
373
 
348
374
  def serialize_content_block(content, role:)
349
375
  case content
350
- when Content::InputText
376
+ when Content::InputText, Content::Text
351
377
  {"type" => "text", "text" => content.text}
352
378
  when Content::OutputText
353
379
  text = {"type" => "text", "text" => content.text}
@@ -476,7 +502,7 @@ module PromptBuilder
476
502
  # Document blocks are rejected by the API.
477
503
  def serialize_tool_result_content(content)
478
504
  case content
479
- when Content::InputText, Content::OutputText
505
+ when Content::InputText, Content::OutputText, Content::Text
480
506
  {"type" => "text", "text" => content.text}
481
507
  when Content::InputImage
482
508
  serialize_content(content, role: "user")
@@ -10,6 +10,15 @@ module PromptBuilder
10
10
  class << self
11
11
  private
12
12
 
13
+ # Anthropic errors arrive as
14
+ # {"type" => "error", "error" => {"type" => ..., "message" => ...}}.
15
+ def error_response_message(hash)
16
+ error = hash["error"]
17
+ return nil unless error.is_a?(Hash) && !hash.key?("content")
18
+
19
+ [error["type"], error["message"]].compact.join(": ")
20
+ end
21
+
13
22
  def deserialize_response(hash)
14
23
  require_response_key!(hash, "content")
15
24
  require_response_key!(hash, "stop_reason")
@@ -4,6 +4,9 @@ module PromptBuilder
4
4
  module Serializers
5
5
  class OpenResponses < Base
6
6
  # Request serializer for the OpenAI Open Responses API format.
7
+ #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
7
10
  class Request < Base
8
11
  # Content extra keys that are part of the Open Responses API schema and
9
12
  # must survive extra-stripping (the canonical content classes have no
@@ -20,9 +23,12 @@ module PromptBuilder
20
23
  # @param session [Session] the session to export
21
24
  # @return [Hash] the serialized request payload
22
25
  def request_payload(session)
26
+ raise UnsupportedFormatError, "Open Responses format requires session.model" unless session.model
27
+
23
28
  payload = session.to_h
24
29
  items = apply_server_state!(payload, session)
25
30
  payload.delete("extra")
31
+ payload.delete("response_boundary_index")
26
32
  strip_extra(payload, items, session.tool_definitions)
27
33
  normalize_content_urls!(payload)
28
34
  strip_non_replayable_reasoning!(payload)
@@ -8,6 +8,16 @@ module PromptBuilder
8
8
  class << self
9
9
  private
10
10
 
11
+ # Open Responses request errors arrive as a bare {"error" => {...}}
12
+ # envelope. A failed response object also carries an "error" field,
13
+ # but it has a "status" and parses into a failed Response instead.
14
+ def error_response_message(hash)
15
+ error = hash["error"]
16
+ return nil unless error.is_a?(Hash) && !hash.key?("status")
17
+
18
+ [error["code"] || error["type"], error["message"]].compact.join(": ")
19
+ end
20
+
11
21
  def deserialize_response(hash)
12
22
  require_response_key!(hash, "status")
13
23
  require_response_key!(hash, "object")