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,634 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class Messages < Base
6
+ # Request serializer for the Anthropic Messages API format.
7
+ #
8
+ # === Unsupported Open Responses features
9
+ #
10
+ # These session fields are not supported and are silently omitted from the
11
+ # serialized output:
12
+ # - +frequency_penalty+ — not supported by the Messages API
13
+ # - +include+ — response-field inclusion is an Open Responses-only concept
14
+ # - +max_tool_calls+ — per-request tool-call caps are not supported
15
+ # - +presence_penalty+ — not supported by the Messages API
16
+ # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
17
+ # - +store+ — server-side response storage is not supported
18
+ # - +stream_options+ — stream event options are not supported
19
+ # - +top_logprobs+ — log probability output is not supported
20
+ # - +truncation+ — server-side context truncation is not supported
21
+ # - +background+ — background/async mode is not supported on the Messages endpoint
22
+ # - +text.verbosity+ — Anthropic Messages has no equivalent verbosity control
23
+ #
24
+ # Partially supported session fields (unsupported keys/values are omitted):
25
+ # - +metadata+ — only the +user_id+ key is forwarded; +safety_identifier+ is
26
+ # also mapped into +metadata.user_id+ automatically
27
+ # - +service_tier+ — only +auto+ and +standard_only+ are accepted
28
+ # - +text+ — +format.type=json_schema+ is mapped to +output_config.format+
29
+ # - +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
31
+ #
32
+ # Input content restrictions:
33
+ # - +InputVideo+ content is not supported and is omitted
34
+ # - +RefusalContent+ is dropped silently (a parsed refusal can stay in
35
+ # session history without breaking subsequent request_payload calls)
36
+ # - +InputImage+ content is only supported in user messages (assistant images are omitted)
37
+ # - +InputImage.detail+ is not part of the Anthropic schema and is dropped
38
+ # - +InputImage.file_id+ is mapped to a +file+ source (Anthropic Files API beta)
39
+ # - +InputFile+ content is only supported in user messages (assistant files are omitted)
40
+ # - +InputFile+ is sent as a +document+ block; +media_type+ is forwarded when
41
+ # provided, otherwise +application/pdf+ is used for base64 sources
42
+ # - +InputFile.file_id+ is mapped to a +file+ source (Anthropic Files API beta)
43
+ # - Thinking blocks without a +signature+ are dropped silently (cross-provider
44
+ # reasoning history doesn't round-trip into Anthropic)
45
+ # - +Reasoning+ items with +summary+ blocks have the summary dropped
46
+ # - Forced tool choice (+any+/+tool+ type) is incompatible with thinking enabled (raises)
47
+ # - +Compaction+ and +ItemReference+ items are silently skipped
48
+ # - +FunctionCallOutput.status+ values +incomplete+, +failed+, and +error+
49
+ # are mapped to +tool_result.is_error: true+
50
+ #
51
+ # === Features in the Messages API not available through Open Responses
52
+ #
53
+ # The following Messages API parameters cannot be set through the Open Responses
54
+ # canonical format:
55
+ # - +top_k+ — top-K sampling parameter
56
+ # - +stop_sequences+ — custom stop sequences
57
+ # - +cache_control+ — top-level prompt-cache breakpoint selection
58
+ # - +inference_geo+ — geographic inference routing
59
+ # - +mcp_servers+ — MCP connector beta parameter
60
+ # - +container+ — code execution container reuse parameter
61
+ # - +cache_control+ markers on system blocks, message content blocks,
62
+ # tool definitions, or document blocks (prompt caching)
63
+ # - Citations on documents and tool_result content blocks
64
+ # - +search_result+ content blocks
65
+ # - Web search, code execution, computer use, bash tool, text editor, and
66
+ # memory built-in tools
67
+ # - Redacted thinking round-trip (+redacted_thinking+ blocks are supported
68
+ # when they appear in conversation history but cannot be requested via OR)
69
+ # - Cryptographic thinking signatures (passed through in history but not
70
+ # configurable as a generation parameter)
71
+ # - +anthropic-beta+ headers and API versioning (this gem produces no HTTP
72
+ # request — set headers in your HTTP client)
73
+ class Request < Base
74
+ DEFAULT_MAX_TOKENS = 4096
75
+ SUPPORTED_METADATA_KEYS = ["user_id"].freeze
76
+ EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"].freeze
77
+ SUPPORTED_THINKING_TYPES = ["adaptive", "disabled", "enabled"].freeze
78
+ SUPPORTED_TOOL_CHOICE_TYPES = ["any", "auto", "none", "tool"].freeze
79
+
80
+ class << self
81
+ private
82
+
83
+ def serialize_request(session)
84
+ h = {}
85
+ raise UnsupportedFormatError, "Messages format requires session.model" unless session.model
86
+
87
+ h["model"] = session.model
88
+ h["max_tokens"] = session.max_output_tokens || DEFAULT_MAX_TOKENS
89
+ h["temperature"] = session.temperature if session.temperature
90
+ h["top_p"] = session.top_p if session.top_p
91
+ effective_metadata = build_effective_metadata(session)
92
+ h["metadata"] = effective_metadata if effective_metadata
93
+ service_tier = serialize_service_tier(session.service_tier) if session.service_tier
94
+ h["service_tier"] = service_tier if service_tier
95
+ h["stream"] = session.stream unless session.stream.nil?
96
+
97
+ # Session extra: recognized keys for Messages API
98
+ apply_session_extra!(h, session.extra) if session.extra
99
+
100
+ output_config = build_output_config(session)
101
+ h["output_config"] = output_config unless output_config.empty?
102
+
103
+ thinking = serialize_thinking(session.reasoning)
104
+ validate_thinking_compatibility!(session, thinking) if thinking_active?(thinking)
105
+ h["thinking"] = thinking if thinking
106
+
107
+ system_parts = build_system(session)
108
+ h["system"] = system_parts unless system_parts.empty?
109
+
110
+ messages = build_messages(session)
111
+ if messages.empty?
112
+ raise UnsupportedFormatError,
113
+ "Messages format requires at least one user/assistant message"
114
+ end
115
+ unless messages.first["role"] == "user"
116
+ raise UnsupportedFormatError,
117
+ "Messages format requires the first message to have role \"user\""
118
+ end
119
+ h["messages"] = messages
120
+
121
+ tools = build_tools(session)
122
+ h["tools"] = tools unless tools.empty?
123
+
124
+ tool_choice = serialize_tool_choice(
125
+ session.tool_choice,
126
+ tools: tools,
127
+ parallel_tool_calls: session.parallel_tool_calls,
128
+ thinking_enabled: thinking_active?(thinking)
129
+ )
130
+ h["tool_choice"] = tool_choice if tool_choice
131
+
132
+ h
133
+ end
134
+
135
+ def apply_session_extra!(h, extra)
136
+ h["top_k"] = extra["top_k"] if extra.key?("top_k")
137
+ h["stop_sequences"] = extra["stop_sequences"] if extra.key?("stop_sequences")
138
+ h["cache_control"] = extra["cache_control"] if extra.key?("cache_control")
139
+ end
140
+
141
+ def build_effective_metadata(session)
142
+ metadata = session.metadata&.dup || {}
143
+
144
+ if session.safety_identifier
145
+ existing_user_id = metadata["user_id"]
146
+ if existing_user_id && existing_user_id != session.safety_identifier
147
+ raise UnsupportedFormatError,
148
+ "Messages format has conflicting safety_identifier and metadata.user_id values"
149
+ end
150
+ metadata["user_id"] = session.safety_identifier
151
+ end
152
+
153
+ return nil if metadata.empty?
154
+
155
+ serialize_metadata(metadata)
156
+ end
157
+
158
+ # Only the user_id key is forwarded; other metadata keys are silently
159
+ # omitted since the Messages API does not support them.
160
+ def serialize_metadata(metadata)
161
+ supported = metadata.slice(*SUPPORTED_METADATA_KEYS)
162
+ supported.empty? ? nil : supported
163
+ end
164
+
165
+ # Unsupported service_tier values are silently omitted.
166
+ def serialize_service_tier(service_tier)
167
+ service_tier if ["auto", "standard_only"].include?(service_tier)
168
+ end
169
+
170
+ def serialize_thinking(reasoning)
171
+ return nil unless reasoning
172
+
173
+ thinking = {}
174
+ thinking["budget_tokens"] = reasoning["budget_tokens"] if reasoning.key?("budget_tokens")
175
+ thinking["display"] = reasoning["display"] if reasoning["display"]
176
+ return nil if thinking.empty? && !reasoning["type"]
177
+
178
+ thinking["type"] = reasoning["type"] || "enabled"
179
+
180
+ # Unsupported reasoning.type values are silently omitted.
181
+ return nil unless SUPPORTED_THINKING_TYPES.include?(thinking["type"])
182
+
183
+ if thinking["type"] == "enabled" && !thinking.key?("budget_tokens")
184
+ raise UnsupportedFormatError,
185
+ "Messages format requires reasoning.budget_tokens when thinking is enabled"
186
+ end
187
+
188
+ if thinking["type"] != "enabled" && thinking.key?("budget_tokens")
189
+ raise UnsupportedFormatError,
190
+ "Messages format only supports reasoning.budget_tokens when reasoning.type is enabled"
191
+ end
192
+
193
+ if thinking["type"] == "disabled" && thinking.key?("display")
194
+ raise UnsupportedFormatError,
195
+ "Messages format does not support reasoning.display when reasoning.type is disabled"
196
+ end
197
+
198
+ thinking
199
+ end
200
+
201
+ def thinking_active?(thinking)
202
+ thinking && thinking["type"] != "disabled"
203
+ end
204
+
205
+ def build_output_config(session)
206
+ output_config = {}
207
+
208
+ if session.reasoning && session.reasoning["effort"]
209
+ effort = session.reasoning["effort"]
210
+ # Unsupported effort levels are silently omitted.
211
+ output_config["effort"] = effort if EFFORT_LEVELS.include?(effort)
212
+ end
213
+
214
+ if session.text
215
+ # Unsupported text.* keys are silently omitted; only format is mapped.
216
+ output_format = serialize_output_format(session.text["format"])
217
+ output_config["format"] = output_format if output_format
218
+ end
219
+
220
+ output_config
221
+ end
222
+
223
+ def serialize_output_format(format)
224
+ return nil unless format
225
+
226
+ # Only json_schema output is supported; other format types (and any
227
+ # unrecognized format/json_schema keys) are silently omitted.
228
+ return nil unless format.is_a?(Hash) && format["type"] == "json_schema"
229
+
230
+ schema = format.dig("json_schema", "schema") || format["schema"]
231
+ unless schema
232
+ raise UnsupportedFormatError,
233
+ "Messages format requires text.format.schema for json_schema output"
234
+ end
235
+
236
+ {"type" => "json_schema", "schema" => schema}
237
+ end
238
+
239
+ def validate_thinking_compatibility!(session, thinking)
240
+ return unless thinking
241
+
242
+ if session.temperature
243
+ raise UnsupportedFormatError,
244
+ "Messages format does not support temperature when thinking is enabled"
245
+ end
246
+
247
+ return unless session.top_p && session.top_p < 0.95
248
+
249
+ raise UnsupportedFormatError,
250
+ "Messages format requires top_p >= 0.95 when thinking is enabled"
251
+ end
252
+
253
+ def build_system(session)
254
+ parts = []
255
+
256
+ if session.instructions
257
+ parts << {"type" => "text", "text" => session.instructions}
258
+ end
259
+
260
+ session.items.each do |item|
261
+ next unless item.is_a?(Items::Message)
262
+ next unless item.role == "system" || item.role == "developer"
263
+
264
+ item.content.each do |content|
265
+ if content.is_a?(Content::InputText)
266
+ part = {"type" => "text", "text" => content.text}
267
+ if content.extra && content.extra["cache_control"]
268
+ part["cache_control"] = content.extra["cache_control"]
269
+ end
270
+ parts << part
271
+ end
272
+ end
273
+ end
274
+
275
+ parts
276
+ end
277
+
278
+ def build_messages(session)
279
+ raw_messages = []
280
+
281
+ session.items.each do |item|
282
+ case item
283
+ when Items::Message
284
+ next if item.role == "system" || item.role == "developer"
285
+
286
+ role = (item.role == "assistant") ? "assistant" : "user"
287
+ # RefusalContent is dropped silently: it can land in the session
288
+ # via a parsed Chat Completions response, but cannot be sent back
289
+ # to any provider in a request payload.
290
+ visible_content = item.content.reject { |c| c.is_a?(Content::RefusalContent) }
291
+ next if visible_content.empty?
292
+ content = visible_content.filter_map { |message_content| serialize_content(message_content, role: role) }
293
+ next if content.empty?
294
+ raw_messages << {"role" => role, "content" => content}
295
+ when Items::FunctionCall
296
+ raw_messages << {
297
+ "role" => "assistant",
298
+ "content" => [{
299
+ "type" => "tool_use",
300
+ "id" => item.call_id,
301
+ "name" => item.name,
302
+ "input" => item.parsed_arguments
303
+ }]
304
+ }
305
+ when Items::FunctionCallOutput
306
+ raw_messages << {
307
+ "role" => "user",
308
+ "content" => [serialize_tool_result(item)]
309
+ }
310
+ when Items::Reasoning
311
+ # Reasoning summary blocks come from the Responses API and cannot be
312
+ # replayed as signed Anthropic thinking blocks; they are silently skipped.
313
+ content_blocks = item.content.map { |block| serialize_reasoning_block(block) }.compact
314
+ unless content_blocks.empty?
315
+ raw_messages << {"role" => "assistant", "content" => content_blocks}
316
+ end
317
+ when Items::Compaction, Items::ItemReference
318
+ # Compaction and ItemReference items are not supported; skip them.
319
+ next
320
+ end
321
+ end
322
+
323
+ merge_consecutive_messages(raw_messages)
324
+ end
325
+
326
+ def serialize_content(content, role:)
327
+ block = serialize_content_block(content, role: role)
328
+ return nil unless block
329
+
330
+ # Apply cache_control from content extra if present
331
+ if content.respond_to?(:extra) && content.extra && content.extra["cache_control"]
332
+ block["cache_control"] = content.extra["cache_control"]
333
+ end
334
+
335
+ block
336
+ end
337
+
338
+ def serialize_content_block(content, role:)
339
+ case content
340
+ when Content::InputText
341
+ {"type" => "text", "text" => content.text}
342
+ when Content::OutputText
343
+ text = {"type" => "text", "text" => content.text}
344
+ text["citations"] = content.annotations unless content.annotations.empty?
345
+ text
346
+ when Content::InputImage
347
+ # Assistant image content is not supported; omit it.
348
+ return nil if role == "assistant"
349
+
350
+ file_id = content.extra && content.extra["file_id"]
351
+
352
+ if content.url
353
+ parsed = PromptBuilder.parse_data_url(content.url)
354
+ if parsed
355
+ {
356
+ "type" => "image",
357
+ "source" => {
358
+ "type" => "base64",
359
+ "media_type" => parsed[0],
360
+ "data" => parsed[1]
361
+ }
362
+ }
363
+ else
364
+ {
365
+ "type" => "image",
366
+ "source" => {"type" => "url", "url" => content.url}
367
+ }
368
+ end
369
+ elsif file_id
370
+ {
371
+ "type" => "image",
372
+ "source" => {"type" => "file", "file_id" => file_id}
373
+ }
374
+ else
375
+ raise UnsupportedFormatError,
376
+ "Messages format requires InputImage.url or file_id in extra"
377
+ end
378
+ when Content::InputFile
379
+ # Assistant file content is not supported; omit it.
380
+ return nil if role == "assistant"
381
+
382
+ file_id = content.extra && content.extra["file_id"]
383
+ media_type = content.extra && content.extra["media_type"]
384
+
385
+ parsed = PromptBuilder.parse_data_url(content.url)
386
+ if parsed
387
+ document = {
388
+ "type" => "document",
389
+ "source" => {
390
+ "type" => "base64",
391
+ "media_type" => media_type || parsed[0],
392
+ "data" => parsed[1]
393
+ }
394
+ }
395
+ elsif content.url
396
+ document = {
397
+ "type" => "document",
398
+ "source" => {"type" => "url", "url" => content.url}
399
+ }
400
+ elsif file_id
401
+ document = {
402
+ "type" => "document",
403
+ "source" => {"type" => "file", "file_id" => file_id}
404
+ }
405
+ else
406
+ raise UnsupportedFormatError,
407
+ "Messages format requires InputFile.url or file_id in extra"
408
+ end
409
+
410
+ document["title"] = content.filename if content.filename
411
+ # Apply citations opt-in from extra
412
+ if content.extra && content.extra["citations"]
413
+ document["citations"] = content.extra["citations"]
414
+ end
415
+ document
416
+ when Content::InputVideo
417
+ # InputVideo content is not supported; omit it.
418
+ nil
419
+ when Content::RefusalContent
420
+ # Filtered out before reaching here; treat as a defensive no-op.
421
+ nil
422
+ else
423
+ # Unsupported content types are silently omitted.
424
+ nil
425
+ end
426
+ end
427
+
428
+ # FunctionCallOutput.status values that map to tool_result.is_error.
429
+ # "incomplete" and "failed" cover the OR canonical statuses; "error"
430
+ # is accepted as a convenience alias.
431
+ ERROR_TOOL_OUTPUT_STATUSES = %w[incomplete failed error].freeze
432
+ private_constant :ERROR_TOOL_OUTPUT_STATUSES
433
+
434
+ def serialize_tool_result(item)
435
+ result = {
436
+ "type" => "tool_result",
437
+ "tool_use_id" => item.call_id
438
+ }
439
+ content = if item.output.is_a?(Array)
440
+ item.output.filter_map { |c| serialize_tool_result_content(c) }
441
+ elsif !item.output.nil?
442
+ # String outputs are passed through directly per Anthropic's schema.
443
+ item.output
444
+ end
445
+
446
+ # Anthropic requires tool_result.content; collapse missing/empty
447
+ # array outputs to a single empty text block.
448
+ content = [{"type" => "text", "text" => ""}] if content.nil? || (content.is_a?(Array) && content.empty?)
449
+ result["content"] = content
450
+ result["is_error"] = true if ERROR_TOOL_OUTPUT_STATUSES.include?(item.status)
451
+ result
452
+ end
453
+
454
+ # Anthropic's tool_result.content only accepts text and image blocks.
455
+ # Document blocks are rejected by the API.
456
+ def serialize_tool_result_content(content)
457
+ case content
458
+ when Content::InputText, Content::OutputText
459
+ {"type" => "text", "text" => content.text}
460
+ when Content::InputImage
461
+ serialize_content(content, role: "user")
462
+ else
463
+ # Anthropic only accepts text and image blocks in tool_result.content;
464
+ # other content types are silently omitted.
465
+ nil
466
+ end
467
+ end
468
+
469
+ def serialize_reasoning_block(block)
470
+ case block["type"]
471
+ when "thinking"
472
+ # Anthropic only accepts thinking blocks it has signed. Unsigned
473
+ # thinking from cross-provider history (e.g. parsed from a Gemini
474
+ # response) is dropped rather than rejected.
475
+ return nil unless block["signature"]
476
+
477
+ {
478
+ "type" => "thinking",
479
+ "thinking" => block.fetch("thinking", ""),
480
+ "signature" => block["signature"]
481
+ }
482
+ when "redacted_thinking"
483
+ unless block["data"]
484
+ raise UnsupportedFormatError,
485
+ "Messages format requires reasoning.data for redacted_thinking blocks"
486
+ end
487
+
488
+ {
489
+ "type" => "redacted_thinking",
490
+ "data" => block["data"]
491
+ }
492
+ else
493
+ # Unsupported reasoning block types are silently omitted.
494
+ nil
495
+ end
496
+ end
497
+
498
+ def merge_consecutive_messages(messages)
499
+ return messages if messages.empty?
500
+
501
+ merged = [messages.first]
502
+
503
+ messages[1..].each do |message|
504
+ if merged.last["role"] == message["role"]
505
+ merged.last["content"].concat(message["content"])
506
+ else
507
+ merged << message
508
+ end
509
+ end
510
+
511
+ merged
512
+ end
513
+
514
+ def build_tools(session)
515
+ session.tool_definitions.map do |definition|
516
+ tool = {"name" => definition.name}
517
+ tool["description"] = definition.description if definition.description
518
+ tool["input_schema"] = definition.parameters || {"type" => "object", "properties" => {}}
519
+ tool["strict"] = true if definition.strict
520
+ tool["cache_control"] = definition.extra["cache_control"] if definition.extra["cache_control"]
521
+ tool
522
+ end
523
+ end
524
+
525
+ def serialize_tool_choice(choice, tools:, parallel_tool_calls:, thinking_enabled:)
526
+ if choice.nil?
527
+ return nil if parallel_tool_calls.nil?
528
+
529
+ # parallel_tool_calls cannot be expressed without tools; omit it.
530
+ return nil if tools.empty?
531
+
532
+ return nil if parallel_tool_calls
533
+
534
+ return {
535
+ "type" => "auto",
536
+ "disable_parallel_tool_use" => true
537
+ }
538
+ end
539
+
540
+ normalized_choice = normalize_tool_choice(choice)
541
+ # Unsupported tool_choice values are silently omitted.
542
+ return nil if normalized_choice.nil?
543
+
544
+ # tool_choice cannot be expressed without tools; omit it.
545
+ return nil if tools.empty? && normalized_choice["type"] != "none"
546
+
547
+ if thinking_enabled && ["any", "tool"].include?(normalized_choice["type"])
548
+ raise UnsupportedFormatError,
549
+ "Messages format does not support forced tool_choice when thinking is enabled"
550
+ end
551
+
552
+ apply_parallel_tool_calls(normalized_choice, parallel_tool_calls)
553
+ end
554
+
555
+ def normalize_tool_choice(choice)
556
+ case choice
557
+ when "auto"
558
+ {"type" => "auto"}
559
+ when "required"
560
+ {"type" => "any"}
561
+ when "none"
562
+ {"type" => "none"}
563
+ when Hash
564
+ if choice["type"] == "function"
565
+ name = choice["name"] || choice.dig("function", "name")
566
+ unless name
567
+ raise UnsupportedFormatError,
568
+ "Messages format requires tool_choice.name for function tool choices"
569
+ end
570
+
571
+ {"type" => "tool", "name" => name}
572
+ else
573
+ normalize_tool_choice_hash(choice)
574
+ end
575
+ else
576
+ # Unsupported tool_choice values are silently omitted.
577
+ nil
578
+ end
579
+ end
580
+
581
+ def normalize_tool_choice_hash(choice)
582
+ type = choice["type"]
583
+ # Unsupported tool_choice.type values are silently omitted.
584
+ return nil unless SUPPORTED_TOOL_CHOICE_TYPES.include?(type)
585
+
586
+ if type == "tool" && !choice["name"]
587
+ raise UnsupportedFormatError,
588
+ "Messages format requires tool_choice.name when type is tool"
589
+ end
590
+
591
+ allowed_keys = case type
592
+ when "auto", "any"
593
+ ["disable_parallel_tool_use", "type"]
594
+ when "tool"
595
+ ["disable_parallel_tool_use", "name", "type"]
596
+ when "none"
597
+ ["type"]
598
+ end
599
+
600
+ # Unsupported keys are silently dropped.
601
+ choice.slice(*allowed_keys)
602
+ end
603
+
604
+ def apply_parallel_tool_calls(choice, parallel_tool_calls)
605
+ return choice if parallel_tool_calls.nil?
606
+
607
+ if choice["type"] == "none"
608
+ raise UnsupportedFormatError,
609
+ "Messages format does not support parallel_tool_calls with tool_choice.none"
610
+ end
611
+
612
+ if parallel_tool_calls
613
+ if choice["disable_parallel_tool_use"] == true
614
+ raise UnsupportedFormatError,
615
+ "Messages format received conflicting parallel_tool_calls and tool_choice.disable_parallel_tool_use"
616
+ end
617
+
618
+ choice.delete("disable_parallel_tool_use")
619
+ return choice
620
+ end
621
+
622
+ if choice["disable_parallel_tool_use"] == false
623
+ raise UnsupportedFormatError,
624
+ "Messages format received conflicting parallel_tool_calls and tool_choice.disable_parallel_tool_use"
625
+ end
626
+
627
+ choice["disable_parallel_tool_use"] = true
628
+ choice
629
+ end
630
+ end
631
+ end
632
+ end
633
+ end
634
+ end