From 876f0183a43a061a5acc4f1c8ac5eed5c65736e0 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Tue, 1 Jul 2025 19:01:44 +0300 Subject: [PATCH 1/3] moved connection to http --- lib/rubyai/chat.rb | 27 +-------------------------- lib/rubyai/client.rb | 26 +------------------------- lib/rubyai/http.rb | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 51 deletions(-) diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index cf8f982..b7cb74d 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -13,31 +13,6 @@ def initialize(provider, def call(messages) raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? - body = HTTP.build_body(messages, @provider, @model, @temperature) - headers = HTTP.build_headers(provider) - - response = connection.post do |req| - req.url Provider::PROVIDERS[@provider, @model] - req.headers.merge!(headers) - req.body = body.to_json - end - - JSON.parse(response.body) - end - - private - - def connection - @connection ||= Faraday.new do |faraday| - faraday.adapter Faraday.default_adapter - faraday.headers["Content-Type"] = "application/json" - end - rescue Faraday::Error => e - raise "Connection error: #{e.message}" - rescue JSON::ParserError => e - raise "Response parsing error: #{e.message}" - rescue StandardError => e - raise "An unexpected error occurred: #{e.message}" - end + HTTP.connection(messages, provider, model, temperature) end end end diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb index 2e25fb5..5f2086a 100644 --- a/lib/rubyai/client.rb +++ b/lib/rubyai/client.rb @@ -13,31 +13,7 @@ def call temperature = configuration.temperature raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? - body = HTTP.build_body(messages, provider, model, temperature) - headers = HTTP.build_headers(provider) - - response = connection.post do |req| - req.url Provider::PROVIDERS[provider, model] - req.headers.merge!(headers) - req.body = body.to_json - end - - JSON.parse(response.body) - end - - private - - def connection - connection ||= Faraday.new do |faraday| - faraday.adapter Faraday.default_adapter - faraday.headers["Content-Type"] = "application/json" - end - rescue Faraday::Error => e - raise "Connection error: #{e.message}" - rescue JSON::ParserError => e - raise "Response parsing error: #{e.message}" - rescue StandardError => e - raise "An unexpected error occurred: #{e.message}" + HTTP.connection(messages, provider, model, temperature) end end end diff --git a/lib/rubyai/http.rb b/lib/rubyai/http.rb index 967c031..2b68e15 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -9,5 +9,33 @@ def build_body(messages, provider, model, temperature) def build_headers(provider) RubyAI.config.send(provider).build_http_headers(provider) end + + def self.connection + body = HTTP.build_body(messages, @provider, @model, @temperature) + headers = HTTP.build_headers(provider) + + response = connection.post do |req| + req.url Configuration::PROVIDERS[@provider, @model] + req.headers.merge!(headers) + req.body = body.to_json + end + + JSON.parse(response.body) + end + + private + + def connection + @connection ||= Faraday.new do |faraday| + faraday.adapter Faraday.default_adapter + faraday.headers["Content-Type"] = "application/json" + end + rescue Faraday::Error => e + raise "Connection error: #{e.message}" + rescue JSON::ParserError => e + raise "Response parsing error: #{e.message}" + rescue StandardError => e + raise "An unexpected error occurred: #{e.message}" + end end end From ad904fdd7807f82fa1ba8aa7cac0e9f13f9b4c34 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Sun, 6 Jul 2025 15:36:00 +0300 Subject: [PATCH 2/3] add specs --- spec/rubyai/chat_spec.rb | 86 +++++++++--------- spec/rubyai/client_spec.rb | 97 ++++++++++++++++++++ spec/rubyai/http_spec.rb | 179 ++++++++++++++++++++++++++++++++++++- 3 files changed, 321 insertions(+), 41 deletions(-) create mode 100644 spec/rubyai/client_spec.rb diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb index 2bce5ef..a5576da 100644 --- a/spec/rubyai/chat_spec.rb +++ b/spec/rubyai/chat_spec.rb @@ -1,8 +1,6 @@ require_relative "../../lib/rubyai" require "webmock/rspec" -# spec/ruby_ai/chat_spec.rb - require "spec_helper" RSpec.describe RubyAI::Chat do @@ -27,6 +25,11 @@ chat_instance = described_class.new(nil) expect(chat_instance.provider).to eq("default_provider") end + + it "uses default temperature when not specified" do + chat_instance = described_class.new(provider, model: model) + expect(chat_instance.temperature).to eq(0.75) + end end describe "#call" do @@ -38,30 +41,39 @@ end context "when messages are valid" do - let(:fake_connection) { instance_double(Faraday::Connection) } let(:fake_response) { instance_double(Faraday::Response, body: response_body) } - let(:fake_connection) do - instance_double(Faraday::Connection).tap do |conn| - allow(conn).to receive(:post).and_return(fake_response) - allow(conn).to receive(:headers).and_return({}) - allow(conn).to receive(:adapter) - end - end - - let(:url) { "https://fake.provider/api" } + let(:built_body) { { body: "data" } } + let(:built_headers) { { "Authorization" => "Bearer token" } } before do - allow(RubyAI::HTTP).to receive(:build_body).with(messages, provider, model, - temperature).and_return({ body: "data" }) - allow(RubyAI::HTTP).to receive(:build_headers).with(provider).and_return({ "Authorization" => "Bearer token" }) + allow(RubyAI::HTTP).to receive(:build_body).with(messages, provider, model, temperature).and_return(built_body) + allow(RubyAI::HTTP).to receive(:build_headers).with(provider).and_return(built_headers) + allow(RubyAI::HTTP).to receive(:connect).with( + model: model, + provider: provider, + body: built_body, + headers: built_headers + ).and_return(fake_response) + end - stub_const("RubyAI::Configuration::PROVIDERS", { [provider, model] => url }) + it "calls HTTP.build_body with correct parameters" do + chat.call(messages) + expect(RubyAI::HTTP).to have_received(:build_body).with(messages, provider, model, temperature) + end - allow(Faraday).to receive(:new).and_return(fake_connection) - allow(fake_connection).to receive(:headers).and_return({}) - allow(fake_connection).to receive(:adapter) + it "calls HTTP.build_headers with correct parameters" do + chat.call(messages) + expect(RubyAI::HTTP).to have_received(:build_headers).with(provider) + end - allow(fake_connection).to receive(:post).and_return(fake_response) + it "calls HTTP.connect with correct parameters" do + chat.call(messages) + expect(RubyAI::HTTP).to have_received(:connect).with( + model: model, + provider: provider, + body: built_body, + headers: built_headers + ) end it "returns parsed JSON response" do @@ -70,13 +82,15 @@ end end - context "when Faraday connection fails" do + context "when HTTP.connect raises Faraday::ConnectionFailed" do before do - allow(Faraday).to receive(:new).and_raise(Faraday::ConnectionFailed.new("no internet")) + allow(RubyAI::HTTP).to receive(:build_body).and_return({}) + allow(RubyAI::HTTP).to receive(:build_headers).and_return({}) + allow(RubyAI::HTTP).to receive(:connect).and_raise(Faraday::ConnectionFailed.new("no internet")) end - it "raises a connection error" do - expect { chat.call(messages) }.to raise_error("Connection error: no internet") + it "allows the connection error to propagate" do + expect { chat.call(messages) }.to raise_error(Faraday::ConnectionFailed, "no internet") end end @@ -84,17 +98,9 @@ let(:bad_response) { instance_double(Faraday::Response, body: "not_json") } before do - stub_const("RubyAI::Configuration::PROVIDERS", { [provider, model] => "fake_url" }) - allow(RubyAI::HTTP).to receive(:build_body).and_return({}) allow(RubyAI::HTTP).to receive(:build_headers).and_return({}) - - allow(Faraday).to receive(:new).and_return(double( - headers: {}, - adapter: nil, - post: bad_response - )) - + allow(RubyAI::HTTP).to receive(:connect).and_return(bad_response) allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new("unexpected token")) end @@ -103,16 +109,16 @@ end end - context "when any other error occurs" do + context "when HTTP.connect raises any other error" do before do - allow(Faraday).to receive(:new).and_raise(StandardError.new("something went wrong")) + allow(RubyAI::HTTP).to receive(:build_body).and_return({}) + allow(RubyAI::HTTP).to receive(:build_headers).and_return({}) + allow(RubyAI::HTTP).to receive(:connect).and_raise(StandardError.new("something went wrong")) end - it "raises a generic error" do - expect do - chat.call(messages) - end.to raise_error("An unexpected error occurred: something went wrong") + it "allows the error to propagate" do + expect { chat.call(messages) }.to raise_error(StandardError, "something went wrong") end end end -end +end \ No newline at end of file diff --git a/spec/rubyai/client_spec.rb b/spec/rubyai/client_spec.rb new file mode 100644 index 0000000..b2a72a0 --- /dev/null +++ b/spec/rubyai/client_spec.rb @@ -0,0 +1,97 @@ +require_relative "../../lib/rubyai" +require "webmock/rspec" + +require "spec_helper" + +RSpec.describe RubyAI::Client do + let(:config) { { provider: "openai", model: "gpt-4", temperature: 0.8, messages: ["Hello"] } } + let(:mock_configuration) { instance_double(RubyAI::Configuration) } + let(:response_body) { { "reply" => "Hi!" }.to_json } + + subject(:client) { described_class.new(config) } + + describe "#initialize" do + it "creates configuration from provided config" do + expect(RubyAI).to receive(:config).with(config).and_return(mock_configuration) + client_instance = described_class.new(config) + expect(client_instance.configuration).to eq(mock_configuration) + end + + it "uses empty hash as default config" do + expect(RubyAI).to receive(:config).with({}).and_return(mock_configuration) + client_instance = described_class.new + expect(client_instance.configuration).to eq(mock_configuration) + end + end + + describe "#call" do + let(:messages) { ["Hello", "How are you?"] } + let(:provider) { "openai" } + let(:model) { "gpt-4" } + let(:temperature) { 0.7 } + let(:built_body) { { prompt: "data" } } + let(:built_headers) { { "Authorization" => "Bearer token" } } + let(:fake_response) { instance_double(Faraday::Response, body: response_body) } + + before do + allow(mock_configuration).to receive(:messages).and_return(messages) + allow(mock_configuration).to receive(:provider).and_return(provider) + allow(mock_configuration).to receive(:model).and_return(model) + allow(mock_configuration).to receive(:temperature).and_return(temperature) + allow(RubyAI).to receive(:config).and_return(mock_configuration) + end + + context "when configuration is valid" do + before do + allow(RubyAI::HTTP).to receive(:build_body).and_return(built_body) + allow(RubyAI::HTTP).to receive(:build_headers).and_return(built_headers) + allow(RubyAI::HTTP).to receive(:connect).and_return(fake_response) + end + + it "calls HTTP methods with correct parameters" do + client.call + expect(RubyAI::HTTP).to have_received(:build_body).with(messages, provider, model, temperature) + expect(RubyAI::HTTP).to have_received(:build_headers).with(provider) + expect(RubyAI::HTTP).to have_received(:connect).with( + model: model, provider: provider, body: built_body, headers: built_headers + ) + end + + it "returns parsed JSON response" do + result = client.call + expect(result).to eq({ "reply" => "Hi!" }) + end + end + + context "when messages are nil or empty" do + it "raises ArgumentError for nil messages" do + allow(mock_configuration).to receive(:messages).and_return(nil) + expect { client.call }.to raise_error(ArgumentError, "Messages cannot be empty") + end + + it "raises ArgumentError for empty messages" do + allow(mock_configuration).to receive(:messages).and_return([]) + expect { client.call }.to raise_error(ArgumentError, "Messages cannot be empty") + end + end + + context "when HTTP operations fail" do + before do + allow(RubyAI::HTTP).to receive(:build_body).and_return(built_body) + allow(RubyAI::HTTP).to receive(:build_headers).and_return(built_headers) + end + + it "propagates connection errors" do + allow(RubyAI::HTTP).to receive(:connect).and_raise(Faraday::ConnectionFailed.new("network error")) + expect { client.call }.to raise_error(Faraday::ConnectionFailed, "network error") + end + + it "propagates JSON parsing errors" do + bad_response = instance_double(Faraday::Response, body: "invalid_json") + allow(RubyAI::HTTP).to receive(:connect).and_return(bad_response) + allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new("parse error")) + expect { client.call }.to raise_error(JSON::ParserError, "parse error") + end + end + end +end \ No newline at end of file diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb index 1600d17..085fc5e 100644 --- a/spec/rubyai/http_spec.rb +++ b/spec/rubyai/http_spec.rb @@ -16,6 +16,35 @@ allow(mock_config).to receive(:send).with(provider).and_return(mock_provider_config) end + context "when provider is gemini" do + let(:mock_connection) { instance_double(Faraday::Connection) } + let(:mock_response) { instance_double(Faraday::Response, body: '{"reply": "Hi!"}') } + let(:provider) { "gemini" } + let(:model) { "gemini-pro" } + let(:mock_gemini_config) { double("gemini_config") } + let(:body) { { messages: messages, model: model, temperature: temperature } } + let(:headers) { { "Authorization" => "Bearer test_api_key" } } + + before do + allow(RubyAI).to receive(:config).and_return(double(gemini: mock_gemini_config)) + allow(mock_gemini_config).to receive(:api).and_return("test_api_key") + allow(described_class).to receive(:connection).and_return(mock_connection) + end + + it "constructs correct URL with model and API key" do + request_double = double + expected_url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=test_api_key" + expect(request_double).to receive(:url).with(expected_url) + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(provider: provider, model: model, body: body, headers: headers) + end + end + + context "when provider configuration exists" do it "calls build_http_body on the provider configuration" do expect(mock_provider_config).to receive(:build_http_body) @@ -159,6 +188,152 @@ end end + describe ".connect" do + let(:provider) { "openai" } + let(:model) { "gpt-4" } + let(:body) { { messages: [{ role: "user", content: "Hello" }] } } + let(:headers) { { "Authorization" => "Bearer token" } } + let(:mock_connection) { instance_double(Faraday::Connection) } + let(:mock_response) { instance_double(Faraday::Response, body: '{"reply": "Hi!"}') } + + before do + allow(described_class).to receive(:connection).and_return(mock_connection) + providers_hash = { + "openai" => "https://api.openai.com/v1/chat/completions", + "anthropic" => "https://api.anthropic.com/v1/chat/completions", + "gemini" => "https://generativelanguage.googleapis.com/v1beta/models", + "bedrock_anthropic" => "bedrock_anthropic" + } + def providers_hash.[](provider, model = nil) + return super(provider) unless !model.nil? && provider == "gemini" + "#{super(provider)}/#{model}:generateContent?key=#{RubyAI.config.gemini.api}" + end + stub_const("RubyAI::Configuration::PROVIDERS", providers_hash) + end + + context "when provider is not bedrock_anthropic" do + it "makes a POST request with correct parameters" do + request_double = double + expect(request_double).to receive(:url).with("https://api.openai.com/v1/chat/completions") + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + result = described_class.connect(provider: provider, model: model, body: body, headers: headers) + expect(result).to eq(mock_response) + end + + + it "sets the correct URL from PROVIDERS configuration" do + request_double = double + expect(request_double).to receive(:url).with("https://api.openai.com/v1/chat/completions") + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(provider: provider, model: model, body: body, headers: headers) + end + + it "merges headers correctly" do + request_double = double + request_headers = double + expect(request_double).to receive(:url) + expect(request_double).to receive(:headers).and_return(request_headers) + expect(request_headers).to receive(:merge!).with(headers) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(provider: provider, model: model, body: body, headers: headers) + end + + it "sets body as JSON" do + request_double = double + expect(request_double).to receive(:url) + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=).with(body.to_json) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(provider: provider, model: model, body: body, headers: headers) + end + end + + context "when provider is bedrock_anthropic" do + let(:provider) { "bedrock_anthropic" } + let(:mock_config) { double("config") } + let(:mock_bedrock_config) { double("bedrock_config") } + let(:mock_client) { double("bedrock_client") } + let(:bedrock_response) { double("bedrock_response") } + + before do + allow(RubyAI).to receive(:config).and_return(mock_config) + allow(mock_config).to receive(:bedrock_anthropic).and_return(mock_bedrock_config) + allow(mock_bedrock_config).to receive(:client).and_return(mock_client) + end + + it "calls invoke_model on bedrock client" do + expect(mock_client).to receive(:invoke_model).with( + model_id: model, + body: body, + content_type: "application/json" + ).and_return(bedrock_response) + + result = described_class.connect(provider: provider, model: model, body: body, headers: headers) + expect(result).to eq(bedrock_response) + end + end + end + + describe ".connection" do + it "returns a Faraday connection" do + connection = described_class.connection + expect(connection).to be_a(Faraday::Connection) + end + + it "sets Content-Type header to application/json" do + connection = described_class.connection + expect(connection.headers["Content-Type"]).to eq("application/json") + end + + it "uses default adapter" do + expect(Faraday).to receive(:new).and_yield(double.as_null_object) + described_class.connection + end + + context "when Faraday::Error is raised" do + before do + allow(Faraday).to receive(:new).and_raise(Faraday::Error.new("network error")) + end + + it "raises connection error with message" do + expect { described_class.connection }.to raise_error("Connection error: network error") + end + end + + context "when JSON::ParserError is raised" do + before do + allow(Faraday).to receive(:new).and_raise(JSON::ParserError.new("invalid json")) + end + + it "raises response parsing error with message" do + expect { described_class.connection }.to raise_error("Response parsing error: invalid json") + end + end + + context "when StandardError is raised" do + before do + allow(Faraday).to receive(:new).and_raise(StandardError.new("unexpected error")) + end + + it "raises generic error with message" do + expect { described_class.connection }.to raise_error("An unexpected error occurred: unexpected error") + end + end + end + describe "module structure" do it "is a module" do expect(RubyAI::HTTP).to be_a(Module) @@ -171,6 +346,8 @@ it "has the expected public methods" do expect(described_class).to respond_to(:build_body) expect(described_class).to respond_to(:build_headers) + expect(described_class).to respond_to(:connect) + expect(described_class).to respond_to(:connection) end end @@ -221,4 +398,4 @@ expect(anthropic_body).to eq({ anthropic: "body" }) end end -end +end \ No newline at end of file From a9ba3089e675334dab521b8f942850360d9bbbc2 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Wed, 9 Jul 2025 17:51:16 +0300 Subject: [PATCH 3/3] fixed specs and a little bit refactor of code --- lib/rubyai/chat.rb | 3 +- lib/rubyai/client.rb | 2 +- lib/rubyai/http.rb | 25 +++-- spec/rubyai/chat_spec.rb | 52 ++-------- spec/rubyai/client_spec.rb | 33 +++---- spec/rubyai/http_spec.rb | 198 +++++++++++++++++++------------------ 6 files changed, 139 insertions(+), 174 deletions(-) diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index b7cb74d..f1ad821 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -13,6 +13,7 @@ def initialize(provider, def call(messages) raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? - HTTP.connection(messages, provider, model, temperature) end + HTTP.connect(messages: messages, provider: provider, model: model, temperature: temperature) + end end end diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb index 5f2086a..d17a1cd 100644 --- a/lib/rubyai/client.rb +++ b/lib/rubyai/client.rb @@ -13,7 +13,7 @@ def call temperature = configuration.temperature raise ArgumentError, "Messages cannot be empty" if messages.nil? || messages.empty? - HTTP.connection(messages, provider, model, temperature) + HTTP.connect(messages: messages, provider: provider, model: model, temperature: temperature) end end end diff --git a/lib/rubyai/http.rb b/lib/rubyai/http.rb index 2b68e15..9444ffe 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -10,21 +10,26 @@ def build_headers(provider) RubyAI.config.send(provider).build_http_headers(provider) end - def self.connection - body = HTTP.build_body(messages, @provider, @model, @temperature) - headers = HTTP.build_headers(provider) + def connect(messages:, provider: , model:, temperature:) + body = self.build_body(messages, provider, model, temperature) + headers = self.build_headers(provider) - response = connection.post do |req| - req.url Configuration::PROVIDERS[@provider, @model] - req.headers.merge!(headers) - req.body = body.to_json - end + response = case provider + when "bedrock_anthropic" + RubyAI.config.bedrock_anthropic.client.invoke_model(model_id: model, + body: BedrockAnthropic.build_http_body.to_json, + content_type: "application/json") + else + connection.post do |req| + req.url Provider::PROVIDERS[provider, model] + req.headers.merge!(headers) + req.body = body.to_json + end + end JSON.parse(response.body) end - private - def connection @connection ||= Faraday.new do |faraday| faraday.adapter Faraday.default_adapter diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb index a5576da..8a12f85 100644 --- a/spec/rubyai/chat_spec.rb +++ b/spec/rubyai/chat_spec.rb @@ -20,8 +20,7 @@ end it "uses default provider if none is given" do - allow(RubyAI).to receive_message_chain(:config, - :default_provider).and_return("default_provider") + allow(RubyAI).to receive_message_chain(:config, :default_provider).and_return("default_provider") chat_instance = described_class.new(nil) expect(chat_instance.provider).to eq("default_provider") end @@ -42,50 +41,34 @@ context "when messages are valid" do let(:fake_response) { instance_double(Faraday::Response, body: response_body) } - let(:built_body) { { body: "data" } } - let(:built_headers) { { "Authorization" => "Bearer token" } } before do - allow(RubyAI::HTTP).to receive(:build_body).with(messages, provider, model, temperature).and_return(built_body) - allow(RubyAI::HTTP).to receive(:build_headers).with(provider).and_return(built_headers) allow(RubyAI::HTTP).to receive(:connect).with( - model: model, + messages: messages, provider: provider, - body: built_body, - headers: built_headers + model: model, + temperature: temperature ).and_return(fake_response) end - it "calls HTTP.build_body with correct parameters" do - chat.call(messages) - expect(RubyAI::HTTP).to have_received(:build_body).with(messages, provider, model, temperature) - end - - it "calls HTTP.build_headers with correct parameters" do - chat.call(messages) - expect(RubyAI::HTTP).to have_received(:build_headers).with(provider) - end - it "calls HTTP.connect with correct parameters" do chat.call(messages) expect(RubyAI::HTTP).to have_received(:connect).with( - model: model, + messages: messages, provider: provider, - body: built_body, - headers: built_headers + model: model, + temperature: temperature ) end - it "returns parsed JSON response" do + it "returns the HTTP response" do result = chat.call(messages) - expect(result).to eq({ "reply" => "Hi!" }) + expect(result).to eq(fake_response) end end context "when HTTP.connect raises Faraday::ConnectionFailed" do before do - allow(RubyAI::HTTP).to receive(:build_body).and_return({}) - allow(RubyAI::HTTP).to receive(:build_headers).and_return({}) allow(RubyAI::HTTP).to receive(:connect).and_raise(Faraday::ConnectionFailed.new("no internet")) end @@ -94,25 +77,8 @@ end end - context "when JSON parsing fails" do - let(:bad_response) { instance_double(Faraday::Response, body: "not_json") } - - before do - allow(RubyAI::HTTP).to receive(:build_body).and_return({}) - allow(RubyAI::HTTP).to receive(:build_headers).and_return({}) - allow(RubyAI::HTTP).to receive(:connect).and_return(bad_response) - allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new("unexpected token")) - end - - it "raises a JSON parse error" do - expect { chat.call(messages) }.to raise_error(JSON::ParserError, "unexpected token") - end - end - context "when HTTP.connect raises any other error" do before do - allow(RubyAI::HTTP).to receive(:build_body).and_return({}) - allow(RubyAI::HTTP).to receive(:build_headers).and_return({}) allow(RubyAI::HTTP).to receive(:connect).and_raise(StandardError.new("something went wrong")) end diff --git a/spec/rubyai/client_spec.rb b/spec/rubyai/client_spec.rb index b2a72a0..b94a3dc 100644 --- a/spec/rubyai/client_spec.rb +++ b/spec/rubyai/client_spec.rb @@ -1,13 +1,12 @@ require_relative "../../lib/rubyai" require "webmock/rspec" - require "spec_helper" RSpec.describe RubyAI::Client do let(:config) { { provider: "openai", model: "gpt-4", temperature: 0.8, messages: ["Hello"] } } let(:mock_configuration) { instance_double(RubyAI::Configuration) } let(:response_body) { { "reply" => "Hi!" }.to_json } - + subject(:client) { described_class.new(config) } describe "#initialize" do @@ -29,8 +28,6 @@ let(:provider) { "openai" } let(:model) { "gpt-4" } let(:temperature) { 0.7 } - let(:built_body) { { prompt: "data" } } - let(:built_headers) { { "Authorization" => "Bearer token" } } let(:fake_response) { instance_double(Faraday::Response, body: response_body) } before do @@ -43,23 +40,22 @@ context "when configuration is valid" do before do - allow(RubyAI::HTTP).to receive(:build_body).and_return(built_body) - allow(RubyAI::HTTP).to receive(:build_headers).and_return(built_headers) allow(RubyAI::HTTP).to receive(:connect).and_return(fake_response) end - it "calls HTTP methods with correct parameters" do + it "calls HTTP.connect with correct parameters" do client.call - expect(RubyAI::HTTP).to have_received(:build_body).with(messages, provider, model, temperature) - expect(RubyAI::HTTP).to have_received(:build_headers).with(provider) expect(RubyAI::HTTP).to have_received(:connect).with( - model: model, provider: provider, body: built_body, headers: built_headers + messages: messages, + provider: provider, + model: model, + temperature: temperature ) end - it "returns parsed JSON response" do + it "returns the HTTP response" do result = client.call - expect(result).to eq({ "reply" => "Hi!" }) + expect(result).to eq(fake_response) end end @@ -76,21 +72,14 @@ end context "when HTTP operations fail" do - before do - allow(RubyAI::HTTP).to receive(:build_body).and_return(built_body) - allow(RubyAI::HTTP).to receive(:build_headers).and_return(built_headers) - end - it "propagates connection errors" do allow(RubyAI::HTTP).to receive(:connect).and_raise(Faraday::ConnectionFailed.new("network error")) expect { client.call }.to raise_error(Faraday::ConnectionFailed, "network error") end - it "propagates JSON parsing errors" do - bad_response = instance_double(Faraday::Response, body: "invalid_json") - allow(RubyAI::HTTP).to receive(:connect).and_return(bad_response) - allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new("parse error")) - expect { client.call }.to raise_error(JSON::ParserError, "parse error") + it "propagates any other errors" do + allow(RubyAI::HTTP).to receive(:connect).and_raise(StandardError.new("something went wrong")) + expect { client.call }.to raise_error(StandardError, "something went wrong") end end end diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb index 085fc5e..9009dbf 100644 --- a/spec/rubyai/http_spec.rb +++ b/spec/rubyai/http_spec.rb @@ -16,35 +16,6 @@ allow(mock_config).to receive(:send).with(provider).and_return(mock_provider_config) end - context "when provider is gemini" do - let(:mock_connection) { instance_double(Faraday::Connection) } - let(:mock_response) { instance_double(Faraday::Response, body: '{"reply": "Hi!"}') } - let(:provider) { "gemini" } - let(:model) { "gemini-pro" } - let(:mock_gemini_config) { double("gemini_config") } - let(:body) { { messages: messages, model: model, temperature: temperature } } - let(:headers) { { "Authorization" => "Bearer test_api_key" } } - - before do - allow(RubyAI).to receive(:config).and_return(double(gemini: mock_gemini_config)) - allow(mock_gemini_config).to receive(:api).and_return("test_api_key") - allow(described_class).to receive(:connection).and_return(mock_connection) - end - - it "constructs correct URL with model and API key" do - request_double = double - expected_url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=test_api_key" - expect(request_double).to receive(:url).with(expected_url) - expect(request_double).to receive(:headers).and_return(double(merge!: nil)) - expect(request_double).to receive(:body=) - - expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) - - described_class.connect(provider: provider, model: model, body: body, headers: headers) - end - end - - context "when provider configuration exists" do it "calls build_http_body on the provider configuration" do expect(mock_provider_config).to receive(:build_http_body) @@ -191,98 +162,126 @@ describe ".connect" do let(:provider) { "openai" } let(:model) { "gpt-4" } - let(:body) { { messages: [{ role: "user", content: "Hello" }] } } - let(:headers) { { "Authorization" => "Bearer token" } } + let(:messages) { [{ role: "user", content: "Hello" }] } + let(:temperature) { 0.7 } let(:mock_connection) { instance_double(Faraday::Connection) } let(:mock_response) { instance_double(Faraday::Response, body: '{"reply": "Hi!"}') } + let(:mock_body) { { messages: messages, model: model, temperature: temperature } } + let(:mock_headers) { { "Authorization" => "Bearer token" } } before do allow(described_class).to receive(:connection).and_return(mock_connection) + allow(described_class).to receive(:build_body).and_return(mock_body) + allow(described_class).to receive(:build_headers).and_return(mock_headers) + providers_hash = { "openai" => "https://api.openai.com/v1/chat/completions", "anthropic" => "https://api.anthropic.com/v1/chat/completions", - "gemini" => "https://generativelanguage.googleapis.com/v1beta/models", - "bedrock_anthropic" => "bedrock_anthropic" + "gemini" => "https://generativelanguage.googleapis.com/v1beta/models" } + def providers_hash.[](provider, model = nil) return super(provider) unless !model.nil? && provider == "gemini" - "#{super(provider)}/#{model}:generateContent?key=#{RubyAI.config.gemini.api}" + "#{super(provider)}/#{model}:generateContent" end - stub_const("RubyAI::Configuration::PROVIDERS", providers_hash) + + stub_const("RubyAI::Provider::PROVIDERS", providers_hash) end - context "when provider is not bedrock_anthropic" do - it "makes a POST request with correct parameters" do - request_double = double - expect(request_double).to receive(:url).with("https://api.openai.com/v1/chat/completions") - expect(request_double).to receive(:headers).and_return(double(merge!: nil)) - expect(request_double).to receive(:body=) + it "calls build_body with correct parameters" do + request_double = double + allow(request_double).to receive(:url) + allow(request_double).to receive(:headers).and_return(double(merge!: nil)) + allow(request_double).to receive(:body=) + allow(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) - expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) + + expect(described_class).to have_received(:build_body).with(messages, provider, model, temperature) + end - result = described_class.connect(provider: provider, model: model, body: body, headers: headers) - expect(result).to eq(mock_response) - end + it "calls build_headers with correct parameters" do + request_double = double + allow(request_double).to receive(:url) + allow(request_double).to receive(:headers).and_return(double(merge!: nil)) + allow(request_double).to receive(:body=) + allow(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) + + expect(described_class).to have_received(:build_headers).with(provider) + end - it "sets the correct URL from PROVIDERS configuration" do - request_double = double - expect(request_double).to receive(:url).with("https://api.openai.com/v1/chat/completions") - expect(request_double).to receive(:headers).and_return(double(merge!: nil)) - expect(request_double).to receive(:body=) - - expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) - - described_class.connect(provider: provider, model: model, body: body, headers: headers) - end + it "makes a POST request with correct parameters" do + request_double = double + expect(request_double).to receive(:url).with("https://api.openai.com/v1/chat/completions") + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=).with(mock_body.to_json) - it "merges headers correctly" do - request_double = double - request_headers = double - expect(request_double).to receive(:url) - expect(request_double).to receive(:headers).and_return(request_headers) - expect(request_headers).to receive(:merge!).with(headers) - expect(request_double).to receive(:body=) - - expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) - - described_class.connect(provider: provider, model: model, body: body, headers: headers) - end + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) - it "sets body as JSON" do - request_double = double - expect(request_double).to receive(:url) - expect(request_double).to receive(:headers).and_return(double(merge!: nil)) - expect(request_double).to receive(:body=).with(body.to_json) - - expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) - - described_class.connect(provider: provider, model: model, body: body, headers: headers) - end + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) end - context "when provider is bedrock_anthropic" do - let(:provider) { "bedrock_anthropic" } - let(:mock_config) { double("config") } - let(:mock_bedrock_config) { double("bedrock_config") } - let(:mock_client) { double("bedrock_client") } - let(:bedrock_response) { double("bedrock_response") } + it "sets the correct URL from PROVIDERS configuration" do + request_double = double + expect(request_double).to receive(:url).with("https://api.openai.com/v1/chat/completions") + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) + end - before do - allow(RubyAI).to receive(:config).and_return(mock_config) - allow(mock_config).to receive(:bedrock_anthropic).and_return(mock_bedrock_config) - allow(mock_bedrock_config).to receive(:client).and_return(mock_client) - end + it "merges headers correctly" do + request_double = double + request_headers = double + expect(request_double).to receive(:url) + expect(request_double).to receive(:headers).and_return(request_headers) + expect(request_headers).to receive(:merge!).with(mock_headers) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) + end - it "calls invoke_model on bedrock client" do - expect(mock_client).to receive(:invoke_model).with( - model_id: model, - body: body, - content_type: "application/json" - ).and_return(bedrock_response) + it "sets body as JSON" do + request_double = double + expect(request_double).to receive(:url) + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=).with(mock_body.to_json) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) + end + + it "returns parsed JSON response" do + request_double = double + allow(request_double).to receive(:url) + allow(request_double).to receive(:headers).and_return(double(merge!: nil)) + allow(request_double).to receive(:body=) + allow(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + result = described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) + expect(result).to eq({ "reply" => "Hi!" }) + end + + context "when gemini provider is used" do + let(:provider) { "gemini" } + let(:model) { "gemini-pro" } - result = described_class.connect(provider: provider, model: model, body: body, headers: headers) - expect(result).to eq(bedrock_response) + it "constructs correct URL with model" do + request_double = double + expect(request_double).to receive(:url).with("https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent") + expect(request_double).to receive(:headers).and_return(double(merge!: nil)) + expect(request_double).to receive(:body=) + + expect(mock_connection).to receive(:post).and_yield(request_double).and_return(mock_response) + + described_class.connect(messages: messages, provider: provider, model: model, temperature: temperature) end end end @@ -299,12 +298,15 @@ def providers_hash.[](provider, model = nil) end it "uses default adapter" do - expect(Faraday).to receive(:new).and_yield(double.as_null_object) + connection_double = double(headers: {}, adapter: nil) + expect(described_class).to receive(:connection).and_return(connection_double) described_class.connection end + context "when Faraday::Error is raised" do before do + RubyAI::HTTP.instance_variable_set(:@connection, nil) allow(Faraday).to receive(:new).and_raise(Faraday::Error.new("network error")) end @@ -315,6 +317,7 @@ def providers_hash.[](provider, model = nil) context "when JSON::ParserError is raised" do before do + RubyAI::HTTP.instance_variable_set(:@connection, nil) allow(Faraday).to receive(:new).and_raise(JSON::ParserError.new("invalid json")) end @@ -325,6 +328,7 @@ def providers_hash.[](provider, model = nil) context "when StandardError is raised" do before do + RubyAI::HTTP.instance_variable_set(:@connection, nil) allow(Faraday).to receive(:new).and_raise(StandardError.new("unexpected error")) end