diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index cf8f982..f1ad821 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -13,31 +13,7 @@ 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}" + 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 2e25fb5..d17a1cd 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.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 967c031..9444ffe 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -9,5 +9,38 @@ def build_body(messages, provider, model, temperature) def build_headers(provider) RubyAI.config.send(provider).build_http_headers(provider) end + + def connect(messages:, provider: , model:, temperature:) + body = self.build_body(messages, provider, model, temperature) + headers = self.build_headers(provider) + + 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 + + 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 diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb index 2bce5ef..8a12f85 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 @@ -22,11 +20,15 @@ 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 + + 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,81 +40,51 @@ 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" } 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" }) - - stub_const("RubyAI::Configuration::PROVIDERS", { [provider, model] => url }) - - allow(Faraday).to receive(:new).and_return(fake_connection) - allow(fake_connection).to receive(:headers).and_return({}) - allow(fake_connection).to receive(:adapter) - - allow(fake_connection).to receive(:post).and_return(fake_response) - end - - it "returns parsed JSON response" do - result = chat.call(messages) - expect(result).to eq({ "reply" => "Hi!" }) + allow(RubyAI::HTTP).to receive(:connect).with( + messages: messages, + provider: provider, + model: model, + temperature: temperature + ).and_return(fake_response) end - end - context "when Faraday connection fails" do - before do - allow(Faraday).to receive(:new).and_raise(Faraday::ConnectionFailed.new("no internet")) + it "calls HTTP.connect with correct parameters" do + chat.call(messages) + expect(RubyAI::HTTP).to have_received(:connect).with( + messages: messages, + provider: provider, + model: model, + temperature: temperature + ) end - it "raises a connection error" do - expect { chat.call(messages) }.to raise_error("Connection error: no internet") + it "returns the HTTP response" do + result = chat.call(messages) + expect(result).to eq(fake_response) end end - context "when JSON parsing fails" do - let(:bad_response) { instance_double(Faraday::Response, body: "not_json") } - + context "when HTTP.connect raises Faraday::ConnectionFailed" do 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(JSON).to receive(:parse).and_raise(JSON::ParserError.new("unexpected token")) + allow(RubyAI::HTTP).to receive(:connect).and_raise(Faraday::ConnectionFailed.new("no internet")) end - it "raises a JSON parse error" do - expect { chat.call(messages) }.to raise_error(JSON::ParserError, "unexpected token") + it "allows the connection error to propagate" do + expect { chat.call(messages) }.to raise_error(Faraday::ConnectionFailed, "no internet") 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(: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..b94a3dc --- /dev/null +++ b/spec/rubyai/client_spec.rb @@ -0,0 +1,86 @@ +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(: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(:connect).and_return(fake_response) + end + + it "calls HTTP.connect with correct parameters" do + client.call + expect(RubyAI::HTTP).to have_received(:connect).with( + messages: messages, + provider: provider, + model: model, + temperature: temperature + ) + end + + it "returns the HTTP response" do + result = client.call + expect(result).to eq(fake_response) + 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 + 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 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 +end \ No newline at end of file diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb index 1600d17..9009dbf 100644 --- a/spec/rubyai/http_spec.rb +++ b/spec/rubyai/http_spec.rb @@ -159,6 +159,185 @@ end end + describe ".connect" do + let(:provider) { "openai" } + let(:model) { "gpt-4" } + 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" + } + + def providers_hash.[](provider, model = nil) + return super(provider) unless !model.nil? && provider == "gemini" + "#{super(provider)}/#{model}:generateContent" + end + + stub_const("RubyAI::Provider::PROVIDERS", providers_hash) + end + + 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) + + 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 + + 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 "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) + + 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 "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 + + 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 "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" } + + 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 + + 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 + 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 + + 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 + RubyAI::HTTP.instance_variable_set(:@connection, nil) + 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 + RubyAI::HTTP.instance_variable_set(:@connection, nil) + 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 +350,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 +402,4 @@ expect(anthropic_body).to eq({ anthropic: "body" }) end end -end +end \ No newline at end of file