From 589580e90c8aec9b8902cc8a123fa5ae5a94496c Mon Sep 17 00:00:00 2001 From: Reion19 Date: Wed, 11 Jun 2025 14:46:55 +0300 Subject: [PATCH 01/12] dependencies -> updated --- Gemfile.lock | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 513c283..991b400 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,47 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) - bigdecimal (3.1.6) - connection_pool (2.4.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + bigdecimal (3.2.2) + connection_pool (2.5.3) crack (1.0.0) bigdecimal rexml - diff-lcs (1.5.1) - faraday (2.12.0) - faraday-net_http (>= 2.0, < 3.4) + diff-lcs (1.6.2) + faraday (2.13.1) + faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.3.0) - net-http + faraday-net_http (3.4.0) + net-http (>= 0.5.0) faraday-net_http_persistent (2.3.0) faraday (~> 2.5) net-http-persistent (>= 4.0.4, < 5) - hashdiff (1.1.0) - json (2.7.2) - logger (1.6.1) - net-http (0.4.1) + hashdiff (1.2.0) + json (2.12.2) + logger (1.7.0) + net-http (0.6.0) uri - net-http-persistent (4.0.4) - connection_pool (~> 2.2) - public_suffix (5.0.4) - rexml (3.2.6) - rspec (3.13.0) + net-http-persistent (4.0.6) + connection_pool (~> 2.2, >= 2.2.4) + public_suffix (6.0.2) + rexml (3.4.1) + rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.0) + rspec-core (3.13.4) rspec-support (~> 3.13.0) - rspec-expectations (3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.0) + rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.0) - uri (0.13.1) - webmock (3.22.0) + rspec-support (3.13.4) + uri (1.0.3) + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) From 003af808ed2f3e9df248f6461ad0ef794dd9576e Mon Sep 17 00:00:00 2001 From: Reion19 Date: Wed, 11 Jun 2025 15:52:57 +0300 Subject: [PATCH 02/12] Added .models .update and .chat methods and specs for them --- lib/rubyai.rb | 12 ++++++++++ spec/{ => rubyai}/client_spec.rb | 2 +- spec/{ => rubyai}/configuration_spec.rb | 2 +- spec/rubyai/rubyai_spec.rb | 31 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) rename spec/{ => rubyai}/client_spec.rb (94%) rename spec/{ => rubyai}/configuration_spec.rb (95%) create mode 100644 spec/rubyai/rubyai_spec.rb diff --git a/lib/rubyai.rb b/lib/rubyai.rb index 211a3bc..2850fc7 100644 --- a/lib/rubyai.rb +++ b/lib/rubyai.rb @@ -9,4 +9,16 @@ module RubyAI class Error < StandardError; end + + def self.models + Configuration::MODELS.keys + end + + def self.chat(config_hash = {}) + Client.new(config_hash).call + end + + def self.configure + yield(Configuration.new) if block_given? + end end diff --git a/spec/client_spec.rb b/spec/rubyai/client_spec.rb similarity index 94% rename from spec/client_spec.rb rename to spec/rubyai/client_spec.rb index f74c472..aadfc86 100644 --- a/spec/client_spec.rb +++ b/spec/rubyai/client_spec.rb @@ -1,5 +1,5 @@ require 'webmock/rspec' -require_relative '../lib/rubyai/client.rb' +require_relative '../../lib/rubyai/client.rb' RSpec.describe RubyAI::Client do let(:api_key) { 'your_api_key' } diff --git a/spec/configuration_spec.rb b/spec/rubyai/configuration_spec.rb similarity index 95% rename from spec/configuration_spec.rb rename to spec/rubyai/configuration_spec.rb index e9298f1..caf3d58 100644 --- a/spec/configuration_spec.rb +++ b/spec/rubyai/configuration_spec.rb @@ -1,5 +1,5 @@ require 'webmock/rspec' -require_relative '../lib/rubyai/client.rb' +require_relative '../../lib/rubyai/client.rb' RSpec.describe RubyAI::Client do let(:api_key) { 'your_api_key' } diff --git a/spec/rubyai/rubyai_spec.rb b/spec/rubyai/rubyai_spec.rb new file mode 100644 index 0000000..54ee4a0 --- /dev/null +++ b/spec/rubyai/rubyai_spec.rb @@ -0,0 +1,31 @@ +require_relative '../../lib/rubyai' + +RSpec.describe RubyAI do + + describe '.models' do + it 'should return available models' do + expect(RubyAI.models).to eq(RubyAI::Configuration::MODELS.keys) + end + end + + describe '.chat' do + let(:api_key) { 'your_api_key' } + let(:messages) { 'Hello, how are you?' } + let(:temperature) { 0.7 } + let(:model) { 'gpt-3.5-turbo' } + let(:client) { described_class.chat(api_key: api_key, messages: messages, temperature: temperature, model: model) } + + + let(:response_body) { { 'completion' => 'This is a response from the model.' } } + let(:status) { 200 } + + before do + stub_request(:post, RubyAI::Configuration::BASE_URL) + .to_return(status: status, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns parsed JSON response when passing through client directly' do + expect(described_class.chat).to eq(response_body) + end + end +end \ No newline at end of file From 7153f1a585eb50fd861fee2ba2cfac947730240f Mon Sep 17 00:00:00 2001 From: Reion19 Date: Thu, 12 Jun 2025 19:19:23 +0300 Subject: [PATCH 03/12] Add multi-AI provider support & update config handling --- lib/rubyai.rb | 16 +- lib/rubyai/chat.rb | 41 +++++ lib/rubyai/client.rb | 10 +- lib/rubyai/configuration.rb | 39 ++-- lib/rubyai/http.rb | 46 ++++- lib/rubyai/provider.rb | 15 ++ lib/rubyai/providers/anthropic.rb | 17 ++ lib/rubyai/providers/openai.rb | 20 ++ spec/rubyai/chat_spec.rb | 2 + spec/rubyai/client_spec.rb | 3 +- spec/rubyai/configuration_spec.rb | 36 +++- spec/rubyai/http_spec.rb | 235 ++++++++++++++++++++++++ spec/rubyai/provider_spec.rb | 16 ++ spec/rubyai/providers/anthropic_spec.rb | 15 ++ spec/rubyai/providers/openai_spec.rb | 16 ++ spec/rubyai/rubyai_spec.rb | 25 ++- 16 files changed, 518 insertions(+), 34 deletions(-) create mode 100644 lib/rubyai/chat.rb create mode 100644 lib/rubyai/provider.rb create mode 100644 lib/rubyai/providers/anthropic.rb create mode 100644 lib/rubyai/providers/openai.rb create mode 100644 spec/rubyai/chat_spec.rb create mode 100644 spec/rubyai/http_spec.rb create mode 100644 spec/rubyai/provider_spec.rb create mode 100644 spec/rubyai/providers/anthropic_spec.rb create mode 100644 spec/rubyai/providers/openai_spec.rb diff --git a/lib/rubyai.rb b/lib/rubyai.rb index 2850fc7..5a9d381 100644 --- a/lib/rubyai.rb +++ b/lib/rubyai.rb @@ -2,23 +2,31 @@ require 'faraday/net_http_persistent' require 'json' +require_relative "rubyai/providers/openai" +require_relative "rubyai/providers/anthropic" +require_relative "rubyai/provider" require_relative "rubyai/client" require_relative "rubyai/configuration" require_relative "rubyai/http" +require_relative "rubyai/chat" require_relative "rubyai/version" module RubyAI class Error < StandardError; end def self.models - Configuration::MODELS.keys + Configuration::MODELS end - def self.chat(config_hash = {}) - Client.new(config_hash).call + def self.chat(config = {}) + Client.new(config).call end def self.configure - yield(Configuration.new) if block_given? + yield config + end + + def self.config(config = {}) + @config ||= Configuration.new(config) end end diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb new file mode 100644 index 0000000..e86688f --- /dev/null +++ b/lib/rubyai/chat.rb @@ -0,0 +1,41 @@ +module RubyAI + class Chat + attr_accessor :provider, :model, :temperature + + def initialize(provider, model: nil, temperature: 0.7) + @provider = provider + @model = model || RubyAI::Configuration::DEFAULT_MODEL + @temperature = temperature + end + + 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, RubyAI.config) + + response = connection.post do |req| + req.url Configuration::PROVIDERS[@provider] || Configuration::BASE_URL + 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 \ No newline at end of file diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb index 6cbc096..c9fe4b6 100644 --- a/lib/rubyai/client.rb +++ b/lib/rubyai/client.rb @@ -2,15 +2,15 @@ module RubyAI class Client attr_reader :configuration - def initialize(config_hash = {}) - @configuration = Configuration.new(config_hash) + def initialize(config = {}) + @configuration ||= RubyAI.config(config) end def call response = connection.post do |req| - req.url Configuration::BASE_URL - req.headers.merge!(HTTP.build_headers(configuration.api_key)) - req.body = HTTP.build_body(configuration.messages, configuration.model, configuration.temperature).to_json + req.url Configuration::PROVIDERS[configuration.provider] || Configuration::BASE_URL + req.headers = (HTTP.build_headers(configuration.provider || RubyAI::Configuration::DEFAULT_PROVIDER, RubyAI.config )) + req.body = HTTP.build_body(configuration.messages, configuration.provider, configuration.model, configuration.temperature).to_json end JSON.parse(response.body) diff --git a/lib/rubyai/configuration.rb b/lib/rubyai/configuration.rb index ed0bc1b..9c0ad27 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -1,31 +1,44 @@ module RubyAI class Configuration - BASE_URL = "https://api.openai.com/v1/chat/completions" + PROVIDERS = { + 'openai' => "https://api.openai.com/v1/chat/completions", + 'anthropic' => "https://api.anthropic.com/v1/chat/completions" + }.freeze + + MODELS = PROVIDERS.to_h do |provider, _url| + [provider, Provider[provider].models] + end.freeze - MODELS = { - "gpt-4" => "gpt-4", - "gpt-4-32k" => "gpt-4-32k", - "gpt-4-turbo" => "gpt-4-turbo", - "gpt-4o-mini" => "gpt-4o-mini", - "o1-mini" => "o1-mini", - "o1-preview" => "o1-preview", - "text-davinci-003" => "text-davinci-003" - } + + BASE_URL = "https://api.openai.com/v1/chat/completions" DEFAULT_MODEL = "gpt-3.5-turbo" - attr_accessor :api_key, :model, :messages, :temperature + DEFAULT_PROVIDER = 'openai' + + # default values for configuration + attr_accessor :api_key, + :model, + :messages, + :temperature, + :provider, + # :providers + :provider, + :openai_api_key, + :anthropic_api_key def initialize(config = {}) - @api_key = config[:api_key] + @api_key = config.fetch(:api_key, openai_api_key) + @openai_api_key = config.fetch(:openai_api_key, api_key) @model = config.fetch(:model, DEFAULT_MODEL) @messages = config.fetch(:messages, nil) @temperature = config.fetch(:temperature, 0.7) + @provider = config.fetch(:provider, "openai") end end def self.configuration - @configuration ||= Configuration.new + @configuration ||= RubyAI.config(config = {}) end def self.configure diff --git a/lib/rubyai/http.rb b/lib/rubyai/http.rb index cd73806..aaf38e5 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -2,19 +2,49 @@ module RubyAI module HTTP extend self - def build_body(messages, model, temperature) - { - 'model': Configuration::MODELS[model], + def build_body(messages, provider, model, temperature) + case provider + when 'openai' + { + 'model': Configuration::MODELS[provider][model], 'messages': [{ "role": "user", "content": messages }], 'temperature': temperature - } + } + when 'anthropic' + { + 'model' => Configuration::MODELS[provider][model], + 'max_tokens' => 1024, # Required parameter for Anthropic API + 'messages' => format_messages_for_antropic(messages), + 'temperature' => temperature + } + end end - def build_headers(api_key) - { + def build_headers(provider, config) + case provider + when 'openai' + { 'Content-Type': 'application/json', - 'Authorization': "Bearer #{api_key}" - } + 'Authorization': "Bearer #{config.openai_api_key}" + } + when 'anthropic' + { + 'x-api-key' => config.anthropic_api_key, + 'anthropic-version' => '2023-06-01' + } + end + end + + private + + def format_messages_for_antropic(messages) + # Messages should be an array of message objects + # Each message needs 'role' (either 'user' or 'assistant') and 'content' + if messages.is_a?(String) + [{ 'role' => 'user', 'content' => messages }] + else + messages + end end end end diff --git a/lib/rubyai/provider.rb b/lib/rubyai/provider.rb new file mode 100644 index 0000000..8d52466 --- /dev/null +++ b/lib/rubyai/provider.rb @@ -0,0 +1,15 @@ +module RubyAI + module Provider + PROVIDERS = { + 'openai' => RubyAI::Providers::OpenAI, + # doesn't tested yet because i don't have an anthropic api key + 'anthropic' => RubyAI::Providers::Anthropic + } + + module_function + + def [](provider) + PROVIDERS.fetch(provider) + end + end +end \ No newline at end of file diff --git a/lib/rubyai/providers/anthropic.rb b/lib/rubyai/providers/anthropic.rb new file mode 100644 index 0000000..530c841 --- /dev/null +++ b/lib/rubyai/providers/anthropic.rb @@ -0,0 +1,17 @@ +module RubyAI + module Providers + # doesn't tested yet because i don't have an anthropic api key + class Anthropic + def self.models = { + "claude-2" => "claude-2", + "claude-instant-100k" => "claude-instant-100k", + "claude-1" => "claude-1", + "claude-1.3" => "claude-1.3", + "claude-1.3-sonnet" => "claude-1.3-sonnet", + "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" + }.freeze + end + + # todo: configuration of separate models + end +end \ No newline at end of file diff --git a/lib/rubyai/providers/openai.rb b/lib/rubyai/providers/openai.rb new file mode 100644 index 0000000..14f81e8 --- /dev/null +++ b/lib/rubyai/providers/openai.rb @@ -0,0 +1,20 @@ +module RubyAI + module Providers + class OpenAI + DEFAULT_MODEL = "gpt-3.5-turbo".freeze + + def self.models + {"gpt-3.5-turbo" => "gpt-3.5-turbo", + "gpt-4" => "gpt-4", + "gpt-4-32k" => "gpt-4-32k", + "gpt-4-turbo" => "gpt-4-turbo", + "gpt-4o-mini" => "gpt-4o-mini", + "o1-mini" => "o1-mini", + "o1-preview" => "o1-preview", + "text-davinci-003" => "text-davinci-003" } + end + + # todo: configuration of separate models + end + end +end \ No newline at end of file diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb new file mode 100644 index 0000000..fc13386 --- /dev/null +++ b/spec/rubyai/chat_spec.rb @@ -0,0 +1,2 @@ +require_relative '../../lib/rubyai/chat' +require 'webmock/rspec' diff --git a/spec/rubyai/client_spec.rb b/spec/rubyai/client_spec.rb index aadfc86..dfe3f03 100644 --- a/spec/rubyai/client_spec.rb +++ b/spec/rubyai/client_spec.rb @@ -6,7 +6,8 @@ let(:messages) { 'Hello, how are you?' } let(:temperature) { 0.7 } let(:model) { 'gpt-3.5-turbo' } - let(:client) { described_class.new(api_key: api_key, messages: messages, temperature: temperature, model: model) } + let(:provider) { 'openai' } + let(:client) { described_class.new(api_key: api_key, messages: messages, temperature: temperature, provider: provider, model: model) } describe '#call' do let(:response_body) { { 'completion' => 'This is a response from the model.' } } diff --git a/spec/rubyai/configuration_spec.rb b/spec/rubyai/configuration_spec.rb index caf3d58..81e8fe1 100644 --- a/spec/rubyai/configuration_spec.rb +++ b/spec/rubyai/configuration_spec.rb @@ -1,14 +1,23 @@ require 'webmock/rspec' -require_relative '../../lib/rubyai/client.rb' +require_relative '../../lib/rubyai/client' +require_relative '../../lib/rubyai/providers/openai' +require_relative '../../lib/rubyai/providers/anthropic' +require_relative '../../lib/rubyai/provider' +require_relative '../../lib/rubyai/configuration' + RSpec.describe RubyAI::Client do let(:api_key) { 'your_api_key' } let(:messages) { 'Hello, how are you?' } let(:temperature) { 0.7 } let(:model) { 'gpt-3.5-turbo' } + let(:provider) { 'openai' } before do RubyAI.configure do |config| + config.provider = provider + config.model = model + config.temperature = temperature config.api_key = api_key config.messages = messages end @@ -30,4 +39,29 @@ expect(result.dig('choices', 0, 'message', 'content')).to eq('This is a response from the model.') end end + + describe 'Constants' do + specify "PROVIDERS" do + expect(RubyAI::Configuration::PROVIDERS).to eq( + 'openai' => "https://api.openai.com/v1/chat/completions", + 'anthropic' => "https://api.anthropic.com/v1/chat/completions" + ) + end + + specify "MODELS should return Hash" do + expect(RubyAI::Configuration::MODELS).to be_an_instance_of(Hash) + end + + specify "BASE_URL" do + expect(RubyAI::Configuration::BASE_URL).to eq("https://api.openai.com/v1/chat/completions") + end + + specify "DEFAULT_MODEL" do + expect(RubyAI::Configuration::DEFAULT_MODEL).to eq("gpt-3.5-turbo") + end + + specify "DEFAULT_PROVIDER" do + expect(RubyAI::Configuration::DEFAULT_PROVIDER).to eq("openai") + end + end end diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb new file mode 100644 index 0000000..cc9b045 --- /dev/null +++ b/spec/rubyai/http_spec.rb @@ -0,0 +1,235 @@ +require_relative '../../lib/rubyai/http' + +RSpec.describe RubyAI::HTTP do + let(:config) do + double('config', + openai_api_key: 'test-openai-key', + anthropic_api_key: 'test-anthropic-key' + ) + end + + let(:messages) { "Hello, how are you?" } + let(:temperature) { 0.7 } + + # Mock the Configuration::MODELS constant + before do + stub_const('RubyAI::Configuration::MODELS', { + 'openai' => { + 'gpt-3.5-turbo' => 'gpt-3.5-turbo', + 'gpt-4' => 'gpt-4' + }, + 'anthropic' => { + 'claude-3-sonnet' => 'claude-3-sonnet-20240229', + 'claude-3-haiku' => 'claude-3-haiku-20240307' + } + }) + end + + describe '.build_body' do + context 'when provider is openai' do + let(:provider) { 'openai' } + let(:model) { 'gpt-3.5-turbo' } + + it 'returns correct body structure for OpenAI' do + result = described_class.build_body(messages, provider, model, temperature) + + expect(result).to eq({ + :'model' => 'gpt-3.5-turbo', + :'messages' => [{ :"role" => "user", :"content" => messages }], + :'temperature' => temperature + }) + end + + it 'uses the correct model mapping from configuration' do + model = 'gpt-4' + result = described_class.build_body(messages, provider, model, temperature) + + expect(result[:model]).to eq('gpt-4') + end + + it 'includes temperature parameter' do + custom_temp = 0.9 + result = described_class.build_body(messages, provider, model, custom_temp) + + expect(result[:temperature]).to eq(custom_temp) + end + end + + context 'when provider is anthropic' do + let(:provider) { 'anthropic' } + let(:model) { 'claude-3-sonnet' } + + it 'returns correct body structure for Anthropic' do + result = described_class.build_body(messages, provider, model, temperature) + + expect(result).to eq({ + 'model' => 'claude-3-sonnet-20240229', + 'max_tokens' => 1024, + 'messages' => [{ 'role' => 'user', 'content' => messages }], + 'temperature' => temperature + }) + end + + it 'uses the correct model mapping from configuration' do + model = 'claude-3-haiku' + result = described_class.build_body(messages, provider, model, temperature) + + expect(result['model']).to eq('claude-3-haiku-20240307') + end + + it 'includes required max_tokens parameter' do + result = described_class.build_body(messages, provider, model, temperature) + + expect(result['max_tokens']).to eq(1024) + end + + it 'formats messages correctly for single string input' do + result = described_class.build_body(messages, provider, model, temperature) + + expect(result['messages']).to eq([{ 'role' => 'user', 'content' => messages }]) + end + + it 'passes through array messages without modification' do + array_messages = [ + { 'role' => 'user', 'content' => 'Hello' }, + { 'role' => 'assistant', 'content' => 'Hi there!' } + ] + + result = described_class.build_body(array_messages, provider, model, temperature) + + expect(result['messages']).to eq(array_messages) + end + end + + context 'when provider is unsupported' do + it 'returns nil for unsupported provider' do + result = described_class.build_body(messages, 'unsupported', 'model', temperature) + + expect(result).to be_nil + end + end + end + + describe '.build_headers' do + context 'when provider is openai' do + let(:provider) { 'openai' } + + it 'returns correct headers for OpenAI' do + result = described_class.build_headers(provider, config) + + expect(result).to eq({ + 'Content-Type': 'application/json', + 'Authorization': "Bearer #{config.openai_api_key}" + }) + end + + it 'includes the API key in Authorization header' do + result = described_class.build_headers(provider, config) + + expect(result[:'Authorization']).to eq("Bearer test-openai-key") + end + end + + context 'when provider is anthropic' do + let(:provider) { 'anthropic' } + + it 'returns correct headers for Anthropic' do + result = described_class.build_headers(provider, config) + + expect(result).to eq({ + 'x-api-key' => config.anthropic_api_key, + 'anthropic-version' => '2023-06-01' + }) + end + + it 'includes the API key in x-api-key header' do + result = described_class.build_headers(provider, config) + + expect(result['x-api-key']).to eq('test-anthropic-key') + end + + it 'includes the correct anthropic-version' do + result = described_class.build_headers(provider, config) + + expect(result['anthropic-version']).to eq('2023-06-01') + end + end + + context 'when provider is unsupported' do + it 'returns nil for unsupported provider' do + result = described_class.build_headers('unsupported', config) + + expect(result).to be_nil + end + end + end + + describe '.format_messages_for_antropic' do + context 'when messages is a string' do + it 'converts string to proper message format' do + string_message = "Hello world" + result = described_class.send(:format_messages_for_antropic, string_message) + + expect(result).to eq([{ 'role' => 'user', 'content' => string_message }]) + end + end + + context 'when messages is already an array' do + it 'returns the array unchanged' do + array_messages = [ + { 'role' => 'user', 'content' => 'Question' }, + { 'role' => 'assistant', 'content' => 'Answer' } + ] + + result = described_class.send(:format_messages_for_antropic, array_messages) + + expect(result).to eq(array_messages) + end + end + + context 'when messages is empty string' do + it 'handles empty string correctly' do + result = described_class.send(:format_messages_for_antropic, "") + + expect(result).to eq([{ 'role' => 'user', 'content' => "" }]) + end + end + + context 'when messages is nil' do + it 'returns nil unchanged' do + result = described_class.send(:format_messages_for_antropic, nil) + + expect(result).to be_nil + end + end + end + + describe 'integration scenarios' do + it 'builds complete OpenAI request components' do + provider = 'openai' + model = 'gpt-4' + + body = described_class.build_body(messages, provider, model, temperature) + headers = described_class.build_headers(provider, config) + + expect(body[:model]).to eq('gpt-4') + expect(body[:messages]).to be_an(Array) + expect(headers[:'Content-Type']).to eq('application/json') + expect(headers[:'Authorization']).to include('Bearer') + end + + it 'builds complete Anthropic request components' do + provider = 'anthropic' + model = 'claude-3-sonnet' + + body = described_class.build_body(messages, provider, model, temperature) + headers = described_class.build_headers(provider, config) + + expect(body['model']).to eq('claude-3-sonnet-20240229') + expect(body['messages']).to be_an(Array) + expect(body['max_tokens']).to eq(1024) + expect(headers['x-api-key']).to eq('test-anthropic-key') + expect(headers['anthropic-version']).to eq('2023-06-01') + end + end +end \ No newline at end of file diff --git a/spec/rubyai/provider_spec.rb b/spec/rubyai/provider_spec.rb new file mode 100644 index 0000000..69d3321 --- /dev/null +++ b/spec/rubyai/provider_spec.rb @@ -0,0 +1,16 @@ +require 'webmock/rspec' +require_relative '../../lib/rubyai/providers/openai.rb' +require_relative '../../lib/rubyai/providers/anthropic.rb' +require_relative '../../lib/rubyai/provider.rb' + +RSpec.describe RubyAI::Provider do + describe '[]' do + it 'should return the OpenAI provider when "openai" is passed' do + expect(RubyAI::Provider['openai']).to eq(RubyAI::Providers::OpenAI) + end + + it 'should return the Anthropic provider when "anthropic" is passed' do + expect(RubyAI::Provider['anthropic']).to eq(RubyAI::Providers::Anthropic) + end + end +end \ No newline at end of file diff --git a/spec/rubyai/providers/anthropic_spec.rb b/spec/rubyai/providers/anthropic_spec.rb new file mode 100644 index 0000000..0c37ef0 --- /dev/null +++ b/spec/rubyai/providers/anthropic_spec.rb @@ -0,0 +1,15 @@ +require_relative '../../../lib/rubyai/providers/anthropic' + +RSpec.describe RubyAI::Providers::Anthropic do + describe '.models' do + it 'should return an list of models' do + expect(described_class.models).to eq( "claude-2" => "claude-2", + "claude-instant-100k" => "claude-instant-100k", + "claude-1" => "claude-1", + "claude-1.3" => "claude-1.3", + "claude-1.3-sonnet" => "claude-1.3-sonnet", + "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" + ) + end + end +end \ No newline at end of file diff --git a/spec/rubyai/providers/openai_spec.rb b/spec/rubyai/providers/openai_spec.rb new file mode 100644 index 0000000..636d80a --- /dev/null +++ b/spec/rubyai/providers/openai_spec.rb @@ -0,0 +1,16 @@ +require_relative '../../../lib/rubyai/providers/openai' + +RSpec.describe RubyAI::Providers::OpenAI do + describe '.models' do + it 'should return an list of models' do + expect(described_class.models).to eq("gpt-3.5-turbo" => "gpt-3.5-turbo", + "gpt-4" => "gpt-4", + "gpt-4-32k" => "gpt-4-32k", + "gpt-4-turbo" => "gpt-4-turbo", + "gpt-4o-mini" => "gpt-4o-mini", + "o1-mini" => "o1-mini", + "o1-preview" => "o1-preview", + "text-davinci-003" => "text-davinci-003") + end + end +end \ No newline at end of file diff --git a/spec/rubyai/rubyai_spec.rb b/spec/rubyai/rubyai_spec.rb index 54ee4a0..bdcd471 100644 --- a/spec/rubyai/rubyai_spec.rb +++ b/spec/rubyai/rubyai_spec.rb @@ -4,7 +4,7 @@ describe '.models' do it 'should return available models' do - expect(RubyAI.models).to eq(RubyAI::Configuration::MODELS.keys) + expect(RubyAI.models).to eq(RubyAI::Configuration::MODELS) end end @@ -13,7 +13,8 @@ let(:messages) { 'Hello, how are you?' } let(:temperature) { 0.7 } let(:model) { 'gpt-3.5-turbo' } - let(:client) { described_class.chat(api_key: api_key, messages: messages, temperature: temperature, model: model) } + let(:provider) { 'openai' } + let(:client) { described_class.chat(api_key: api_key, messages: messages, temperature: temperature, provider: provider, model: model) } let(:response_body) { { 'completion' => 'This is a response from the model.' } } @@ -28,4 +29,24 @@ expect(described_class.chat).to eq(response_body) end end + + describe '.configure' do + let(:configuration) {RubyAI.config} + + it 'allows configuration of the client' do + described_class.configure do |config| + config.api_key = 'your_api_key' + config.messages = 'Hello, how are you?' + config.temperature = 0.7 + config.provider = 'openai' + config.model = 'gpt-3.5-turbo' + end + + expect(configuration.api_key).to eq('your_api_key') + expect(configuration.messages).to eq('Hello, how are you?') + expect(configuration.temperature).to eq(0.7) + expect(configuration.provider).to eq('openai') + expect(configuration.model).to eq('gpt-3.5-turbo') + end + end end \ No newline at end of file From c8db6afeeceb28c1b8b14d2d6e1cb6b61b88840e Mon Sep 17 00:00:00 2001 From: Reion19 Date: Thu, 12 Jun 2025 20:17:37 +0300 Subject: [PATCH 04/12] Fix header assignment and update model list descriptions in specs --- lib/rubyai/client.rb | 2 +- lib/rubyai/configuration.rb | 2 -- spec/rubyai/providers/anthropic_spec.rb | 2 +- spec/rubyai/providers/openai_spec.rb | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb index c9fe4b6..7380792 100644 --- a/lib/rubyai/client.rb +++ b/lib/rubyai/client.rb @@ -9,7 +9,7 @@ def initialize(config = {}) def call response = connection.post do |req| req.url Configuration::PROVIDERS[configuration.provider] || Configuration::BASE_URL - req.headers = (HTTP.build_headers(configuration.provider || RubyAI::Configuration::DEFAULT_PROVIDER, RubyAI.config )) + req.headers.merge!(HTTP.build_headers(configuration.provider || RubyAI::Configuration::DEFAULT_PROVIDER, RubyAI.config )) req.body = HTTP.build_body(configuration.messages, configuration.provider, configuration.model, configuration.temperature).to_json end diff --git a/lib/rubyai/configuration.rb b/lib/rubyai/configuration.rb index 9c0ad27..3e0b9fa 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -23,8 +23,6 @@ class Configuration :temperature, :provider, # :providers - :provider, - :openai_api_key, :anthropic_api_key def initialize(config = {}) diff --git a/spec/rubyai/providers/anthropic_spec.rb b/spec/rubyai/providers/anthropic_spec.rb index 0c37ef0..ccc6792 100644 --- a/spec/rubyai/providers/anthropic_spec.rb +++ b/spec/rubyai/providers/anthropic_spec.rb @@ -2,7 +2,7 @@ RSpec.describe RubyAI::Providers::Anthropic do describe '.models' do - it 'should return an list of models' do + it 'should return a list of models' do expect(described_class.models).to eq( "claude-2" => "claude-2", "claude-instant-100k" => "claude-instant-100k", "claude-1" => "claude-1", diff --git a/spec/rubyai/providers/openai_spec.rb b/spec/rubyai/providers/openai_spec.rb index 636d80a..37b1f7d 100644 --- a/spec/rubyai/providers/openai_spec.rb +++ b/spec/rubyai/providers/openai_spec.rb @@ -2,7 +2,7 @@ RSpec.describe RubyAI::Providers::OpenAI do describe '.models' do - it 'should return an list of models' do + it 'should return a list of models' do expect(described_class.models).to eq("gpt-3.5-turbo" => "gpt-3.5-turbo", "gpt-4" => "gpt-4", "gpt-4-32k" => "gpt-4-32k", From 36cea0cbabd7cbaca8c4286eb688ad8c2fe49196 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Mon, 16 Jun 2025 18:43:16 +0300 Subject: [PATCH 05/12] Refactor AI provider classes and HTTP handling for improved structure and functionality --- lib/rubyai.rb | 19 +++++----- lib/rubyai/http.rb | 43 ++-------------------- lib/rubyai/providers/anthropic.rb | 60 +++++++++++++++++++++++++------ lib/rubyai/providers/gemini.rb | 50 ++++++++++++++++++++++++++ lib/rubyai/providers/openai.rb | 47 ++++++++++++++++++------ 5 files changed, 146 insertions(+), 73 deletions(-) create mode 100644 lib/rubyai/providers/gemini.rb diff --git a/lib/rubyai.rb b/lib/rubyai.rb index 5a9d381..e09d833 100644 --- a/lib/rubyai.rb +++ b/lib/rubyai.rb @@ -1,11 +1,12 @@ -require 'faraday' -require 'faraday/net_http_persistent' -require 'json' +require "faraday" +require "faraday/net_http_persistent" +require "json" +require_relative "rubyai/providers/provider_configuration" require_relative "rubyai/providers/openai" require_relative "rubyai/providers/anthropic" +require_relative "rubyai/providers/gemini" require_relative "rubyai/provider" -require_relative "rubyai/client" require_relative "rubyai/configuration" require_relative "rubyai/http" require_relative "rubyai/chat" @@ -14,19 +15,15 @@ module RubyAI class Error < StandardError; end - def self.models + def self.models Configuration::MODELS end - def self.chat(config = {}) - Client.new(config).call - end - def self.configure yield config end - def self.config(config = {}) - @config ||= Configuration.new(config) + def self.config + @config ||= Configuration.new end end diff --git a/lib/rubyai/http.rb b/lib/rubyai/http.rb index aaf38e5..1e16b9a 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -1,50 +1,13 @@ module RubyAI module HTTP - extend self + module_function def build_body(messages, provider, model, temperature) - case provider - when 'openai' - { - 'model': Configuration::MODELS[provider][model], - 'messages': [{ "role": "user", "content": messages }], - 'temperature': temperature - } - when 'anthropic' - { - 'model' => Configuration::MODELS[provider][model], - 'max_tokens' => 1024, # Required parameter for Anthropic API - 'messages' => format_messages_for_antropic(messages), - 'temperature' => temperature - } - end + Provider::PROVIDERS.fetch(provider).build_http_body(messages, model, temperature) end def build_headers(provider, config) - case provider - when 'openai' - { - 'Content-Type': 'application/json', - 'Authorization': "Bearer #{config.openai_api_key}" - } - when 'anthropic' - { - 'x-api-key' => config.anthropic_api_key, - 'anthropic-version' => '2023-06-01' - } - end - end - - private - - def format_messages_for_antropic(messages) - # Messages should be an array of message objects - # Each message needs 'role' (either 'user' or 'assistant') and 'content' - if messages.is_a?(String) - [{ 'role' => 'user', 'content' => messages }] - else - messages - end + Provider::PROVIDERS.fetch(provider).build_http_headers(provider, config) end end end diff --git a/lib/rubyai/providers/anthropic.rb b/lib/rubyai/providers/anthropic.rb index 530c841..f9ee8bc 100644 --- a/lib/rubyai/providers/anthropic.rb +++ b/lib/rubyai/providers/anthropic.rb @@ -1,17 +1,55 @@ module RubyAI - module Providers + module Providers # doesn't tested yet because i don't have an anthropic api key class Anthropic - def self.models = { - "claude-2" => "claude-2", - "claude-instant-100k" => "claude-instant-100k", - "claude-1" => "claude-1", - "claude-1.3" => "claude-1.3", - "claude-1.3-sonnet" => "claude-1.3-sonnet", - "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" - }.freeze + extend RubyAI::Providers::ProviderConfiguration + + attr_accessor :api, :messages, :temperature + + def initialize(api:, messages: nil, temperature: 0.7) + @api = api + @messages = messages + @temperature = temperature + end + + def self.models + { + "claude-2" => "claude-2", + "claude-instant-100k" => "claude-instant-100k", + "claude-1" => "claude-1", + "claude-1.3" => "claude-1.3", + "claude-1.3-sonnet" => "claude-1.3-sonnet", + "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" + }.freeze + end + end + + def self.build_http_body(messages, model, temperature = 0.7) + { + "model" => Configuration::MODELS[provider][model], + "max_tokens" => 1024, # Required parameter for Anthropic API + "messages" => format_messages_for_antropic(messages), + "temperature" => temperature + } end - # todo: configuration of separate models + def self.build_http_headers(_provider, config) + { + "x-api-key" => config.anthropic_api_key, + "anthropic-version" => "2023-06-01" + } + end + + private + + def format_messages_for_antropic(messages) + # Messages should be an array of message objects + # Each message needs 'role' (either 'user' or 'assistant') and 'content' + if messages.is_a?(String) + [{ "role" => "user", "content" => messages }] + else + messages + end + end end -end \ No newline at end of file +end diff --git a/lib/rubyai/providers/gemini.rb b/lib/rubyai/providers/gemini.rb new file mode 100644 index 0000000..5d35ee5 --- /dev/null +++ b/lib/rubyai/providers/gemini.rb @@ -0,0 +1,50 @@ +module RubyAI + module Providers + class Gemini + extend RubyAI::Providers::ProviderConfiguration + + attr_accessor :api, :messages, :temperature, :max_tokens + + def initialize(api:, messages: nil, temperature: 0.7, max_tokens: 1000) + @api = api + @messages = messages + @temperature = temperature + @max_tokens = max_tokens + end + + def self.models + { + "gemini-1.5-pro" => "gemini-1.5-pro", + "gemini-1.5-flash" => "gemini-1.5-flash", + "gemini-1.0-pro" => "gemini-1.0-pro" + } + end + + def self.build_http_body(messages, temperature = 0.7) + { + contents: [ + { + parts: [ + { + text: messages + } + ] + } + ], + generationConfig: { + temperature: temperature, + maxOutputTokens: max_tokens, + topP: 0.8, + topK: 10 + } + } + end + + def self.build_http_headers(_provider, _config) + { + "Content-Type" => "application/json" + } + end + end + end +end diff --git a/lib/rubyai/providers/openai.rb b/lib/rubyai/providers/openai.rb index 14f81e8..e5d5a72 100644 --- a/lib/rubyai/providers/openai.rb +++ b/lib/rubyai/providers/openai.rb @@ -1,20 +1,45 @@ module RubyAI module Providers - class OpenAI + class OpenAI + extend RubyAI::Providers::ProviderConfiguration + + attr_accessor :api, :messages, :temperature + + def initialize(api:, messages: nil, temperature: 0.7) + @api = api + @messages = messages + @temperature = temperature + end + DEFAULT_MODEL = "gpt-3.5-turbo".freeze def self.models - {"gpt-3.5-turbo" => "gpt-3.5-turbo", - "gpt-4" => "gpt-4", - "gpt-4-32k" => "gpt-4-32k", - "gpt-4-turbo" => "gpt-4-turbo", - "gpt-4o-mini" => "gpt-4o-mini", - "o1-mini" => "o1-mini", - "o1-preview" => "o1-preview", - "text-davinci-003" => "text-davinci-003" } + { + "gpt-3.5-turbo" => "gpt-3.5-turbo", + "gpt-4" => "gpt-4", + "gpt-4-32k" => "gpt-4-32k", + "gpt-4-turbo" => "gpt-4-turbo", + "gpt-4o-mini" => "gpt-4o-mini", + "o1-mini" => "o1-mini", + "o1-preview" => "o1-preview", + "text-davinci-003" => "text-davinci-003" + } end - # todo: configuration of separate models + def self.build_http_body(messages, model, temperature = 0.7) + { + model: Configuration::MODELS["openai"][model] || DEFAULT_MODEL, + messages: [{ role: "user", content: messages }], + temperature: temperature + } + end + + def self.build_http_headers(_provider, _config) + { + "Content-Type": "application/json", + Authorization: "Bearer #{api}" + } + end end end -end \ No newline at end of file +end From c2f7db9e41fffa6dd29cdc760c35239d7740f7a0 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Mon, 16 Jun 2025 18:43:42 +0300 Subject: [PATCH 06/12] rubocop -A --- Gemfile | 12 +- lib/rubyai/chat.rb | 8 +- lib/rubyai/client.rb | 9 +- lib/rubyai/configuration.rb | 31 ++-- lib/rubyai/provider.rb | 12 +- rubyai.gemspec | 5 +- spec/rubyai/chat_spec.rb | 4 +- spec/rubyai/client_spec.rb | 25 +-- spec/rubyai/configuration_spec.rb | 53 +++--- spec/rubyai/http_spec.rb | 211 ++++++++++++------------ spec/rubyai/provider_spec.rb | 18 +- spec/rubyai/providers/anthropic_spec.rb | 21 ++- spec/rubyai/providers/openai_spec.rb | 22 +-- spec/rubyai/rubyai_spec.rb | 55 +++--- spec/spec_helper.rb | 100 ++++++----- 15 files changed, 296 insertions(+), 290 deletions(-) diff --git a/Gemfile b/Gemfile index 2ec8963..5e151c3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,8 +1,8 @@ -source 'https://rubygems.org' +source "https://rubygems.org" -ruby '>= 2.7' +ruby ">= 2.7" -gem 'faraday' -gem 'faraday-net_http_persistent' -gem 'rspec' -gem 'webmock' +gem "faraday" +gem "faraday-net_http_persistent" +gem "rspec" +gem "webmock" diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index e86688f..46a062d 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -1,7 +1,7 @@ module RubyAI class Chat attr_accessor :provider, :model, :temperature - + def initialize(provider, model: nil, temperature: 0.7) @provider = provider @model = model || RubyAI::Configuration::DEFAULT_MODEL @@ -28,14 +28,14 @@ def call(messages) def connection @connection ||= Faraday.new do |faraday| faraday.adapter Faraday.default_adapter - faraday.headers['Content-Type'] = 'application/json' + 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}" + raise "An unexpected error occurred: #{e.message}" end end -end \ No newline at end of file +end diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb index 7380792..f0481e5 100644 --- a/lib/rubyai/client.rb +++ b/lib/rubyai/client.rb @@ -3,14 +3,17 @@ class Client attr_reader :configuration def initialize(config = {}) - @configuration ||= RubyAI.config(config) + @configuration = RubyAI.config(config) end def call response = connection.post do |req| req.url Configuration::PROVIDERS[configuration.provider] || Configuration::BASE_URL - req.headers.merge!(HTTP.build_headers(configuration.provider || RubyAI::Configuration::DEFAULT_PROVIDER, RubyAI.config )) - req.body = HTTP.build_body(configuration.messages, configuration.provider, configuration.model, configuration.temperature).to_json + req.headers.merge!(HTTP.build_headers( + configuration.provider || RubyAI::Configuration::DEFAULT_PROVIDER, RubyAI.config + )) + req.body = HTTP.build_body(configuration.messages, configuration.provider, + configuration.model, configuration.temperature).to_json end JSON.parse(response.body) diff --git a/lib/rubyai/configuration.rb b/lib/rubyai/configuration.rb index 3e0b9fa..38dd2b6 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -1,33 +1,32 @@ module RubyAI class Configuration - PROVIDERS = { - 'openai' => "https://api.openai.com/v1/chat/completions", - 'anthropic' => "https://api.anthropic.com/v1/chat/completions" - }.freeze + PROVIDERS = { + "openai" => "https://api.openai.com/v1/chat/completions", + "anthropic" => "https://api.anthropic.com/v1/chat/completions" + }.freeze MODELS = PROVIDERS.to_h do |provider, _url| - [provider, Provider[provider].models] - end.freeze + [provider, Provider[provider].models] + end.freeze + BASE_URL = "https://api.openai.com/v1/chat/completions".freeze - BASE_URL = "https://api.openai.com/v1/chat/completions" + DEFAULT_MODEL = "gpt-3.5-turbo".freeze - DEFAULT_MODEL = "gpt-3.5-turbo" - - DEFAULT_PROVIDER = 'openai' + DEFAULT_PROVIDER = "openai".freeze # default values for configuration attr_accessor :api_key, - :model, - :messages, - :temperature, + :model, + :messages, + :temperature, :provider, # :providers :anthropic_api_key def initialize(config = {}) - @api_key = config.fetch(:api_key, openai_api_key) - @openai_api_key = config.fetch(:openai_api_key, api_key) + @api_key = config.fetch(:api_key, openai_api_key) + @openai_api_key = config.fetch(:openai_api_key, api_key) @model = config.fetch(:model, DEFAULT_MODEL) @messages = config.fetch(:messages, nil) @temperature = config.fetch(:temperature, 0.7) @@ -36,7 +35,7 @@ def initialize(config = {}) end def self.configuration - @configuration ||= RubyAI.config(config = {}) + @configuration ||= RubyAI.config({}) end def self.configure diff --git a/lib/rubyai/provider.rb b/lib/rubyai/provider.rb index 8d52466..9559664 100644 --- a/lib/rubyai/provider.rb +++ b/lib/rubyai/provider.rb @@ -1,15 +1,15 @@ module RubyAI - module Provider + module Provider PROVIDERS = { - 'openai' => RubyAI::Providers::OpenAI, + "openai" => RubyAI::Providers::OpenAI, # doesn't tested yet because i don't have an anthropic api key - 'anthropic' => RubyAI::Providers::Anthropic - } + "anthropic" => RubyAI::Providers::Anthropic + }.freeze - module_function + module_function def [](provider) PROVIDERS.fetch(provider) end end -end \ No newline at end of file +end diff --git a/rubyai.gemspec b/rubyai.gemspec index e9ebaff..326f938 100644 --- a/rubyai.gemspec +++ b/rubyai.gemspec @@ -32,7 +32,8 @@ Gem::Specification.new do |s| # Metadata information (optional but useful for gem hosts) s.metadata = { "source_code_uri" => "https://github.com/alexshapalov/rubyai", - "changelog_uri" => "https://github.com/alexshapalov/rubyai/CHANGELOG.md", - "documentation_uri" => "https://github.com/alexshapalov/rubyai#readme" + "changelog_uri" => "https://github.com/alexshapalov/rubyai/CHANGELOG.md", + "documentation_uri" => "https://github.com/alexshapalov/rubyai#readme", + "rubygems_mfa_required" => "true" } end diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb index fc13386..794f0fb 100644 --- a/spec/rubyai/chat_spec.rb +++ b/spec/rubyai/chat_spec.rb @@ -1,2 +1,2 @@ -require_relative '../../lib/rubyai/chat' -require 'webmock/rspec' +require_relative "../../lib/rubyai/chat" +require "webmock/rspec" diff --git a/spec/rubyai/client_spec.rb b/spec/rubyai/client_spec.rb index dfe3f03..77511d6 100644 --- a/spec/rubyai/client_spec.rb +++ b/spec/rubyai/client_spec.rb @@ -1,24 +1,27 @@ -require 'webmock/rspec' -require_relative '../../lib/rubyai/client.rb' +require "webmock/rspec" +require_relative "../../lib/rubyai/client" RSpec.describe RubyAI::Client do - let(:api_key) { 'your_api_key' } - let(:messages) { 'Hello, how are you?' } + let(:api_key) { "your_api_key" } + let(:messages) { "Hello, how are you?" } let(:temperature) { 0.7 } - let(:model) { 'gpt-3.5-turbo' } - let(:provider) { 'openai' } - let(:client) { described_class.new(api_key: api_key, messages: messages, temperature: temperature, provider: provider, model: model) } + let(:model) { "gpt-3.5-turbo" } + let(:provider) { "openai" } + let(:client) do + described_class.new(api_key: api_key, messages: messages, temperature: temperature, + provider: provider, model: model) + end - describe '#call' do - let(:response_body) { { 'completion' => 'This is a response from the model.' } } + describe "#call" do + let(:response_body) { { "completion" => "This is a response from the model." } } let(:status) { 200 } before do stub_request(:post, RubyAI::Configuration::BASE_URL) - .to_return(status: status, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + .to_return(status: status, body: response_body.to_json, headers: { "Content-Type" => "application/json" }) end - it 'returns parsed JSON response when passing through client directly' do + it "returns parsed JSON response when passing through client directly" do expect(client.call).to eq(response_body) end end diff --git a/spec/rubyai/configuration_spec.rb b/spec/rubyai/configuration_spec.rb index 81e8fe1..86be815 100644 --- a/spec/rubyai/configuration_spec.rb +++ b/spec/rubyai/configuration_spec.rb @@ -1,17 +1,16 @@ -require 'webmock/rspec' -require_relative '../../lib/rubyai/client' -require_relative '../../lib/rubyai/providers/openai' -require_relative '../../lib/rubyai/providers/anthropic' -require_relative '../../lib/rubyai/provider' -require_relative '../../lib/rubyai/configuration' - +require "webmock/rspec" +require_relative "../../lib/rubyai/client" +require_relative "../../lib/rubyai/providers/openai" +require_relative "../../lib/rubyai/providers/anthropic" +require_relative "../../lib/rubyai/provider" +require_relative "../../lib/rubyai/configuration" RSpec.describe RubyAI::Client do - let(:api_key) { 'your_api_key' } - let(:messages) { 'Hello, how are you?' } + let(:api_key) { "your_api_key" } + let(:messages) { "Hello, how are you?" } let(:temperature) { 0.7 } - let(:model) { 'gpt-3.5-turbo' } - let(:provider) { 'openai' } + let(:model) { "gpt-3.5-turbo" } + let(:provider) { "openai" } before do RubyAI.configure do |config| @@ -23,44 +22,48 @@ end end - describe '#call' do - let(:response_body) { { 'choices' => [{ 'message' => { 'content' => 'This is a response from the model.' } }] } } + describe "#call" do + let(:response_body) do + { "choices" => [{ "message" => { "content" => "This is a response from the model." } }] } + end let(:status) { 200 } before do stub_request(:post, RubyAI::Configuration::BASE_URL) - .to_return(status: status, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + .to_return(status: status, body: response_body.to_json, headers: { "Content-Type" => "application/json" }) end - it 'returns parsed JSON response when passing through client via configuration' do - configuration = { api_key: RubyAI.configuration.api_key, messages: RubyAI.configuration.messages } + it "returns parsed JSON response when passing through client via configuration" do + configuration = { api_key: RubyAI.configuration.api_key, + messages: RubyAI.configuration.messages } client = described_class.new(configuration) result = client.call - expect(result.dig('choices', 0, 'message', 'content')).to eq('This is a response from the model.') + expect(result.dig("choices", 0, "message", + "content")).to eq("This is a response from the model.") end end - describe 'Constants' do - specify "PROVIDERS" do + describe "Constants" do + specify "PROVIDERS" do expect(RubyAI::Configuration::PROVIDERS).to eq( - 'openai' => "https://api.openai.com/v1/chat/completions", - 'anthropic' => "https://api.anthropic.com/v1/chat/completions" + "openai" => "https://api.openai.com/v1/chat/completions", + "anthropic" => "https://api.anthropic.com/v1/chat/completions" ) end - specify "MODELS should return Hash" do + specify "MODELS should return Hash" do expect(RubyAI::Configuration::MODELS).to be_an_instance_of(Hash) end - specify "BASE_URL" do + specify "BASE_URL" do expect(RubyAI::Configuration::BASE_URL).to eq("https://api.openai.com/v1/chat/completions") end - specify "DEFAULT_MODEL" do + specify "DEFAULT_MODEL" do expect(RubyAI::Configuration::DEFAULT_MODEL).to eq("gpt-3.5-turbo") end - specify "DEFAULT_PROVIDER" do + specify "DEFAULT_PROVIDER" do expect(RubyAI::Configuration::DEFAULT_PROVIDER).to eq("openai") end end diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb index cc9b045..f575313 100644 --- a/spec/rubyai/http_spec.rb +++ b/spec/rubyai/http_spec.rb @@ -1,11 +1,10 @@ -require_relative '../../lib/rubyai/http' +require_relative "../../lib/rubyai/http" RSpec.describe RubyAI::HTTP do let(:config) do - double('config', - openai_api_key: 'test-openai-key', - anthropic_api_key: 'test-anthropic-key' - ) + double("config", + openai_api_key: "test-openai-key", + anthropic_api_key: "test-anthropic-key") end let(:messages) { "Hello, how are you?" } @@ -13,41 +12,41 @@ # Mock the Configuration::MODELS constant before do - stub_const('RubyAI::Configuration::MODELS', { - 'openai' => { - 'gpt-3.5-turbo' => 'gpt-3.5-turbo', - 'gpt-4' => 'gpt-4' - }, - 'anthropic' => { - 'claude-3-sonnet' => 'claude-3-sonnet-20240229', - 'claude-3-haiku' => 'claude-3-haiku-20240307' - } - }) + stub_const("RubyAI::Configuration::MODELS", { + "openai" => { + "gpt-3.5-turbo" => "gpt-3.5-turbo", + "gpt-4" => "gpt-4" + }, + "anthropic" => { + "claude-3-sonnet" => "claude-3-sonnet-20240229", + "claude-3-haiku" => "claude-3-haiku-20240307" + } + }) end - describe '.build_body' do - context 'when provider is openai' do - let(:provider) { 'openai' } - let(:model) { 'gpt-3.5-turbo' } + describe ".build_body" do + context "when provider is openai" do + let(:provider) { "openai" } + let(:model) { "gpt-3.5-turbo" } - it 'returns correct body structure for OpenAI' do + it "returns correct body structure for OpenAI" do result = described_class.build_body(messages, provider, model, temperature) expect(result).to eq({ - :'model' => 'gpt-3.5-turbo', - :'messages' => [{ :"role" => "user", :"content" => messages }], - :'temperature' => temperature - }) + model: "gpt-3.5-turbo", + messages: [{ role: "user", content: messages }], + temperature: temperature + }) end - it 'uses the correct model mapping from configuration' do - model = 'gpt-4' + it "uses the correct model mapping from configuration" do + model = "gpt-4" result = described_class.build_body(messages, provider, model, temperature) - expect(result[:model]).to eq('gpt-4') + expect(result[:model]).to eq("gpt-4") end - it 'includes temperature parameter' do + it "includes temperature parameter" do custom_temp = 0.9 result = described_class.build_body(messages, provider, model, custom_temp) @@ -55,148 +54,148 @@ end end - context 'when provider is anthropic' do - let(:provider) { 'anthropic' } - let(:model) { 'claude-3-sonnet' } + context "when provider is anthropic" do + let(:provider) { "anthropic" } + let(:model) { "claude-3-sonnet" } - it 'returns correct body structure for Anthropic' do + it "returns correct body structure for Anthropic" do result = described_class.build_body(messages, provider, model, temperature) expect(result).to eq({ - 'model' => 'claude-3-sonnet-20240229', - 'max_tokens' => 1024, - 'messages' => [{ 'role' => 'user', 'content' => messages }], - 'temperature' => temperature - }) + "model" => "claude-3-sonnet-20240229", + "max_tokens" => 1024, + "messages" => [{ "role" => "user", "content" => messages }], + "temperature" => temperature + }) end - it 'uses the correct model mapping from configuration' do - model = 'claude-3-haiku' + it "uses the correct model mapping from configuration" do + model = "claude-3-haiku" result = described_class.build_body(messages, provider, model, temperature) - expect(result['model']).to eq('claude-3-haiku-20240307') + expect(result["model"]).to eq("claude-3-haiku-20240307") end - it 'includes required max_tokens parameter' do + it "includes required max_tokens parameter" do result = described_class.build_body(messages, provider, model, temperature) - expect(result['max_tokens']).to eq(1024) + expect(result["max_tokens"]).to eq(1024) end - it 'formats messages correctly for single string input' do + it "formats messages correctly for single string input" do result = described_class.build_body(messages, provider, model, temperature) - expect(result['messages']).to eq([{ 'role' => 'user', 'content' => messages }]) + expect(result["messages"]).to eq([{ "role" => "user", "content" => messages }]) end - it 'passes through array messages without modification' do + it "passes through array messages without modification" do array_messages = [ - { 'role' => 'user', 'content' => 'Hello' }, - { 'role' => 'assistant', 'content' => 'Hi there!' } + { "role" => "user", "content" => "Hello" }, + { "role" => "assistant", "content" => "Hi there!" } ] - + result = described_class.build_body(array_messages, provider, model, temperature) - expect(result['messages']).to eq(array_messages) + expect(result["messages"]).to eq(array_messages) end end - context 'when provider is unsupported' do - it 'returns nil for unsupported provider' do - result = described_class.build_body(messages, 'unsupported', 'model', temperature) + context "when provider is unsupported" do + it "returns nil for unsupported provider" do + result = described_class.build_body(messages, "unsupported", "model", temperature) expect(result).to be_nil end end end - describe '.build_headers' do - context 'when provider is openai' do - let(:provider) { 'openai' } + describe ".build_headers" do + context "when provider is openai" do + let(:provider) { "openai" } - it 'returns correct headers for OpenAI' do + it "returns correct headers for OpenAI" do result = described_class.build_headers(provider, config) expect(result).to eq({ - 'Content-Type': 'application/json', - 'Authorization': "Bearer #{config.openai_api_key}" - }) + "Content-Type": "application/json", + Authorization: "Bearer #{config.openai_api_key}" + }) end - it 'includes the API key in Authorization header' do + it "includes the API key in Authorization header" do result = described_class.build_headers(provider, config) - expect(result[:'Authorization']).to eq("Bearer test-openai-key") + expect(result[:Authorization]).to eq("Bearer test-openai-key") end end - context 'when provider is anthropic' do - let(:provider) { 'anthropic' } + context "when provider is anthropic" do + let(:provider) { "anthropic" } - it 'returns correct headers for Anthropic' do + it "returns correct headers for Anthropic" do result = described_class.build_headers(provider, config) expect(result).to eq({ - 'x-api-key' => config.anthropic_api_key, - 'anthropic-version' => '2023-06-01' - }) + "x-api-key" => config.anthropic_api_key, + "anthropic-version" => "2023-06-01" + }) end - it 'includes the API key in x-api-key header' do + it "includes the API key in x-api-key header" do result = described_class.build_headers(provider, config) - expect(result['x-api-key']).to eq('test-anthropic-key') + expect(result["x-api-key"]).to eq("test-anthropic-key") end - it 'includes the correct anthropic-version' do + it "includes the correct anthropic-version" do result = described_class.build_headers(provider, config) - expect(result['anthropic-version']).to eq('2023-06-01') + expect(result["anthropic-version"]).to eq("2023-06-01") end end - context 'when provider is unsupported' do - it 'returns nil for unsupported provider' do - result = described_class.build_headers('unsupported', config) + context "when provider is unsupported" do + it "returns nil for unsupported provider" do + result = described_class.build_headers("unsupported", config) expect(result).to be_nil end end end - describe '.format_messages_for_antropic' do - context 'when messages is a string' do - it 'converts string to proper message format' do + describe ".format_messages_for_antropic" do + context "when messages is a string" do + it "converts string to proper message format" do string_message = "Hello world" result = described_class.send(:format_messages_for_antropic, string_message) - expect(result).to eq([{ 'role' => 'user', 'content' => string_message }]) + expect(result).to eq([{ "role" => "user", "content" => string_message }]) end end - context 'when messages is already an array' do - it 'returns the array unchanged' do + context "when messages is already an array" do + it "returns the array unchanged" do array_messages = [ - { 'role' => 'user', 'content' => 'Question' }, - { 'role' => 'assistant', 'content' => 'Answer' } + { "role" => "user", "content" => "Question" }, + { "role" => "assistant", "content" => "Answer" } ] - + result = described_class.send(:format_messages_for_antropic, array_messages) expect(result).to eq(array_messages) end end - context 'when messages is empty string' do - it 'handles empty string correctly' do + context "when messages is empty string" do + it "handles empty string correctly" do result = described_class.send(:format_messages_for_antropic, "") - expect(result).to eq([{ 'role' => 'user', 'content' => "" }]) + expect(result).to eq([{ "role" => "user", "content" => "" }]) end end - context 'when messages is nil' do - it 'returns nil unchanged' do + context "when messages is nil" do + it "returns nil unchanged" do result = described_class.send(:format_messages_for_antropic, nil) expect(result).to be_nil @@ -204,32 +203,32 @@ end end - describe 'integration scenarios' do - it 'builds complete OpenAI request components' do - provider = 'openai' - model = 'gpt-4' - + describe "integration scenarios" do + it "builds complete OpenAI request components" do + provider = "openai" + model = "gpt-4" + body = described_class.build_body(messages, provider, model, temperature) headers = described_class.build_headers(provider, config) - expect(body[:model]).to eq('gpt-4') + expect(body[:model]).to eq("gpt-4") expect(body[:messages]).to be_an(Array) - expect(headers[:'Content-Type']).to eq('application/json') - expect(headers[:'Authorization']).to include('Bearer') + expect(headers[:"Content-Type"]).to eq("application/json") + expect(headers[:Authorization]).to include("Bearer") end - it 'builds complete Anthropic request components' do - provider = 'anthropic' - model = 'claude-3-sonnet' - + it "builds complete Anthropic request components" do + provider = "anthropic" + model = "claude-3-sonnet" + body = described_class.build_body(messages, provider, model, temperature) headers = described_class.build_headers(provider, config) - expect(body['model']).to eq('claude-3-sonnet-20240229') - expect(body['messages']).to be_an(Array) - expect(body['max_tokens']).to eq(1024) - expect(headers['x-api-key']).to eq('test-anthropic-key') - expect(headers['anthropic-version']).to eq('2023-06-01') + expect(body["model"]).to eq("claude-3-sonnet-20240229") + expect(body["messages"]).to be_an(Array) + expect(body["max_tokens"]).to eq(1024) + expect(headers["x-api-key"]).to eq("test-anthropic-key") + expect(headers["anthropic-version"]).to eq("2023-06-01") end end -end \ No newline at end of file +end diff --git a/spec/rubyai/provider_spec.rb b/spec/rubyai/provider_spec.rb index 69d3321..70b1e9c 100644 --- a/spec/rubyai/provider_spec.rb +++ b/spec/rubyai/provider_spec.rb @@ -1,16 +1,16 @@ -require 'webmock/rspec' -require_relative '../../lib/rubyai/providers/openai.rb' -require_relative '../../lib/rubyai/providers/anthropic.rb' -require_relative '../../lib/rubyai/provider.rb' +require "webmock/rspec" +require_relative "../../lib/rubyai/providers/openai" +require_relative "../../lib/rubyai/providers/anthropic" +require_relative "../../lib/rubyai/provider" -RSpec.describe RubyAI::Provider do - describe '[]' do +RSpec.describe RubyAI::Provider do + describe "[]" do it 'should return the OpenAI provider when "openai" is passed' do - expect(RubyAI::Provider['openai']).to eq(RubyAI::Providers::OpenAI) + expect(RubyAI::Provider["openai"]).to eq(RubyAI::Providers::OpenAI) end it 'should return the Anthropic provider when "anthropic" is passed' do - expect(RubyAI::Provider['anthropic']).to eq(RubyAI::Providers::Anthropic) + expect(RubyAI::Provider["anthropic"]).to eq(RubyAI::Providers::Anthropic) end end -end \ No newline at end of file +end diff --git a/spec/rubyai/providers/anthropic_spec.rb b/spec/rubyai/providers/anthropic_spec.rb index ccc6792..6078138 100644 --- a/spec/rubyai/providers/anthropic_spec.rb +++ b/spec/rubyai/providers/anthropic_spec.rb @@ -1,15 +1,14 @@ -require_relative '../../../lib/rubyai/providers/anthropic' +require_relative "../../../lib/rubyai/providers/anthropic" RSpec.describe RubyAI::Providers::Anthropic do - describe '.models' do - it 'should return a list of models' do - expect(described_class.models).to eq( "claude-2" => "claude-2", - "claude-instant-100k" => "claude-instant-100k", - "claude-1" => "claude-1", - "claude-1.3" => "claude-1.3", - "claude-1.3-sonnet" => "claude-1.3-sonnet", - "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" - ) + describe ".models" do + it "should return a list of models" do + expect(described_class.models).to eq("claude-2" => "claude-2", + "claude-instant-100k" => "claude-instant-100k", + "claude-1" => "claude-1", + "claude-1.3" => "claude-1.3", + "claude-1.3-sonnet" => "claude-1.3-sonnet", + "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k") end end -end \ No newline at end of file +end diff --git a/spec/rubyai/providers/openai_spec.rb b/spec/rubyai/providers/openai_spec.rb index 37b1f7d..cbd331e 100644 --- a/spec/rubyai/providers/openai_spec.rb +++ b/spec/rubyai/providers/openai_spec.rb @@ -1,16 +1,16 @@ -require_relative '../../../lib/rubyai/providers/openai' +require_relative "../../../lib/rubyai/providers/openai" RSpec.describe RubyAI::Providers::OpenAI do - describe '.models' do - it 'should return a list of models' do + describe ".models" do + it "should return a list of models" do expect(described_class.models).to eq("gpt-3.5-turbo" => "gpt-3.5-turbo", - "gpt-4" => "gpt-4", - "gpt-4-32k" => "gpt-4-32k", - "gpt-4-turbo" => "gpt-4-turbo", - "gpt-4o-mini" => "gpt-4o-mini", - "o1-mini" => "o1-mini", - "o1-preview" => "o1-preview", - "text-davinci-003" => "text-davinci-003") + "gpt-4" => "gpt-4", + "gpt-4-32k" => "gpt-4-32k", + "gpt-4-turbo" => "gpt-4-turbo", + "gpt-4o-mini" => "gpt-4o-mini", + "o1-mini" => "o1-mini", + "o1-preview" => "o1-preview", + "text-davinci-003" => "text-davinci-003") end end -end \ No newline at end of file +end diff --git a/spec/rubyai/rubyai_spec.rb b/spec/rubyai/rubyai_spec.rb index bdcd471..fa3c298 100644 --- a/spec/rubyai/rubyai_spec.rb +++ b/spec/rubyai/rubyai_spec.rb @@ -1,52 +1,53 @@ -require_relative '../../lib/rubyai' +require_relative "../../lib/rubyai" -RSpec.describe RubyAI do - - describe '.models' do - it 'should return available models' do +RSpec.describe RubyAI do + describe ".models" do + it "should return available models" do expect(RubyAI.models).to eq(RubyAI::Configuration::MODELS) end end - describe '.chat' do - let(:api_key) { 'your_api_key' } - let(:messages) { 'Hello, how are you?' } + describe ".chat" do + let(:api_key) { "your_api_key" } + let(:messages) { "Hello, how are you?" } let(:temperature) { 0.7 } - let(:model) { 'gpt-3.5-turbo' } - let(:provider) { 'openai' } - let(:client) { described_class.chat(api_key: api_key, messages: messages, temperature: temperature, provider: provider, model: model) } - + let(:model) { "gpt-3.5-turbo" } + let(:provider) { "openai" } + let(:client) do + described_class.chat(api_key: api_key, messages: messages, temperature: temperature, + provider: provider, model: model) + end - let(:response_body) { { 'completion' => 'This is a response from the model.' } } + let(:response_body) { { "completion" => "This is a response from the model." } } let(:status) { 200 } before do stub_request(:post, RubyAI::Configuration::BASE_URL) - .to_return(status: status, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + .to_return(status: status, body: response_body.to_json, headers: { "Content-Type" => "application/json" }) end - it 'returns parsed JSON response when passing through client directly' do + it "returns parsed JSON response when passing through client directly" do expect(described_class.chat).to eq(response_body) end end - describe '.configure' do - let(:configuration) {RubyAI.config} + describe ".configure" do + let(:configuration) { RubyAI.config } - it 'allows configuration of the client' do + it "allows configuration of the client" do described_class.configure do |config| - config.api_key = 'your_api_key' - config.messages = 'Hello, how are you?' + config.api_key = "your_api_key" + config.messages = "Hello, how are you?" config.temperature = 0.7 - config.provider = 'openai' - config.model = 'gpt-3.5-turbo' + config.provider = "openai" + config.model = "gpt-3.5-turbo" end - expect(configuration.api_key).to eq('your_api_key') - expect(configuration.messages).to eq('Hello, how are you?') + expect(configuration.api_key).to eq("your_api_key") + expect(configuration.messages).to eq("Hello, how are you?") expect(configuration.temperature).to eq(0.7) - expect(configuration.provider).to eq('openai') - expect(configuration.model).to eq('gpt-3.5-turbo') + expect(configuration.provider).to eq("openai") + expect(configuration.model).to eq("gpt-3.5-turbo") end end -end \ No newline at end of file +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c80d44b..4a323fa 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -44,55 +44,53 @@ # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - config.disable_monkey_patching! - - # This setting enables warnings. It's recommended, but in some cases may - # be too noisy due to issues in dependencies. - config.warnings = true - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -=end + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + # config.disable_monkey_patching! + # + # # This setting enables warnings. It's recommended, but in some cases may + # # be too noisy due to issues in dependencies. + # config.warnings = true + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed end From b86cbeb3f12db6608517560e231f76ceb7faddfb Mon Sep 17 00:00:00 2001 From: Reion19 Date: Mon, 16 Jun 2025 20:02:37 +0300 Subject: [PATCH 07/12] Refactor provider configuration and enhance initialization for multi-provider support --- lib/rubyai.rb | 6 +-- lib/rubyai/chat.rb | 13 +++-- lib/rubyai/client.rb | 30 ----------- lib/rubyai/configuration.rb | 37 +++++++------ lib/rubyai/http.rb | 4 +- lib/rubyai/provider.rb | 3 +- lib/rubyai/providers/anthropic.rb | 54 ++++++++++--------- lib/rubyai/providers/gemini.rb | 16 +++--- lib/rubyai/providers/openai.rb | 21 ++++---- .../providers/providers_configuration.rb | 9 ++++ spec/rubyai/client_spec.rb | 28 ---------- 11 files changed, 93 insertions(+), 128 deletions(-) delete mode 100644 lib/rubyai/client.rb create mode 100644 lib/rubyai/providers/providers_configuration.rb delete mode 100644 spec/rubyai/client_spec.rb diff --git a/lib/rubyai.rb b/lib/rubyai.rb index e09d833..8a8dec5 100644 --- a/lib/rubyai.rb +++ b/lib/rubyai.rb @@ -2,7 +2,7 @@ require "faraday/net_http_persistent" require "json" -require_relative "rubyai/providers/provider_configuration" +require_relative "rubyai/providers/providers_configuration" require_relative "rubyai/providers/openai" require_relative "rubyai/providers/anthropic" require_relative "rubyai/providers/gemini" @@ -23,7 +23,7 @@ def self.configure yield config end - def self.config - @config ||= Configuration.new + def self.config(params = {}) + @config ||= Configuration.new(params) end end diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index 46a062d..00d8821 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -2,9 +2,11 @@ module RubyAI class Chat attr_accessor :provider, :model, :temperature - def initialize(provider, model: nil, temperature: 0.7) - @provider = provider - @model = model || RubyAI::Configuration::DEFAULT_MODEL + def initialize(provider, + model: RubyAI.config.default_model, + temperature: RubyAI.config.default_temperature) + @provider = provider || RubyAI.config.default_provider + @model = model @temperature = temperature end @@ -12,14 +14,15 @@ 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, RubyAI.config) + headers = HTTP.build_headers(provider) response = connection.post do |req| - req.url Configuration::PROVIDERS[@provider] || Configuration::BASE_URL + req.url Configuration::PROVIDERS[@provider, @model] req.headers.merge!(headers) req.body = body.to_json end + puts response.body.inspect + "\n\n\n\n\n\n\n\n\n" JSON.parse(response.body) end diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb deleted file mode 100644 index f0481e5..0000000 --- a/lib/rubyai/client.rb +++ /dev/null @@ -1,30 +0,0 @@ -module RubyAI - class Client - attr_reader :configuration - - def initialize(config = {}) - @configuration = RubyAI.config(config) - end - - def call - response = connection.post do |req| - req.url Configuration::PROVIDERS[configuration.provider] || Configuration::BASE_URL - req.headers.merge!(HTTP.build_headers( - configuration.provider || RubyAI::Configuration::DEFAULT_PROVIDER, RubyAI.config - )) - req.body = HTTP.build_body(configuration.messages, configuration.provider, - configuration.model, configuration.temperature).to_json - end - - JSON.parse(response.body) - end - - private - - def connection - @connection ||= Faraday.new do |faraday| - faraday.adapter Faraday.default_adapter - end - end - end -end diff --git a/lib/rubyai/configuration.rb b/lib/rubyai/configuration.rb index 38dd2b6..e007a91 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -2,8 +2,14 @@ module RubyAI class Configuration PROVIDERS = { "openai" => "https://api.openai.com/v1/chat/completions", - "anthropic" => "https://api.anthropic.com/v1/chat/completions" - }.freeze + "anthropic" => "https://api.anthropic.com/v1/chat/completions", + "gemini" => "https://generativelanguage.googleapis.com/v1beta/models" + } + + def PROVIDERS.[](provider, model = nil) + return super(provider) unless !model.nil? && provider == "gemini" + "#{super(provider)}/#{model}:generateContent?key=#{RubyAI.configuration.gemini.api}" + end MODELS = PROVIDERS.to_h do |provider, _url| [provider, Provider[provider].models] @@ -16,21 +22,22 @@ class Configuration DEFAULT_PROVIDER = "openai".freeze # default values for configuration - attr_accessor :api_key, - :model, - :messages, - :temperature, - :provider, - # :providers - :anthropic_api_key + attr_accessor :default_provider, + :default_provider_api, + :default_temperature, + :default_model, + :openai, + :anthropic, + :gemini def initialize(config = {}) - @api_key = config.fetch(:api_key, openai_api_key) - @openai_api_key = config.fetch(:openai_api_key, api_key) - @model = config.fetch(:model, DEFAULT_MODEL) - @messages = config.fetch(:messages, nil) - @temperature = config.fetch(:temperature, 0.7) - @provider = config.fetch(:provider, "openai") + @default_provider_api = config.fetch(:default_provider_api, nil) + @default_provider = config.fetch(:default_provider, DEFAULT_PROVIDER) + @default_temperature = config.fetch(:default_temperature, 0.7) + @default_model = config.fetch(:default_model, DEFAULT_MODEL) + @openai ||= Providers::OpenAI.new + @anthropic ||= Providers::Anthropic.new + @gemini ||= Providers::Gemini.new end end diff --git a/lib/rubyai/http.rb b/lib/rubyai/http.rb index 1e16b9a..bf3c399 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -6,8 +6,8 @@ def build_body(messages, provider, model, temperature) Provider::PROVIDERS.fetch(provider).build_http_body(messages, model, temperature) end - def build_headers(provider, config) - Provider::PROVIDERS.fetch(provider).build_http_headers(provider, config) + def build_headers(provider) + Provider::PROVIDERS.fetch(provider).build_http_headers(provider) end end end diff --git a/lib/rubyai/provider.rb b/lib/rubyai/provider.rb index 9559664..e655803 100644 --- a/lib/rubyai/provider.rb +++ b/lib/rubyai/provider.rb @@ -3,7 +3,8 @@ module Provider PROVIDERS = { "openai" => RubyAI::Providers::OpenAI, # doesn't tested yet because i don't have an anthropic api key - "anthropic" => RubyAI::Providers::Anthropic + "anthropic" => RubyAI::Providers::Anthropic, + 'gemini' => RubyAI::Providers::Gemini }.freeze module_function diff --git a/lib/rubyai/providers/anthropic.rb b/lib/rubyai/providers/anthropic.rb index f9ee8bc..1d62c93 100644 --- a/lib/rubyai/providers/anthropic.rb +++ b/lib/rubyai/providers/anthropic.rb @@ -2,14 +2,16 @@ module RubyAI module Providers # doesn't tested yet because i don't have an anthropic api key class Anthropic - extend RubyAI::Providers::ProviderConfiguration + include ProvidersConfiguration - attr_accessor :api, :messages, :temperature + attr_accessor :api, :messages, :temperature, :max_tokens - def initialize(api:, messages: nil, temperature: 0.7) + # todo: make an initialization for separate instances for using multiple of them + def initialize(api: nil, messages: nil, temperature: 0.7, model: "claude-2") @api = api @messages = messages @temperature = temperature + @model = model end def self.models @@ -22,33 +24,33 @@ def self.models "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" }.freeze end - end - def self.build_http_body(messages, model, temperature = 0.7) - { - "model" => Configuration::MODELS[provider][model], - "max_tokens" => 1024, # Required parameter for Anthropic API - "messages" => format_messages_for_antropic(messages), - "temperature" => temperature - } - end + def self.build_http_body(messages = nil, model = nil, temperature = nil) + { + "model" => Configuration::MODELS["anthropic"][model || @model], + "max_tokens" => 1024, # Required parameter for Anthropic API + "messages" => format_messages_for_antropic(messages || @messages), + "temperature" => temperature || @temperature + } + end - def self.build_http_headers(_provider, config) - { - "x-api-key" => config.anthropic_api_key, - "anthropic-version" => "2023-06-01" - } - end + def self.build_http_headers(_provider) + { + "x-api-key" => RubyAI.configuration.anthropic.api, + "anthropic-version" => "2023-06-01" + } + end - private + private - def format_messages_for_antropic(messages) - # Messages should be an array of message objects - # Each message needs 'role' (either 'user' or 'assistant') and 'content' - if messages.is_a?(String) - [{ "role" => "user", "content" => messages }] - else - messages + def self.format_messages_for_antropic(messages) + # Messages should be an array of message objects + # Each message needs 'role' (either 'user' or 'assistant') and 'content' + if messages.is_a?(String) + [{ "role" => "user", "content" => messages }] + else + messages + end end end end diff --git a/lib/rubyai/providers/gemini.rb b/lib/rubyai/providers/gemini.rb index 5d35ee5..7b7539f 100644 --- a/lib/rubyai/providers/gemini.rb +++ b/lib/rubyai/providers/gemini.rb @@ -1,11 +1,13 @@ module RubyAI module Providers class Gemini - extend RubyAI::Providers::ProviderConfiguration + include ProvidersConfiguration + attr_accessor :api, :messages, :temperature, :max_tokens - def initialize(api:, messages: nil, temperature: 0.7, max_tokens: 1000) + # todo: make an initialization for separate instances for using multiple of them + def initialize(api: nil, messages: nil, temperature: 0.7, max_tokens: 1000) @api = api @messages = messages @temperature = temperature @@ -20,27 +22,27 @@ def self.models } end - def self.build_http_body(messages, temperature = 0.7) + def self.build_http_body(messages = nil, model, temperature) { contents: [ { parts: [ { - text: messages + text: messages || @messages } ] } ], generationConfig: { - temperature: temperature, - maxOutputTokens: max_tokens, + temperature: temperature || @temperature, + maxOutputTokens: @max_tokens, topP: 0.8, topK: 10 } } end - def self.build_http_headers(_provider, _config) + def self.build_http_headers(_provider) { "Content-Type" => "application/json" } diff --git a/lib/rubyai/providers/openai.rb b/lib/rubyai/providers/openai.rb index e5d5a72..1b2e57f 100644 --- a/lib/rubyai/providers/openai.rb +++ b/lib/rubyai/providers/openai.rb @@ -1,18 +1,17 @@ module RubyAI module Providers class OpenAI - extend RubyAI::Providers::ProviderConfiguration - + include ProvidersConfiguration + attr_accessor :api, :messages, :temperature - def initialize(api:, messages: nil, temperature: 0.7) + # todo: make an initialization for separate instances for using multiple of them + def initialize(api: nil, messages: nil, temperature: 0.7) @api = api @messages = messages @temperature = temperature end - DEFAULT_MODEL = "gpt-3.5-turbo".freeze - def self.models { "gpt-3.5-turbo" => "gpt-3.5-turbo", @@ -26,18 +25,18 @@ def self.models } end - def self.build_http_body(messages, model, temperature = 0.7) + def self.build_http_body(messages = nil, model = "gpt-3.5-turbo", temperature = nil) { - model: Configuration::MODELS["openai"][model] || DEFAULT_MODEL, - messages: [{ role: "user", content: messages }], - temperature: temperature + model: Configuration::MODELS["openai"][model], + messages: [{ role: "user", content: messages || @messages}], + temperature: temperature || @temperature } end - def self.build_http_headers(_provider, _config) + def self.build_http_headers(_provider) { "Content-Type": "application/json", - Authorization: "Bearer #{api}" + Authorization: "Bearer #{@api || RubyAI.configuration.openai.api}" } end end diff --git a/lib/rubyai/providers/providers_configuration.rb b/lib/rubyai/providers/providers_configuration.rb new file mode 100644 index 0000000..4944007 --- /dev/null +++ b/lib/rubyai/providers/providers_configuration.rb @@ -0,0 +1,9 @@ +module RubyAI + module Providers + module ProvidersConfiguration + def configure + yield self + end + end + end +end \ No newline at end of file diff --git a/spec/rubyai/client_spec.rb b/spec/rubyai/client_spec.rb deleted file mode 100644 index 77511d6..0000000 --- a/spec/rubyai/client_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require "webmock/rspec" -require_relative "../../lib/rubyai/client" - -RSpec.describe RubyAI::Client do - let(:api_key) { "your_api_key" } - let(:messages) { "Hello, how are you?" } - let(:temperature) { 0.7 } - let(:model) { "gpt-3.5-turbo" } - let(:provider) { "openai" } - let(:client) do - described_class.new(api_key: api_key, messages: messages, temperature: temperature, - provider: provider, model: model) - end - - describe "#call" do - let(:response_body) { { "completion" => "This is a response from the model." } } - let(:status) { 200 } - - before do - stub_request(:post, RubyAI::Configuration::BASE_URL) - .to_return(status: status, body: response_body.to_json, headers: { "Content-Type" => "application/json" }) - end - - it "returns parsed JSON response when passing through client directly" do - expect(client.call).to eq(response_body) - end - end -end From 2198cff8e772ab84f3beac3b16d1ed8b4d263db4 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Mon, 16 Jun 2025 21:39:25 +0300 Subject: [PATCH 08/12] Refactor provider configuration by removing default values and adding model attribute for enhanced flexibility --- lib/rubyai/chat.rb | 4 ++-- lib/rubyai/configuration.rb | 15 +-------------- lib/rubyai/providers/gemini.rb | 2 +- lib/rubyai/providers/openai.rb | 2 +- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index 00d8821..2d96f2c 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -3,8 +3,8 @@ class Chat attr_accessor :provider, :model, :temperature def initialize(provider, - model: RubyAI.config.default_model, - temperature: RubyAI.config.default_temperature) + model: nil, + temperature: 0.75) @provider = provider || RubyAI.config.default_provider @model = model @temperature = temperature diff --git a/lib/rubyai/configuration.rb b/lib/rubyai/configuration.rb index e007a91..d0b4e60 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -15,26 +15,13 @@ def PROVIDERS.[](provider, model = nil) [provider, Provider[provider].models] end.freeze - BASE_URL = "https://api.openai.com/v1/chat/completions".freeze - - DEFAULT_MODEL = "gpt-3.5-turbo".freeze - - DEFAULT_PROVIDER = "openai".freeze # default values for configuration - attr_accessor :default_provider, - :default_provider_api, - :default_temperature, - :default_model, - :openai, + attr_accessor :openai, :anthropic, :gemini def initialize(config = {}) - @default_provider_api = config.fetch(:default_provider_api, nil) - @default_provider = config.fetch(:default_provider, DEFAULT_PROVIDER) - @default_temperature = config.fetch(:default_temperature, 0.7) - @default_model = config.fetch(:default_model, DEFAULT_MODEL) @openai ||= Providers::OpenAI.new @anthropic ||= Providers::Anthropic.new @gemini ||= Providers::Gemini.new diff --git a/lib/rubyai/providers/gemini.rb b/lib/rubyai/providers/gemini.rb index 7b7539f..3123367 100644 --- a/lib/rubyai/providers/gemini.rb +++ b/lib/rubyai/providers/gemini.rb @@ -4,7 +4,7 @@ class Gemini include ProvidersConfiguration - attr_accessor :api, :messages, :temperature, :max_tokens + attr_accessor :api, :messages, :temperature, :max_tokens, :model # todo: make an initialization for separate instances for using multiple of them def initialize(api: nil, messages: nil, temperature: 0.7, max_tokens: 1000) diff --git a/lib/rubyai/providers/openai.rb b/lib/rubyai/providers/openai.rb index 1b2e57f..e800b44 100644 --- a/lib/rubyai/providers/openai.rb +++ b/lib/rubyai/providers/openai.rb @@ -3,7 +3,7 @@ module Providers class OpenAI include ProvidersConfiguration - attr_accessor :api, :messages, :temperature + attr_accessor :api, :messages, :temperature, :model # todo: make an initialization for separate instances for using multiple of them def initialize(api: nil, messages: nil, temperature: 0.7) From 4ae32340bcece7c53010961197ec054744282de3 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Mon, 16 Jun 2025 21:48:18 +0300 Subject: [PATCH 09/12] Updated readme --- README.md | 157 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 109 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 0e8b0ef..4318a1b 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,168 @@ # RubyAI - OpenAI integration Ruby gem + + [![Gem Version](https://badge.fury.io/rb/rubyai.svg)](https://badge.fury.io/rb/rubyai) + [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/alexshapalov/rubyai/blob/main/LICENSE) + + ## Use the [OpenAI API 🤖 ](https://openai.com/blog/openai-api/) with Ruby! ❤️ -Generate text with ChatGPT (Generative Pre-trained Transformer) + +Generate text with ChatGPT, Claude and Gemini! + + + # Installation -Add this line to your application's Gemfile: +install a latest version via Bundler: -```ruby -gem "rubyai" +```ruby +bundle add rubyai ``` -And then execute: +``` - $ bundle install + +And then execute: -Or install with: + - $ gem install rubyai +$ bundle install -and require with: + + -```ruby -require "rubyai" -``` +Or install with: -# Usage + -- Get your API key from [https://beta.openai.com/account/api-keys](https://beta.openai.com/account/api-keys) +$ gem install rubyai -- If you belong to multiple organizations, you can get your Organization ID from [https://beta.openai.com/account/org-settings](https://beta.openai.com/account/org-settings) + -### Quickstart +and require with: -For a quick test you can pass your token directly to a new client: + ```ruby -result = RubyAI::Client.new(access_token, messages).call + +require "rubyai" + ``` -### ChatGPT + -ChatGPT is a conversational-style text generation model. -You can use it to [generate a response](https://platform.openai.com/docs/api-reference/chat/create) to a sequence of [messages](https://platform.openai.com/docs/guides/chat/introduction): +# Usage -```ruby -api_key = "YOUR API KEY" -messages = "Who is the best chess player in history?" + +- Get your API key from [https://beta.openai.com/account/api-keys](https://beta.openai.com/account/api-keys), https://console.anthropic.com, https://aistudio.google.com/apikey + +- If you belong to multiple organizations, you can get your Organization ID from [https://beta.openai.com/account/org-settings](https://beta.openai.com/account/org-settings) + +### Configuration +Our gem is using separate configurations for every provider it supports, example: +```ruby +# for openai LLM's it could be: +RubyAI.configuration.openai.configure do |config| + config.api = "your-api" + config.model = "o1-mini" + config.temoperature = 0.75 +end -result = RubyAI::Client.new(api_key, messages, model: "gpt-4").call -puts result.dig("choices", 0, "message", "content") +# for anthropic: +RubyAI.configuration.anthropic.configure do |config| + config.api = "your-api" + config.model="claude-2" + temperature = 0.75 + config.max_tokens = 1000 +end -# => As an AI language model, I do not have personal opinions, but according to historical records, Garry Kasparov is often considered as one of the best chess players in history. Other notable players include Magnus Carlsen, Bobby Fischer, and Jose Capablanca. +# for gemini: +RubyAI.configuration.gemini.configure do |config| + config.api = "your-api" + config.model="gemini-1.5-pro" + temperature = 0.75 + config.max_tokens = 1000 +end ``` -You can also pass client variables using the configuration file. -Create configruation file like on example: +### Chat +After configuration you can chat with models by calling `Chat` class, example: ```ruby -configuration = RubyAI::Configuration.new("YOUR API KEY", "Who is the best chess player in history?") -client = RubyAI::Client.new(configuration) -result = client.call -puts result.dig("choices", 0, "message", "content") -``` +claude2 = RubyAI::Chat.new('anthropic', model: "claude-2") +claude2.call("Hello world") # => Hash response +# Or -Also (mostly) if you are using Rails you can use configure method: -```ruby -RubyAI.configure do |config| - config.api_key = "YOUR API KEY" - config.messages = "Who is the best chess player in history?" - config.model = "gpt-4o-mini" -end +gpt = RubyAI::Chat.new("openai", "gpt-4", temperature: 1) +gpt.call("Hello world!") # => Hash response ``` +### -## Models + -We support all popular GPT models: +We support most of the popular GPT models: + + +```ruby +p RubyAI::Configuration::MODELS.each.each_key +"openai" => + {"gpt-3.5-turbo" => "gpt-3.5-turbo", + "gpt-4" => "gpt-4", + "gpt-4-32k" => "gpt-4-32k", + "gpt-4-turbo" => "gpt-4-turbo", + "gpt-4o-mini" => "gpt-4o-mini", + "o1-mini" => "o1-mini", + "o1-preview" => "o1-preview", + "text-davinci-003" => "text-davinci-003"}, + "anthropic" => + {"claude-2" => "claude-2", + "claude-instant-100k" => "claude-instant-100k", + "claude-1" => "claude-1", + "claude-1.3" => "claude-1.3", + "claude-1.3-sonnet" => "claude-1.3-sonnet", + "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k"}, + "gemini" => {"gemini-1.5-pro" => "gemini-1.5-pro", "gemini-1.5-flash" => "gemini-1.5-flash", "gemini-1.0-pro" => "gemini-1.0-pro"}} + +``` -gpt-4-turbo: A powerful variant of GPT-4 optimized for efficiency and speed, perfect for high-demand tasks. -gpt-4o-mini: A streamlined version of GPT-4, designed to provide a balance between performance and resource efficiency. +### TODO: -o1-mini: A compact, yet effective model that is well-suited for lightweight tasks. -o1-preview: A preview version of the o1 model, offering insights into upcoming advancements and features. +1. Support for Gemini models to be configurated via `configure` block +2. Implement more LLM's support +3. Write an Specs for most of use cases +4. Stream responses ## Development + After checking out the repo, run `bin/setup` to install dependencies. You can run `bin/console` for an interactive prompt that will allow you to experiment. + + To install this gem onto your local machine, run `bundle exec rake install`. + + ## Contributing + + Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors. + + ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). \ No newline at end of file From 7050db0b87f423d4c0f279e24d7ebc0e09096528 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Mon, 16 Jun 2025 21:54:25 +0300 Subject: [PATCH 10/12] Remove unnecessary debug output and update specs for provider configuration --- lib/rubyai/chat.rb | 1 - spec/rubyai/configuration_spec.rb | 12 ++++++------ spec/rubyai/http_spec.rb | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index 2d96f2c..586d11b 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -22,7 +22,6 @@ def call(messages) req.body = body.to_json end - puts response.body.inspect + "\n\n\n\n\n\n\n\n\n" JSON.parse(response.body) end diff --git a/spec/rubyai/configuration_spec.rb b/spec/rubyai/configuration_spec.rb index 86be815..8f699a1 100644 --- a/spec/rubyai/configuration_spec.rb +++ b/spec/rubyai/configuration_spec.rb @@ -55,16 +55,16 @@ expect(RubyAI::Configuration::MODELS).to be_an_instance_of(Hash) end - specify "BASE_URL" do - expect(RubyAI::Configuration::BASE_URL).to eq("https://api.openai.com/v1/chat/completions") + specify "OpenAI base URL from PROVIDERS" do + expect(RubyAI::Configuration::PROVIDERS["openai"]).to eq("https://api.openai.com/v1/chat/completions") end - specify "DEFAULT_MODEL" do - expect(RubyAI::Configuration::DEFAULT_MODEL).to eq("gpt-3.5-turbo") + specify "Default model from configuration" do + expect(RubyAI.configuration.model).to eq("gpt-3.5-turbo") end - specify "DEFAULT_PROVIDER" do - expect(RubyAI::Configuration::DEFAULT_PROVIDER).to eq("openai") + specify "Default provider from configuration" do + expect(RubyAI.configuration.provider).to eq("openai") end end end diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb index f575313..c68a5ab 100644 --- a/spec/rubyai/http_spec.rb +++ b/spec/rubyai/http_spec.rb @@ -114,7 +114,7 @@ let(:provider) { "openai" } it "returns correct headers for OpenAI" do - result = described_class.build_headers(provider, config) + result = described_class.build_headers(provider) expect(result).to eq({ "Content-Type": "application/json", From d0ba5f34bd6ec3d399ea3916494c31d4a427ce2d Mon Sep 17 00:00:00 2001 From: Reion19 Date: Tue, 17 Jun 2025 18:20:28 +0300 Subject: [PATCH 11/12] Added specs for all files and rubocop -A --- lib/rubyai/chat.rb | 2 +- lib/rubyai/configuration.rb | 12 +- lib/rubyai/provider.rb | 2 +- lib/rubyai/providers/anthropic.rb | 6 +- lib/rubyai/providers/gemini.rb | 5 +- lib/rubyai/providers/openai.rb | 6 +- .../providers/providers_configuration.rb | 2 +- spec/rubyai/chat_spec.rb | 118 ++++++++- spec/rubyai/configuration_spec.rb | 116 +++++---- spec/rubyai/http_spec.rb | 233 ++---------------- spec/rubyai/provider_spec.rb | 30 ++- spec/rubyai/providers/anthropic_spec.rb | 89 ++++++- spec/rubyai/providers/gemini_spec.rb | 52 ++++ spec/rubyai/providers/openai_spec.rb | 73 +++++- spec/rubyai/rubyai_spec.rb | 62 ++--- 15 files changed, 462 insertions(+), 346 deletions(-) create mode 100644 spec/rubyai/providers/gemini_spec.rb diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb index 586d11b..7236f9e 100644 --- a/lib/rubyai/chat.rb +++ b/lib/rubyai/chat.rb @@ -3,7 +3,7 @@ class Chat attr_accessor :provider, :model, :temperature def initialize(provider, - model: nil, + model: nil, temperature: 0.75) @provider = provider || RubyAI.config.default_provider @model = model diff --git a/lib/rubyai/configuration.rb b/lib/rubyai/configuration.rb index d0b4e60..f045f65 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -4,10 +4,11 @@ class Configuration "openai" => "https://api.openai.com/v1/chat/completions", "anthropic" => "https://api.anthropic.com/v1/chat/completions", "gemini" => "https://generativelanguage.googleapis.com/v1beta/models" - } + }.freeze def PROVIDERS.[](provider, model = nil) return super(provider) unless !model.nil? && provider == "gemini" + "#{super(provider)}/#{model}:generateContent?key=#{RubyAI.configuration.gemini.api}" end @@ -15,16 +16,15 @@ def PROVIDERS.[](provider, model = nil) [provider, Provider[provider].models] end.freeze - # default values for configuration attr_accessor :openai, :anthropic, :gemini - def initialize(config = {}) - @openai ||= Providers::OpenAI.new - @anthropic ||= Providers::Anthropic.new - @gemini ||= Providers::Gemini.new + def initialize(_config = {}) + @openai = Providers::OpenAI.new + @anthropic = Providers::Anthropic.new + @gemini = Providers::Gemini.new end end diff --git a/lib/rubyai/provider.rb b/lib/rubyai/provider.rb index e655803..d60a1d6 100644 --- a/lib/rubyai/provider.rb +++ b/lib/rubyai/provider.rb @@ -4,7 +4,7 @@ module Provider "openai" => RubyAI::Providers::OpenAI, # doesn't tested yet because i don't have an anthropic api key "anthropic" => RubyAI::Providers::Anthropic, - 'gemini' => RubyAI::Providers::Gemini + "gemini" => RubyAI::Providers::Gemini }.freeze module_function diff --git a/lib/rubyai/providers/anthropic.rb b/lib/rubyai/providers/anthropic.rb index 1d62c93..00d5819 100644 --- a/lib/rubyai/providers/anthropic.rb +++ b/lib/rubyai/providers/anthropic.rb @@ -6,12 +6,12 @@ class Anthropic attr_accessor :api, :messages, :temperature, :max_tokens - # todo: make an initialization for separate instances for using multiple of them + # TODO: make an initialization for separate instances for using multiple of them def initialize(api: nil, messages: nil, temperature: 0.7, model: "claude-2") @api = api @messages = messages @temperature = temperature - @model = model + @model = model end def self.models @@ -41,8 +41,6 @@ def self.build_http_headers(_provider) } end - private - def self.format_messages_for_antropic(messages) # Messages should be an array of message objects # Each message needs 'role' (either 'user' or 'assistant') and 'content' diff --git a/lib/rubyai/providers/gemini.rb b/lib/rubyai/providers/gemini.rb index 3123367..0e9332d 100644 --- a/lib/rubyai/providers/gemini.rb +++ b/lib/rubyai/providers/gemini.rb @@ -3,10 +3,9 @@ module Providers class Gemini include ProvidersConfiguration - attr_accessor :api, :messages, :temperature, :max_tokens, :model - # todo: make an initialization for separate instances for using multiple of them + # TODO: make an initialization for separate instances for using multiple of them def initialize(api: nil, messages: nil, temperature: 0.7, max_tokens: 1000) @api = api @messages = messages @@ -22,7 +21,7 @@ def self.models } end - def self.build_http_body(messages = nil, model, temperature) + def self.build_http_body(messages = nil, _model, temperature) { contents: [ { diff --git a/lib/rubyai/providers/openai.rb b/lib/rubyai/providers/openai.rb index e800b44..e506516 100644 --- a/lib/rubyai/providers/openai.rb +++ b/lib/rubyai/providers/openai.rb @@ -2,10 +2,10 @@ module RubyAI module Providers class OpenAI include ProvidersConfiguration - + attr_accessor :api, :messages, :temperature, :model - # todo: make an initialization for separate instances for using multiple of them + # TODO: make an initialization for separate instances for using multiple of them def initialize(api: nil, messages: nil, temperature: 0.7) @api = api @messages = messages @@ -28,7 +28,7 @@ def self.models def self.build_http_body(messages = nil, model = "gpt-3.5-turbo", temperature = nil) { model: Configuration::MODELS["openai"][model], - messages: [{ role: "user", content: messages || @messages}], + messages: [{ role: "user", content: messages || @messages }], temperature: temperature || @temperature } end diff --git a/lib/rubyai/providers/providers_configuration.rb b/lib/rubyai/providers/providers_configuration.rb index 4944007..77754e2 100644 --- a/lib/rubyai/providers/providers_configuration.rb +++ b/lib/rubyai/providers/providers_configuration.rb @@ -6,4 +6,4 @@ def configure end end end -end \ No newline at end of file +end diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb index 794f0fb..2bce5ef 100644 --- a/spec/rubyai/chat_spec.rb +++ b/spec/rubyai/chat_spec.rb @@ -1,2 +1,118 @@ -require_relative "../../lib/rubyai/chat" +require_relative "../../lib/rubyai" require "webmock/rspec" + +# spec/ruby_ai/chat_spec.rb + +require "spec_helper" + +RSpec.describe RubyAI::Chat do + let(:provider) { "openai" } + let(:model) { "gpt-4" } + let(:temperature) { 0.9 } + let(:messages) { ["Hello"] } + let(:response_body) { { "reply" => "Hi!" }.to_json } + + subject(:chat) { described_class.new(provider, model: model, temperature: temperature) } + + describe "#initialize" do + it "sets the provider, model, and temperature" do + expect(chat.provider).to eq(provider) + expect(chat.model).to eq(model) + expect(chat.temperature).to eq(temperature) + end + + it "uses default provider if none is given" do + 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 + end + + describe "#call" do + context "when messages are nil or empty" do + it "raises ArgumentError" do + expect { chat.call(nil) }.to raise_error(ArgumentError, "Messages cannot be empty") + expect { chat.call([]) }.to raise_error(ArgumentError, "Messages cannot be empty") + end + 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!" }) + end + end + + context "when Faraday connection fails" do + before do + allow(Faraday).to receive(:new).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") + end + end + + context "when JSON parsing fails" do + 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(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 any other error occurs" do + before do + allow(Faraday).to receive(:new).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") + end + end + end +end diff --git a/spec/rubyai/configuration_spec.rb b/spec/rubyai/configuration_spec.rb index 8f699a1..d9bd838 100644 --- a/spec/rubyai/configuration_spec.rb +++ b/spec/rubyai/configuration_spec.rb @@ -1,70 +1,88 @@ -require "webmock/rspec" -require_relative "../../lib/rubyai/client" -require_relative "../../lib/rubyai/providers/openai" -require_relative "../../lib/rubyai/providers/anthropic" +require_relative "../../lib/rubyai" require_relative "../../lib/rubyai/provider" -require_relative "../../lib/rubyai/configuration" - -RSpec.describe RubyAI::Client do - let(:api_key) { "your_api_key" } - let(:messages) { "Hello, how are you?" } - let(:temperature) { 0.7 } - let(:model) { "gpt-3.5-turbo" } - let(:provider) { "openai" } - - before do - RubyAI.configure do |config| - config.provider = provider - config.model = model - config.temperature = temperature - config.api_key = api_key - config.messages = messages + +require "spec_helper" + +RSpec.describe RubyAI::Configuration do + describe "::PROVIDERS" do + it "returns URL for openai and anthropic without model" do + expect(described_class::PROVIDERS["openai"]).to eq("https://api.openai.com/v1/chat/completions") + expect(described_class::PROVIDERS["anthropic"]).to eq("https://api.anthropic.com/v1/chat/completions") + end + + it "returns extended Gemini URL with model and API key" do + providers_hash = described_class::PROVIDERS + + # Stub API key + fake_api_key = "dummy_key" + allow(RubyAI).to receive_message_chain(:configuration, :gemini, :api).and_return(fake_api_key) + + gemini_provider = providers_hash["gemini", "gemini-1.5-pro"] + expect(gemini_provider).to eq( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=#{fake_api_key}" + ) end end - describe "#call" do - let(:response_body) do - { "choices" => [{ "message" => { "content" => "This is a response from the model." } }] } + describe "#initialize" do + let(:config) { described_class.new } + + it "initializes OpenAI provider" do + expect(config.openai).to be_a(RubyAI::Providers::OpenAI) end - let(:status) { 200 } - before do - stub_request(:post, RubyAI::Configuration::BASE_URL) - .to_return(status: status, body: response_body.to_json, headers: { "Content-Type" => "application/json" }) + it "initializes Anthropic provider" do + expect(config.anthropic).to be_a(RubyAI::Providers::Anthropic) end - it "returns parsed JSON response when passing through client via configuration" do - configuration = { api_key: RubyAI.configuration.api_key, - messages: RubyAI.configuration.messages } - client = described_class.new(configuration) - result = client.call - expect(result.dig("choices", 0, "message", - "content")).to eq("This is a response from the model.") + it "initializes Gemini provider" do + expect(config.gemini).to be_a(RubyAI::Providers::Gemini) end end - describe "Constants" do - specify "PROVIDERS" do - expect(RubyAI::Configuration::PROVIDERS).to eq( - "openai" => "https://api.openai.com/v1/chat/completions", - "anthropic" => "https://api.anthropic.com/v1/chat/completions" - ) - end + describe "::MODELS" do + before do + stub_const("RubyAI::Configuration::PROVIDERS", { + "openai" => "https://openai.test", + "gemini" => "https://gemini.test" + }) + + fake_openai = double("OpenAI", models: { "gpt-test" => "gpt-test" }) + fake_gemini = double("Gemini", models: { "gemini-test" => "gemini-test" }) - specify "MODELS should return Hash" do - expect(RubyAI::Configuration::MODELS).to be_an_instance_of(Hash) + allow(RubyAI::Provider).to receive(:[]).with("openai").and_return(fake_openai) + allow(RubyAI::Provider).to receive(:[]).with("gemini").and_return(fake_gemini) + + # Reload MODELS constant + stub_const("RubyAI::Configuration::MODELS", { + "openai" => fake_openai.models, + "gemini" => fake_gemini.models + }) end - specify "OpenAI base URL from PROVIDERS" do - expect(RubyAI::Configuration::PROVIDERS["openai"]).to eq("https://api.openai.com/v1/chat/completions") + it "returns correct models hash" do + expect(described_class::MODELS["openai"]).to eq({ "gpt-test" => "gpt-test" }) + expect(described_class::MODELS["gemini"]).to eq({ "gemini-test" => "gemini-test" }) end + end - specify "Default model from configuration" do - expect(RubyAI.configuration.model).to eq("gpt-3.5-turbo") + describe "RubyAI.configuration" do + it "returns a singleton configuration object" do + config1 = RubyAI.configuration + config2 = RubyAI.configuration + expect(config1).to be_a(described_class) + expect(config1).to equal(config2) end + end + + describe "RubyAI.configure" do + it "yields configuration object for custom setup" do + RubyAI.configure do |config| + expect(config).to be_a(described_class) + config.openai.api = "custom_key" + end - specify "Default provider from configuration" do - expect(RubyAI.configuration.provider).to eq("openai") + expect(RubyAI.configuration.openai.api).to eq("custom_key") end end end diff --git a/spec/rubyai/http_spec.rb b/spec/rubyai/http_spec.rb index c68a5ab..a6c04e1 100644 --- a/spec/rubyai/http_spec.rb +++ b/spec/rubyai/http_spec.rb @@ -1,234 +1,33 @@ require_relative "../../lib/rubyai/http" -RSpec.describe RubyAI::HTTP do - let(:config) do - double("config", - openai_api_key: "test-openai-key", - anthropic_api_key: "test-anthropic-key") - end +require "spec_helper" - let(:messages) { "Hello, how are you?" } - let(:temperature) { 0.7 } +RSpec.describe RubyAI::HTTP do + let(:provider_class) { class_double("RubyAI::Providers::OpenAI") } - # Mock the Configuration::MODELS constant before do - stub_const("RubyAI::Configuration::MODELS", { - "openai" => { - "gpt-3.5-turbo" => "gpt-3.5-turbo", - "gpt-4" => "gpt-4" - }, - "anthropic" => { - "claude-3-sonnet" => "claude-3-sonnet-20240229", - "claude-3-haiku" => "claude-3-haiku-20240307" - } - }) + stub_const("RubyAI::Provider::PROVIDERS", { "openai" => provider_class }) end describe ".build_body" do - context "when provider is openai" do - let(:provider) { "openai" } - let(:model) { "gpt-3.5-turbo" } - - it "returns correct body structure for OpenAI" do - result = described_class.build_body(messages, provider, model, temperature) - - expect(result).to eq({ - model: "gpt-3.5-turbo", - messages: [{ role: "user", content: messages }], - temperature: temperature - }) - end - - it "uses the correct model mapping from configuration" do - model = "gpt-4" - result = described_class.build_body(messages, provider, model, temperature) - - expect(result[:model]).to eq("gpt-4") - end - - it "includes temperature parameter" do - custom_temp = 0.9 - result = described_class.build_body(messages, provider, model, custom_temp) + it "delegates to the correct provider class with params" do + expect(provider_class).to receive(:build_http_body) + .with("Hello", "gpt-3.5-turbo", 0.7) + .and_return({ body: "ok" }) - expect(result[:temperature]).to eq(custom_temp) - end - end - - context "when provider is anthropic" do - let(:provider) { "anthropic" } - let(:model) { "claude-3-sonnet" } - - it "returns correct body structure for Anthropic" do - result = described_class.build_body(messages, provider, model, temperature) - - expect(result).to eq({ - "model" => "claude-3-sonnet-20240229", - "max_tokens" => 1024, - "messages" => [{ "role" => "user", "content" => messages }], - "temperature" => temperature - }) - end - - it "uses the correct model mapping from configuration" do - model = "claude-3-haiku" - result = described_class.build_body(messages, provider, model, temperature) - - expect(result["model"]).to eq("claude-3-haiku-20240307") - end - - it "includes required max_tokens parameter" do - result = described_class.build_body(messages, provider, model, temperature) - - expect(result["max_tokens"]).to eq(1024) - end - - it "formats messages correctly for single string input" do - result = described_class.build_body(messages, provider, model, temperature) - - expect(result["messages"]).to eq([{ "role" => "user", "content" => messages }]) - end - - it "passes through array messages without modification" do - array_messages = [ - { "role" => "user", "content" => "Hello" }, - { "role" => "assistant", "content" => "Hi there!" } - ] - - result = described_class.build_body(array_messages, provider, model, temperature) - - expect(result["messages"]).to eq(array_messages) - end - end - - context "when provider is unsupported" do - it "returns nil for unsupported provider" do - result = described_class.build_body(messages, "unsupported", "model", temperature) - - expect(result).to be_nil - end + result = described_class.build_body("Hello", "openai", "gpt-3.5-turbo", 0.7) + expect(result).to eq({ body: "ok" }) end end describe ".build_headers" do - context "when provider is openai" do - let(:provider) { "openai" } - - it "returns correct headers for OpenAI" do - result = described_class.build_headers(provider) - - expect(result).to eq({ - "Content-Type": "application/json", - Authorization: "Bearer #{config.openai_api_key}" - }) - end - - it "includes the API key in Authorization header" do - result = described_class.build_headers(provider, config) - - expect(result[:Authorization]).to eq("Bearer test-openai-key") - end - end - - context "when provider is anthropic" do - let(:provider) { "anthropic" } - - it "returns correct headers for Anthropic" do - result = described_class.build_headers(provider, config) - - expect(result).to eq({ - "x-api-key" => config.anthropic_api_key, - "anthropic-version" => "2023-06-01" - }) - end - - it "includes the API key in x-api-key header" do - result = described_class.build_headers(provider, config) - - expect(result["x-api-key"]).to eq("test-anthropic-key") - end - - it "includes the correct anthropic-version" do - result = described_class.build_headers(provider, config) - - expect(result["anthropic-version"]).to eq("2023-06-01") - end - end - - context "when provider is unsupported" do - it "returns nil for unsupported provider" do - result = described_class.build_headers("unsupported", config) - - expect(result).to be_nil - end - end - end - - describe ".format_messages_for_antropic" do - context "when messages is a string" do - it "converts string to proper message format" do - string_message = "Hello world" - result = described_class.send(:format_messages_for_antropic, string_message) - - expect(result).to eq([{ "role" => "user", "content" => string_message }]) - end - end - - context "when messages is already an array" do - it "returns the array unchanged" do - array_messages = [ - { "role" => "user", "content" => "Question" }, - { "role" => "assistant", "content" => "Answer" } - ] - - result = described_class.send(:format_messages_for_antropic, array_messages) - - expect(result).to eq(array_messages) - end - end - - context "when messages is empty string" do - it "handles empty string correctly" do - result = described_class.send(:format_messages_for_antropic, "") - - expect(result).to eq([{ "role" => "user", "content" => "" }]) - end - end - - context "when messages is nil" do - it "returns nil unchanged" do - result = described_class.send(:format_messages_for_antropic, nil) - - expect(result).to be_nil - end - end - end - - describe "integration scenarios" do - it "builds complete OpenAI request components" do - provider = "openai" - model = "gpt-4" - - body = described_class.build_body(messages, provider, model, temperature) - headers = described_class.build_headers(provider, config) - - expect(body[:model]).to eq("gpt-4") - expect(body[:messages]).to be_an(Array) - expect(headers[:"Content-Type"]).to eq("application/json") - expect(headers[:Authorization]).to include("Bearer") - end - - it "builds complete Anthropic request components" do - provider = "anthropic" - model = "claude-3-sonnet" - - body = described_class.build_body(messages, provider, model, temperature) - headers = described_class.build_headers(provider, config) + it "delegates to the correct provider class" do + expect(provider_class).to receive(:build_http_headers) + .with("openai") + .and_return({ header: "ok" }) - expect(body["model"]).to eq("claude-3-sonnet-20240229") - expect(body["messages"]).to be_an(Array) - expect(body["max_tokens"]).to eq(1024) - expect(headers["x-api-key"]).to eq("test-anthropic-key") - expect(headers["anthropic-version"]).to eq("2023-06-01") + result = described_class.build_headers("openai") + expect(result).to eq({ header: "ok" }) end end end diff --git a/spec/rubyai/provider_spec.rb b/spec/rubyai/provider_spec.rb index 70b1e9c..bbfeca1 100644 --- a/spec/rubyai/provider_spec.rb +++ b/spec/rubyai/provider_spec.rb @@ -1,16 +1,34 @@ -require "webmock/rspec" +require_relative "../../lib/rubyai/providers/providers_configuration" require_relative "../../lib/rubyai/providers/openai" require_relative "../../lib/rubyai/providers/anthropic" +require_relative "../../lib/rubyai/providers/gemini" require_relative "../../lib/rubyai/provider" +require "spec_helper" + RSpec.describe RubyAI::Provider do - describe "[]" do - it 'should return the OpenAI provider when "openai" is passed' do - expect(RubyAI::Provider["openai"]).to eq(RubyAI::Providers::OpenAI) + describe "::PROVIDERS" do + it "includes openai, anthropic and gemini" do + expect(described_class::PROVIDERS.keys).to include("openai", "anthropic", "gemini") + expect(described_class::PROVIDERS["openai"]).to eq(RubyAI::Providers::OpenAI) + end + end + + describe ".[]" do + it "returns the corresponding provider class for openai" do + expect(described_class["openai"]).to eq(RubyAI::Providers::OpenAI) + end + + it "returns the corresponding provider class for anthropic" do + expect(described_class["anthropic"]).to eq(RubyAI::Providers::Anthropic) + end + + it "returns the corresponding provider class for gemini" do + expect(described_class["gemini"]).to eq(RubyAI::Providers::Gemini) end - it 'should return the Anthropic provider when "anthropic" is passed' do - expect(RubyAI::Provider["anthropic"]).to eq(RubyAI::Providers::Anthropic) + it "raises KeyError if provider is unknown" do + expect { described_class["unknown"] }.to raise_error(KeyError) end end end diff --git a/spec/rubyai/providers/anthropic_spec.rb b/spec/rubyai/providers/anthropic_spec.rb index 6078138..271cc66 100644 --- a/spec/rubyai/providers/anthropic_spec.rb +++ b/spec/rubyai/providers/anthropic_spec.rb @@ -1,14 +1,87 @@ -require_relative "../../../lib/rubyai/providers/anthropic" +require_relative "../../../lib/rubyai" + +require "spec_helper" RSpec.describe RubyAI::Providers::Anthropic do + subject(:anthropic) do + described_class.new(api: "test_api", messages: "Hello Claude", temperature: 0.5) + end + + describe "#initialize" do + it "initializes instance variables correctly" do + expect(anthropic.api).to eq("test_api") + expect(anthropic.messages).to eq("Hello Claude") + expect(anthropic.temperature).to eq(0.5) + end + + it "sets default model if not provided" do + expect(anthropic.instance_variable_get(:@model)).to eq("claude-2") + end + end + describe ".models" do - it "should return a list of models" do - expect(described_class.models).to eq("claude-2" => "claude-2", - "claude-instant-100k" => "claude-instant-100k", - "claude-1" => "claude-1", - "claude-1.3" => "claude-1.3", - "claude-1.3-sonnet" => "claude-1.3-sonnet", - "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k") + it "returns the list of Anthropic models" do + expect(described_class.models).to include( + "claude-2" => "claude-2", + "claude-instant-100k" => "claude-instant-100k", + "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k" + ) + end + end + + describe ".build_http_body" do + before do + stub_const("Configuration::MODELS", { + "anthropic" => { + "claude-2" => "claude-2" + } + }) + end + + it "builds correct body with string message" do + result = described_class.build_http_body("Hi!", "claude-2", 0.6) + + expect(result["model"]).to eq("claude-2") + expect(result["max_tokens"]).to eq(1024) + expect(result["temperature"]).to eq(0.6) + expect(result["messages"]).to eq([{ "role" => "user", "content" => "Hi!" }]) + end + + it "builds correct body with array of messages" do + messages = [ + { "role" => "user", "content" => "Hello" }, + { "role" => "assistant", "content" => "Hi!" } + ] + + result = described_class.build_http_body(messages, "claude-2", 0.5) + expect(result["messages"]).to eq(messages) + end + end + + describe ".build_http_headers" do + before do + allow(RubyAI).to receive_message_chain(:configuration, :anthropic, + :api).and_return("real_api_key") + end + + it "returns correct headers for Anthropic API" do + headers = described_class.build_http_headers("anthropic") + + expect(headers["x-api-key"]).to eq("real_api_key") + expect(headers["anthropic-version"]).to eq("2023-06-01") + end + end + + describe ".format_messages_for_antropic" do + it "wraps string in user role message" do + result = described_class.send(:format_messages_for_antropic, "Hello") + expect(result).to eq([{ "role" => "user", "content" => "Hello" }]) + end + + it "returns array unchanged if already properly formatted" do + messages = [{ "role" => "assistant", "content" => "Hi" }] + result = described_class.send(:format_messages_for_antropic, messages) + expect(result).to eq(messages) end end end diff --git a/spec/rubyai/providers/gemini_spec.rb b/spec/rubyai/providers/gemini_spec.rb new file mode 100644 index 0000000..8d03880 --- /dev/null +++ b/spec/rubyai/providers/gemini_spec.rb @@ -0,0 +1,52 @@ +require "spec_helper" +require_relative "../../../lib/rubyai/providers/providers_configuration" +require_relative "../../../lib/rubyai/providers/gemini" + +RSpec.describe RubyAI::Providers::Gemini do + subject(:gemini) do + described_class.new(api: "fake_api", messages: "Hello", temperature: 0.9, max_tokens: 500) + end + + describe "#initialize" do + it "sets instance variables correctly" do + expect(gemini.api).to eq("fake_api") + expect(gemini.messages).to eq("Hello") + expect(gemini.temperature).to eq(0.9) + expect(gemini.max_tokens).to eq(500) + end + + it "sets default values if not provided" do + default_instance = described_class.new + expect(default_instance.temperature).to eq(0.7) + expect(default_instance.max_tokens).to eq(1000) + end + end + + describe ".models" do + it "returns a hash of available Gemini models" do + expect(described_class.models).to include( + "gemini-1.5-pro" => "gemini-1.5-pro", + "gemini-1.5-flash" => "gemini-1.5-flash", + "gemini-1.0-pro" => "gemini-1.0-pro" + ) + end + end + + describe ".build_http_body" do + let(:body) { described_class.build_http_body("Hi", "gemini-1.5-pro", 0.6) } + + it "builds the correct request body format" do + expect(body).to include(:contents, :generationConfig) + expect(body[:contents].first[:parts].first[:text]).to eq("Hi") + expect(body[:generationConfig][:temperature]).to eq(0.6) + expect(body[:generationConfig][:topP]).to eq(0.8) + expect(body[:generationConfig][:topK]).to eq(10) + end + end + + describe ".build_http_headers" do + it "returns headers with content type JSON" do + expect(described_class.build_http_headers("any")).to eq({ "Content-Type" => "application/json" }) + end + end +end diff --git a/spec/rubyai/providers/openai_spec.rb b/spec/rubyai/providers/openai_spec.rb index cbd331e..483c716 100644 --- a/spec/rubyai/providers/openai_spec.rb +++ b/spec/rubyai/providers/openai_spec.rb @@ -1,16 +1,69 @@ -require_relative "../../../lib/rubyai/providers/openai" +require_relative "../../../lib/rubyai" + +# spec/ruby_ai/providers/openai_spec.rb + +require "spec_helper" RSpec.describe RubyAI::Providers::OpenAI do + subject(:openai) do + described_class.new(api: "test_api", messages: "Hello OpenAI", temperature: 0.8) + end + + describe "#initialize" do + it "initializes instance variables correctly" do + expect(openai.api).to eq("test_api") + expect(openai.messages).to eq("Hello OpenAI") + expect(openai.temperature).to eq(0.8) + end + + it "uses default temperature if none is provided" do + instance = described_class.new + expect(instance.temperature).to eq(0.7) + end + end + describe ".models" do - it "should return a list of models" do - expect(described_class.models).to eq("gpt-3.5-turbo" => "gpt-3.5-turbo", - "gpt-4" => "gpt-4", - "gpt-4-32k" => "gpt-4-32k", - "gpt-4-turbo" => "gpt-4-turbo", - "gpt-4o-mini" => "gpt-4o-mini", - "o1-mini" => "o1-mini", - "o1-preview" => "o1-preview", - "text-davinci-003" => "text-davinci-003") + it "returns all available OpenAI models" do + expect(described_class.models).to include( + "gpt-3.5-turbo" => "gpt-3.5-turbo", + "gpt-4" => "gpt-4", + "gpt-4-32k" => "gpt-4-32k", + "gpt-4-turbo" => "gpt-4-turbo", + "gpt-4o-mini" => "gpt-4o-mini", + "o1-mini" => "o1-mini", + "o1-preview" => "o1-preview", + "text-davinci-003" => "text-davinci-003" + ) + end + end + + describe ".build_http_body" do + before do + stub_const("Configuration::MODELS", { + "openai" => { + "gpt-3.5-turbo" => "gpt-3.5-turbo" + } + }) + end + + it "builds a correct request body with given params" do + result = described_class.build_http_body("Hi there", "gpt-3.5-turbo", 0.6) + expect(result[:model]).to eq("gpt-3.5-turbo") + expect(result[:messages]).to eq([{ role: "user", content: "Hi there" }]) + expect(result[:temperature]).to eq(0.6) + end + end + + describe ".build_http_headers" do + before do + allow(RubyAI).to receive_message_chain(:configuration, :openai, + :api).and_return("fallback_token") + end + + it "returns headers with Authorization using fallback token" do + headers = described_class.build_http_headers("openai") + expect(headers[:"Content-Type"]).to eq("application/json") + expect(headers[:Authorization]).to eq("Bearer fallback_token") end end end diff --git a/spec/rubyai/rubyai_spec.rb b/spec/rubyai/rubyai_spec.rb index fa3c298..d751f92 100644 --- a/spec/rubyai/rubyai_spec.rb +++ b/spec/rubyai/rubyai_spec.rb @@ -1,53 +1,43 @@ require_relative "../../lib/rubyai" +require "spec_helper" + RSpec.describe RubyAI do describe ".models" do - it "should return available models" do - expect(RubyAI.models).to eq(RubyAI::Configuration::MODELS) + it "returns a hash of provider models" do + models = described_class.models + expect(models).to be_a(Hash) + expect(models.keys).to include("openai", "anthropic", "gemini") + expect(models["openai"]).to be_a(Hash) end end - describe ".chat" do - let(:api_key) { "your_api_key" } - let(:messages) { "Hello, how are you?" } - let(:temperature) { 0.7 } - let(:model) { "gpt-3.5-turbo" } - let(:provider) { "openai" } - let(:client) do - described_class.chat(api_key: api_key, messages: messages, temperature: temperature, - provider: provider, model: model) - end - - let(:response_body) { { "completion" => "This is a response from the model." } } - let(:status) { 200 } - - before do - stub_request(:post, RubyAI::Configuration::BASE_URL) - .to_return(status: status, body: response_body.to_json, headers: { "Content-Type" => "application/json" }) + describe ".config" do + it "returns a Configuration instance" do + config = described_class.config + expect(config).to be_a(RubyAI::Configuration) end - it "returns parsed JSON response when passing through client directly" do - expect(described_class.chat).to eq(response_body) + it "returns a memoized instance" do + expect(described_class.config.object_id).to eq(described_class.config.object_id) end end describe ".configure" do - let(:configuration) { RubyAI.config } - - it "allows configuration of the client" do - described_class.configure do |config| - config.api_key = "your_api_key" - config.messages = "Hello, how are you?" - config.temperature = 0.7 - config.provider = "openai" - config.model = "gpt-3.5-turbo" - end + it "yields the configuration for modification" do + expect do + described_class.configure do |config| + config.openai.temperature = 0.99 + end + end.not_to raise_error + + expect(described_class.config.openai.temperature).to eq(0.99) + end + end - expect(configuration.api_key).to eq("your_api_key") - expect(configuration.messages).to eq("Hello, how are you?") - expect(configuration.temperature).to eq(0.7) - expect(configuration.provider).to eq("openai") - expect(configuration.model).to eq("gpt-3.5-turbo") + describe "RubyAI::Error" do + it "inherits from StandardError" do + expect(RubyAI::Error.new).to be_a(StandardError) end end end From 552c26a3a6811120a9c93320fe15363f33e3b819 Mon Sep 17 00:00:00 2001 From: Reion19 Date: Tue, 17 Jun 2025 18:53:36 +0300 Subject: [PATCH 12/12] updated readme --- README.md | 200 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 158 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 4318a1b..6ce1059 100644 --- a/README.md +++ b/README.md @@ -2,167 +2,283 @@ + + [![Gem Version](https://badge.fury.io/rb/rubyai.svg)](https://badge.fury.io/rb/rubyai) + + [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/alexshapalov/rubyai/blob/main/LICENSE) + + ## Use the [OpenAI API 🤖 ](https://openai.com/blog/openai-api/) with Ruby! ❤️ + + Generate text with ChatGPT, Claude and Gemini! + # Installation -install a latest version via Bundler: + + +install a latest version via Bundler: + + + +```ruby -```ruby bundle add rubyai + ``` + + ``` + + And then execute: + + $ bundle install + Or install with: + + $ gem install rubyai + + and require with: + + ```ruby + + require "rubyai" + + ``` + + # Usage + - Get your API key from [https://beta.openai.com/account/api-keys](https://beta.openai.com/account/api-keys), https://console.anthropic.com, https://aistudio.google.com/apikey -- If you belong to multiple organizations, you can get your Organization ID from [https://beta.openai.com/account/org-settings](https://beta.openai.com/account/org-settings) -### Configuration + +- If you belong to multiple organizations, you can get your Organization ID from [https://beta.openai.com/account/org-settings](https://beta.openai.com/account/org-settings) + +### Configuration + Our gem is using separate configurations for every provider it supports, example: -```ruby -# for openai LLM's it could be: + +```ruby + +# for openai LLM's it could be: + RubyAI.configuration.openai.configure do |config| - config.api = "your-api" - config.model = "o1-mini" - config.temoperature = 0.75 + +config.api = "your-api" + +config.model = "o1-mini" + +config.temoperature = 0.75 + end -# for anthropic: -RubyAI.configuration.anthropic.configure do |config| - config.api = "your-api" - config.model="claude-2" - temperature = 0.75 - config.max_tokens = 1000 + + +# for anthropic: + +RubyAI.configuration.anthropic.configure do |config| + +config.api = "your-api" + +config.model="claude-2" + +temperature = 0.75 + +config.max_tokens = 1000 + end -# for gemini: -RubyAI.configuration.gemini.configure do |config| - config.api = "your-api" - config.model="gemini-1.5-pro" - temperature = 0.75 - config.max_tokens = 1000 + + +# for gemini: + +RubyAI.configuration.gemini.configure do |config| + +config.api = "your-api" + +config.model="gemini-1.5-pro" + +temperature = 0.75 + +config.max_tokens = 1000 + end + ``` -### Chat -After configuration you can chat with models by calling `Chat` class, example: + + +### Chat + +After configuration you can chat with models by calling `Chat` class, example: + ```ruby + claude2 = RubyAI::Chat.new('anthropic', model: "claude-2") + claude2.call("Hello world") # => Hash response -# Or + +# Or + + gpt = RubyAI::Chat.new("openai", "gpt-4", temperature: 1) + gpt.call("Hello world!") # => Hash response + ``` + ### + + We support most of the popular GPT models: + ```ruby + p RubyAI::Configuration::MODELS.each.each_key + "openai" => - {"gpt-3.5-turbo" => "gpt-3.5-turbo", - "gpt-4" => "gpt-4", - "gpt-4-32k" => "gpt-4-32k", - "gpt-4-turbo" => "gpt-4-turbo", - "gpt-4o-mini" => "gpt-4o-mini", - "o1-mini" => "o1-mini", - "o1-preview" => "o1-preview", - "text-davinci-003" => "text-davinci-003"}, - "anthropic" => - {"claude-2" => "claude-2", - "claude-instant-100k" => "claude-instant-100k", - "claude-1" => "claude-1", - "claude-1.3" => "claude-1.3", - "claude-1.3-sonnet" => "claude-1.3-sonnet", - "claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k"}, - "gemini" => {"gemini-1.5-pro" => "gemini-1.5-pro", "gemini-1.5-flash" => "gemini-1.5-flash", "gemini-1.0-pro" => "gemini-1.0-pro"}} + +{"gpt-3.5-turbo" => "gpt-3.5-turbo", + +"gpt-4" => "gpt-4", + +"gpt-4-32k" => "gpt-4-32k", + +"gpt-4-turbo" => "gpt-4-turbo", + +"gpt-4o-mini" => "gpt-4o-mini", + +"o1-mini" => "o1-mini", + +"o1-preview" => "o1-preview", + +"text-davinci-003" => "text-davinci-003"}, + +"anthropic" => + +{"claude-2" => "claude-2", + +"claude-instant-100k" => "claude-instant-100k", + +"claude-1" => "claude-1", + +"claude-1.3" => "claude-1.3", + +"claude-1.3-sonnet" => "claude-1.3-sonnet", + +"claude-1.3-sonnet-100k" => "claude-1.3-sonnet-100k"}, + +"gemini" => {"gemini-1.5-pro" => "gemini-1.5-pro", "gemini-1.5-flash" => "gemini-1.5-flash", "gemini-1.0-pro" => "gemini-1.0-pro"}} + + ``` + + -### TODO: +### TODO: + + 1. Support for Gemini models to be configurated via `configure` block + 2. Implement more LLM's support -3. Write an Specs for most of use cases + +3. ~~Write an Specs for most of use cases~~ + 4. Stream responses + + ## Development + After checking out the repo, run `bin/setup` to install dependencies. You can run `bin/console` for an interactive prompt that will allow you to experiment. + + To install this gem onto your local machine, run `bundle exec rake install`. + ## Contributing + + Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors. + + ## License + + The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). \ No newline at end of file