prompt_builder 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/MIT-LICENSE +20 -0
- data/README.md +763 -0
- data/VERSION +1 -0
- data/lib/prompt_builder/content/base.rb +44 -0
- data/lib/prompt_builder/content/input_file.rb +63 -0
- data/lib/prompt_builder/content/input_image.rb +64 -0
- data/lib/prompt_builder/content/input_text.rb +42 -0
- data/lib/prompt_builder/content/input_video.rb +43 -0
- data/lib/prompt_builder/content/output_text.rb +59 -0
- data/lib/prompt_builder/content/reasoning_text.rb +42 -0
- data/lib/prompt_builder/content/refusal_content.rb +42 -0
- data/lib/prompt_builder/content/summary_text.rb +42 -0
- data/lib/prompt_builder/content/text.rb +42 -0
- data/lib/prompt_builder/content.rb +28 -0
- data/lib/prompt_builder/errors.rb +18 -0
- data/lib/prompt_builder/items/base.rb +41 -0
- data/lib/prompt_builder/items/compaction.rb +60 -0
- data/lib/prompt_builder/items/function_call.rb +97 -0
- data/lib/prompt_builder/items/function_call_output.rb +110 -0
- data/lib/prompt_builder/items/item_reference.rb +42 -0
- data/lib/prompt_builder/items/message.rb +113 -0
- data/lib/prompt_builder/items/reasoning.rb +75 -0
- data/lib/prompt_builder/items.rb +13 -0
- data/lib/prompt_builder/response.rb +257 -0
- data/lib/prompt_builder/serializers/base.rb +37 -0
- data/lib/prompt_builder/serializers/chat_completion/request.rb +389 -0
- data/lib/prompt_builder/serializers/chat_completion/response.rb +139 -0
- data/lib/prompt_builder/serializers/chat_completion.rb +30 -0
- data/lib/prompt_builder/serializers/converse/request.rb +623 -0
- data/lib/prompt_builder/serializers/converse/response.rb +140 -0
- data/lib/prompt_builder/serializers/converse.rb +30 -0
- data/lib/prompt_builder/serializers/gemini/request.rb +562 -0
- data/lib/prompt_builder/serializers/gemini/response.rb +233 -0
- data/lib/prompt_builder/serializers/gemini.rb +30 -0
- data/lib/prompt_builder/serializers/messages/request.rb +634 -0
- data/lib/prompt_builder/serializers/messages/response.rb +157 -0
- data/lib/prompt_builder/serializers/messages.rb +30 -0
- data/lib/prompt_builder/serializers/open_responses/request.rb +229 -0
- data/lib/prompt_builder/serializers/open_responses/response.rb +18 -0
- data/lib/prompt_builder/serializers/open_responses.rb +30 -0
- data/lib/prompt_builder/serializers.rb +35 -0
- data/lib/prompt_builder/session.rb +383 -0
- data/lib/prompt_builder/tool_registry.rb +75 -0
- data/lib/prompt_builder/tools/definition.rb +66 -0
- data/lib/prompt_builder/tools.rb +7 -0
- data/lib/prompt_builder/usage.rb +100 -0
- data/lib/prompt_builder.rb +86 -0
- data/prompt_builder.gemspec +41 -0
- metadata +107 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module PromptBuilder
|
|
7
|
+
module Serializers
|
|
8
|
+
class Converse < Base
|
|
9
|
+
# Request serializer for the Amazon Bedrock Converse API format.
|
|
10
|
+
#
|
|
11
|
+
# === Unsupported Open Responses features
|
|
12
|
+
#
|
|
13
|
+
# These session fields are not supported and are silently omitted from the
|
|
14
|
+
# serialized output:
|
|
15
|
+
# - +background+ — background/async mode is not supported on the Converse endpoint
|
|
16
|
+
# - +frequency_penalty+ — not supported by the Converse API
|
|
17
|
+
# - +include+ — response-field inclusion is an Open Responses-only concept
|
|
18
|
+
# - +max_tool_calls+ — per-request tool-call caps are not supported
|
|
19
|
+
# - +parallel_tool_calls+ — parallel tool call control is not supported
|
|
20
|
+
# - +presence_penalty+ — not supported by the Converse API
|
|
21
|
+
# - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
|
|
22
|
+
# - +reasoning+ — extended thinking is not supported on the Converse endpoint
|
|
23
|
+
# - +safety_identifier+ — no equivalent user-safety field on the Converse endpoint
|
|
24
|
+
# - +store+ — server-side response storage is not supported
|
|
25
|
+
# - +stream+ — SSE streaming is handled outside the Converse request payload
|
|
26
|
+
# - +stream_options+ — stream event options are not supported
|
|
27
|
+
# - +top_logprobs+ — log probability output is not supported
|
|
28
|
+
# - +truncation+ — server-side context truncation is not supported
|
|
29
|
+
#
|
|
30
|
+
# Partially supported session fields (unsupported keys/values are omitted):
|
|
31
|
+
# - +metadata+ — only scalar values are forwarded to +requestMetadata+; Hash/Array values are omitted
|
|
32
|
+
# - +text+ — only +format.type == "json_schema"+ is supported
|
|
33
|
+
#
|
|
34
|
+
# Tool choice restrictions:
|
|
35
|
+
# - +tool_choice: "none"+ has no Converse representation and is omitted
|
|
36
|
+
#
|
|
37
|
+
# Input content restrictions:
|
|
38
|
+
# - +Reasoning+ items are silently skipped
|
|
39
|
+
# - +RefusalContent+ is dropped silently (a parsed response refusal can
|
|
40
|
+
# stay in session history without breaking subsequent request_payload calls)
|
|
41
|
+
# - +OutputText.annotations+ and +OutputText.logprobs+ are dropped silently
|
|
42
|
+
# - +InputImage+ content is only supported in user messages (assistant images are omitted)
|
|
43
|
+
# - +InputImage.detail+ and +InputImage.file_id+ are silently ignored
|
|
44
|
+
# - +InputFile+ content is only supported in user messages (assistant files are omitted)
|
|
45
|
+
# - +InputFile.file_id+ is silently ignored
|
|
46
|
+
# - +InputVideo+ content is only supported in user messages (assistant videos are omitted)
|
|
47
|
+
# - +InputImage+ requires base64 +data+ or an S3 URI (+s3://...+); content with a
|
|
48
|
+
# public URL is omitted
|
|
49
|
+
# - +InputFile+ requires base64 +file_data+ or an S3 URI; format is detected from the
|
|
50
|
+
# filename or +file_url+ extension
|
|
51
|
+
# - +InputVideo+ requires an S3 URI (+s3://...+); a non-S3 URL is omitted
|
|
52
|
+
# - Tool (+FunctionCallOutput+) results support text, image, document, and video content;
|
|
53
|
+
# other content is omitted
|
|
54
|
+
#
|
|
55
|
+
# === Features in Converse not available through Open Responses
|
|
56
|
+
#
|
|
57
|
+
# The following Converse parameters cannot be set through the Open Responses
|
|
58
|
+
# canonical format:
|
|
59
|
+
# - +stopSequences+ — custom stop sequences
|
|
60
|
+
# - Guardrail policies (+guardrailConfig+)
|
|
61
|
+
# - Per-model passthrough fields (+additionalModelRequestFields+)
|
|
62
|
+
# - Requested provider response fields (+additionalModelResponseFieldPaths+)
|
|
63
|
+
# - Cross-region routing via inference profiles
|
|
64
|
+
# - +performanceConfig+ latency settings beyond +serviceTier+
|
|
65
|
+
# - Prompt management variables (+promptVariables+)
|
|
66
|
+
# - Prompt caching markers (+cachePoint+)
|
|
67
|
+
class Request < Base
|
|
68
|
+
IMAGE_MEDIA_TYPE_FORMATS = {
|
|
69
|
+
"image/jpeg" => "jpeg",
|
|
70
|
+
"image/jpg" => "jpeg",
|
|
71
|
+
"image/png" => "png",
|
|
72
|
+
"image/gif" => "gif",
|
|
73
|
+
"image/webp" => "webp"
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
IMAGE_URL_EXTENSION_FORMATS = {
|
|
77
|
+
"jpg" => "jpeg",
|
|
78
|
+
"jpeg" => "jpeg",
|
|
79
|
+
"png" => "png",
|
|
80
|
+
"gif" => "gif",
|
|
81
|
+
"webp" => "webp"
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
DOCUMENT_FILENAME_EXTENSION_FORMATS = {
|
|
85
|
+
"pdf" => "pdf",
|
|
86
|
+
"csv" => "csv",
|
|
87
|
+
"doc" => "doc",
|
|
88
|
+
"docx" => "docx",
|
|
89
|
+
"xls" => "xls",
|
|
90
|
+
"xlsx" => "xlsx",
|
|
91
|
+
"html" => "html",
|
|
92
|
+
"htm" => "html",
|
|
93
|
+
"txt" => "txt",
|
|
94
|
+
"md" => "md",
|
|
95
|
+
"markdown" => "md"
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
VIDEO_URL_EXTENSION_FORMATS = {
|
|
99
|
+
"mkv" => "mkv",
|
|
100
|
+
"mov" => "mov",
|
|
101
|
+
"mp4" => "mp4",
|
|
102
|
+
"webm" => "webm",
|
|
103
|
+
"flv" => "flv",
|
|
104
|
+
"mpeg" => "mpeg",
|
|
105
|
+
"mpg" => "mpeg",
|
|
106
|
+
"wmv" => "wmv",
|
|
107
|
+
"3gp" => "three_gp"
|
|
108
|
+
}.freeze
|
|
109
|
+
|
|
110
|
+
class << self
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def serialize_request(session)
|
|
114
|
+
# document_name_counts is scoped per request (passed through the
|
|
115
|
+
# build chain) so concurrent serializations don't share counter state.
|
|
116
|
+
ctx = {document_name_counts: Hash.new(0)}
|
|
117
|
+
|
|
118
|
+
h = {}
|
|
119
|
+
h["modelId"] = session.model if session.model
|
|
120
|
+
|
|
121
|
+
system = build_system(session)
|
|
122
|
+
h["system"] = system unless system.empty?
|
|
123
|
+
|
|
124
|
+
messages = build_messages(session, ctx)
|
|
125
|
+
if messages.empty?
|
|
126
|
+
raise UnsupportedFormatError,
|
|
127
|
+
"Converse format requires at least one user/assistant message"
|
|
128
|
+
end
|
|
129
|
+
unless messages.first["role"] == "user"
|
|
130
|
+
raise UnsupportedFormatError,
|
|
131
|
+
"Converse format requires the first message to have role \"user\""
|
|
132
|
+
end
|
|
133
|
+
h["messages"] = messages
|
|
134
|
+
|
|
135
|
+
inference_config = build_inference_config(session)
|
|
136
|
+
h["inferenceConfig"] = inference_config unless inference_config.empty?
|
|
137
|
+
|
|
138
|
+
output_config = build_output_config(session)
|
|
139
|
+
h["outputConfig"] = output_config unless output_config.empty?
|
|
140
|
+
|
|
141
|
+
metadata = build_request_metadata(session)
|
|
142
|
+
h["requestMetadata"] = metadata unless metadata.empty?
|
|
143
|
+
|
|
144
|
+
h["serviceTier"] = {"type" => session.service_tier} if session.service_tier
|
|
145
|
+
|
|
146
|
+
tool_config = build_tool_config(session)
|
|
147
|
+
h["toolConfig"] = tool_config if tool_config
|
|
148
|
+
|
|
149
|
+
# Session extra: recognized keys for Converse API
|
|
150
|
+
apply_session_extra!(h, session.extra) if session.extra
|
|
151
|
+
|
|
152
|
+
h
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def apply_session_extra!(h, extra)
|
|
156
|
+
if extra.key?("stop_sequences")
|
|
157
|
+
inference_config = h["inferenceConfig"] ||= {}
|
|
158
|
+
inference_config["stopSequences"] = extra["stop_sequences"]
|
|
159
|
+
end
|
|
160
|
+
h["guardrailConfig"] = extra["guardrail_config"] if extra.key?("guardrail_config")
|
|
161
|
+
h["additionalModelRequestFields"] = extra["additional_model_request_fields"] if extra.key?("additional_model_request_fields")
|
|
162
|
+
h["additionalModelResponseFieldPaths"] = extra["additional_model_response_field_paths"] if extra.key?("additional_model_response_field_paths")
|
|
163
|
+
h["performanceConfig"] = extra["performance_config"] if extra.key?("performance_config")
|
|
164
|
+
h["promptVariables"] = extra["prompt_variables"] if extra.key?("prompt_variables")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_system(session)
|
|
168
|
+
parts = []
|
|
169
|
+
|
|
170
|
+
parts << {"text" => session.instructions} if session.instructions
|
|
171
|
+
|
|
172
|
+
session.items.each do |item|
|
|
173
|
+
next unless item.is_a?(Items::Message)
|
|
174
|
+
next unless item.role == "system" || item.role == "developer"
|
|
175
|
+
|
|
176
|
+
item.content.each do |content|
|
|
177
|
+
serialized = serialize_system_content(content)
|
|
178
|
+
next unless serialized
|
|
179
|
+
|
|
180
|
+
parts << serialized
|
|
181
|
+
if content.respond_to?(:extra) && content.extra && content.extra["cache_point"]
|
|
182
|
+
parts << {"cachePoint" => {"type" => "default"}}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
parts
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_messages(session, ctx)
|
|
191
|
+
raw_messages = []
|
|
192
|
+
|
|
193
|
+
session.items.each do |item|
|
|
194
|
+
case item
|
|
195
|
+
when Items::Message
|
|
196
|
+
next if item.role == "system" || item.role == "developer"
|
|
197
|
+
|
|
198
|
+
role = (item.role == "assistant") ? "assistant" : "user"
|
|
199
|
+
visible_content = item.content.reject { |c| c.is_a?(Content::RefusalContent) }
|
|
200
|
+
next if visible_content.empty?
|
|
201
|
+
content = []
|
|
202
|
+
visible_content.each do |c|
|
|
203
|
+
serialized = serialize_content(c, role: role, ctx: ctx)
|
|
204
|
+
next unless serialized
|
|
205
|
+
|
|
206
|
+
content << serialized
|
|
207
|
+
if c.respond_to?(:extra) && c.extra && c.extra["cache_point"]
|
|
208
|
+
content << {"cachePoint" => {"type" => "default"}}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
next if content.empty?
|
|
212
|
+
raw_messages << {"role" => role, "content" => content}
|
|
213
|
+
when Items::FunctionCall
|
|
214
|
+
raw_messages << {
|
|
215
|
+
"role" => "assistant",
|
|
216
|
+
"content" => [{
|
|
217
|
+
"toolUse" => {
|
|
218
|
+
"toolUseId" => item.call_id,
|
|
219
|
+
"name" => item.name,
|
|
220
|
+
"input" => parse_tool_use_input(item)
|
|
221
|
+
}
|
|
222
|
+
}]
|
|
223
|
+
}
|
|
224
|
+
when Items::FunctionCallOutput
|
|
225
|
+
raw_messages << {
|
|
226
|
+
"role" => "user",
|
|
227
|
+
"content" => [serialize_tool_result(item, ctx)]
|
|
228
|
+
}
|
|
229
|
+
when Items::Reasoning, Items::Compaction, Items::ItemReference
|
|
230
|
+
# Reasoning, Compaction, and ItemReference items are not supported
|
|
231
|
+
# in the request, so ignore them rather than raising an error.
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
merge_consecutive_messages(raw_messages)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def serialize_system_content(content)
|
|
240
|
+
case content
|
|
241
|
+
when Content::InputText
|
|
242
|
+
{"text" => content.text}
|
|
243
|
+
when Content::OutputText
|
|
244
|
+
validate_output_text!(content)
|
|
245
|
+
{"text" => content.text}
|
|
246
|
+
else
|
|
247
|
+
# Only text content is supported in system/developer messages;
|
|
248
|
+
# other content types are silently omitted.
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Bedrock toolUse.input must be a JSON object. Wrap parser errors as
|
|
254
|
+
# UnsupportedFormatError and reject non-object JSON values.
|
|
255
|
+
def parse_tool_use_input(item)
|
|
256
|
+
parsed = item.parsed_arguments
|
|
257
|
+
unless parsed.is_a?(Hash)
|
|
258
|
+
raise UnsupportedFormatError,
|
|
259
|
+
"Converse format requires FunctionCall arguments to be a JSON object"
|
|
260
|
+
end
|
|
261
|
+
parsed
|
|
262
|
+
rescue PromptBuilder::InvalidItemError => e
|
|
263
|
+
raise UnsupportedFormatError,
|
|
264
|
+
"Converse format could not parse FunctionCall arguments: #{e.message}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def serialize_content(content, role:, ctx: nil)
|
|
268
|
+
case content
|
|
269
|
+
when Content::InputText, Content::OutputText
|
|
270
|
+
validate_output_text!(content) if content.is_a?(Content::OutputText)
|
|
271
|
+
{"text" => content.text}
|
|
272
|
+
when Content::InputImage
|
|
273
|
+
# Assistant image content is not supported; omit it.
|
|
274
|
+
return nil if role == "assistant"
|
|
275
|
+
|
|
276
|
+
serialize_image(content)
|
|
277
|
+
when Content::InputFile
|
|
278
|
+
# Assistant file content is not supported; omit it.
|
|
279
|
+
return nil if role == "assistant"
|
|
280
|
+
|
|
281
|
+
serialize_document(content, ctx)
|
|
282
|
+
when Content::InputVideo
|
|
283
|
+
# Assistant video content is not supported; omit it.
|
|
284
|
+
return nil if role == "assistant"
|
|
285
|
+
|
|
286
|
+
serialize_video(content)
|
|
287
|
+
when Content::RefusalContent
|
|
288
|
+
# RefusalContent can land in the session via a parsed response;
|
|
289
|
+
# drop it silently so subsequent request_payload calls don't fail.
|
|
290
|
+
nil
|
|
291
|
+
else
|
|
292
|
+
# Unsupported content types are silently omitted.
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# OutputText.annotations and logprobs are output-only metadata from
|
|
298
|
+
# a parsed response; silently ignore them on request serialization so
|
|
299
|
+
# a response with citations/logprobs can sit in session history without
|
|
300
|
+
# breaking subsequent request_payload calls.
|
|
301
|
+
def validate_output_text!(content) # rubocop:disable Lint/UnusedMethodArgument
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def serialize_image(content)
|
|
305
|
+
# InputImage.detail and file_id (in extra) are not supported and are
|
|
306
|
+
# silently ignored.
|
|
307
|
+
format = detect_image_format(content)
|
|
308
|
+
parsed = PromptBuilder.parse_data_url(content.url)
|
|
309
|
+
source = if parsed
|
|
310
|
+
{"bytes" => parsed[1]}
|
|
311
|
+
elsif content.url&.start_with?("s3://")
|
|
312
|
+
{"s3Location" => {"uri" => content.url}}
|
|
313
|
+
else
|
|
314
|
+
raise UnsupportedFormatError,
|
|
315
|
+
"Converse format requires a data URL in InputImage.url or an S3 URI"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
{"image" => {"format" => format, "source" => source}}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def detect_image_format(content)
|
|
322
|
+
media_type = content.extra && content.extra["media_type"]
|
|
323
|
+
if media_type
|
|
324
|
+
fmt = IMAGE_MEDIA_TYPE_FORMATS[media_type]
|
|
325
|
+
return fmt if fmt
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Try to extract media type from data URL
|
|
329
|
+
parsed = PromptBuilder.parse_data_url(content.url)
|
|
330
|
+
if parsed
|
|
331
|
+
fmt = IMAGE_MEDIA_TYPE_FORMATS[parsed[0]]
|
|
332
|
+
return fmt if fmt
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
url = content.url
|
|
336
|
+
if url
|
|
337
|
+
ext = File.extname(url).delete_prefix(".").downcase
|
|
338
|
+
fmt = IMAGE_URL_EXTENSION_FORMATS[ext]
|
|
339
|
+
return fmt if fmt
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
raise UnsupportedFormatError,
|
|
343
|
+
"Converse format could not detect image format; set media_type in extra (e.g. \"image/jpeg\")"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
DOCUMENT_MEDIA_TYPE_FORMATS = {
|
|
347
|
+
"application/pdf" => "pdf",
|
|
348
|
+
"text/csv" => "csv",
|
|
349
|
+
"application/msword" => "doc",
|
|
350
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
|
|
351
|
+
"application/vnd.ms-excel" => "xls",
|
|
352
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
|
|
353
|
+
"text/html" => "html",
|
|
354
|
+
"text/plain" => "txt",
|
|
355
|
+
"text/markdown" => "md"
|
|
356
|
+
}.freeze
|
|
357
|
+
|
|
358
|
+
def serialize_document(content, ctx)
|
|
359
|
+
# file_id (in extra) is not supported and is silently ignored.
|
|
360
|
+
format = detect_document_format(content)
|
|
361
|
+
parsed = PromptBuilder.parse_data_url(content.url)
|
|
362
|
+
source = if parsed
|
|
363
|
+
{"bytes" => parsed[1]}
|
|
364
|
+
elsif content.url&.start_with?("s3://")
|
|
365
|
+
{"s3Location" => {"uri" => content.url}}
|
|
366
|
+
else
|
|
367
|
+
raise UnsupportedFormatError,
|
|
368
|
+
"Converse format requires InputFile with a data URL or an S3 URI"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
name = document_name(content, ctx)
|
|
372
|
+
{"document" => {"format" => format, "name" => name, "source" => source}}
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def detect_document_format(content)
|
|
376
|
+
media_type = content.extra && content.extra["media_type"]
|
|
377
|
+
if media_type
|
|
378
|
+
fmt = DOCUMENT_MEDIA_TYPE_FORMATS[media_type]
|
|
379
|
+
return fmt if fmt
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Check the data URL media type
|
|
383
|
+
parsed = PromptBuilder.parse_data_url(content.url)
|
|
384
|
+
if parsed
|
|
385
|
+
fmt = DOCUMENT_MEDIA_TYPE_FORMATS[parsed[0]]
|
|
386
|
+
return fmt if fmt
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
[content.filename, content.url].each do |path|
|
|
390
|
+
next unless path
|
|
391
|
+
|
|
392
|
+
ext = File.extname(path).delete_prefix(".").downcase
|
|
393
|
+
fmt = DOCUMENT_FILENAME_EXTENSION_FORMATS[ext]
|
|
394
|
+
return fmt if fmt
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
raise UnsupportedFormatError,
|
|
398
|
+
"Converse format could not detect document format; set media_type in extra or use a recognized file extension on InputFile.filename or InputFile.url"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Bedrock requires document.name to be unique within a request and to
|
|
402
|
+
# match /^[A-Za-z0-9 \-\(\)\[\]]{1,256}$/. Prefer the filename, fall
|
|
403
|
+
# back to the url basename, then a random suffix to avoid
|
|
404
|
+
# collisions when multiple unnamed documents are attached. The counter
|
|
405
|
+
# state lives on the per-request `ctx` Hash so concurrent calls don't
|
|
406
|
+
# share state.
|
|
407
|
+
def document_name(content, ctx)
|
|
408
|
+
source = content.filename || content.url
|
|
409
|
+
candidate = nil
|
|
410
|
+
|
|
411
|
+
if source
|
|
412
|
+
base = File.basename(source, ".*")
|
|
413
|
+
sanitized = base.gsub(/[^A-Za-z0-9 \-()\[\]]/, "-")
|
|
414
|
+
candidate = sanitized[0, 256]
|
|
415
|
+
candidate = nil if candidate.empty?
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
candidate ||= "document-#{SecureRandom.hex(4)}"
|
|
419
|
+
|
|
420
|
+
counts = ctx[:document_name_counts]
|
|
421
|
+
counts[candidate] += 1
|
|
422
|
+
|
|
423
|
+
return candidate if counts[candidate] == 1
|
|
424
|
+
|
|
425
|
+
suffix = "-#{counts[candidate]}"
|
|
426
|
+
"#{candidate[0, 256 - suffix.length]}#{suffix}"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def serialize_video(content)
|
|
430
|
+
unless content.url
|
|
431
|
+
raise UnsupportedFormatError,
|
|
432
|
+
"Converse format requires InputVideo.url"
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Only S3 URIs are supported; other video URLs are silently omitted.
|
|
436
|
+
return nil unless content.url.start_with?("s3://")
|
|
437
|
+
|
|
438
|
+
ext = File.extname(content.url).delete_prefix(".").downcase
|
|
439
|
+
format = VIDEO_URL_EXTENSION_FORMATS[ext]
|
|
440
|
+
|
|
441
|
+
unless format
|
|
442
|
+
raise UnsupportedFormatError,
|
|
443
|
+
"Converse format could not detect video format from InputVideo.url extension"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
{"video" => {"format" => format, "source" => {"s3Location" => {"uri" => content.url}}}}
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def serialize_tool_result(item, ctx)
|
|
450
|
+
result = {"toolUseId" => item.call_id}
|
|
451
|
+
|
|
452
|
+
status = converse_tool_result_status(item.status)
|
|
453
|
+
result["status"] = status if status
|
|
454
|
+
|
|
455
|
+
content = if item.output.is_a?(Array)
|
|
456
|
+
item.output.filter_map { |c| serialize_tool_result_content(c, ctx) }
|
|
457
|
+
elsif !item.output.nil? && !item.output.empty?
|
|
458
|
+
[{"text" => item.output}]
|
|
459
|
+
else
|
|
460
|
+
[]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Bedrock requires toolResult.content to be a non-empty array.
|
|
464
|
+
content = [{"text" => ""}] if content.empty?
|
|
465
|
+
result["content"] = content
|
|
466
|
+
|
|
467
|
+
{"toolResult" => result}
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Bedrock toolResult.status only accepts "success" or "error". Map
|
|
471
|
+
# Open Responses-style status values to that shape; pass through
|
|
472
|
+
# already-valid values; ignore everything else.
|
|
473
|
+
def converse_tool_result_status(status)
|
|
474
|
+
case status
|
|
475
|
+
when nil, ""
|
|
476
|
+
nil
|
|
477
|
+
when "success", "error"
|
|
478
|
+
status
|
|
479
|
+
when "failed", "incomplete"
|
|
480
|
+
"error"
|
|
481
|
+
when "completed"
|
|
482
|
+
"success"
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def serialize_tool_result_content(content, ctx)
|
|
487
|
+
case content
|
|
488
|
+
when Content::InputText, Content::OutputText
|
|
489
|
+
validate_output_text!(content) if content.is_a?(Content::OutputText)
|
|
490
|
+
{"text" => content.text}
|
|
491
|
+
when Content::InputImage
|
|
492
|
+
serialize_image(content)
|
|
493
|
+
when Content::InputFile
|
|
494
|
+
serialize_document(content, ctx)
|
|
495
|
+
when Content::InputVideo
|
|
496
|
+
serialize_video(content)
|
|
497
|
+
else
|
|
498
|
+
# Unsupported content types in tool output are silently omitted.
|
|
499
|
+
nil
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def merge_consecutive_messages(messages)
|
|
504
|
+
return messages if messages.empty?
|
|
505
|
+
|
|
506
|
+
merged = [messages.first]
|
|
507
|
+
|
|
508
|
+
messages[1..].each do |message|
|
|
509
|
+
if merged.last["role"] == message["role"]
|
|
510
|
+
merged.last["content"].concat(message["content"])
|
|
511
|
+
else
|
|
512
|
+
merged << message
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
merged
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def build_inference_config(session)
|
|
520
|
+
config = {}
|
|
521
|
+
config["maxTokens"] = session.max_output_tokens if session.max_output_tokens
|
|
522
|
+
config["temperature"] = session.temperature if session.temperature
|
|
523
|
+
config["topP"] = session.top_p if session.top_p
|
|
524
|
+
config
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def build_output_config(session)
|
|
528
|
+
return {} unless session.text
|
|
529
|
+
|
|
530
|
+
# Unsupported text.* keys are silently omitted; only format is mapped.
|
|
531
|
+
format = session.text["format"]
|
|
532
|
+
return {} unless format
|
|
533
|
+
|
|
534
|
+
# Only json_schema output is supported; other format types are omitted.
|
|
535
|
+
return {} unless format.is_a?(Hash) && format["type"] == "json_schema"
|
|
536
|
+
|
|
537
|
+
schema = format.dig("json_schema", "schema") || format["schema"]
|
|
538
|
+
unless schema
|
|
539
|
+
raise UnsupportedFormatError,
|
|
540
|
+
"Converse format requires text.format.schema for json_schema output"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
json_schema = {"schema" => schema.is_a?(String) ? schema : JSON.generate(schema)}
|
|
544
|
+
name = format.dig("json_schema", "name") || format["name"]
|
|
545
|
+
description = format.dig("json_schema", "description") || format["description"]
|
|
546
|
+
json_schema["name"] = name if name
|
|
547
|
+
json_schema["description"] = description if description
|
|
548
|
+
|
|
549
|
+
{
|
|
550
|
+
"textFormat" => {
|
|
551
|
+
"type" => "json_schema",
|
|
552
|
+
"structure" => {"jsonSchema" => json_schema}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def build_request_metadata(session)
|
|
558
|
+
return {} unless session.metadata
|
|
559
|
+
|
|
560
|
+
# Only scalar metadata values are supported; Hash/Array values are
|
|
561
|
+
# silently omitted.
|
|
562
|
+
session.metadata.each_with_object({}) do |(key, value), metadata|
|
|
563
|
+
next if value.is_a?(Hash) || value.is_a?(Array)
|
|
564
|
+
|
|
565
|
+
metadata[key.to_s] = value.to_s
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def build_tool_config(session)
|
|
570
|
+
tools = build_tools(session)
|
|
571
|
+
return nil if tools.empty? && session.tool_choice.nil?
|
|
572
|
+
|
|
573
|
+
config = {}
|
|
574
|
+
config["tools"] = tools unless tools.empty?
|
|
575
|
+
if session.tool_choice
|
|
576
|
+
tool_choice = serialize_tool_choice(session.tool_choice, tools.empty?)
|
|
577
|
+
config["toolChoice"] = tool_choice if tool_choice
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
return nil if config.empty?
|
|
581
|
+
|
|
582
|
+
config
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def build_tools(session)
|
|
586
|
+
session.tool_definitions.map do |definition|
|
|
587
|
+
tool_spec = {"name" => definition.name}
|
|
588
|
+
tool_spec["description"] = definition.description if definition.description
|
|
589
|
+
tool_spec["inputSchema"] = {
|
|
590
|
+
"json" => definition.parameters || {"type" => "object", "properties" => {}}
|
|
591
|
+
}
|
|
592
|
+
{"toolSpec" => tool_spec}
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Unsupported tool_choice values (including tool_choice without tools
|
|
597
|
+
# and "none", which Converse has no representation for) are silently
|
|
598
|
+
# omitted by returning nil.
|
|
599
|
+
def serialize_tool_choice(choice, tools_empty)
|
|
600
|
+
return nil if tools_empty
|
|
601
|
+
|
|
602
|
+
case choice
|
|
603
|
+
when "auto"
|
|
604
|
+
{"auto" => {}}
|
|
605
|
+
when "required"
|
|
606
|
+
{"any" => {}}
|
|
607
|
+
when Hash
|
|
608
|
+
if choice["type"] == "function"
|
|
609
|
+
name = choice["name"] || choice.dig("function", "name")
|
|
610
|
+
unless name
|
|
611
|
+
raise UnsupportedFormatError,
|
|
612
|
+
"Converse format requires tool_choice.name for function tool choices"
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
{"tool" => {"name" => name}}
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|