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