main
1# frozen_string_literal: true
2
3module Net
4 module Llm
5 class OpenAI
6 attr_reader :api_key, :base_url, :model, :http
7
8 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)
9 @api_key = api_key
10 @base_url = base_url
11 @model = model
12 @http = http
13 end
14
15 def chat(messages, tools)
16 handle_response(http.post(
17 "#{base_url}/chat/completions",
18 headers: headers,
19 body: { model: model, messages: messages, tools: tools, tool_choice: "auto" }
20 ))
21 end
22
23 def fetch(messages, tools = [], &block)
24 if block_given?
25 fetch_streaming(messages, tools, &block)
26 else
27 fetch_non_streaming(messages, tools)
28 end
29 end
30
31 def models
32 handle_response(http.get("#{base_url}/models", headers: headers))
33 end
34
35 def embeddings(input, model: "text-embedding-ada-002")
36 handle_response(http.post(
37 "#{base_url}/embeddings",
38 headers: headers,
39 body: { model: model, input: input },
40 ))
41 end
42
43 private
44
45 def headers
46 { "Authorization" => Net::Hippie.bearer_auth(api_key) }
47 end
48
49 def handle_response(response)
50 if response.is_a?(Net::HTTPSuccess)
51 JSON.parse(response.body)
52 else
53 { "code" => response.code, "body" => response.body }
54 end
55 end
56
57 def fetch_non_streaming(messages, tools)
58 body = { model: model, messages: messages }
59 body[:tools] = tools unless tools.empty?
60 body[:tool_choice] = "auto" unless tools.empty?
61
62 result = handle_response(http.post("#{base_url}/chat/completions", headers: headers, body: body))
63 return result if result["code"]
64
65 msg = result.dig("choices", 0, "message") || {}
66 {
67 type: :complete,
68 content: msg["content"],
69 thinking: nil,
70 tool_calls: normalize_tool_calls(msg["tool_calls"]),
71 stop_reason: map_stop_reason(result.dig("choices", 0, "finish_reason"))
72 }
73 end
74
75 def fetch_streaming(messages, tools, &block)
76 body = { model: model, messages: messages, stream: true }
77 body[:tools] = tools unless tools.empty?
78 body[:tool_choice] = "auto" unless tools.empty?
79
80 content = ""
81 tool_calls = {}
82 stop_reason = :end_turn
83
84 http.post("#{base_url}/chat/completions", headers: headers, body: body) do |response|
85 raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
86
87 buffer = ""
88 response.read_body do |chunk|
89 buffer += chunk
90
91 while (line = extract_line(buffer))
92 next if line.empty? || !line.start_with?("data: ")
93
94 data = line[6..]
95 break if data == "[DONE]"
96
97 json = JSON.parse(data)
98 delta = json.dig("choices", 0, "delta") || {}
99
100 if delta["content"]
101 content += delta["content"]
102 block.call({ type: :delta, content: delta["content"], thinking: nil, tool_calls: nil })
103 end
104
105 if delta["tool_calls"]
106 delta["tool_calls"].each do |tc|
107 idx = tc["index"]
108 tool_calls[idx] ||= { id: nil, name: nil, arguments_json: "" }
109 tool_calls[idx][:id] = tc["id"] if tc["id"]
110 tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
111 tool_calls[idx][:arguments_json] += tc.dig("function", "arguments") || ""
112 end
113 end
114
115 if json.dig("choices", 0, "finish_reason")
116 stop_reason = map_stop_reason(json.dig("choices", 0, "finish_reason"))
117 end
118 end
119 end
120 end
121
122 final_tool_calls = tool_calls.values.map do |tc|
123 args = begin
124 JSON.parse(tc[:arguments_json])
125 rescue
126 {}
127 end
128 { id: tc[:id], name: tc[:name], arguments: args }
129 end
130
131 block.call({
132 type: :complete,
133 content: content,
134 thinking: nil,
135 tool_calls: final_tool_calls,
136 stop_reason: stop_reason
137 })
138 end
139
140 def extract_line(buffer)
141 line_end = buffer.index("\n")
142 return nil unless line_end
143
144 line = buffer[0...line_end]
145 buffer.replace(buffer[(line_end + 1)..] || "")
146 line
147 end
148
149 def normalize_tool_calls(tool_calls)
150 return [] if tool_calls.nil? || tool_calls.empty?
151
152 tool_calls.map do |tc|
153 args = tc.dig("function", "arguments")
154 {
155 id: tc["id"],
156 name: tc.dig("function", "name"),
157 arguments: args.is_a?(String) ? (JSON.parse(args) rescue {}) : (args || {})
158 }
159 end
160 end
161
162 def map_stop_reason(reason)
163 case reason
164 when "stop" then :end_turn
165 when "tool_calls" then :tool_use
166 when "length" then :max_tokens
167 else :end_turn
168 end
169 end
170 end
171 end
172end