Commit 4fbf8d3

2026-09-23 06:46:54
test: add provider spec via injectable http adapter
1 parent 4ce6ce0
Changed files (4)
lib
elelem
anthropic
spec
lib/elelem/anthropic/provider.rb
@@ -3,13 +3,14 @@
 module Elelem
   module Anthropic
     class Provider
-      def initialize(endpoint:, headers:, model:, version: nil, read_timeout: 3600, open_timeout: 10)
+      def initialize(endpoint:, headers:, model:, version: nil, read_timeout: 3600, open_timeout: 10, http: nil)
         @uri = URI.parse(endpoint)
         @headers_source = headers
         @model = model
         @version = version
         @read_timeout = read_timeout
         @open_timeout = open_timeout
+        @http = http
       end
 
       def fetch(messages, tools = [], &)
@@ -74,25 +75,17 @@ module Elelem
       def stream(messages, system_prompt, tools, &)
         body = build_request_body(messages, system_prompt, tools)
 
-        request = Net::HTTP::Post.new(@uri)
-        request["content-type"] = "application/json"
-        headers.each { |k, v| request[k] = v }
-        request.body = JSON.generate(body)
+        http.post(@uri, headers:, 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 build_request_body(messages, system_prompt, tools)
         body = { max_tokens: 64_000, messages:, stream: true }
         body[:model] = @model unless @version
@@ -191,6 +184,29 @@ module Elelem
           }
         end
       end
+
+      class HttpAdapter
+        def initialize(read_timeout:, open_timeout:)
+          @read_timeout = read_timeout
+          @open_timeout = open_timeout
+        end
+
+        def post(uri, headers:, body:, &)
+          request = Net::HTTP::Post.new(uri)
+          request["content-type"] = "application/json"
+          headers.each { |k, v| request[k] = v }
+          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/anthropic/provider_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Anthropic::Provider do
+  subject(:provider) do
+    described_class.new(
+      endpoint: "https://api.anthropic.com/v1/messages", headers: {}, model: "claude-opus-4-5-20250514", 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) { [{ "type" => "message_stop" }] }
+
+  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\n" }.join
+  end
+
+  describe "#fetch" do
+    it "sends model and messages by default" do
+      provider.fetch(messages, [])
+
+      expect(body).to eq(max_tokens: 64_000, messages:, stream: true, model: "claude-opus-4-5-20250514")
+    end
+
+    it "omits tools when empty" do
+      provider.fetch(messages, [])
+
+      expect(body).not_to have_key(:tools)
+    end
+
+    it "sends tools when present" do
+      tools = [{ type: "function",
+                 function: { name: "read", description: "reads a file", parameters: { type: "object" } } }]
+
+      provider.fetch(messages, tools)
+
+      expect(body[:tools]).to eq([{ name: "read", description: "reads a file", input_schema: { type: "object" } }])
+    end
+
+    it "extracts a system message out of messages" do
+      provider.fetch([{ role: "system", content: "be terse" }, *messages], [])
+
+      expect(body[:system]).to eq("be terse")
+      expect(body[:messages]).to eq(messages)
+    end
+
+    context "with text and thinking deltas" do
+      let(:events) do
+        [
+          { "type" => "content_block_delta", "delta" => { "type" => "text_delta", "text" => "hi" } },
+          { "type" => "content_block_delta", "delta" => { "type" => "thinking_delta", "thinking" => "hmm" } }
+        ]
+      end
+
+      it "yields saying and thinking blocks" do
+        seen = []
+
+        provider.fetch(messages, []) { |event| seen << event }
+
+        expect(seen).to eq([{ type: "saying", text: "hi" }, { type: "thinking", text: "hmm" }])
+      end
+    end
+
+    context "with a streamed tool call" do
+      let(:events) do
+        [
+          { "type" => "content_block_start",
+            "content_block" => { "type" => "tool_use", "id" => "1", "name" => "read" } },
+          { "type" => "content_block_delta",
+            "delta" => { "type" => "input_json_delta", "partial_json" => '{"path":' } },
+          { "type" => "content_block_delta", "delta" => { "type" => "input_json_delta", "partial_json" => '"a.rb"}' } }
+        ]
+      end
+
+      it "accumulates partial json into parsed arguments" do
+        result = provider.fetch(messages, []) {}
+
+        expect(result).to eq([{ id: "1", name: "read", arguments: { "path" => "a.rb" } }])
+      end
+
+      it "yields a doing event once finalized" do
+        seen = []
+
+        provider.fetch(messages, []) { |event| seen << event }
+
+        expect(seen).to eq([{ type: "doing", id: "1", name: "read", arguments: { "path" => "a.rb" } }])
+      end
+    end
+  end
+end
spec/spec_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require_relative "../lib/elelem/anthropic"
+
+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