Commit cdfd491
Changed files (1)
lib
elelem
lib/elelem/openai.rb
@@ -3,168 +3,59 @@
module Net
module Llm
class OpenAI
- attr_reader :api_key, :base_url, :model, :http
-
- def initialize(api_key: ENV.fetch("OPENAI_API_KEY"), base_url: ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1"), model: "gpt-4o-mini", http: Net::Llm.http)
- @api_key = api_key
- @base_url = base_url
- @model = model
- @http = http
- end
-
- def chat(messages, tools)
- handle_response(http.post(
- "#{base_url}/chat/completions",
- headers: headers,
- body: { model: model, messages: messages, tools: tools, tool_choice: "auto" }
- ))
+ def initialize(model:, api_key: ENV.fetch("OPENAI_API_KEY"), base_url: ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1"), http: Net::Llm.http)
+ @url = "#{base_url}/chat/completions"
+ @model, @api_key, @http = model, api_key, http
end
def fetch(messages, tools = [], &block)
- if block_given?
- fetch_streaming(messages, tools, &block)
- else
- fetch_non_streaming(messages, tools)
- end
- end
+ content, tool_calls, stop = "", {}, :end_turn
+ body = { model: @model, messages:, stream: true }
+ body.merge!(tools:, tool_choice: "auto") unless tools.empty?
- def models
- handle_response(http.get("#{base_url}/models", headers: headers))
- end
-
- def embeddings(input, model: "text-embedding-ada-002")
- handle_response(http.post(
- "#{base_url}/embeddings",
- headers: headers,
- body: { model: model, input: input },
- ))
- end
+ stream(body) do |json|
+ delta = json.dig("choices", 0, "delta") || {}
- private
+ if (text = delta["content"])
+ content += text
+ block.call(type: :delta, content: text, thinking: nil, tool_calls: nil)
+ end
- def headers
- { "Authorization" => Net::Hippie.bearer_auth(api_key) }
- end
+ delta["tool_calls"]&.each do |tc|
+ idx = tc["index"]
+ tool_calls[idx] ||= { id: nil, name: nil, args: "" }
+ tool_calls[idx][:id] ||= tc["id"]
+ tool_calls[idx][:name] ||= tc.dig("function", "name")
+ tool_calls[idx][:args] += tc.dig("function", "arguments").to_s
+ end
- def handle_response(response)
- if response.is_a?(Net::HTTPSuccess)
- JSON.parse(response.body)
- else
- { "code" => response.code, "body" => response.body }
+ stop = json.dig("choices", 0, "finish_reason")&.to_sym || stop
end
- end
-
- def fetch_non_streaming(messages, tools)
- body = { model: model, messages: messages }
- body[:tools] = tools unless tools.empty?
- body[:tool_choice] = "auto" unless tools.empty?
-
- result = handle_response(http.post("#{base_url}/chat/completions", headers: headers, body: body))
- return result if result["code"]
- msg = result.dig("choices", 0, "message") || {}
- {
- type: :complete,
- content: msg["content"],
- thinking: nil,
- tool_calls: normalize_tool_calls(msg["tool_calls"]),
- stop_reason: map_stop_reason(result.dig("choices", 0, "finish_reason"))
- }
+ block.call(type: :complete, content:, thinking: nil, tool_calls: finalize_tools(tool_calls))
end
- def fetch_streaming(messages, tools, &block)
- body = { model: model, messages: messages, stream: true }
- body[:tools] = tools unless tools.empty?
- body[:tool_choice] = "auto" unless tools.empty?
-
- content = ""
- tool_calls = {}
- stop_reason = :end_turn
-
- http.post("#{base_url}/chat/completions", headers: headers, body: body) do |response|
- raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
-
- buffer = ""
- response.read_body do |chunk|
- buffer += chunk
-
- while (line = extract_line(buffer))
- next if line.empty? || !line.start_with?("data: ")
-
- data = line[6..]
- break if data == "[DONE]"
-
- json = JSON.parse(data)
- delta = json.dig("choices", 0, "delta") || {}
-
- if delta["content"]
- content += delta["content"]
- block.call({ type: :delta, content: delta["content"], thinking: nil, tool_calls: nil })
- end
-
- if delta["tool_calls"]
- delta["tool_calls"].each do |tc|
- idx = tc["index"]
- tool_calls[idx] ||= { id: nil, name: nil, arguments_json: "" }
- tool_calls[idx][:id] = tc["id"] if tc["id"]
- tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
- tool_calls[idx][:arguments_json] += tc.dig("function", "arguments") || ""
- end
- end
+ private
- if json.dig("choices", 0, "finish_reason")
- stop_reason = map_stop_reason(json.dig("choices", 0, "finish_reason"))
- end
+ def stream(body, &block)
+ @http.post(@url, headers: { "Authorization" => "Bearer #{@api_key}" }, body:) do |res|
+ raise "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
+ buf = ""
+ res.read_body do |chunk|
+ buf += chunk
+ while (i = buf.index("\n"))
+ line = buf.slice!(0, i + 1).strip
+ next unless line.start_with?("data: ") && line != "data: [DONE]"
+ block.call(JSON.parse(line[6..]))
end
end
end
-
- final_tool_calls = tool_calls.values.map do |tc|
- args = begin
- JSON.parse(tc[:arguments_json])
- rescue
- {}
- end
- { id: tc[:id], name: tc[:name], arguments: args }
- end
-
- block.call({
- type: :complete,
- content: content,
- thinking: nil,
- tool_calls: final_tool_calls,
- stop_reason: stop_reason
- })
end
- def extract_line(buffer)
- line_end = buffer.index("\n")
- return nil unless line_end
-
- line = buffer[0...line_end]
- buffer.replace(buffer[(line_end + 1)..] || "")
- line
- end
-
- def normalize_tool_calls(tool_calls)
- return [] if tool_calls.nil? || tool_calls.empty?
-
- tool_calls.map do |tc|
- args = tc.dig("function", "arguments")
- {
- id: tc["id"],
- name: tc.dig("function", "name"),
- arguments: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
- }
- end
- end
-
- def map_stop_reason(reason)
- case reason
- when "stop" then :end_turn
- when "tool_calls" then :tool_use
- when "length" then :max_tokens
- else :end_turn
+ def finalize_tools(tcs)
+ tcs.values.map do |tc|
+ args = begin; JSON.parse(tc[:args]); rescue; {}; end
+ { id: tc[:id], name: tc[:name], arguments: args }
end
end
end