Commit c371632
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