ruby_llm-responses_api 0.6.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +37 -0
- data/MIGRATION.md +276 -0
- data/README.md +42 -32
- data/lib/ruby_llm/providers/openai_responses/active_record_extension.rb +12 -109
- data/lib/ruby_llm/providers/openai_responses/base.rb +2 -7
- data/lib/ruby_llm/providers/openai_responses/batch.rb +8 -8
- data/lib/ruby_llm/providers/openai_responses/batches.rb +6 -14
- data/lib/ruby_llm/providers/openai_responses/built_in_tools.rb +1 -1
- data/lib/ruby_llm/providers/openai_responses/chat_extension.rb +3 -12
- data/lib/ruby_llm/providers/openai_responses/compaction.rb +1 -1
- data/lib/ruby_llm/providers/openai_responses/message_extension.rb +11 -21
- data/lib/ruby_llm/providers/openai_responses/model_registry.rb +17 -261
- data/lib/ruby_llm/providers/openai_responses/protocol.rb +103 -0
- data/lib/ruby_llm/providers/openai_responses/stream_accumulator.rb +48 -0
- data/lib/ruby_llm/providers/openai_responses/web_socket.rb +51 -77
- data/lib/ruby_llm/providers/openai_responses.rb +29 -65
- data/lib/rubyllm_responses_api.rb +13 -46
- metadata +17 -18
- data/lib/ruby_llm/providers/openai_responses/capabilities.rb +0 -263
- data/lib/ruby_llm/providers/openai_responses/chat.rb +0 -273
- data/lib/ruby_llm/providers/openai_responses/media.rb +0 -95
- data/lib/ruby_llm/providers/openai_responses/models.rb +0 -48
- data/lib/ruby_llm/providers/openai_responses/stream_accumulator_extension.rb +0 -39
- data/lib/ruby_llm/providers/openai_responses/streaming.rb +0 -137
- data/lib/ruby_llm/providers/openai_responses/tools.rb +0 -222
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RubyLLM
|
|
4
|
-
module Providers
|
|
5
|
-
class OpenAIResponses
|
|
6
|
-
# Model capabilities for OpenAI Responses API models.
|
|
7
|
-
# Defines which models support which features.
|
|
8
|
-
module Capabilities
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
# Models that support the Responses API
|
|
12
|
-
RESPONSES_API_MODELS = %w[
|
|
13
|
-
gpt-5.5
|
|
14
|
-
gpt-5.2 gpt-5.1 gpt-5.1-codex-max gpt-5.1-codex gpt-5.1-codex-mini gpt-5.1-chat
|
|
15
|
-
gpt-5 gpt-5-pro gpt-5-mini gpt-5-nano
|
|
16
|
-
gpt-4o gpt-4o-mini gpt-4o-2024-05-13 gpt-4o-2024-08-06 gpt-4o-2024-11-20
|
|
17
|
-
gpt-4o-mini-2024-07-18
|
|
18
|
-
gpt-4.1 gpt-4.1-mini gpt-4.1-nano
|
|
19
|
-
gpt-4-turbo gpt-4-turbo-2024-04-09 gpt-4-turbo-preview
|
|
20
|
-
o1 o1-mini o1-preview o1-2024-12-17
|
|
21
|
-
o3 o3-mini o4-mini
|
|
22
|
-
chatgpt-4o-latest
|
|
23
|
-
].freeze
|
|
24
|
-
|
|
25
|
-
# Models with vision capabilities
|
|
26
|
-
VISION_MODELS = %w[
|
|
27
|
-
gpt-5.5
|
|
28
|
-
gpt-5.2 gpt-5.1 gpt-5.1-chat gpt-5 gpt-5-pro gpt-5-mini gpt-5-nano
|
|
29
|
-
gpt-4o gpt-4o-mini gpt-4o-2024-05-13 gpt-4o-2024-08-06 gpt-4o-2024-11-20
|
|
30
|
-
gpt-4o-mini-2024-07-18
|
|
31
|
-
gpt-4.1 gpt-4.1-mini gpt-4.1-nano
|
|
32
|
-
gpt-4-turbo gpt-4-turbo-2024-04-09
|
|
33
|
-
o1 o3 o4-mini
|
|
34
|
-
chatgpt-4o-latest
|
|
35
|
-
].freeze
|
|
36
|
-
|
|
37
|
-
# Reasoning models (o-series)
|
|
38
|
-
REASONING_MODELS = %w[
|
|
39
|
-
gpt-5.5 gpt-5.2 gpt-5.1 gpt-5.1-codex-max gpt-5.1-codex gpt-5.1-codex-mini
|
|
40
|
-
gpt-5 gpt-5-pro gpt-5-mini gpt-5-nano
|
|
41
|
-
o1 o1-mini o1-preview o1-2024-12-17 o3 o3-mini o4-mini
|
|
42
|
-
].freeze
|
|
43
|
-
|
|
44
|
-
# Models that support web search
|
|
45
|
-
WEB_SEARCH_MODELS = %w[
|
|
46
|
-
gpt-5.5 gpt-5.2 gpt-5.1 gpt-5.1-codex-max gpt-5 gpt-5-pro gpt-5-mini
|
|
47
|
-
gpt-4o gpt-4o-mini gpt-4.1 gpt-4.1-mini gpt-4.1-nano
|
|
48
|
-
o1 o3 o3-mini o4-mini
|
|
49
|
-
].freeze
|
|
50
|
-
|
|
51
|
-
# Models that support code interpreter
|
|
52
|
-
CODE_INTERPRETER_MODELS = %w[
|
|
53
|
-
gpt-5.5 gpt-5.2 gpt-5.1 gpt-5 gpt-5-pro gpt-5-mini
|
|
54
|
-
gpt-4o gpt-4o-mini gpt-4.1 gpt-4.1-mini gpt-4.1-nano
|
|
55
|
-
o1 o3 o3-mini o4-mini
|
|
56
|
-
].freeze
|
|
57
|
-
|
|
58
|
-
# Context windows by model
|
|
59
|
-
CONTEXT_WINDOWS = {
|
|
60
|
-
'gpt-5.5' => 1_050_000,
|
|
61
|
-
'gpt-5.2' => 400_000,
|
|
62
|
-
'gpt-5.1' => 400_000,
|
|
63
|
-
'gpt-5.1-codex-max' => 400_000,
|
|
64
|
-
'gpt-5.1-codex' => 400_000,
|
|
65
|
-
'gpt-5.1-codex-mini' => 400_000,
|
|
66
|
-
'gpt-5.1-chat' => 128_000,
|
|
67
|
-
'gpt-5' => 400_000,
|
|
68
|
-
'gpt-5-pro' => 400_000,
|
|
69
|
-
'gpt-5-mini' => 400_000,
|
|
70
|
-
'gpt-5-nano' => 400_000,
|
|
71
|
-
'gpt-4o' => 128_000,
|
|
72
|
-
'gpt-4o-mini' => 128_000,
|
|
73
|
-
'gpt-4o-2024-05-13' => 128_000,
|
|
74
|
-
'gpt-4o-2024-08-06' => 128_000,
|
|
75
|
-
'gpt-4o-2024-11-20' => 128_000,
|
|
76
|
-
'gpt-4o-mini-2024-07-18' => 128_000,
|
|
77
|
-
'gpt-4.1' => 1_000_000,
|
|
78
|
-
'gpt-4.1-mini' => 1_000_000,
|
|
79
|
-
'gpt-4.1-nano' => 1_000_000,
|
|
80
|
-
'gpt-4-turbo' => 128_000,
|
|
81
|
-
'gpt-4-turbo-2024-04-09' => 128_000,
|
|
82
|
-
'o1' => 200_000,
|
|
83
|
-
'o1-mini' => 128_000,
|
|
84
|
-
'o1-preview' => 128_000,
|
|
85
|
-
'o3' => 200_000,
|
|
86
|
-
'o3-mini' => 200_000,
|
|
87
|
-
'o4-mini' => 200_000
|
|
88
|
-
}.freeze
|
|
89
|
-
|
|
90
|
-
# Max output tokens by model
|
|
91
|
-
MAX_OUTPUT_TOKENS = {
|
|
92
|
-
'gpt-5.5' => 128_000,
|
|
93
|
-
'gpt-5.2' => 128_000,
|
|
94
|
-
'gpt-5.1' => 128_000,
|
|
95
|
-
'gpt-5.1-codex-max' => 128_000,
|
|
96
|
-
'gpt-5.1-codex' => 128_000,
|
|
97
|
-
'gpt-5.1-codex-mini' => 128_000,
|
|
98
|
-
'gpt-5.1-chat' => 16_384,
|
|
99
|
-
'gpt-5' => 128_000,
|
|
100
|
-
'gpt-5-pro' => 128_000,
|
|
101
|
-
'gpt-5-mini' => 128_000,
|
|
102
|
-
'gpt-5-nano' => 128_000,
|
|
103
|
-
'gpt-4o' => 16_384,
|
|
104
|
-
'gpt-4o-mini' => 16_384,
|
|
105
|
-
'gpt-4o-2024-05-13' => 4_096,
|
|
106
|
-
'gpt-4o-2024-08-06' => 16_384,
|
|
107
|
-
'gpt-4o-2024-11-20' => 16_384,
|
|
108
|
-
'gpt-4o-mini-2024-07-18' => 16_384,
|
|
109
|
-
'gpt-4.1' => 32_768,
|
|
110
|
-
'gpt-4.1-mini' => 32_768,
|
|
111
|
-
'gpt-4.1-nano' => 32_768,
|
|
112
|
-
'gpt-4-turbo' => 4_096,
|
|
113
|
-
'o1' => 100_000,
|
|
114
|
-
'o1-mini' => 65_536,
|
|
115
|
-
'o3' => 100_000,
|
|
116
|
-
'o3-mini' => 100_000,
|
|
117
|
-
'o4-mini' => 100_000
|
|
118
|
-
}.freeze
|
|
119
|
-
|
|
120
|
-
# Pricing per million tokens (as of late 2024)
|
|
121
|
-
PRICING = {
|
|
122
|
-
'gpt-4o' => { input: 2.50, output: 10.00, cached_input: 1.25 },
|
|
123
|
-
'gpt-4o-mini' => { input: 0.15, output: 0.60, cached_input: 0.075 },
|
|
124
|
-
'gpt-4.1' => { input: 2.00, output: 8.00, cached_input: 0.50 },
|
|
125
|
-
'gpt-4.1-mini' => { input: 0.40, output: 1.60, cached_input: 0.10 },
|
|
126
|
-
'gpt-4.1-nano' => { input: 0.10, output: 0.40, cached_input: 0.025 },
|
|
127
|
-
'o1' => { input: 15.00, output: 60.00, cached_input: 7.50 },
|
|
128
|
-
'o1-mini' => { input: 1.10, output: 4.40, cached_input: 0.55 },
|
|
129
|
-
'o3' => { input: 10.00, output: 40.00, cached_input: 2.50 },
|
|
130
|
-
'o3-mini' => { input: 1.10, output: 4.40, cached_input: 0.275 },
|
|
131
|
-
'o4-mini' => { input: 1.10, output: 4.40, cached_input: 0.275 }
|
|
132
|
-
}.freeze
|
|
133
|
-
|
|
134
|
-
def supports_responses_api?(model_id)
|
|
135
|
-
model_matches?(model_id, RESPONSES_API_MODELS)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def supports_vision?(model_id)
|
|
139
|
-
model_matches?(model_id, VISION_MODELS)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def supports_functions?(model_id)
|
|
143
|
-
supports_responses_api?(model_id)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def supports_structured_output?(model_id)
|
|
147
|
-
supports_responses_api?(model_id)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def supports_web_search?(model_id)
|
|
151
|
-
model_matches?(model_id, WEB_SEARCH_MODELS)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def supports_code_interpreter?(model_id)
|
|
155
|
-
model_matches?(model_id, CODE_INTERPRETER_MODELS)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def reasoning_model?(model_id)
|
|
159
|
-
model_matches?(model_id, REASONING_MODELS)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def context_window_for(model_id)
|
|
163
|
-
find_capability(model_id, CONTEXT_WINDOWS) || 128_000
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def max_tokens_for(model_id)
|
|
167
|
-
find_capability(model_id, MAX_OUTPUT_TOKENS) || 16_384
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def input_price_for(model_id)
|
|
171
|
-
pricing = find_capability(model_id, PRICING)
|
|
172
|
-
pricing ? pricing[:input] : 0.0
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def output_price_for(model_id)
|
|
176
|
-
pricing = find_capability(model_id, PRICING)
|
|
177
|
-
pricing ? pricing[:output] : 0.0
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def pricing_for(model_id)
|
|
181
|
-
pricing = find_capability(model_id, PRICING) || { input: 0.0, output: 0.0 }
|
|
182
|
-
{
|
|
183
|
-
text_tokens: {
|
|
184
|
-
standard: {
|
|
185
|
-
input_per_million: pricing[:input],
|
|
186
|
-
output_per_million: pricing[:output],
|
|
187
|
-
cached_input_per_million: pricing[:cached_input] || (pricing[:input] / 2)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def modalities_for(model_id)
|
|
194
|
-
input = ['text']
|
|
195
|
-
input << 'image' if supports_vision?(model_id)
|
|
196
|
-
|
|
197
|
-
{
|
|
198
|
-
input: input,
|
|
199
|
-
output: ['text']
|
|
200
|
-
}
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def capabilities_for(model_id)
|
|
204
|
-
caps = %w[streaming function_calling structured_output]
|
|
205
|
-
caps << 'vision' if supports_vision?(model_id)
|
|
206
|
-
caps << 'web_search' if supports_web_search?(model_id)
|
|
207
|
-
caps << 'code_interpreter' if supports_code_interpreter?(model_id)
|
|
208
|
-
caps << 'reasoning' if reasoning_model?(model_id)
|
|
209
|
-
caps
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def model_family(model_id)
|
|
213
|
-
case model_id
|
|
214
|
-
when /^gpt-5\.5/ then 'gpt-5.5'
|
|
215
|
-
when /^gpt-5\.2/ then 'gpt-5.2'
|
|
216
|
-
when /^gpt-5\.1/ then 'gpt-5.1'
|
|
217
|
-
when /^gpt-5/ then 'gpt-5'
|
|
218
|
-
when /^gpt-4\.1/ then 'gpt-4.1'
|
|
219
|
-
when /^gpt-4o-mini/ then 'gpt-4o-mini'
|
|
220
|
-
when /^gpt-4o/ then 'gpt-4o'
|
|
221
|
-
when /^gpt-4-turbo/ then 'gpt-4-turbo'
|
|
222
|
-
when /^o1/ then 'o1'
|
|
223
|
-
when /^o3/ then 'o3'
|
|
224
|
-
when /^o4/ then 'o4'
|
|
225
|
-
else 'other'
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def format_display_name(model_id)
|
|
230
|
-
model_id
|
|
231
|
-
.gsub(/[-_]/, ' ')
|
|
232
|
-
.split
|
|
233
|
-
.map(&:capitalize)
|
|
234
|
-
.join(' ')
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
# Temperature is not supported for reasoning models
|
|
238
|
-
def normalize_temperature(temperature, model_id)
|
|
239
|
-
return nil if reasoning_model?(model_id)
|
|
240
|
-
|
|
241
|
-
temperature
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
private_class_method def find_capability(model_id, mapping)
|
|
245
|
-
# Direct match
|
|
246
|
-
return mapping[model_id] if mapping.key?(model_id)
|
|
247
|
-
|
|
248
|
-
# Try base model name (without date suffix)
|
|
249
|
-
base_model = model_id.gsub(/-\d{4}-\d{2}-\d{2}$/, '')
|
|
250
|
-
return mapping[base_model] if mapping.key?(base_model)
|
|
251
|
-
|
|
252
|
-
nil
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
private_class_method def model_matches?(model_id, model_list)
|
|
256
|
-
model_list.any? do |pattern|
|
|
257
|
-
model_id == pattern || model_id.start_with?("#{pattern}-")
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
end
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RubyLLM
|
|
4
|
-
module Providers
|
|
5
|
-
class OpenAIResponses
|
|
6
|
-
# Chat completion methods for the OpenAI Responses API.
|
|
7
|
-
# Handles converting RubyLLM messages to Responses API format and parsing responses.
|
|
8
|
-
module Chat
|
|
9
|
-
def completion_url
|
|
10
|
-
'responses'
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
module_function
|
|
14
|
-
|
|
15
|
-
# rubocop:disable Metrics/ParameterLists
|
|
16
|
-
def render_payload(messages, tools:, temperature:, model:, stream: false,
|
|
17
|
-
schema: nil, thinking: nil, tool_prefs: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
-
tool_prefs ||= {}
|
|
19
|
-
system_messages, non_system_messages = messages.partition { |m| m.role == :system }
|
|
20
|
-
|
|
21
|
-
instructions = system_messages.map { |m| extract_text_content(m.content) }.join("\n\n")
|
|
22
|
-
|
|
23
|
-
last_response_id = extract_last_response_id(messages)
|
|
24
|
-
input_messages = unchained_messages(non_system_messages, last_response_id)
|
|
25
|
-
|
|
26
|
-
payload = {
|
|
27
|
-
model: model.id,
|
|
28
|
-
input: format_input(input_messages),
|
|
29
|
-
stream: stream
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
payload[:instructions] = instructions unless instructions.empty?
|
|
33
|
-
payload[:temperature] = temperature unless temperature.nil?
|
|
34
|
-
apply_tools(payload, tools, tool_prefs)
|
|
35
|
-
payload[:text] = build_schema_format(schema) if schema
|
|
36
|
-
payload[:previous_response_id] = last_response_id if last_response_id
|
|
37
|
-
|
|
38
|
-
payload
|
|
39
|
-
end
|
|
40
|
-
# rubocop:enable Metrics/ParameterLists
|
|
41
|
-
|
|
42
|
-
# Apply tools and tool preferences to the payload.
|
|
43
|
-
def apply_tools(payload, tools, tool_prefs)
|
|
44
|
-
return unless tools.any?
|
|
45
|
-
|
|
46
|
-
payload[:tools] = tools.map { |_, tool| Tools.tool_for(tool) }
|
|
47
|
-
payload[:tool_choice] = build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
|
|
48
|
-
payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Convert a RubyLLM tool choice symbol to the Responses API format.
|
|
52
|
-
# Responses API accepts "auto", "required", "none", or
|
|
53
|
-
# { type: "function", name: "fn_name" } for a specific function.
|
|
54
|
-
def build_tool_choice(choice)
|
|
55
|
-
case choice
|
|
56
|
-
when :auto, :none, :required
|
|
57
|
-
choice.to_s
|
|
58
|
-
else
|
|
59
|
-
{ type: 'function', name: choice.to_s }
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Build the Responses API text format block from a schema.
|
|
64
|
-
# Schema arrives pre-normalized as { name:, schema:, strict: } from
|
|
65
|
-
# RubyLLM::Chat.with_schema (v1.13+), or as a raw hash (legacy).
|
|
66
|
-
def build_schema_format(schema)
|
|
67
|
-
schema_name = schema[:name] || 'response'
|
|
68
|
-
schema_def = schema[:schema] || schema
|
|
69
|
-
strict = schema.key?(:strict) ? schema[:strict] : true
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
format: {
|
|
73
|
-
type: 'json_schema',
|
|
74
|
-
name: schema_name,
|
|
75
|
-
schema: schema_def,
|
|
76
|
-
strict: strict
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def extract_last_response_id(messages)
|
|
82
|
-
messages
|
|
83
|
-
.select { |m| m.role == :assistant && m.respond_to?(:response_id) }
|
|
84
|
-
.map(&:response_id)
|
|
85
|
-
.compact
|
|
86
|
-
.last
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# When chaining via previous_response_id, the API expects only the new
|
|
90
|
-
# items in `input` -- the rest already lives in the server-side response
|
|
91
|
-
# chain. Sending the full history every turn appends it to that chain
|
|
92
|
-
# and causes O(N^2) input_tokens growth. See issue #10.
|
|
93
|
-
def unchained_messages(messages, last_response_id)
|
|
94
|
-
return messages unless last_response_id
|
|
95
|
-
|
|
96
|
-
anchor = messages.rindex do |m|
|
|
97
|
-
m.role == :assistant && m.respond_to?(:response_id) && m.response_id == last_response_id
|
|
98
|
-
end
|
|
99
|
-
return messages unless anchor
|
|
100
|
-
|
|
101
|
-
messages[(anchor + 1)..] || []
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def parse_completion_response(response)
|
|
105
|
-
data = response.body
|
|
106
|
-
return if data.nil? || data.empty?
|
|
107
|
-
|
|
108
|
-
data = JSON.parse(data) if data.is_a?(String)
|
|
109
|
-
|
|
110
|
-
raise RubyLLM::Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
|
|
111
|
-
|
|
112
|
-
output = data['output'] || []
|
|
113
|
-
|
|
114
|
-
# Extract text content from output
|
|
115
|
-
content = extract_output_text(output)
|
|
116
|
-
|
|
117
|
-
# Extract tool calls from function_call outputs
|
|
118
|
-
tool_calls = extract_tool_calls(output)
|
|
119
|
-
|
|
120
|
-
usage = data['usage'] || {}
|
|
121
|
-
cached_tokens = usage.dig('input_tokens_details', 'cached_tokens')
|
|
122
|
-
|
|
123
|
-
Message.new(
|
|
124
|
-
role: :assistant,
|
|
125
|
-
content: content,
|
|
126
|
-
tool_calls: tool_calls,
|
|
127
|
-
input_tokens: usage['input_tokens'],
|
|
128
|
-
output_tokens: usage['output_tokens'],
|
|
129
|
-
cached_tokens: cached_tokens,
|
|
130
|
-
cache_creation_tokens: 0,
|
|
131
|
-
model_id: data['model'],
|
|
132
|
-
response_id: data['id'],
|
|
133
|
-
built_in_tool_events: BuiltInTools.extract_events(output),
|
|
134
|
-
raw: response
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def format_input(messages) # rubocop:disable Metrics/MethodLength
|
|
139
|
-
result = []
|
|
140
|
-
|
|
141
|
-
messages.each do |msg|
|
|
142
|
-
if msg.tool_call_id
|
|
143
|
-
# Tool result message - function_call_output type
|
|
144
|
-
result << {
|
|
145
|
-
type: 'function_call_output',
|
|
146
|
-
call_id: msg.tool_call_id,
|
|
147
|
-
output: extract_text_content(msg.content)
|
|
148
|
-
}
|
|
149
|
-
elsif msg.tool_calls&.any?
|
|
150
|
-
# Assistant message with tool calls
|
|
151
|
-
# First add any text content as a message
|
|
152
|
-
text = extract_text_content(msg.content)
|
|
153
|
-
if text && !text.empty?
|
|
154
|
-
result << {
|
|
155
|
-
type: 'message',
|
|
156
|
-
role: 'assistant',
|
|
157
|
-
content: text
|
|
158
|
-
}
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Then add each function call as a separate item
|
|
162
|
-
msg.tool_calls.each_value do |tc|
|
|
163
|
-
result << {
|
|
164
|
-
type: 'function_call',
|
|
165
|
-
call_id: tc.id,
|
|
166
|
-
name: tc.name,
|
|
167
|
-
arguments: tc.arguments.is_a?(String) ? tc.arguments : JSON.generate(tc.arguments)
|
|
168
|
-
}
|
|
169
|
-
end
|
|
170
|
-
else
|
|
171
|
-
# Regular message
|
|
172
|
-
result << {
|
|
173
|
-
type: 'message',
|
|
174
|
-
role: format_role(msg.role),
|
|
175
|
-
content: format_message_content(msg.content, nil)
|
|
176
|
-
}
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
result
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def format_message_content(content, tool_calls = nil)
|
|
184
|
-
parts = []
|
|
185
|
-
|
|
186
|
-
# Add text content
|
|
187
|
-
text = extract_text_content(content)
|
|
188
|
-
parts << { type: 'input_text', text: text } if text && !text.empty?
|
|
189
|
-
|
|
190
|
-
# Add attachments if present
|
|
191
|
-
if content.is_a?(RubyLLM::Content)
|
|
192
|
-
content.attachments.each do |attachment|
|
|
193
|
-
parts << Media.format_attachment(attachment)
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# Add tool calls if present (for assistant messages)
|
|
198
|
-
if tool_calls&.any?
|
|
199
|
-
tool_calls.each_value do |tc|
|
|
200
|
-
parts << {
|
|
201
|
-
type: 'function_call',
|
|
202
|
-
call_id: tc.id,
|
|
203
|
-
name: tc.name,
|
|
204
|
-
arguments: tc.arguments.is_a?(String) ? tc.arguments : JSON.generate(tc.arguments)
|
|
205
|
-
}
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Return simple text for single text content
|
|
210
|
-
return parts.first[:text] if parts.length == 1 && parts.first[:type] == 'input_text'
|
|
211
|
-
|
|
212
|
-
parts
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def extract_text_content(content)
|
|
216
|
-
case content
|
|
217
|
-
when String
|
|
218
|
-
content
|
|
219
|
-
when RubyLLM::Content
|
|
220
|
-
content.text
|
|
221
|
-
when Hash
|
|
222
|
-
content[:text] || content['text']
|
|
223
|
-
else
|
|
224
|
-
content.to_s
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def format_role(role)
|
|
229
|
-
case role
|
|
230
|
-
when :system then 'developer'
|
|
231
|
-
when :assistant then 'assistant'
|
|
232
|
-
when :tool then 'user' # Tool results come from user perspective
|
|
233
|
-
else role.to_s
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def extract_output_text(output)
|
|
238
|
-
output
|
|
239
|
-
.select { |item| item['type'] == 'message' }
|
|
240
|
-
.flat_map { |item| item['content'] || [] }
|
|
241
|
-
.select { |c| c['type'] == 'output_text' }
|
|
242
|
-
.map { |c| c['text'] }
|
|
243
|
-
.join
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def extract_tool_calls(output)
|
|
247
|
-
function_calls = output.select { |item| item['type'] == 'function_call' }
|
|
248
|
-
return nil if function_calls.empty?
|
|
249
|
-
|
|
250
|
-
function_calls.to_h do |fc|
|
|
251
|
-
[
|
|
252
|
-
fc['call_id'],
|
|
253
|
-
ToolCall.new(
|
|
254
|
-
id: fc['call_id'],
|
|
255
|
-
name: fc['name'],
|
|
256
|
-
arguments: parse_arguments(fc['arguments'])
|
|
257
|
-
)
|
|
258
|
-
]
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def parse_arguments(arguments)
|
|
263
|
-
return {} if arguments.nil? || arguments.empty?
|
|
264
|
-
return arguments if arguments.is_a?(Hash)
|
|
265
|
-
|
|
266
|
-
JSON.parse(arguments)
|
|
267
|
-
rescue JSON::ParserError
|
|
268
|
-
{ raw: arguments }
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
end
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RubyLLM
|
|
4
|
-
module Providers
|
|
5
|
-
class OpenAIResponses
|
|
6
|
-
# Media handling methods for the OpenAI Responses API.
|
|
7
|
-
# Handles images, audio, PDFs, and other file types.
|
|
8
|
-
module Media
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
def format_attachment(attachment)
|
|
12
|
-
case attachment.type
|
|
13
|
-
when :image
|
|
14
|
-
format_image(attachment)
|
|
15
|
-
when :pdf
|
|
16
|
-
format_pdf(attachment)
|
|
17
|
-
when :audio
|
|
18
|
-
format_audio(attachment)
|
|
19
|
-
when :text
|
|
20
|
-
format_text_file(attachment)
|
|
21
|
-
else
|
|
22
|
-
raise UnsupportedAttachmentError, attachment.type
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def format_image(image)
|
|
27
|
-
if image.url?
|
|
28
|
-
{
|
|
29
|
-
type: 'input_image',
|
|
30
|
-
image_url: image.source
|
|
31
|
-
}
|
|
32
|
-
else
|
|
33
|
-
{
|
|
34
|
-
type: 'input_image',
|
|
35
|
-
image_url: image.for_llm
|
|
36
|
-
}
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def format_pdf(pdf)
|
|
41
|
-
{
|
|
42
|
-
type: 'input_file',
|
|
43
|
-
filename: extract_filename(pdf.source),
|
|
44
|
-
file_data: pdf.for_llm
|
|
45
|
-
}
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def format_audio(audio)
|
|
49
|
-
{
|
|
50
|
-
type: 'input_audio',
|
|
51
|
-
data: audio.for_llm,
|
|
52
|
-
format: detect_audio_format(audio.source)
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def format_text_file(text_file)
|
|
57
|
-
{
|
|
58
|
-
type: 'input_text',
|
|
59
|
-
text: text_file.for_llm
|
|
60
|
-
}
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def extract_filename(source)
|
|
64
|
-
return 'file' unless source
|
|
65
|
-
|
|
66
|
-
if source.respond_to?(:path)
|
|
67
|
-
File.basename(source.path)
|
|
68
|
-
else
|
|
69
|
-
File.basename(source.to_s)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def detect_audio_format(source)
|
|
74
|
-
return 'mp3' unless source
|
|
75
|
-
|
|
76
|
-
ext = if source.respond_to?(:path)
|
|
77
|
-
File.extname(source.path)
|
|
78
|
-
else
|
|
79
|
-
File.extname(source.to_s)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
case ext.downcase
|
|
83
|
-
when '.mp3' then 'mp3'
|
|
84
|
-
when '.wav' then 'wav'
|
|
85
|
-
when '.webm' then 'webm'
|
|
86
|
-
when '.ogg' then 'ogg'
|
|
87
|
-
when '.flac' then 'flac'
|
|
88
|
-
when '.m4a' then 'm4a'
|
|
89
|
-
else 'mp3'
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RubyLLM
|
|
4
|
-
module Providers
|
|
5
|
-
class OpenAIResponses
|
|
6
|
-
# Model listing methods for the OpenAI Responses API.
|
|
7
|
-
module Models
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
def models_url
|
|
11
|
-
'models'
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def parse_list_models_response(response, slug, capabilities)
|
|
15
|
-
models_data = response.body
|
|
16
|
-
models_data = models_data['data'] if models_data.is_a?(Hash) && models_data['data']
|
|
17
|
-
|
|
18
|
-
Array(models_data).filter_map do |model_data|
|
|
19
|
-
model_id = model_data['id']
|
|
20
|
-
|
|
21
|
-
# Only include models that support the Responses API
|
|
22
|
-
next unless capabilities.supports_responses_api?(model_id)
|
|
23
|
-
|
|
24
|
-
Model::Info.new(
|
|
25
|
-
id: model_id,
|
|
26
|
-
name: capabilities.format_display_name(model_id),
|
|
27
|
-
provider: slug,
|
|
28
|
-
family: capabilities.model_family(model_id),
|
|
29
|
-
context_window: capabilities.context_window_for(model_id),
|
|
30
|
-
max_output_tokens: capabilities.max_tokens_for(model_id),
|
|
31
|
-
modalities: capabilities.modalities_for(model_id),
|
|
32
|
-
capabilities: capabilities.capabilities_for(model_id),
|
|
33
|
-
pricing: capabilities.pricing_for(model_id),
|
|
34
|
-
metadata: {
|
|
35
|
-
created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
|
|
36
|
-
owned_by: model_data['owned_by'],
|
|
37
|
-
supports_responses_api: true,
|
|
38
|
-
supports_web_search: capabilities.supports_web_search?(model_id),
|
|
39
|
-
supports_code_interpreter: capabilities.supports_code_interpreter?(model_id),
|
|
40
|
-
reasoning_model: capabilities.reasoning_model?(model_id)
|
|
41
|
-
}.compact
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|