diff --git a/CHANGELOG.md b/CHANGELOG.md index ee003e8..56dafdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initialise repository. + +## [0.1.0] - 2023-07-18 + +### Changed + +- Got the gem working with the API. MVP diff --git a/Gemfile.lock b/Gemfile.lock index 3b2c661..851fcdc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - anthropic (0.0.0) + anthropic (0.1.0) faraday (>= 1) faraday-multipart (>= 1) diff --git a/README.md b/README.md index 812f04e..6f9565d 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/alexrudall/anthropic/blob/main/LICENSE.txt) [![CircleCI Build Status](https://circleci.com/gh/alexrudall/anthropic.svg?style=shield)](https://circleci.com/gh/alexrudall/anthropic) -Use the [Anthropic API](https://anthropic.com/blog/anthropic-api/) with Ruby! 🤖❤️ +Use the [Anthropic API](https://docs.anthropic.com/claude/reference/getting-started-with-the-api) with Ruby! 🌌❤️ -This is very much a WIP and probably doesn't work as I don't have access to the Anthropic API as yet, but it will get a lot better once I do! Hopefully very soon :D +You can apply for access to the API [here](https://docs.anthropic.com/claude/docs/getting-access-to-claude). [Ruby AI Builders Discord](https://discord.gg/k4Uc224xVD) @@ -40,8 +40,7 @@ require "anthropic" ## Usage -- Get your API key from [https://platform.anthropic.com/account/api-keys](https://platform.anthropic.com/account/api-keys) -- If you belong to multiple organizations, you can get your Organization ID from [https://platform.anthropic.com/account/org-settings](https://platform.anthropic.com/account/org-settings) +- Get your API key from [https://console.anthropic.com/account/keys](https://console.anthropic.com/account/keys) ### Quickstart @@ -67,26 +66,27 @@ Then you can create a client like this: client = Anthropic::Client.new ``` -#### Custom timeout or base URI +#### Change version or timeout + +You can change to a different dated version (different from the URL version which is just `v1`) of Anthropic's API by passing `anthropic_version` when initializing the client. If you don't the default latest will be used, which is "2023-06-01". [More info](https://docs.anthropic.com/claude/reference/versioning) The default timeout for any request using this library is 120 seconds. You can change that by passing a number of seconds to the `request_timeout` when initializing the client. ```ruby client = Anthropic::Client.new( access_token: "access_token_goes_here", - request_timeout: 240 + anthropic_version: "2023-01-01", # Optional + request_timeout: 240 # Optional ) ``` -or when configuring the gem: +You can also set these keys when configuring the gem: ```ruby Anthropic.configure do |config| config.access_token = ENV.fetch("ANTHROPIC_API_KEY") + config.anthropic_version = "2023-01-01" # Optional config.request_timeout = 240 # Optional - config.extra_headers = { - "X-Proxy-Refresh": "true" - } # Optional end ``` @@ -95,16 +95,30 @@ end Hit the Anthropic API for a completion: ```ruby -response = client.completions( +response = client.complete( parameters: { model: "claude-2", - prompt: "Once upon a time", - max_tokens: 5 + prompt: "How high is the sky?", + max_tokens_to_sample: 5 }) -puts response["choices"].map { |c| c["text"] } -# => [", there lived a great"] +puts response["completion"] +# => " The sky has no definitive" ``` +Note that all requests are prepended by this library with + +`\n\nHuman: ` + +and appended with + +`\n\nAssistant:` + +so whatever prompt you pass will be sent to the API as + +`\n\nHuman: How high is the sky?\n\nAssistant:` + +This is a requirement of [the API](https://docs.anthropic.com/claude/reference/complete_post). + ## 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. diff --git a/anthropic.gemspec b/anthropic.gemspec index a05d4c3..fe94571 100644 --- a/anthropic.gemspec +++ b/anthropic.gemspec @@ -6,7 +6,7 @@ Gem::Specification.new do |spec| spec.authors = ["Alex"] spec.email = ["alexrudall@users.noreply.github.com"] - spec.summary = "Anthropic API + Ruby! 🤖❤️" + spec.summary = "Anthropic API + Ruby! 🌌❤️" spec.homepage = "https://github.com/alexrudall/anthropic" spec.license = "MIT" spec.required_ruby_version = Gem::Requirement.new(">= 2.6.0") diff --git a/lib/anthropic.rb b/lib/anthropic.rb index 0c8f87a..9a6fc29 100644 --- a/lib/anthropic.rb +++ b/lib/anthropic.rb @@ -3,10 +3,6 @@ require_relative "anthropic/http" require_relative "anthropic/client" -require_relative "anthropic/files" -require_relative "anthropic/finetunes" -require_relative "anthropic/images" -require_relative "anthropic/models" require_relative "anthropic/version" module Anthropic @@ -15,15 +11,18 @@ class ConfigurationError < Error; end class Configuration attr_writer :access_token - attr_accessor :api_version, :organization_id, :uri_base, :request_timeout, :extra_headers + attr_accessor :anthropic_version, :api_version, :extra_headers, :organization_id, + :request_timeout, :uri_base DEFAULT_API_VERSION = "v1".freeze + DEFAULT_ANTHROPIC_VERSION = "2023-06-01".freeze DEFAULT_URI_BASE = "https://api.anthropic.com/".freeze DEFAULT_REQUEST_TIMEOUT = 120 def initialize @access_token = nil @api_version = DEFAULT_API_VERSION + @anthropic_version = DEFAULT_ANTHROPIC_VERSION @organization_id = nil @uri_base = DEFAULT_URI_BASE @request_timeout = DEFAULT_REQUEST_TIMEOUT diff --git a/lib/anthropic/client.rb b/lib/anthropic/client.rb index 349b771..3ea291d 100644 --- a/lib/anthropic/client.rb +++ b/lib/anthropic/client.rb @@ -11,8 +11,19 @@ def initialize(access_token: nil, organization_id: nil, uri_base: nil, request_t Anthropic.configuration.extra_headers = extra_headers end - def completions(parameters: {}) - Anthropic::Client.json_post(path: "/completions", parameters: parameters) + def complete(parameters: {}) + parameters[:prompt] = wrap_prompt(prompt: parameters[:prompt]) + Anthropic::Client.json_post(path: "/complete", parameters: parameters) + end + + private + + def wrap_prompt(prompt:, prefix: "\n\nHuman: ", suffix: "\n\nAssistant:") + return if prompt.nil? + + prompt.prepend(prefix) unless prompt.start_with?(prefix) + prompt.concat(suffix) unless prompt.end_with?(suffix) + prompt end end end diff --git a/lib/anthropic/http.rb b/lib/anthropic/http.rb index 075867d..4a72c04 100644 --- a/lib/anthropic/http.rb +++ b/lib/anthropic/http.rb @@ -74,8 +74,8 @@ def uri(path:) def headers { "Content-Type" => "application/json", - "Authorization" => "Bearer #{Anthropic.configuration.access_token}", - "Anthropic-Organization" => Anthropic.configuration.organization_id + "x-api-key" => Anthropic.configuration.access_token, + "Anthropic-Version" => Anthropic.configuration.anthropic_version }.merge(Anthropic.configuration.extra_headers) end @@ -83,7 +83,7 @@ def multipart_parameters(parameters) parameters&.transform_values do |value| next value unless value.is_a?(File) - # Doesn't seem like Anthropic need mime_type yet, so not worth + # Doesn't seem like Anthropic needs mime_type yet, so not worth # the library to figure this out. Hence the empty string # as the second argument. Faraday::UploadIO.new(value, "", value.path) diff --git a/lib/anthropic/version.rb b/lib/anthropic/version.rb index 0c18896..8665306 100644 --- a/lib/anthropic/version.rb +++ b/lib/anthropic/version.rb @@ -1,3 +1,3 @@ module Anthropic - VERSION = "0.0.0".freeze + VERSION = "0.1.0".freeze end diff --git a/spec/anthropic/client/complete_spec.rb b/spec/anthropic/client/complete_spec.rb new file mode 100644 index 0000000..0eab12d --- /dev/null +++ b/spec/anthropic/client/complete_spec.rb @@ -0,0 +1,30 @@ +RSpec.describe Anthropic::Client do + describe "#complete" do + context "with a prompt and max_tokens", :vcr do + let(:prompt) { "How high is the sky?" } + let(:max_tokens) { 5 } + + let(:response) do + Anthropic::Client.new.complete( + parameters: { + model: model, + max_tokens_to_sample: max_tokens, + prompt: prompt + } + ) + end + let(:text) { response.dig("choices", 0, "text") } + let(:cassette) { "#{model} complete #{prompt}".downcase } + + context "with model: claude-2" do + let(:model) { "claude-2" } + + it "succeeds" do + VCR.use_cassette(cassette) do + expect(response["completion"].empty?).to eq(false) + end + end + end + end + end +end diff --git a/spec/anthropic_spec.rb b/spec/anthropic_spec.rb new file mode 100644 index 0000000..9b54677 --- /dev/null +++ b/spec/anthropic_spec.rb @@ -0,0 +1,58 @@ +RSpec.describe Anthropic do + it "has a version number" do + expect(Anthropic::VERSION).not_to be nil + end + + describe "#configure" do + let(:access_token) { "abc123" } + let(:api_version) { "v2" } + let(:organization_id) { "def456" } + let(:custom_uri_base) { "ghi789" } + let(:custom_request_timeout) { 25 } + let(:extra_headers) { { "User-Agent" => "Anthropic Ruby Gem #{Anthropic::VERSION}" } } + + before do + Anthropic.configure do |config| + config.access_token = access_token + config.api_version = api_version + config.organization_id = organization_id + config.extra_headers = extra_headers + end + end + + it "returns the config" do + expect(Anthropic.configuration.access_token).to eq(access_token) + expect(Anthropic.configuration.api_version).to eq(api_version) + expect(Anthropic.configuration.organization_id).to eq(organization_id) + expect(Anthropic.configuration.uri_base).to eq("https://api.anthropic.com/") + expect(Anthropic.configuration.request_timeout).to eq(120) + expect(Anthropic.configuration.extra_headers).to eq(extra_headers) + end + + context "without an access token" do + let(:access_token) { nil } + + it "raises an error" do + expect { Anthropic::Client.new.complete }.to raise_error(Anthropic::ConfigurationError) + end + end + + context "with custom timeout and uri base" do + before do + Anthropic.configure do |config| + config.uri_base = custom_uri_base + config.request_timeout = custom_request_timeout + end + end + + it "returns the config" do + expect(Anthropic.configuration.access_token).to eq(access_token) + expect(Anthropic.configuration.api_version).to eq(api_version) + expect(Anthropic.configuration.organization_id).to eq(organization_id) + expect(Anthropic.configuration.uri_base).to eq(custom_uri_base) + expect(Anthropic.configuration.request_timeout).to eq(custom_request_timeout) + expect(Anthropic.configuration.extra_headers).to eq(extra_headers) + end + end + end +end diff --git a/spec/compatibility_spec.rb b/spec/compatibility_spec.rb new file mode 100644 index 0000000..848f41b --- /dev/null +++ b/spec/compatibility_spec.rb @@ -0,0 +1,33 @@ +RSpec.describe "compatibility" do + context "for moved constants" do + describe "::Ruby::Anthropic::VERSION" do + it "is mapped to ::Anthropic::VERSION" do + expect(Ruby::Anthropic::VERSION).to eq(Anthropic::VERSION) + end + end + + describe "::Ruby::Anthropic::Error" do + it "is mapped to ::Anthropic::Error" do + expect(Ruby::Anthropic::Error).to eq(Anthropic::Error) + expect(Ruby::Anthropic::Error.new).to be_a(Anthropic::Error) + expect(Anthropic::Error.new).to be_a(Ruby::Anthropic::Error) + end + end + + describe "::Ruby::Anthropic::ConfigurationError" do + it "is mapped to ::Anthropic::ConfigurationError" do + expect(Ruby::Anthropic::ConfigurationError).to eq(Anthropic::ConfigurationError) + expect(Ruby::Anthropic::ConfigurationError.new).to be_a(Anthropic::ConfigurationError) + expect(Anthropic::ConfigurationError.new).to be_a(Ruby::Anthropic::ConfigurationError) + end + end + + describe "::Ruby::Anthropic::Configuration" do + it "is mapped to ::Anthropic::Configuration" do + expect(Ruby::Anthropic::Configuration).to eq(Anthropic::Configuration) + expect(Ruby::Anthropic::Configuration.new).to be_a(Anthropic::Configuration) + expect(Anthropic::Configuration.new).to be_a(Ruby::Anthropic::Configuration) + end + end + end +end diff --git a/spec/fixtures/cassettes/claude-2_complete_how_high_is_the_sky_.yml b/spec/fixtures/cassettes/claude-2_complete_how_high_is_the_sky_.yml new file mode 100644 index 0000000..6f989b0 --- /dev/null +++ b/spec/fixtures/cassettes/claude-2_complete_how_high_is_the_sky_.yml @@ -0,0 +1,48 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.anthropic.com/v1/complete + body: + encoding: UTF-8 + string: '{"model":"claude-2","max_tokens_to_sample":5,"prompt":"\n\nHuman: How + high is the sky?\n\nAssistant:"}' + headers: + Content-Type: + - application/json + X-Api-Key: + - "" + Anthropic-Version: + - '2023-06-01' + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json + Request-Id: + - b5a12f0ec9900c47ed5c9254d2794f72c14afa6c61571a11ce33f45034ae7bce + X-Cloud-Trace-Context: + - 10e11e101cb2e344ac40eb006d258e04 + Date: + - Tue, 18 Jul 2023 13:42:05 GMT + Server: + - Google Frontend + Content-Length: + - '179' + Via: + - 1.1 google + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + body: + encoding: UTF-8 + string: '{"completion":" The sky has no definitive","stop_reason":"max_tokens","model":"claude-2.0","stop":null,"log_id":"b5a12f0ec9900c47ed5c9254d2794f72c14afa6c61571a11ce33f45034ae7bce"}' + recorded_at: Tue, 18 Jul 2023 13:42:05 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..53df2ce --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,47 @@ +require "bundler/setup" +require "dotenv/load" +require "anthropic" +require "anthropic/compatibility" +require "vcr" + +Dir[File.expand_path("spec/support/**/*.rb")].sort.each { |f| require f } + +VCR.configure do |c| + c.hook_into :webmock + c.cassette_library_dir = "spec/fixtures/cassettes" + c.default_cassette_options = { + record: ENV.fetch("ANTHROPIC_API_KEY", nil) ? :all : :new_episodes, + match_requests_on: [:method, :uri, VCRMultipartMatcher.new] + } + c.filter_sensitive_data("") { Anthropic.configuration.access_token } +end + +RSpec.configure do |c| + # Enable flags like --only-failures and --next-failure + c.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + c.disable_monkey_patching! + + c.expect_with :rspec do |rspec| + rspec.syntax = :expect + end + + if ENV.fetch("ANTHROPIC_API_KEY", nil) + warning = "WARNING! Specs are hitting the Anthropic API using your ANTHROPIC_API_KEY! This +costs at least 2 cents per run and is very slow! If you don't want this, unset +ANTHROPIC_API_KEY to just run against the stored VCR responses.".freeze + warning = RSpec::Core::Formatters::ConsoleCodes.wrap(warning, :bold_red) + + c.before(:suite) { RSpec.configuration.reporter.message(warning) } + c.after(:suite) { RSpec.configuration.reporter.message(warning) } + end + + c.before(:all) do + Anthropic.configure do |config| + config.access_token = ENV.fetch("ANTHROPIC_API_KEY", "dummy-token") + end + end +end + +RSPEC_ROOT = File.dirname __FILE__ diff --git a/spec/support/vcr_multipart_matcher.rb b/spec/support/vcr_multipart_matcher.rb new file mode 100644 index 0000000..4e51da6 --- /dev/null +++ b/spec/support/vcr_multipart_matcher.rb @@ -0,0 +1,47 @@ +class VCRMultipartMatcher + MULTIPART_HEADER_MATCHER = %r{^multipart/form-data; boundary=(.+)$}.freeze + BOUNDARY_SUBSTITUTION = "----MultipartBoundaryAbcD3fGhiXyz00001".freeze + + def call(request1, request2) + return false unless same_content_type?(request1, request2) + unless headers_excluding_content_type(request1) == headers_excluding_content_type(request2) + return false + end + + normalized_multipart_body(request1) == normalized_multipart_body(request2) + end + + private + + def same_content_type?(request1, request2) + content_type1 = (request1.headers["Content-Type"] || []).first.to_s + content_type2 = (request2.headers["Content-Type"] || []).first.to_s + + if multipart_request?(content_type1) + multipart_request?(content_type2) + elsif multipart_request?(content_type2) + false + else + content_type1 == content_type2 + end + end + + def headers_excluding_content_type(request) + request.headers.reject { |key, _| key == "Content-Type" } + end + + def normalized_multipart_body(request) + content_type = (request.headers["Content-Type"] || []).first.to_s + + return request.headers unless multipart_request?(content_type) + + boundary = MULTIPART_HEADER_MATCHER.match(content_type)[1] + request.body.gsub(boundary, BOUNDARY_SUBSTITUTION) + end + + def multipart_request?(content_type) + return false if content_type.empty? + + MULTIPART_HEADER_MATCHER.match?(content_type) + end +end