Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement beta messages API bindings #15

Merged
merged 1 commit into from
Dec 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .reek.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ detectors:
TooManyStatements:
exclude:
- 'Anthropic::Client#self.post'
BooleanParameter:
exclude:
- 'Anthropic::Messages#initialize'
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

### Added

- Add support for sending headers to client.
- Add support for beta Messages API.

## [0.2.5] - 2023-12-27

### Fixed
Expand Down
72 changes: 54 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The goal of this project is feature parity with Anthropic's Python SDK until an

## Usage

anthropic-rb will default to the value of the `ANTHROPIC_API_KEY` environment variable. However, you may initialize the library with your API key:
anthropic-rb will default to the value of the `ANTHROPIC_API_KEY` environment variable. However, you may initialize the library with your API key. You must set your API key before using the library.

```ruby
require 'anthropic-rb'
Expand All @@ -16,7 +16,7 @@ Anthropic.setup do |config|
end
```

You can also specify an API version to use by setting the `ANTHROPIC_API_VERSION` environment variable or during initialization:
You can also specify an API version to use by setting the `ANTHROPIC_API_VERSION` environment variable or during initialization. This is optional; if not set, the library will default to `2023-06-01`.

```ruby
require 'anthropic-rb'
Expand All @@ -26,7 +26,43 @@ Anthropic.setup do |config|
end
```

The default API version is `2023-06-01`.
### Messages API

You can send a request to the Messages API. The Messages API is currently in beta; as such, you'll need to pass the `beta` flag when calling the API to ensure the correct header is included.

```ruby
Anthropic.messages(beta: true).create(model: 'claude-2.1', max_tokens: 200, messages: [{role: 'user', content: 'Yo what up?'}])

# Output =>
# {
# id: "msg_013ePdwEkb4RMC1hCE61Hbm8",
# type: "message",
# role: "assistant",
# content: [{type: "text", text: "Hello! Not much up with me, just chatting. How about you?"}],
# model: "claude-2.1",
# stop_reason: "end_turn",
# stop_sequence: nil
# }
```

Alternatively, you can stream the response:

```ruby
Anthropic.messages(beta: true).create(model: 'claude-2.1', max_tokens: 200, messages: [{role: 'user', content: 'Yo what up?'}], stream: true) do |event|
puts event
end

# Output =>
# { type: 'message_start', message: { id: 'msg_012pkeozZynwyNvSagwL7kMw', type: 'message', role: 'assistant', content: [], model: 'claude-2.1', stop_reason: nil, stop_sequence: nil } }
# { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }
# { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }
# { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '.' } }
# { type: 'content_block_stop', index: 0 }
# { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: nil } }
# { type: 'message_stop' }
```

### Completions API

To make a request to the Completions API:

