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,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
|