Commit 68397fc

mo khan <mo@mokhan.ca>
2026-01-20 22:41:08
refactor: cleanup net code
1 parent 3af4dbb
Changed files (1)
lib
lib/elelem/openai.rb
@@ -5,48 +5,74 @@ module Elelem
     class OpenAI
       def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", http: Elelem::Net.http)
         @url = "#{base_url}/chat/completions"
-        @model, @api_key, @http = model, api_key, http
+        @model = model
+        @api_key = api_key
+        @http = http
       end
 
       def fetch(messages, tools = [], &block)
         tool_calls = {}
-        body = { model: @model, messages:, stream: true, tools:, tool_choice: "auto" }
-
-        stream(body) do |json|
-          delta = json.dig("choices", 0, "delta") || {}
-          block.call(content: delta["content"], thinking: nil) if delta["content"]
-
-          delta["tool_calls"]&.each do |tool_call|
-            idx = tool_call["index"]
-            tool_calls[idx] ||= { id: nil, name: nil, args: "" }
-            tool_calls[idx][:id] ||= tool_call["id"]
-            tool_calls[idx][:name] ||= tool_call.dig("function", "name")
-            tool_calls[idx][:args] += tool_call.dig("function", "arguments").to_s
-          end
+        body = build_request_body(messages, tools)
+
+        stream(body) do |event|
+          handle_event(event, tool_calls, &block)
         end
 
-        finalize_tools(tool_calls)
+        finalize_tool_calls(tool_calls)
       end
 
       private
 
-      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
+      def build_request_body(messages, tools)
+        { model: @model, messages:, stream: true, tools:, tool_choice: "auto" }
+      end
+
+      def handle_event(event, tool_calls, &block)
+        delta = event.dig("choices", 0, "delta") || {}
+
+        block.call(content: delta["content"], thinking: nil) if delta["content"]
+
+        accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
+      end
+
+      def accumulate_tool_calls(incoming_tool_calls, tool_calls)
+        incoming_tool_calls.each do |tool_call|
+          index = tool_call["index"]
+          tool_calls[index] ||= { id: nil, name: nil, args: String.new }
+          tool_calls[index][:id] ||= tool_call["id"]
+          tool_calls[index][:name] ||= tool_call.dig("function", "name")
+          tool_calls[index][:args] << tool_call.dig("function", "arguments").to_s
+        end
+      end
+
+      def stream(body)
+        @http.post(@url, headers: headers, body:) do |response|
+          raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
+
+          read_sse_stream(response) { |event| yield event }
+        end
+      end
+
+      def headers
+        { "Authorization" => "Bearer #{@api_key}" }
+      end
+
+      def read_sse_stream(response)
+        buffer = String.new
+
+        response.read_body do |chunk|
+          buffer << chunk
+
+          while (index = buffer.index("\n"))
+            line = buffer.slice!(0, index + 1).strip
+            next unless line.start_with?("data: ") && line != "data: [DONE]"
+
+            yield JSON.parse(line.delete_prefix("data: "))
           end
         end
       end
 
-      def finalize_tools(tool_calls)
+      def finalize_tool_calls(tool_calls)
         tool_calls.values.map do |tool_call|
           {
             id: tool_call[:id],