Expand Down Expand Up @@ -55,21 +91,21 @@ Anthropic.completions.create(model: 'claude-2', max_tokens_to_sample: 200, promp
end

# Output =>
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" Hello", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>"!", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" Not", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" much", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>",", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" just", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" chatting", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" with", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" people", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>".", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" How", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" about", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>" you", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>"?", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# {:type=>"completion", :id=>"compl_01G6cEfdZtLEEJVRzwUShiDY", :completion=>"", :stop_reason=>"stop_sequence", :model=>"claude-2.1", :stop=>"\n\nHuman:", :log_id=>"compl_01G6cEfdZtLEEJVRzwUShiDY"}
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' Hello', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: '!', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' Not', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' much', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ',', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' just', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' chatting', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' with', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' people', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: '.', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' How', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' about', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: ' you', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: '?', stop_reason: nil, model: 'claude-2.1', stop: nil, log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
# { type: 'completion', id: 'compl_01G6cEfdZtLEEJVRzwUShiDY', completion: '', stop_reason: 'stop_sequence', model: 'claude-2.1', stop: "\n\nHuman:", log_id: 'compl_01G6cEfdZtLEEJVRzwUShiDY' }
```

## Installation
Expand Down
5 changes: 5 additions & 0 deletions lib/anthropic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

require_relative 'anthropic/client'
require_relative 'anthropic/completions'
require_relative 'anthropic/messages'
require_relative 'anthropic/version'

##
Expand Down Expand Up @@ -43,4 +44,8 @@ def self.api_version=(api_version = nil)
def self.completions
Completions.new
end

def self.messages(...)
Messages.new(...)
end
end
8 changes: 4 additions & 4 deletions lib/anthropic/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ class InternalServerError < StandardError; end
# Provides a client for sending HTTP requests.
class Client
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
def self.post(url, data)
def self.post(url, data, headers = {})
response = HTTPX.with(
headers: {
'Content-Type' => 'application/json',
'x-api-key' => Anthropic.api_key,
'anthropic-version' => Anthropic.api_version
}
}.merge(headers)
).post(url, json: data)

response_body = JSON.parse(response.body, symbolize_names: true)
Expand All @@ -65,13 +65,13 @@ def self.post(url, data)
end
end

def self.post_as_stream(url, data)
def self.post_as_stream(url, data, headers = {})
response = HTTPX.plugin(:stream).with(
headers: {
'Content-Type' => 'application/json',
'x-api-key' => Anthropic.api_key,
'anthropic-version' => Anthropic.api_version
}
}.merge(headers)
).post(url, json: data, stream: true)

response.each_line do |line|
Expand Down
62 changes: 62 additions & 0 deletions lib/anthropic/messages.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

module Anthropic
##
# Provides bindings for the Anthropic messages API
class Messages
# Error for when the API version is not supported.
class UnsupportedApiVersionError < StandardError; end

ENDPOINT = 'https://api.anthropic.com/v1/messages'
V1_SCHEMA = {
type: 'object',
required: %w[model messages max_tokens],
properties: {
model: { type: 'string' },
messages: { type: 'array' },
max_tokens: { type: 'integer' },
system: { type: 'string' },
stop_sequences: { type: 'array', items: { type: 'string' } },
temperature: { type: 'number' },
top_k: { type: 'integer' },
top_p: { type: 'number' },
metadata: { type: 'object' },
stream: { type: 'boolean' }
},
additionalProperties: false
}.freeze

def initialize(beta: false)
@beta = beta
end

def create(**params, &)
JSON::Validator.validate!(schema_for_api_version, params)
return Anthropic::Client.post(ENDPOINT, params, additional_headers) unless params[:stream]

Anthropic::Client.post_as_stream(ENDPOINT, params, additional_headers, &)
rescue JSON::Schema::ValidationError => error
raise ArgumentError, error.message
end

private

attr_reader :beta

def schema_for_api_version
api_version = Anthropic.api_version
case api_version
when '2023-06-01'
V1_SCHEMA
else
raise UnsupportedApiVersionError, "Unsupported API version: #{api_version}"
end
end

def additional_headers
return {} unless beta

{ 'anthropic-beta' => 'messages-2023-12-15' }
end
end
end
124 changes: 124 additions & 0 deletions spec/lib/anthropic/messages_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# frozen_string_literal: true

RSpec.describe Anthropic::Messages do
subject(:messages_api) { described_class.new }

describe '#create' do
subject(:call_method) { messages_api.create(**params) }

context 'with invalid params' do
let(:params) { { model: 'foo' } }

it 'raises an error' do
expect { call_method }.to raise_error(ArgumentError)
end
end

context 'with beta option flagged' do
subject(:call_method) { described_class.new(beta: true).create(**params) }

let(:params) do
{
model: 'claude-2.1',
messages: [{ role: 'user', content: 'foo' }],
max_tokens: 200
}
end

it 'sends the beta header' do
stub_http_request(:post, 'https://api.anthropic.com/v1/messages').to_return(status: 200, body: '{}')
call_method
expect(WebMock)
.to have_requested(:post, 'https://api.anthropic.com/v1/messages')
.with(headers: { 'anthropic-beta' => 'messages-2023-12-15' })
end
end

context 'with valid params' do
# rubocop:disable RSpec/NestedGroups
context 'when stream option is set to true' do
subject(:call_method) { messages_api.create(**params) { |event| events << event } }

let(:params) do
{
model: 'claude-2.1',
messages: [{ role: 'user', content: 'foo' }],
max_tokens: 200,
stream: true
}
end
let(:body) do
[
'data: {"type":"message_start", "message":{"id":"msg_012pkeozZynwyNvSagwL7kMw", "type":"message", "role":"assistant", "content":[], "model":"claude-2.1", "stop_reason":null, "stop_sequence":null}}', # rubocop:disable Layout/LineLength
'data: {"type":"content_block_start", "index":0, "content_block":{"type":"text", "text":""}}',
'data: {"type":"content_block_delta", "index":0, "delta":{"type":"text_delta", "text":"Hello"}}',
'data: {"type":"content_block_delta", "index":0, "delta":{"type":"text_delta", "text":"."}}',
'data: {"type":"content_block_stop", "index":0}',
'data: {"type":"message_delta", "delta":{"stop_reason":"end_turn", "stop_sequence":null}}',
'data: {"type":"message_stop"}'
].join("\r\n")
end
let(:events) { [] }
let(:expected_events) do
[
{ type: 'message_start',
message: { id: 'msg_012pkeozZynwyNvSagwL7kMw', type: 'message', role: 'assistant', content: [],
model: 'claude-2.1', stop_reason: nil, stop_sequence: nil } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '.' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: nil } },
{ type: 'message_stop' }
]
end

it 'raises an error if the API version is not supported' do
allow(Anthropic).to receive(:api_version).and_return('2023-06-02')
expect { call_method }.to raise_error(Anthropic::Messages::UnsupportedApiVersionError)
end

it 'receives streamed events' do
stub_http_request(:post, 'https://api.anthropic.com/v1/messages').to_return(status: 200, body:)
call_method
expect(events).to eq(expected_events)
end
end

context 'when stream option is set to false or not provided' do
let(:params) do
{
model: 'claude-2.1',
messages: [{ role: 'user', content: 'foo' }],
max_tokens: 200
}
end
let(:response_body) do
{
id: 'msg_013ePdwEkb4RMC1hCE61Hbm8',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Hello! Not much up with me, just chatting. How about you?' }],
model: 'claude-2.1',
stop_reason: 'end_turn',
stop_sequence: nil
}
end

it 'raises an error if the API version is not supported' do
allow(Anthropic).to receive(:api_version).and_return('2023-06-02')
expect { call_method }.to raise_error(Anthropic::Messages::UnsupportedApiVersionError)
end

it 'returns the response from the API' do
stub_http_request(:post, 'https://api.anthropic.com/v1/messages').and_return(
status: 200,
body: JSON.generate(response_body)
)
expect(call_method).to eq(response_body)
end
end
# rubocop:enable RSpec/NestedGroups
end
end
end
Loading