Class: Ask::Providers::OpenAI
- Inherits:
-
Ask::Provider
- Object
- Ask::Provider
- Ask::Providers::OpenAI
- Includes:
- LLM::ProviderConfig, LLM::SSEBuffer
- Defined in:
- lib/ask/provider/openai.rb
Overview
OpenAI API provider. Also handles all OpenAI-compatible providers
(OpenRouter, DeepSeek, Azure, XAI, Perplexity, GPUStack, etc.) via
base_url override.
Direct Known Subclasses
Class Method Summary collapse
- .capabilities ⇒ Object
- .configuration_options ⇒ Object
- .configuration_requirements ⇒ Object
- .slug ⇒ Object
Instance Method Summary collapse
- #api_base ⇒ Object
-
#build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params) ⇒ Object
--- Config transformation contract ---.
- #chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block) ⇒ Object
- #embed(texts, model:) ⇒ Object
- #extract_responses_provider_results(output, provider_tools) ⇒ Object
- #extract_responses_tool_calls(output) ⇒ Object
- #format_message(msg) ⇒ Object
- #format_responses_input(messages) ⇒ Object
- #format_responses_tools(provider_tools) ⇒ Object
- #format_tools(tools) ⇒ Object
- #headers ⇒ Object
-
#initialize(config = {}) ⇒ OpenAI
constructor
A new instance of OpenAI.
- #list_models ⇒ Object
- #parse_error(response) ⇒ Object
- #parse_response(body, model) ⇒ Object
- #parse_stream(raw, stream, model, &block) ⇒ Object
-
#responses_chat(messages, model:, regular_tools:, provider_tools:, temperature: nil, stream: nil, schema: nil, **params, &block) ⇒ Object
Use the OpenAI Responses API, which supports provider-executed tools like web_search, file_search, and code_interpreter.
- #responses_chat_nonstream(payload, model, provider_tools) ⇒ Object
- #responses_chat_stream(payload, model, provider_tools, &block) ⇒ Object
- #split_tools(tools) ⇒ Object
Methods included from LLM::SSEBuffer
#each_sse_event, #init_sse_buffer
Constructor Details
#initialize(config = {}) ⇒ OpenAI
Returns a new instance of OpenAI.
12 13 14 15 16 17 |
# File 'lib/ask/provider/openai.rb', line 12 def initialize(config = {}) @provider_keys = extract_provider_keys(config) config = normalize_config(config) super(config) @http = build_http end |
Class Method Details
.capabilities ⇒ Object
75 76 77 78 79 80 81 82 |
# File 'lib/ask/provider/openai.rb', line 75 def capabilities { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, structured_output: true, embed: true, transcribe: true, paint: true, moderate: true, prompt_caching: true } end |
.configuration_options ⇒ Object
84 |
# File 'lib/ask/provider/openai.rb', line 84 def ; %i[api_key base_url organization_id project_id]; end |
.configuration_requirements ⇒ Object
85 |
# File 'lib/ask/provider/openai.rb', line 85 def configuration_requirements; %i[api_key]; end |
.slug ⇒ Object
73 |
# File 'lib/ask/provider/openai.rb', line 73 def slug; "openai"; end |
Instance Method Details
#api_base ⇒ Object
19 20 21 |
# File 'lib/ask/provider/openai.rb', line 19 def api_base @config.base_url || "https://api.openai.com/v1" end |
#build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params) ⇒ Object
--- Config transformation contract ---
90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/ask/provider/openai.rb', line 90 def build_request(, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params) payload = { model:, messages: (), stream: stream || false } payload[:temperature] = temperature if temperature payload[:tools] = format_tools(tools) if tools&.any? if schema payload[:response_format] = { type: "json_schema", json_schema: { name: "response", schema:, strict: true } } end payload.merge(params) end |
#chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block) ⇒ Object
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/ask/provider/openai.rb', line 32 def chat(, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block) msgs = .is_a?(Ask::Conversation) ? .to_a : # Separate provider tools from regular tools regular_tools, provider_tools = split_tools(tools) if provider_tools.any? # Use the Responses API when provider tools are involved responses_chat(msgs, model:, regular_tools:, provider_tools:, temperature:, stream:, schema:, **params, &block) else payload = build_request(msgs, model:, tools: regular_tools, temperature:, stream:, schema:, **params) stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model) end end |
#embed(texts, model:) ⇒ Object
49 50 51 52 53 54 55 56 |
# File 'lib/ask/provider/openai.rb', line 49 def (texts, model:) texts = Array(texts) response = @http.post("embeddings") { |r| r.body = { model:, input: texts } } raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success? = response.body["data"].map { |d| d["embedding"] } Ask::Result.success(.one? ? .first : ) end |
#extract_responses_provider_results(output, provider_tools) ⇒ Object
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 |
# File 'lib/ask/provider/openai.rb', line 289 def extract_responses_provider_results(output, provider_tools) results = {} provider_tool_names = provider_tools.map(&:name) output.each do |item| case item["type"] when "web_search_call" result_item = output.find { |o| o["type"] == "web_search_result" && o["id"] == item["id"] } if result_item results[item["id"]] = { provider_executed: true, tool_name: "web_search", message: result_item.to_s, status: "success" } end when "file_search_call" result_item = output.find { |o| o["type"] == "file_search_result" && o["id"] == item["id"] } if result_item results[item["id"]] = { provider_executed: true, tool_name: "file_search", message: result_item.to_s, status: "success" } end when "function_call" # Regular tool call — handled elsewhere end end results end |
#extract_responses_tool_calls(output) ⇒ Object
322 323 324 325 326 |
# File 'lib/ask/provider/openai.rb', line 322 def extract_responses_tool_calls(output) output.select { |o| o["type"] == "function_call" }.map do |fc| { id: fc["id"], type: "function", name: fc["name"], arguments: fc["arguments"] } end end |
#format_message(msg) ⇒ Object
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/ask/provider/openai.rb', line 178 def (msg) role = msg[:role] || msg["role"] || :user { role: role.to_s, content: msg[:content] || msg["content"] }.tap do |fm| if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any? calls = tc.is_a?(Hash) ? tc.values : tc fm[:tool_calls] = calls.map { |t| id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"]) name = t.respond_to?(:name) ? t.name : (t.dig(:function, :name) || t.dig("function", "name") || t[:name]) raw_args = t.respond_to?(:arguments) ? t.arguments : (t.dig(:function, :arguments) || t.dig("function", "arguments") || t[:arguments]) args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args) { id:, type: "function", function: { name:, arguments: args } } } end fm[:tool_call_id] = msg[:tool_call_id] || msg["tool_call_id"] if msg[:tool_call_id] || msg["tool_call_id"] end.compact end |
#format_responses_input(messages) ⇒ Object
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 |
# File 'lib/ask/provider/openai.rb', line 260 def format_responses_input() .map do |msg| role = msg[:role] || msg["role"] || "user" content = msg[:content] || msg["content"] || "" entry = { role: role.to_s } entry[:content] = [{ type: "input_text", text: content.to_s }] # Handle tool calls in assistant messages if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any? calls = tc.is_a?(Hash) ? tc.values : tc entry[:content] = calls.map { |t| id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"]) name = t.respond_to?(:name) ? t.name : (t[:name] || t["name"] || t.dig(:function, :name)) raw_args = t.respond_to?(:arguments) ? t.arguments : (t[:arguments] || t["arguments"] || t.dig(:function, :arguments)) args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args) { type: "function_call", id: id, name: name, arguments: args, status: "completed" } } end # Handle tool results if (tid = msg[:tool_call_id] || msg["tool_call_id"]) entry[:content] = [{ type: "function_call_output", id: tid, output: content.to_s }] end entry end end |
#format_responses_tools(provider_tools) ⇒ Object
163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/ask/provider/openai.rb', line 163 def format_responses_tools(provider_tools) provider_tools.map do |pt| case pt.name when "web_search" { type: "web_search" }.merge(pt.args) when "file_search" { type: "file_search" }.merge(pt.args) when "code_interpreter" { type: "code_interpreter" }.merge(pt.args) else { type: pt.name }.merge(pt.args) end end end |
#format_tools(tools) ⇒ Object
148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/ask/provider/openai.rb', line 148 def format_tools(tools) return [] unless tools&.any? tools.map do |t| { type: "function", function: { name: t.respond_to?(:name) ? t.name : t[:name], description: t.respond_to?(:description) ? t.description : t[:description], parameters: t.respond_to?(:parameters) ? t.parameters : t[:parameters] } } end end |
#headers ⇒ Object
23 24 25 26 27 28 29 30 |
# File 'lib/ask/provider/openai.rb', line 23 def headers key = @config.api_key || @config.openai_api_key h = { "Content-Type" => "application/json" } h["Authorization"] = "Bearer #{key}" if key h["OpenAI-Organization"] = @config.organization_id if @config.organization_id h["OpenAI-Project"] = @config.project_id if @config.project_id h end |
#list_models ⇒ Object
58 59 60 61 62 63 64 65 |
# File 'lib/ask/provider/openai.rb', line 58 def list_models response = @http.get("models") return [] unless response.success? response.body["data"].map { |m| Ask::ModelInfo.new(id: m["id"], provider: slug, metadata: { owned_by: m["owned_by"] }) } end |
#parse_error(response) ⇒ Object
67 68 69 70 |
# File 'lib/ask/provider/openai.rb', line 67 def parse_error(response) body = response.body rescue nil body&.dig("error", "message") || body&.dig("error", "code") end |
#parse_response(body, model) ⇒ Object
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/ask/provider/openai.rb', line 103 def parse_response(body, model) choice = body.dig("choices", 0) return Ask::Message.new(role: :assistant, content: nil) unless choice msg = choice["message"] usage = body["usage"] || {} Ask::Message.new( role: :assistant, content: msg["content"], tool_calls: parse_tool_calls(msg["tool_calls"]), metadata: { model: body["model"] || model, finish_reason: choice["finish_reason"], input_tokens: usage["prompt_tokens"], output_tokens: usage["completion_tokens"], cached_tokens: usage.dig("prompt_tokens_details", "cached_tokens"), raw: body } ) end |
#parse_stream(raw, stream, model, &block) ⇒ Object
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/ask/provider/openai.rb', line 124 def parse_stream(raw, stream, model, &block) each_sse_event(raw) do |data| parsed = JSON.parse(data) rescue next choice = parsed.dig("choices", 0) or next delta = choice["delta"] || {} thinking = extract_thinking(parsed, delta) chunk = Ask::Chunk.new( content: delta["content"], tool_calls: parse_stream_tool_calls(delta["tool_calls"]), finish_reason: choice["finish_reason"], usage: parsed["usage"], thinking: ) stream.add(chunk) yield chunk if block_given? end end |
#responses_chat(messages, model:, regular_tools:, provider_tools:, temperature: nil, stream: nil, schema: nil, **params, &block) ⇒ Object
Use the OpenAI Responses API, which supports provider-executed tools like web_search, file_search, and code_interpreter.
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'lib/ask/provider/openai.rb', line 197 def responses_chat(, model:, regular_tools:, provider_tools:, temperature: nil, stream: nil, schema: nil, **params, &block) payload = { model: model, input: format_responses_input() } all_tools = [] all_tools.concat(format_tools(regular_tools)) if regular_tools&.any? all_tools.concat(format_responses_tools(provider_tools)) if provider_tools&.any? payload[:tools] = all_tools if all_tools.any? payload[:temperature] = temperature if temperature payload.merge!(params) if stream responses_chat_stream(payload, model, provider_tools, &block) else responses_chat_nonstream(payload, model, provider_tools) end end |
#responses_chat_nonstream(payload, model, provider_tools) ⇒ Object
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/ask/provider/openai.rb', line 218 def responses_chat_nonstream(payload, model, provider_tools) response = @http.post("responses") { |r| r.body = payload } raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success? body = response.body output = body["output"] || [] # Extract text content and provider-executed tool results text_parts = output.select { |o| o["type"] == "message" } content = text_parts.flat_map { |m| (m["content"] || []) } .select { |c| c["type"] == "output_text" } .map { |c| c["text"] } .join # Extract provider-executed tool results provider_results = extract_responses_provider_results(output, provider_tools) # Extract regular tool calls regular_calls = extract_responses_tool_calls(output) usage = body["usage"] || {} Ask::Message.new( role: :assistant, content: content, tool_calls: regular_calls, metadata: { model: body["model"] || model, finish_reason: body.dig("status"), input_tokens: usage["input_tokens"], output_tokens: usage["output_tokens"], provider_results: provider_results, raw: body } ) end |
#responses_chat_stream(payload, model, provider_tools, &block) ⇒ Object
254 255 256 257 258 |
# File 'lib/ask/provider/openai.rb', line 254 def responses_chat_stream(payload, model, provider_tools, &block) # Streaming with the Responses API — for now, fall back to non-streaming # and return the full result. Full streaming support can be added later. responses_chat_nonstream(payload, model, provider_tools) end |
#split_tools(tools) ⇒ Object
142 143 144 145 146 |
# File 'lib/ask/provider/openai.rb', line 142 def split_tools(tools) return [[], []] unless tools&.any? tools.partition { |t| !t.respond_to?(:provider_tool?) || !t.provider_tool? } end |