Class: Ask::Providers::OpenAI

Inherits:
Ask::Provider
  • Object
show all
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

OpenAICompatible

Class Method Summary collapse

Instance Method Summary collapse

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

.assume_models_exist?Boolean

Returns:

  • (Boolean)


74
# File 'lib/ask/provider/openai.rb', line 74

def assume_models_exist?; false; end

.capabilitiesObject



64
65
66
67
68
69
70
# File 'lib/ask/provider/openai.rb', line 64

def capabilities
  {
    chat: true, streaming: true, tool_calls: true, vision: true,
    thinking: true, structured_output: true, embed: true,
    transcribe: true, paint: true, moderate: true
  }
end

.configuration_optionsObject



72
# File 'lib/ask/provider/openai.rb', line 72

def configuration_options; %i[api_key base_url organization_id project_id]; end

.configuration_requirementsObject



73
# File 'lib/ask/provider/openai.rb', line 73

def configuration_requirements; %i[api_key]; end

.slugObject



62
# File 'lib/ask/provider/openai.rb', line 62

def slug; "openai"; end

Instance Method Details

#api_baseObject



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



79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/ask/provider/openai.rb', line 79

def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
  payload = { model:, messages: format_messages(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
# File 'lib/ask/provider/openai.rb', line 32

def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
  payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
  stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model)
end

#embed(texts, model:) ⇒ Object



38
39
40
41
42
43
44
45
# File 'lib/ask/provider/openai.rb', line 38

def embed(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?

  embeddings = response.body["data"].map { |d| d["embedding"] }
  Ask::Result.success(embeddings.one? ? embeddings.first : embeddings)
end

#format_message(msg) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/ask/provider/openai.rb', line 143

def format_message(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"])
      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_tools(tools) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/ask/provider/openai.rb', line 130

def format_tools(tools)
  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

#headersObject



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_modelsObject



47
48
49
50
51
52
53
54
# File 'lib/ask/provider/openai.rb', line 47

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



56
57
58
59
# File 'lib/ask/provider/openai.rb', line 56

def parse_error(response)
  body = response.body rescue nil
  body&.dig("error", "message") || body&.dig("error", "code")
end

#parse_response(body, model) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/ask/provider/openai.rb', line 92

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"],
      raw: body
    }
  )
end

#parse_stream(raw, stream, model, &block) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/ask/provider/openai.rb', line 112

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