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/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) 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 diff --git a/lib/rubyai.rb b/lib/rubyai.rb index 211a3bc..8a8dec5 100644 --- a/lib/rubyai.rb +++ b/lib/rubyai.rb @@ -1,12 +1,29 @@ -require 'faraday' -require 'faraday/net_http_persistent' -require 'json' +require "faraday" +require "faraday/net_http_persistent" +require "json" -require_relative "rubyai/client" +require_relative "rubyai/providers/providers_configuration" +require_relative "rubyai/providers/openai" +require_relative "rubyai/providers/anthropic" +require_relative "rubyai/providers/gemini" +require_relative "rubyai/provider" 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 + end + + def self.configure + yield config + end + + def self.config(params = {}) + @config ||= Configuration.new(params) + end end diff --git a/lib/rubyai/chat.rb b/lib/rubyai/chat.rb new file mode 100644 index 0000000..586d11b --- /dev/null +++ b/lib/rubyai/chat.rb @@ -0,0 +1,43 @@ +module RubyAI + class Chat + attr_accessor :provider, :model, :temperature + + def initialize(provider, + model: nil, + temperature: 0.75) + @provider = provider || RubyAI.config.default_provider + @model = 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) + + response = connection.post do |req| + req.url Configuration::PROVIDERS[@provider, @model] + req.headers.merge!(headers) + req.body = body.to_json + end + + JSON.parse(response.body) + end + + private + + def connection + @connection ||= Faraday.new do |faraday| + faraday.adapter Faraday.default_adapter + faraday.headers["Content-Type"] = "application/json" + end + rescue Faraday::Error => e + raise "Connection error: #{e.message}" + rescue JSON::ParserError => e + raise "Response parsing error: #{e.message}" + rescue StandardError => e + raise "An unexpected error occurred: #{e.message}" + end + end +end diff --git a/lib/rubyai/client.rb b/lib/rubyai/client.rb deleted file mode 100644 index 6cbc096..0000000 --- a/lib/rubyai/client.rb +++ /dev/null @@ -1,27 +0,0 @@ -module RubyAI - class Client - attr_reader :configuration - - def initialize(config_hash = {}) - @configuration = Configuration.new(config_hash) - 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 - 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 ed0bc1b..d0b4e60 100644 --- a/lib/rubyai/configuration.rb +++ b/lib/rubyai/configuration.rb @@ -1,31 +1,35 @@ module RubyAI class Configuration - BASE_URL = "https://api.openai.com/v1/chat/completions" - - 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" + PROVIDERS = { + "openai" => "https://api.openai.com/v1/chat/completions", + "anthropic" => "https://api.anthropic.com/v1/chat/completions", + "gemini" => "https://generativelanguage.googleapis.com/v1beta/models" } - DEFAULT_MODEL = "gpt-3.5-turbo" + 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] + end.freeze + - attr_accessor :api_key, :model, :messages, :temperature + # default values for configuration + attr_accessor :openai, + :anthropic, + :gemini def initialize(config = {}) - @api_key = config[:api_key] - @model = config.fetch(:model, DEFAULT_MODEL) - @messages = config.fetch(:messages, nil) - @temperature = config.fetch(:temperature, 0.7) + @openai ||= Providers::OpenAI.new + @anthropic ||= Providers::Anthropic.new + @gemini ||= Providers::Gemini.new end end def self.configuration - @configuration ||= Configuration.new + @configuration ||= RubyAI.config({}) end def self.configure diff --git a/lib/rubyai/http.rb b/lib/rubyai/http.rb index cd73806..bf3c399 100644 --- a/lib/rubyai/http.rb +++ b/lib/rubyai/http.rb @@ -1,20 +1,13 @@ module RubyAI module HTTP - extend self + module_function - def build_body(messages, model, temperature) - { - 'model': Configuration::MODELS[model], - 'messages': [{ "role": "user", "content": messages }], - 'temperature': temperature - } + def build_body(messages, provider, model, temperature) + Provider::PROVIDERS.fetch(provider).build_http_body(messages, model, temperature) end - def build_headers(api_key) - { - 'Content-Type': 'application/json', - 'Authorization': "Bearer #{api_key}" - } + 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 new file mode 100644 index 0000000..e655803 --- /dev/null +++ b/lib/rubyai/provider.rb @@ -0,0 +1,16 @@ +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, + 'gemini' => RubyAI::Providers::Gemini + }.freeze + + module_function + + def [](provider) + PROVIDERS.fetch(provider) + end + end +end diff --git a/lib/rubyai/providers/anthropic.rb b/lib/rubyai/providers/anthropic.rb new file mode 100644 index 0000000..1d62c93 --- /dev/null +++ b/lib/rubyai/providers/anthropic.rb @@ -0,0 +1,57 @@ +module RubyAI + module Providers + # doesn't tested yet because i don't have an anthropic api key + class Anthropic + include ProvidersConfiguration + + attr_accessor :api, :messages, :temperature, :max_tokens + + # 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 + { + "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 + + 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) + { + "x-api-key" => RubyAI.configuration.anthropic.api, + "anthropic-version" => "2023-06-01" + } + 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' + if messages.is_a?(String) + [{ "role" => "user", "content" => messages }] + else + messages + end + end + end + end +end diff --git a/lib/rubyai/providers/gemini.rb b/lib/rubyai/providers/gemini.rb new file mode 100644 index 0000000..3123367 --- /dev/null +++ b/lib/rubyai/providers/gemini.rb @@ -0,0 +1,52 @@ +module RubyAI + 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 + def initialize(api: nil, 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 = nil, model, temperature) + { + contents: [ + { + parts: [ + { + text: messages || @messages + } + ] + } + ], + generationConfig: { + temperature: temperature || @temperature, + maxOutputTokens: @max_tokens, + topP: 0.8, + topK: 10 + } + } + end + + def self.build_http_headers(_provider) + { + "Content-Type" => "application/json" + } + end + end + end +end diff --git a/lib/rubyai/providers/openai.rb b/lib/rubyai/providers/openai.rb new file mode 100644 index 0000000..e800b44 --- /dev/null +++ b/lib/rubyai/providers/openai.rb @@ -0,0 +1,44 @@ +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 + def initialize(api: nil, messages: nil, temperature: 0.7) + @api = api + @messages = messages + @temperature = temperature + end + + 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 + + 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}], + temperature: temperature || @temperature + } + end + + def self.build_http_headers(_provider) + { + "Content-Type": "application/json", + Authorization: "Bearer #{@api || RubyAI.configuration.openai.api}" + } + end + end + 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/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/client_spec.rb b/spec/client_spec.rb deleted file mode 100644 index f74c472..0000000 --- a/spec/client_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'webmock/rspec' -require_relative '../lib/rubyai/client.rb' - -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(:client) { described_class.new(api_key: api_key, messages: messages, temperature: temperature, model: 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' }) - end - - it 'returns parsed JSON response when passing through client directly' do - expect(client.call).to eq(response_body) - end - end -end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb deleted file mode 100644 index e9298f1..0000000 --- a/spec/configuration_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -require 'webmock/rspec' -require_relative '../lib/rubyai/client.rb' - -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' } - - before do - RubyAI.configure do |config| - config.api_key = api_key - config.messages = messages - end - end - - describe '#call' do - let(:response_body) { { 'choices' => [{ 'message' => { 'content' => '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 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.') - end - end -end diff --git a/spec/rubyai/chat_spec.rb b/spec/rubyai/chat_spec.rb new file mode 100644 index 0000000..794f0fb --- /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/configuration_spec.rb b/spec/rubyai/configuration_spec.rb new file mode 100644 index 0000000..8f699a1 --- /dev/null +++ b/spec/rubyai/configuration_spec.rb @@ -0,0 +1,70 @@ +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(: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 + end + + 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" }) + 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.") + 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 "OpenAI base URL from PROVIDERS" do + expect(RubyAI::Configuration::PROVIDERS["openai"]).to eq("https://api.openai.com/v1/chat/completions") + end + + specify "Default model from configuration" do + expect(RubyAI.configuration.model).to eq("gpt-3.5-turbo") + end + + 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 new file mode 100644 index 0000000..c68a5ab --- /dev/null +++ b/spec/rubyai/http_spec.rb @@ -0,0 +1,234 @@ +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) + + 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 diff --git a/spec/rubyai/provider_spec.rb b/spec/rubyai/provider_spec.rb new file mode 100644 index 0000000..70b1e9c --- /dev/null +++ b/spec/rubyai/provider_spec.rb @@ -0,0 +1,16 @@ +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 + 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 diff --git a/spec/rubyai/providers/anthropic_spec.rb b/spec/rubyai/providers/anthropic_spec.rb new file mode 100644 index 0000000..6078138 --- /dev/null +++ b/spec/rubyai/providers/anthropic_spec.rb @@ -0,0 +1,14 @@ +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") + end + end +end diff --git a/spec/rubyai/providers/openai_spec.rb b/spec/rubyai/providers/openai_spec.rb new file mode 100644 index 0000000..cbd331e --- /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 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") + end + end +end diff --git a/spec/rubyai/rubyai_spec.rb b/spec/rubyai/rubyai_spec.rb new file mode 100644 index 0000000..fa3c298 --- /dev/null +++ b/spec/rubyai/rubyai_spec.rb @@ -0,0 +1,53 @@ +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) + 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" }) + end + + 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 } + + 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 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