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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ # Represents a parsed API response from the Open Responses API.
5
+ # All fields are optional and will be nil if not present in the response.
6
+ class Response
7
+ # All scalar fields returned by the API. Used to drive attr_reader,
8
+ # initialize, from_h, and to_h so each name is declared once.
9
+ FIELDS = %i[
10
+ id
11
+ object
12
+ created_at
13
+ completed_at
14
+ status
15
+ incomplete_details
16
+ model
17
+ previous_response_id
18
+ instructions
19
+ error
20
+ tools
21
+ tool_choice
22
+ truncation
23
+ parallel_tool_calls
24
+ top_p
25
+ presence_penalty
26
+ frequency_penalty
27
+ top_logprobs
28
+ temperature
29
+ reasoning
30
+ max_output_tokens
31
+ max_tool_calls
32
+ store
33
+ background
34
+ service_tier
35
+ metadata
36
+ safety_identifier
37
+ prompt_cache_key
38
+ prompt_cache_retention
39
+ ].freeze
40
+ private_constant :FIELDS
41
+
42
+ # Boolean fields that may legitimately be false; serialized with a nil? check.
43
+ BOOLEAN_FIELDS = %i[parallel_tool_calls store background].freeze
44
+ private_constant :BOOLEAN_FIELDS
45
+
46
+ # String-typed fields coerced with .to_s on assignment.
47
+ STRING_FIELDS = %i[
48
+ id object status model previous_response_id instructions truncation service_tier safety_identifier
49
+ prompt_cache_key prompt_cache_retention
50
+ ].freeze
51
+ private_constant :STRING_FIELDS
52
+
53
+ # Float-typed fields coerced with .to_f on assignment.
54
+ FLOAT_FIELDS = %i[top_p presence_penalty frequency_penalty temperature].freeze
55
+ private_constant :FLOAT_FIELDS
56
+
57
+ # Integer-typed fields coerced with .to_i on assignment.
58
+ INTEGER_FIELDS = %i[created_at completed_at top_logprobs max_output_tokens max_tool_calls].freeze
59
+ private_constant :INTEGER_FIELDS
60
+
61
+ # @!attribute [r] id
62
+ # @return [String, nil] the response identifier
63
+ # @!attribute [r] object
64
+ # @return [String, nil] the object type
65
+ # @!attribute [r] created_at
66
+ # @return [Integer, nil] creation timestamp
67
+ # @!attribute [r] completed_at
68
+ # @return [Integer, nil] completion timestamp
69
+ # @!attribute [r] status
70
+ # @return [String, nil] the response status
71
+ # @!attribute [r] incomplete_details
72
+ # @return [Hash, nil] details about why the response is incomplete
73
+ # @!attribute [r] model
74
+ # @return [String, nil] the model identifier
75
+ # @!attribute [r] previous_response_id
76
+ # @return [String, nil] the previous response identifier
77
+ # @!attribute [r] instructions
78
+ # @return [String, nil] the system instructions
79
+ # @!attribute [r] error
80
+ # @return [Hash, nil] error information
81
+ # @!attribute [r] tools
82
+ # @return [Array<Hash>, nil] the tool definitions
83
+ # @!attribute [r] tool_choice
84
+ # @return [String, Hash, nil] the tool choice configuration
85
+ # @!attribute [r] truncation
86
+ # @return [String, nil] the truncation strategy
87
+ # @!attribute [r] parallel_tool_calls
88
+ # @return [Boolean, nil] whether parallel tool calls are enabled
89
+ # @!attribute [r] top_p
90
+ # @return [Float, nil] the top_p sampling parameter
91
+ # @!attribute [r] presence_penalty
92
+ # @return [Float, nil] the presence penalty
93
+ # @!attribute [r] frequency_penalty
94
+ # @return [Float, nil] the frequency penalty
95
+ # @!attribute [r] top_logprobs
96
+ # @return [Integer, nil] the number of top log probabilities to return
97
+ # @!attribute [r] temperature
98
+ # @return [Float, nil] the temperature
99
+ # @!attribute [r] reasoning
100
+ # @return [Hash, nil] the reasoning configuration
101
+ # @!attribute [r] max_output_tokens
102
+ # @return [Integer, nil] the maximum output tokens
103
+ # @!attribute [r] max_tool_calls
104
+ # @return [Integer, nil] the maximum number of tool calls
105
+ # @!attribute [r] store
106
+ # @return [Boolean, nil] whether to store the response
107
+ # @!attribute [r] background
108
+ # @return [Boolean, nil] whether this is a background response
109
+ # @!attribute [r] service_tier
110
+ # @return [String, nil] the service tier
111
+ # @!attribute [r] metadata
112
+ # @return [Hash, nil] arbitrary metadata
113
+ # @!attribute [r] safety_identifier
114
+ # @return [String, nil] the safety identifier
115
+ # @!attribute [r] prompt_cache_key
116
+ # @return [String, nil] the prompt cache key
117
+ # @!attribute [r] prompt_cache_retention
118
+ # @return [String, nil] the prompt cache retention policy
119
+ # @!attribute [r] text_config
120
+ # @return [Hash, nil] the text configuration
121
+ # @!attribute [r] output
122
+ # @return [Array<Items::Base>] the output items
123
+ # @!attribute [r] usage
124
+ # @return [Usage, nil] token usage statistics
125
+ FIELDS.each { |f| attr_reader f }
126
+ attr_reader :text_config, :output, :usage, :extra
127
+
128
+ BOOLEAN_FIELDS.each { |f| alias_method("#{f}?", f) }
129
+
130
+ # Create a new Response.
131
+ #
132
+ # @param attributes [Hash] response attributes
133
+ # @option attributes [Hash, nil] :extra provider-specific response
134
+ # metadata that has no canonical Open Responses slot (e.g. Gemini grounding
135
+ # and citation metadata, candidate safety ratings, logprobs results)
136
+ def initialize(**attributes)
137
+ FIELDS.each { |f| instance_variable_set(:"@#{f}", coerce_field(f, attributes[f])) }
138
+ @text_config = PromptBuilder.jsonify(attributes[:text_config])
139
+ @output = attributes[:output] || []
140
+ @usage = attributes[:usage]
141
+ @extra = PromptBuilder.jsonify(attributes[:extra])
142
+ end
143
+
144
+ class << self
145
+ # Parse a response hash using the given serializer and return a Response.
146
+ #
147
+ # @param hash [Hash] the API response hash (typically from JSON.parse)
148
+ # @param serializer_class [Class, Symbol] a serializer class (e.g. Serializers::ChatCompletion)
149
+ # or a symbol shorthand (+:open_responses+, +:chat_completion+, +:messages+,
150
+ # +:gemini+, +:converse+)
151
+ # @return [Response]
152
+ # @raise [ArgumentError] if a symbol is given that does not map to a known serializer
153
+ def parse(hash, serializer_class)
154
+ Serializers.resolve(serializer_class).parse_response(hash)
155
+ end
156
+
157
+ # Deserialize a Response from a Hash.
158
+ #
159
+ # @param hash [Hash] a Hash with string keys from the API response
160
+ # @return [Response]
161
+ def from_h(hash)
162
+ output = (hash["output"] || []).map { |item| Items::Base.from_h(item) }
163
+ usage = hash["usage"] ? Usage.from_h(hash["usage"]) : nil
164
+
165
+ attrs = FIELDS.each_with_object({}) { |f, acc| acc[f] = hash[f.to_s] }
166
+ new(**attrs, text_config: hash["text"], output: output, usage: usage, extra: hash["extra"])
167
+ end
168
+ end
169
+
170
+ # Check if the response completed successfully.
171
+ #
172
+ # @return [Boolean]
173
+ def completed?
174
+ @status == "completed"
175
+ end
176
+
177
+ # Check if the response failed.
178
+ #
179
+ # @return [Boolean]
180
+ def failed?
181
+ @status == "failed"
182
+ end
183
+
184
+ # Check if the response is incomplete.
185
+ #
186
+ # @return [Boolean]
187
+ def incomplete?
188
+ @status == "incomplete"
189
+ end
190
+
191
+ # Check if the response contains any tool calls.
192
+ #
193
+ # @return [Boolean]
194
+ def has_tool_calls?
195
+ @output.any? { |item| item.is_a?(Items::FunctionCall) }
196
+ end
197
+
198
+ # Get all function call items from the output.
199
+ #
200
+ # @return [Array<Items::FunctionCall>]
201
+ def tool_calls
202
+ @output.select { |item| item.is_a?(Items::FunctionCall) }
203
+ end
204
+
205
+ # Extract the text from the first output message. Returns the concatenated
206
+ # text of all OutputText content blocks in the first message.
207
+ #
208
+ # @return [String, nil] the text content, or nil if no message with text is found
209
+ def text
210
+ @output.each do |item|
211
+ next unless item.is_a?(Items::Message)
212
+
213
+ texts = item.content.select { |c| c.is_a?(Content::OutputText) }
214
+ return texts.map(&:text).join unless texts.empty?
215
+ end
216
+ nil
217
+ end
218
+
219
+ # Serialize to a Hash with string keys. Nil values are omitted.
220
+ #
221
+ # @return [Hash]
222
+ def to_h
223
+ h = {}
224
+ FIELDS.each do |f|
225
+ val = instance_variable_get(:"@#{f}")
226
+ if BOOLEAN_FIELDS.include?(f)
227
+ h[f.to_s] = val unless val.nil?
228
+ elsif val
229
+ h[f.to_s] = val
230
+ end
231
+ end
232
+ h["text"] = @text_config if @text_config
233
+ h["output"] = @output.map(&:to_h) unless @output.empty?
234
+ h["usage"] = @usage.to_h if @usage
235
+ h["extra"] = @extra if @extra && !@extra.empty?
236
+ h
237
+ end
238
+
239
+ private
240
+
241
+ def coerce_field(field, value)
242
+ return value if value.nil?
243
+
244
+ if STRING_FIELDS.include?(field)
245
+ value.to_s
246
+ elsif FLOAT_FIELDS.include?(field)
247
+ value.to_f
248
+ elsif INTEGER_FIELDS.include?(field)
249
+ value.to_i
250
+ elsif BOOLEAN_FIELDS.include?(field)
251
+ value
252
+ else
253
+ PromptBuilder.jsonify(value)
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ # Base class for format serializers. Provides a common interface for
6
+ # exporting sessions and parsing responses in alternate API formats.
7
+ class Base
8
+ class << self
9
+ # Export a session to the target format's request payload.
10
+ #
11
+ # @param session [Session] the session to export
12
+ # @return [Hash] the serialized request payload
13
+ def request_payload(session)
14
+ serialize_request(session)
15
+ end
16
+
17
+ # Parse a response from the target format into an PromptBuilder::Response.
18
+ #
19
+ # @param hash [Hash] the response hash in the target format
20
+ # @return [Response] the parsed response
21
+ def parse_response(hash)
22
+ deserialize_response(hash)
23
+ end
24
+
25
+ private
26
+
27
+ def serialize_request(_session)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def deserialize_response(_hash)
32
+ raise NotImplementedError
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class ChatCompletion < Base
6
+ # Request serializer for the OpenAI Chat Completions 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
+ # - +background+ — Chat Completions has no background/async mode
13
+ # - +include+ — response-field inclusion is an Open Responses-only concept
14
+ # - +max_tool_calls+ — per-request tool-call caps are not supported
15
+ # - +truncation+ — server-side context truncation is not supported
16
+ #
17
+ # Partially supported session fields (unsupported keys are omitted):
18
+ # - +text+ — only +format+ (mapped to +response_format+) and +verbosity+
19
+ # (mapped to top-level +verbosity+) are supported
20
+ # - +reasoning+ — only the +effort+ key is mapped to +reasoning_effort+
21
+ # - +stream_options+ — only +include_usage+ and +include_obfuscation+ are
22
+ # supported, and only when +stream+ is set (otherwise it is omitted)
23
+ #
24
+ # Input content restrictions:
25
+ # - +InputVideo+ content is not supported in any message (omitted)
26
+ # - +Reasoning+ items are not supported (skipped)
27
+ # - +RefusalContent+ is dropped silently (a parsed Chat Completions
28
+ # refusal can stay in session history without breaking subsequent
29
+ # request_payload calls)
30
+ # - +InputImage+ content is only supported in user messages (assistant/developer/system images are omitted)
31
+ # - +InputImage+ with +file_id+ in +extra+ is not supported (the +image_file+
32
+ # content type is Assistants API only); a +file_id+-only image is omitted
33
+ # - +InputFile+ is mapped to a +file+ content block (Files API id via +extra+,
34
+ # base64 +file_data+, or both with +filename+); a +file_url+-only +InputFile+
35
+ # is omitted because Chat Completions has no remote-URL form for files
36
+ # - Only text content is supported in tool (+FunctionCallOutput+) results;
37
+ # other content is omitted
38
+ # - +OutputText.annotations+ are dropped silently on request serialization
39
+ # so a parsed response with citations can sit in session history without
40
+ # breaking subsequent +request_payload+ calls
41
+ #
42
+ # === Features in Chat Completions not available through Open Responses
43
+ #
44
+ # The following Chat Completions parameters cannot be set through the Open
45
+ # Responses canonical format:
46
+ # - +seed+ — for reproducible outputs
47
+ # - +logit_bias+ — per-token probability adjustments
48
+ # - +n+ — requesting multiple response candidates
49
+ # - +stop+ — custom stop sequences
50
+ # - +prediction+ — speculative decoding hints
51
+ # - Audio input and audio output (model-dependent)
52
+ # - +web_search_options+ — built-in web search tool
53
+ # - +modalities+ — output modality selection (text/audio)
54
+ # - +tool_choice+ +allowed_tools+ shape and custom (non-function) tool types
55
+ class Request < Base
56
+ SUPPORTED_MESSAGE_ROLES = %w[assistant developer system user].freeze
57
+ SUPPORTED_STREAM_OPTION_KEYS = %w[include_obfuscation include_usage].freeze
58
+ SUPPORTED_TOOL_CHOICE_VALUES = %w[auto none required].freeze
59
+
60
+ class << self
61
+ private
62
+
63
+ def serialize_request(session)
64
+ h = {}
65
+ h["model"] = session.model if session.model
66
+ h["messages"] = build_messages(session)
67
+ h["temperature"] = session.temperature if session.temperature
68
+ h["top_p"] = session.top_p if session.top_p
69
+ h["presence_penalty"] = session.presence_penalty if session.presence_penalty
70
+ h["frequency_penalty"] = session.frequency_penalty if session.frequency_penalty
71
+ h["max_completion_tokens"] = session.max_output_tokens if session.max_output_tokens
72
+ h["parallel_tool_calls"] = session.parallel_tool_calls unless session.parallel_tool_calls.nil?
73
+ if session.top_logprobs
74
+ h["logprobs"] = true
75
+ h["top_logprobs"] = session.top_logprobs
76
+ end
77
+ h["store"] = session.store unless session.store.nil?
78
+ h["metadata"] = session.metadata if session.metadata
79
+ h["service_tier"] = session.service_tier if session.service_tier
80
+ h["safety_identifier"] = session.safety_identifier if session.safety_identifier
81
+ h["prompt_cache_key"] = session.prompt_cache_key if session.prompt_cache_key
82
+ h["prompt_cache_retention"] = session.prompt_cache_retention if session.prompt_cache_retention
83
+ h["stream"] = session.stream unless session.stream.nil?
84
+ # stream_options is only valid alongside stream; otherwise it is omitted.
85
+ # Unsupported stream_options keys are dropped.
86
+ if session.stream_options && session.stream
87
+ stream_options = normalize_hash(session.stream_options).slice(*SUPPORTED_STREAM_OPTION_KEYS)
88
+ h["stream_options"] = stream_options unless stream_options.empty?
89
+ end
90
+
91
+ if session.text
92
+ text = normalize_hash(session.text)
93
+ h["response_format"] = extract_response_format(text["format"]) if text["format"]
94
+ h["verbosity"] = text["verbosity"] if text["verbosity"]
95
+ end
96
+
97
+ if session.reasoning
98
+ effort = normalize_hash(session.reasoning)["effort"]
99
+ h["reasoning_effort"] = effort if effort
100
+ end
101
+
102
+ tools = build_tools(session)
103
+ h["tools"] = tools unless tools.empty?
104
+
105
+ if session.tool_choice
106
+ tool_choice = serialize_tool_choice(session.tool_choice, tools.empty?)
107
+ h["tool_choice"] = tool_choice if tool_choice
108
+ end
109
+
110
+ # Session extra: recognized keys for Chat Completions API
111
+ apply_session_extra!(h, session.extra) if session.extra
112
+
113
+ h
114
+ end
115
+
116
+ def apply_session_extra!(h, extra)
117
+ h["stop"] = extra["stop"] if extra.key?("stop")
118
+ h["seed"] = extra["seed"] if extra.key?("seed")
119
+ h["logit_bias"] = extra["logit_bias"] if extra.key?("logit_bias")
120
+ h["n"] = extra["n"] if extra.key?("n")
121
+ h["prediction"] = extra["prediction"] if extra.key?("prediction")
122
+ h["web_search_options"] = extra["web_search_options"] if extra.key?("web_search_options")
123
+ h["modalities"] = extra["modalities"] if extra.key?("modalities")
124
+ h["audio"] = extra["audio"] if extra.key?("audio")
125
+ end
126
+
127
+ def build_messages(session)
128
+ messages = []
129
+
130
+ if session.instructions
131
+ messages << {"role" => "system", "content" => session.instructions}
132
+ end
133
+
134
+ pending_tool_calls = []
135
+ last_assistant_msg = nil
136
+
137
+ session.items.each do |item|
138
+ case item
139
+ when Items::Message
140
+ flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg)
141
+ msg = serialize_message(item)
142
+ next unless msg
143
+ messages << msg
144
+ last_assistant_msg = (item.role == "assistant") ? msg : nil
145
+ when Items::FunctionCall
146
+ pending_tool_calls << serialize_function_call(item)
147
+ when Items::FunctionCallOutput
148
+ flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg)
149
+ last_assistant_msg = nil
150
+ messages << {
151
+ "role" => "tool",
152
+ "tool_call_id" => item.call_id,
153
+ "content" => serialize_function_call_output_content(item.output)
154
+ }
155
+ when Items::Reasoning, Items::Compaction, Items::ItemReference
156
+ # Reasoning, Compaction, and ItemReference items are not supported
157
+ # in the request, so ignore them rather than raising an error.
158
+ next
159
+ end
160
+ end
161
+
162
+ flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg)
163
+ messages
164
+ end
165
+
166
+ def serialize_message(item)
167
+ # Messages with an unsupported role are silently skipped.
168
+ return nil unless SUPPORTED_MESSAGE_ROLES.include?(item.role)
169
+
170
+ # RefusalContent can land in the session via a parsed Chat Completions
171
+ # response; drop it silently so subsequent request_payload calls
172
+ # don't fail mid-loop.
173
+ visible_content = item.content.reject { |c| c.is_a?(Content::RefusalContent) }
174
+
175
+ # Skip messages with no remaining content. For assistants with only
176
+ # tool calls, flush_tool_calls! synthesizes a placeholder later.
177
+ return nil if visible_content.empty?
178
+
179
+ content = visible_content.filter_map { |c| serialize_content(item.role, c) }
180
+ return nil if content.empty?
181
+
182
+ {
183
+ "role" => item.role,
184
+ "content" => content
185
+ }
186
+ end
187
+
188
+ def serialize_content(role, content)
189
+ case content
190
+ when Content::InputText, Content::OutputText
191
+ serialize_text_content(content)
192
+ when Content::InputImage
193
+ # Image content is only supported in user messages; omit otherwise.
194
+ return nil unless role == "user"
195
+
196
+ serialize_image_content(content)
197
+ when Content::InputFile
198
+ # File content is only supported in user messages; omit otherwise.
199
+ return nil unless role == "user"
200
+
201
+ serialize_file_content(content)
202
+ when Content::InputVideo
203
+ # InputVideo content is not supported; omit it.
204
+ nil
205
+ when Content::RefusalContent
206
+ # Filtered out in serialize_message; defensive no-op here.
207
+ nil
208
+ else
209
+ # Unsupported content types are silently omitted.
210
+ nil
211
+ end
212
+ end
213
+
214
+ def serialize_function_call(item)
215
+ {
216
+ "id" => item.call_id,
217
+ "type" => "function",
218
+ "function" => {
219
+ "name" => item.name,
220
+ "arguments" => item.arguments
221
+ }
222
+ }
223
+ end
224
+
225
+ def serialize_function_call_output_content(output)
226
+ return "" if output.nil?
227
+ return output unless output.is_a?(Array)
228
+
229
+ # Only text content is supported in tool output; other content types
230
+ # are silently omitted.
231
+ output.filter_map do |content|
232
+ case content
233
+ when Content::InputText, Content::OutputText
234
+ serialize_text_content(content)
235
+ end
236
+ end
237
+ end
238
+
239
+ def flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg = nil)
240
+ return if pending_tool_calls.empty?
241
+
242
+ if last_assistant_msg
243
+ # Attach to the immediately preceding assistant message rather than emit a duplicate
244
+ last_assistant_msg["tool_calls"] = pending_tool_calls.dup
245
+ else
246
+ messages << {
247
+ "role" => "assistant",
248
+ "content" => nil,
249
+ "tool_calls" => pending_tool_calls.dup
250
+ }
251
+ end
252
+ pending_tool_calls.clear
253
+ end
254
+
255
+ def build_tools(session)
256
+ session.tool_definitions.map do |definition|
257
+ tool = {"type" => "function", "function" => {"name" => definition.name}}
258
+ tool["function"]["description"] = definition.description if definition.description
259
+ tool["function"]["parameters"] = definition.parameters if definition.parameters
260
+ tool["function"]["strict"] = definition.strict if definition.strict
261
+ tool
262
+ end
263
+ end
264
+
265
+ # Unsupported tool_choice values (including a tool_choice that requires
266
+ # tools when none are present) are silently omitted by returning nil.
267
+ def serialize_tool_choice(choice, tools_empty)
268
+ case choice
269
+ when String
270
+ return nil unless SUPPORTED_TOOL_CHOICE_VALUES.include?(choice)
271
+ return nil if tools_empty && choice != "none"
272
+
273
+ choice
274
+ when Hash
275
+ choice = normalize_hash(choice)
276
+
277
+ return nil if choice["type"] != "function"
278
+ return nil if tools_empty
279
+
280
+ name = choice["name"] || choice.dig("function", "name")
281
+ unless name
282
+ raise UnsupportedFormatError, "tool_choice.function.name is required in Chat Completions format"
283
+ end
284
+
285
+ {"type" => "function", "function" => {"name" => name}}
286
+ end
287
+ end
288
+
289
+ def serialize_text_content(content)
290
+ # OutputText.annotations (e.g. URL citations from web_search_options)
291
+ # are dropped silently — the canonical OR shape can carry them, but
292
+ # Chat Completions has no request-side place to put them. Dropping
293
+ # rather than raising lets a parsed response with citations sit in
294
+ # session history without breaking subsequent request_payload calls.
295
+ {"type" => "text", "text" => content.text}
296
+ end
297
+
298
+ def serialize_file_content(content)
299
+ file_id = content.extra && content.extra["file_id"]
300
+ media_type = content.extra && content.extra["media_type"]
301
+
302
+ parsed = PromptBuilder.parse_data_url(content.url)
303
+ mime = media_type || (parsed && parsed[0])
304
+
305
+ # Text-based files are inlined as text content blocks since the Chat
306
+ # Completions file type is not universally supported for text formats.
307
+ if parsed && text_media_type?(mime)
308
+ text = parsed[1].unpack1("m").force_encoding("utf-8")
309
+ return {"type" => "text", "text" => text}
310
+ end
311
+
312
+ file = {}
313
+ file["file_id"] = file_id if file_id
314
+ file["filename"] = content.filename if content.filename
315
+
316
+ if parsed
317
+ file["file_data"] = "data:#{mime};base64,#{parsed[1]}"
318
+ end
319
+
320
+ # InputFile plain URLs have no Chat Completions representation. When a
321
+ # usable source is present it is used; when a plain URL is the only
322
+ # source, the block is omitted rather than raising.
323
+ unless file["file_id"] || file["file_data"]
324
+ return nil if content.url
325
+
326
+ raise UnsupportedFormatError,
327
+ "InputFile requires file_id (in extra) or data in Chat Completions format"
328
+ end
329
+
330
+ {"type" => "file", "file" => file}
331
+ end
332
+
333
+ def text_media_type?(media_type)
334
+ return false unless media_type
335
+
336
+ media_type.start_with?("text/") ||
337
+ media_type == "application/json" ||
338
+ media_type == "application/xml" ||
339
+ media_type == "application/javascript" ||
340
+ media_type == "application/yaml" ||
341
+ media_type == "application/x-yaml" ||
342
+ media_type == "application/csv" ||
343
+ media_type == "application/xhtml+xml" ||
344
+ media_type == "application/sql" ||
345
+ media_type == "application/graphql" ||
346
+ media_type == "application/ld+json" ||
347
+ media_type.end_with?("+xml") ||
348
+ media_type.end_with?("+json")
349
+ end
350
+
351
+ def serialize_image_content(content)
352
+ url = content.url
353
+
354
+ if url
355
+ # InputImage file_id (in extra) is ignored: the image_file content
356
+ # type is Assistants API only. url is used instead.
357
+ image_url = {"url" => url}
358
+ image_url["detail"] = content.detail if content.detail
359
+ return {"type" => "image_url", "image_url" => image_url}
360
+ end
361
+
362
+ # No usable url. If file_id was provided, its only source is the
363
+ # unsupported image_file content type, so omit the block.
364
+ file_id = content.extra && content.extra["file_id"]
365
+ return nil if file_id
366
+
367
+ raise UnsupportedFormatError, "InputImage requires url in Chat Completions format"
368
+ end
369
+
370
+ def extract_response_format(format)
371
+ return format unless format.is_a?(Hash) && format["type"] == "json_schema"
372
+ return format if format["json_schema"].is_a?(Hash)
373
+
374
+ # The Open Responses canonical shape puts name/schema/strict/description
375
+ # flat under text.format; Chat Completions wraps them in a json_schema sub-object.
376
+ json_schema = format.slice("name", "schema", "strict", "description")
377
+ {"type" => "json_schema", "json_schema" => json_schema}
378
+ end
379
+
380
+ def normalize_hash(value)
381
+ value.each_with_object({}) do |(key, nested_value), hash|
382
+ hash[key.to_s] = nested_value.is_a?(Hash) ? normalize_hash(nested_value) : nested_value
383
+ end
384
+ end
385
+ end
386
+ end
387
+ end
388
+ end
389
+ end