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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +763 -0
  5. data/VERSION +1 -0
  6. data/lib/prompt_builder/content/base.rb +44 -0
  7. data/lib/prompt_builder/content/input_file.rb +63 -0
  8. data/lib/prompt_builder/content/input_image.rb +64 -0
  9. data/lib/prompt_builder/content/input_text.rb +42 -0
  10. data/lib/prompt_builder/content/input_video.rb +43 -0
  11. data/lib/prompt_builder/content/output_text.rb +59 -0
  12. data/lib/prompt_builder/content/reasoning_text.rb +42 -0
  13. data/lib/prompt_builder/content/refusal_content.rb +42 -0
  14. data/lib/prompt_builder/content/summary_text.rb +42 -0
  15. data/lib/prompt_builder/content/text.rb +42 -0
  16. data/lib/prompt_builder/content.rb +28 -0
  17. data/lib/prompt_builder/errors.rb +18 -0
  18. data/lib/prompt_builder/items/base.rb +41 -0
  19. data/lib/prompt_builder/items/compaction.rb +60 -0
  20. data/lib/prompt_builder/items/function_call.rb +97 -0
  21. data/lib/prompt_builder/items/function_call_output.rb +110 -0
  22. data/lib/prompt_builder/items/item_reference.rb +42 -0
  23. data/lib/prompt_builder/items/message.rb +113 -0
  24. data/lib/prompt_builder/items/reasoning.rb +75 -0
  25. data/lib/prompt_builder/items.rb +13 -0
  26. data/lib/prompt_builder/response.rb +257 -0
  27. data/lib/prompt_builder/serializers/base.rb +37 -0
  28. data/lib/prompt_builder/serializers/chat_completion/request.rb +389 -0
  29. data/lib/prompt_builder/serializers/chat_completion/response.rb +139 -0
  30. data/lib/prompt_builder/serializers/chat_completion.rb +30 -0
  31. data/lib/prompt_builder/serializers/converse/request.rb +623 -0
  32. data/lib/prompt_builder/serializers/converse/response.rb +140 -0
  33. data/lib/prompt_builder/serializers/converse.rb +30 -0
  34. data/lib/prompt_builder/serializers/gemini/request.rb +562 -0
  35. data/lib/prompt_builder/serializers/gemini/response.rb +233 -0
  36. data/lib/prompt_builder/serializers/gemini.rb +30 -0
  37. data/lib/prompt_builder/serializers/messages/request.rb +634 -0
  38. data/lib/prompt_builder/serializers/messages/response.rb +157 -0
  39. data/lib/prompt_builder/serializers/messages.rb +30 -0
  40. data/lib/prompt_builder/serializers/open_responses/request.rb +229 -0
  41. data/lib/prompt_builder/serializers/open_responses/response.rb +18 -0
  42. data/lib/prompt_builder/serializers/open_responses.rb +30 -0
  43. data/lib/prompt_builder/serializers.rb +35 -0
  44. data/lib/prompt_builder/session.rb +383 -0
  45. data/lib/prompt_builder/tool_registry.rb +75 -0
  46. data/lib/prompt_builder/tools/definition.rb +66 -0
  47. data/lib/prompt_builder/tools.rb +7 -0
  48. data/lib/prompt_builder/usage.rb +100 -0
  49. data/lib/prompt_builder.rb +86 -0
  50. data/prompt_builder.gemspec +41 -0
  51. 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