Commit c371632

mo khan <mo@mokhan.ca>
2026-09-23 06:46:56
test: add provider spec via injectable http adapter
1 parent 3f0360c
Changed files (4)
lib/elelem/openai/provider.rb
@@ -3,12 +3,14 @@
 module Elelem
   module OpenAI
     class Provider
-      def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", read_timeout: 3600, open_timeout: 10)
+      def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", read_timeout: 3600, open_timeout: 10,
+                     http: nil)
         @uri = URI.join("#{base_url}/", "chat/completions")
         @model = model
         @api_key = api_key
         @read_timeout = read_timeout
         @open_timeout = open_timeout
+        @http = http
       end
 
       def fetch(messages, tools = [], &)
@@ -47,25 +49,17 @@ module Elelem
       end
 
       def stream(body, &)
-        request = Net::HTTP::Post.new(@uri)
-        request["content-type"] = "application/json"
-        request["authorization"] = "Bearer #{@api_key}"
-        request.body = JSON.generate(body)
+        http.post(@uri, api_key: @api_key, body:) do |response|
+          raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
 
-        http = Net::HTTP.new(@uri.host, @uri.port)
-        http.use_ssl = @uri.scheme == "https"
-        http.read_timeout = @read_timeout
-        http.open_timeout = @open_timeout
-
-        http.start do |conn|
-          conn.request(request) do |response|
-            raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
-
-            read_sse_stream(response, &)
-          end
+          read_sse_stream(response, &)
         end
       end
 
+      def http
+        @http ||= HttpAdapter.new(read_timeout: @read_timeout, open_timeout: @open_timeout)
+      end
+
       def read_sse_stream(response)
         buffer = String.new
 
@@ -90,6 +84,29 @@ module Elelem
           }.tap { |result| block.call(result.merge(type: "doing")) }
         end
       end
+
+      class HttpAdapter
+        def initialize(read_timeout:, open_timeout:)
+          @read_timeout = read_timeout
+          @open_timeout = open_timeout
+        end
+
+        def post(uri, api_key:, body:, &)
+          request = Net::HTTP::Post.new(uri)
+          request["content-type"] = "application/json"
+          request["authorization"] = "Bearer #{api_key}"
+          request.body = JSON.generate(body)
+
+          http = Net::HTTP.new(uri.host, uri.port)
+          http.use_ssl = uri.scheme == "https"
+          http.read_timeout = @read_timeout
+          http.open_timeout = @open_timeout
+
+          http.start do |conn|
+            conn.request(request, &)
+          end
+        end
+      end
     end
   end
 end
spec/elelem/openai/provider_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::OpenAI::Provider do
+  subject(:provider) do
+    described_class.new(model: "gpt-4o", api_key: "sk-test", http:, **params)
+  end
+
+  let(:params) { {} }
+  let(:messages) { [{ role: "user", content: "hi" }] }
+  let(:body) { http.body }
+
+  let(:response) do
+    Net::HTTPOK.new("1.1", "200", "OK").tap do |res|
+      allow(res).to receive(:read_body).and_yield(sse(events))
+    end
+  end
+
+  let(:events) { [] }
+
+  let(:http) do
+    Class.new do
+      attr_reader :body
+
+      def initialize(response)
+        @response = response
+      end
+
+      def post(_uri, body:, **)
+        @body = body
+        yield @response
+      end
+    end.new(response)
+  end
+
+  def sse(events)
+    events.map { |event| "data: #{JSON.generate(event)}\n" }.join << "data: [DONE]\n"
+  end
+
+  describe "#fetch" do
+    it "always sends tools and tool_choice" do
+      provider.fetch(messages, [])
+
+      expect(body).to eq(model: "gpt-4o", messages:, stream: true, tools: [], tool_choice: "auto")
+    end
+
+    context "with a text delta" do
+      let(:events) { [{ "choices" => [{ "delta" => { "content" => "hi" } }] }] }
+
+      it "yields a saying block" do
+        seen = []
+
+        provider.fetch(messages, []) { |event| seen << event }
+
+        expect(seen).to eq([{ type: "saying", text: "hi" }])
+      end
+    end
+
+    context "with a streamed tool call split across chunks" do
+      let(:first_chunk) do
+        { "index" => 0, "id" => "1", "function" => { "name" => "read", "arguments" => '{"path":' } }
+      end
+      let(:second_chunk) { { "index" => 0, "function" => { "arguments" => '"a.rb"}' } } }
+
+      let(:events) do
+        [
+          { "choices" => [{ "delta" => { "tool_calls" => [first_chunk] } }] },
+          { "choices" => [{ "delta" => { "tool_calls" => [second_chunk] } }] }
+        ]
+      end
+
+      it "accumulates arguments across chunks into parsed json" do
+        result = provider.fetch(messages, []) {}
+
+        expect(result).to eq([{ id: "1", name: "read", arguments: { "path" => "a.rb" } }])
+      end
+    end
+
+    context "with a tool call that has no arguments" do
+      let(:events) do
+        [{ "choices" => [{ "delta" => { "tool_calls" => [{ "index" => 0, "id" => "1",
+                                                           "function" => { "name" => "read" } }] } }] }]
+      end
+
+      it "raises instead of defaulting to an empty hash" do
+        expect { provider.fetch(messages, []) {} }.to raise_error(JSON::ParserError)
+      end
+    end
+  end
+end
spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require_relative "../lib/elelem/openai"
+
+RSpec.configure do |config|
+  config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+end
.rspec
@@ -0,0 +1,1 @@
+--require spec_helper