From 0639b0ef3789b366765a1ad39bd211ec17d75619 Mon Sep 17 00:00:00 2001 From: Dick Davis Date: Tue, 25 Jun 2024 17:05:04 -0500 Subject: [PATCH] Use data objects to encapsulate responses (#26) * Refactor client responses to use data objects * Update README --- README.md | 109 ++++--- lib/anthropic/client.rb | 46 ++- spec/lib/anthropic/api/completions_spec.rb | 27 +- spec/lib/anthropic/api/messages_spec.rb | 35 ++- spec/lib/anthropic/client_spec.rb | 345 +++++++++++++++------ 5 files changed, 379 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index 708ae04..7b27bc2 100644 --- a/README.md +++ b/README.md @@ -36,32 +36,58 @@ You can send a request to the Messages API. Anthropic.messages.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 -# } +#"msg_01UqHiw6oFLjMYiLV8hkXsrR", :type=>"message", :role=>"assistant", :model=>"claude-2.1", :content=>[{:type=>"text", :text=>"Hello! Not much up with me, just chatting with you. How's it going?"}], :stop_reason=>"end_turn", :stop_sequence=>nil, :usage=>{:input_tokens=>13, :output_tokens=>22}}> ``` Alternatively, you can stream the response: ```ruby -Anthropic.messages.create(model: 'claude-2.1', max_tokens: 200, messages: [{role: 'user', content: 'Yo what up?'}], stream: true) do |event| +options = { + model: 'claude-2.1', + max_tokens: 200, + messages: [{role: 'user', content: 'Yo what up?'}], + stream: true +} + +Anthropic.messages.create(**options) 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' } +#"message_start", :message=>{:id=>"msg_01EsYcQkBJrHrtgpY5ZcLzvf", :type=>"message", :role=>"assistant", :model=>"claude-2.1", :content=>[], :stop_reason=>nil, :stop_sequence=>nil, :usage=>{:input_tokens=>13, :output_tokens=>1}}}> +#"content_block_start", :index=>0, :content_block=>{:type=>"text", :text=>""}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"Hello"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"!"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" Not"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" much"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" up"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" with"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" me"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>","}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" I"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"'m"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" an"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" AI"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" assistant"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" create"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"d by"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>" An"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"throp"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"ic"}}> +#"content_block_delta", :index=>0, :delta=>{:type=>"text_delta", :text=>"."}}> +#"content_block_stop", :index=>0}> +#"message_delta", :delta=>{:stop_reason=>"end_turn", :stop_sequence=>nil}, :usage=>{:output_tokens=>23}}> +#"message_stop"}> + +# Or, if you just want to print the text content: +Anthropic.messages.create(**options) do |event| + next unless event.body[:type] == 'content_block_delta' + + print event.body[:delta][:text] +end + +# Output => +# Hello! Not much up with me, I'm an AI assistant created by Anthropic. ``` You can also experiment with the new tools beta by passing the `beta` name when calling the API. This will ensure each request includes the correct beta header and validate properly. @@ -103,38 +129,41 @@ Anthropic.completions.create( ) # Output => -# { -# completion: "Hello! Not much going on with me, just chatting. How about you?", -# stop_reason: "stop_sequence", -# model: "claude-2.1", -# stop: "\n\nHuman:", -# log_id: "2496914137c520ec2b4ae8315864bcf3a4c6ce9f2e3c96e13be4c004587313ca" -# } +#"completion", :id=>"compl_01Y9ptPR7xGHaH9rC3ffJExU", :completion=>" Hello! Not much going on here. How about you?", :stop_reason=>"stop_sequence", :model=>"claude-2.1", :stop=>"\n\nHuman:", :log_id=>"compl_01Y9ptPR7xGHaH9rC3ffJExU"}> ``` Alternatively, you can stream the response: ```ruby -Anthropic.completions.create(model: 'claude-2', max_tokens_to_sample: 200, prompt: 'Human: Yo what up?\n\nAssistant:', stream: true) do |event| +options = { + model: 'claude-2', + max_tokens_to_sample: 200, + prompt: 'Human: Yo what up?\n\nAssistant:', + stream: true +} + +Anthropic.completions.create(**options) do |event| puts event 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' } +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>" Hello", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>"!", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>" Not", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>" much", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>" going", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>" on", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>" here", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>".", :stop_reason=>nil, :model=>"claude-2.1", :stop=>nil, :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> +#"completion", :id=>"compl_015AktggW7tcM4w11YpkuMbP", :completion=>"", :stop_reason=>"stop_sequence", :model=>"claude-2.1", :stop=>"\n\nHuman:", :log_id=>"compl_015AktggW7tcM4w11YpkuMbP"}> + +# Or, if you just want to print the text content: +Anthropic.completions.create(**options) do |event| + print event.body[:completion] +end + +# Output => +# Hello! Not much, just chatting with you. How's it going? ``` ## Installation diff --git a/lib/anthropic/client.rb b/lib/anthropic/client.rb index 81205e1..063801a 100644 --- a/lib/anthropic/client.rb +++ b/lib/anthropic/client.rb @@ -6,6 +6,8 @@ module Anthropic ## # Provides a client for sending HTTP requests. class Client + Response = Data.define(:status, :body) + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity def self.post(url, data, headers = {}) response = HTTPX.with( @@ -16,29 +18,29 @@ def self.post(url, data, headers = {}) }.merge(headers) ).post(url, json: data) - response_body = JSON.parse(response.body, symbolize_names: true) + response_data = build_response(response.body) case response.status when 200 - response_body + response_data when 400 - raise Anthropic::Errors::InvalidRequestError, response_body + raise Anthropic::Errors::InvalidRequestError, response_data when 401 - raise Anthropic::Errors::AuthenticationError, response_body + raise Anthropic::Errors::AuthenticationError, response_data when 403 - raise Anthropic::Errors::PermissionError, response_body + raise Anthropic::Errors::PermissionError, response_data when 404 - raise Anthropic::Errors::NotFoundError, response_body + raise Anthropic::Errors::NotFoundError, response_data when 409 - raise Anthropic::Errors::ConflictError, response_body + raise Anthropic::Errors::ConflictError, response_data when 422 - raise Anthropic::Errors::UnprocessableEntityError, response_body + raise Anthropic::Errors::UnprocessableEntityError, response_data when 429 - raise Anthropic::Errors::RateLimitError, response_body + raise Anthropic::Errors::RateLimitError, response_data when 500 - raise Anthropic::Errors::ApiError, response_body + raise Anthropic::Errors::ApiError, response_data when 529 - raise Anthropic::Errors::OverloadedError, response_body + raise Anthropic::Errors::OverloadedError, response_data end end @@ -53,13 +55,13 @@ def self.post_as_stream(url, data, headers = {}) ).post(url, json: data, stream: true) response.each_line do |line| - event, data = line.split(/(\w+\b:\s)/)[1..2] - next unless event && data + type, event = line.split(/(\w+\b:\s)/)[1..2] + + next unless type&.start_with?('data') && event - if event.start_with?('data') - formatted_data = JSON.parse(data, symbolize_names: true) - yield formatted_data unless %w[ping error].include?(formatted_data[:type]) - end + response_data = build_response(event) + + yield response_data unless %w[ping error].include?(response_data.body[:type]) end rescue HTTPX::HTTPError => error case error.response.status @@ -84,5 +86,15 @@ def self.post_as_stream(url, data, headers = {}) end end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + class << self + private + + def build_response(response) + response_hash = JSON.parse(response, symbolize_names: true) + status = response_hash[:type] == 'error' ? 'failure' : 'success' + Anthropic::Client::Response.new(status:, body: response_hash) + end + end end end diff --git a/spec/lib/anthropic/api/completions_spec.rb b/spec/lib/anthropic/api/completions_spec.rb index 2c950fb..b10780b 100644 --- a/spec/lib/anthropic/api/completions_spec.rb +++ b/spec/lib/anthropic/api/completions_spec.rb @@ -36,15 +36,16 @@ let(:body) { 'data: {"bar":"foo"}' } let(:events) { [] } - 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::Errors::UnsupportedApiVersionError) + before do + stub_http_request(:post, 'https://api.anthropic.com/v1/complete').to_return(status: 200, body:) end it 'receives streamed events' do - stub_http_request(:post, 'https://api.anthropic.com/v1/complete').to_return(status: 200, body:) call_method - expect(events).to eq([{ bar: 'foo' }]) + aggregate_failures do + expect(events.map(&:status).uniq).to eq(%w[success]) + expect(events.map(&:body)).to eq([{ bar: 'foo' }]) + end end end @@ -66,17 +67,19 @@ } 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::Errors::UnsupportedApiVersionError) - end - - it 'returns the response from the API' do + before do stub_http_request(:post, 'https://api.anthropic.com/v1/complete').and_return( status: 200, body: JSON.generate(response_body) ) - expect(call_method).to eq(response_body) + end + + it 'returns the response from the API' do + response = call_method + aggregate_failures do + expect(response.status).to eq('success') + expect(response.body).to eq(response_body) + end end end # rubocop:enable RSpec/NestedGroups diff --git a/spec/lib/anthropic/api/messages_spec.rb b/spec/lib/anthropic/api/messages_spec.rb index 52f2073..0f05463 100644 --- a/spec/lib/anthropic/api/messages_spec.rb +++ b/spec/lib/anthropic/api/messages_spec.rb @@ -92,6 +92,7 @@ 'data: {"type":"message_stop"}' ].join("\r\n") end + let(:events) { [] } let(:expected_events) do [ @@ -107,15 +108,16 @@ ] 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::Errors::UnsupportedApiVersionError) + before do + stub_http_request(:post, 'https://api.anthropic.com/v1/messages').to_return(status: 200, body:) 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) + aggregate_failures do + expect(events.map(&:status).uniq).to eq(['success']) + expect(events.map(&:body)).to eq(expected_events) + end end end @@ -139,17 +141,26 @@ } 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::Errors::UnsupportedApiVersionError) - end - - it 'returns the response from the API' do + before 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 + + it 'returns the response from the API' do + response = call_method + aggregate_failures do + expect(response.status).to eq('success') + expect(response.body).to eq(response_body) + end + end + + context 'with invalid API version configured' do + it 'raises an error' do + allow(Anthropic).to receive(:api_version).and_return('2023-06-02') + expect { call_method }.to raise_error(Anthropic::Errors::UnsupportedApiVersionError) + end end end # rubocop:enable RSpec/NestedGroups diff --git a/spec/lib/anthropic/client_spec.rb b/spec/lib/anthropic/client_spec.rb index cf9947f..09873a8 100644 --- a/spec/lib/anthropic/client_spec.rb +++ b/spec/lib/anthropic/client_spec.rb @@ -1,190 +1,331 @@ # frozen_string_literal: true RSpec.describe Anthropic::Client do - describe '.post' do - subject(:send_request) { described_class.post(url, data) } - - let(:url) { 'https://foo.bar/baz' } - let(:data) { { foo: 'bar' } } - let(:body) { { bar: 'foo' } } - - context 'with successful response' do - it 'returns the response body' do - stub_http_request(:post, url).and_return(status: 200, body: JSON.generate(body)) - expect(send_request).to eq(body) + shared_examples 'handles errors from the API' do + context 'with 400 response' do + let(:event) do + { + type: 'error', + error: { + type: 'invalid_request_error', + message: 'There was an issue with the format or content of your request.' + } + } end - end - context 'with 400 response' do - it 'raises an Anthropic::InvalidRequestError' do - stub_http_request(:post, url).and_return(status: 400, body: JSON.generate(body)) + it 'raises an Anthropic::Errors::InvalidRequestError' do + stub_http_request(:post, url).and_return(status: 400, body:) expect { send_request }.to raise_error(Anthropic::Errors::InvalidRequestError) end end context 'with 401 response' do - it 'raises an Anthropic::AuthenticationError' do - stub_http_request(:post, url).and_return(status: 401, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'authentication_error', + message: 'There’s an issue with your API key.' + } + } + end + + it 'raises an Anthropic::Errors::AuthenticationError' do + stub_http_request(:post, url).and_return(status: 401, body:) expect { send_request }.to raise_error(Anthropic::Errors::AuthenticationError) end end context 'with 403 response' do - it 'raises an Anthropic::PermissionError' do - stub_http_request(:post, url).and_return(status: 403, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'permission_error', + message: 'Your API key does not have permission to use the specified resource.' + } + } + end + + it 'raises an Anthropic::Errors::PermissionError' do + stub_http_request(:post, url).and_return(status: 403, body:) expect { send_request }.to raise_error(Anthropic::Errors::PermissionError) end end context 'with 404 response' do - it 'raises an Anthropic::NotFoundError' do - stub_http_request(:post, url).and_return(status: 404, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'not_found_error', + message: 'The requested resource was not found.' + } + } + end + + it 'raises an Anthropic::Errors::NotFoundError' do + stub_http_request(:post, url).and_return(status: 404, body:) expect { send_request }.to raise_error(Anthropic::Errors::NotFoundError) end end context 'with 409 response' do - it 'raises an Anthropic::ConflictError' do - stub_http_request(:post, url).and_return(status: 409, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'invalid_request_error', + message: 'There was an issue with the format or content of your request.' + } + } + end + + it 'raises an Anthropic::Errors::ConflictError' do + stub_http_request(:post, url).and_return(status: 409, body:) expect { send_request }.to raise_error(Anthropic::Errors::ConflictError) end end context 'with 422 response' do - it 'raises an Anthropic::UnprocessableEntityError' do - stub_http_request(:post, url).and_return(status: 422, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'invalid_request_error', + message: 'There was an issue with the format or content of your request.' + } + } + end + + it 'raises an Anthropic::Errors::UnprocessableEntityError' do + stub_http_request(:post, url).and_return(status: 422, body:) expect { send_request }.to raise_error(Anthropic::Errors::UnprocessableEntityError) end end context 'with 429 response' do - it 'raises an Anthropic::RateLimitError' do - stub_http_request(:post, url).and_return(status: 429, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'rate_limit_error', + message: 'Your account has hit a rate limit.' + } + } + end + + it 'raises an Anthropic::Errors::RateLimitError' do + stub_http_request(:post, url).and_return(status: 429, body:) expect { send_request }.to raise_error(Anthropic::Errors::RateLimitError) end end context 'with 500 response' do - it 'raises an Anthropic::ApiError' do - stub_http_request(:post, url).and_return(status: 500, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'api_error', + message: 'An unexpected error has occurred internal to Anthropic’s systems.' + } + } + end + + it 'raises an Anthropic::Errors::ApiError' do + stub_http_request(:post, url).and_return(status: 500, body:) expect { send_request }.to raise_error(Anthropic::Errors::ApiError) end end context 'with 529 response' do - it 'raises an Anthropic::OverloadedError' do - stub_http_request(:post, url).and_return(status: 529, body: JSON.generate(body)) + let(:event) do + { + type: 'error', + error: { + type: 'overloaded_error', + message: 'Anthropic’s API is temporarily overloaded.' + } + } + end + + it 'raises an Anthropic::Errors::OverloadedError' do + stub_http_request(:post, url).and_return(status: 529, body:) expect { send_request }.to raise_error(Anthropic::Errors::OverloadedError) end end end - describe '.post_as_stream' do - subject(:send_request) { described_class.post_as_stream(url, data) { |event| events << event } } + describe '.post' do + subject(:send_request) { described_class.post(url, data) } let(:url) { 'https://foo.bar/baz' } let(:data) { { foo: 'bar' } } - let(:events) { [] } - let(:body) do - 'data: {"bar":"foo"}' - end + let(:body) { JSON.generate(event) } + + include_examples 'handles errors from the API' context 'with successful response' do - # rubocop:disable RSpec/NestedGroups - context 'when event type ping' do - let(:body) do - 'data: {"bar":"foo","type":"ping"}' - end + let(:event) do + { + content: 'foo', + id: 'foo', + model: 'bar', + role: 'assistant', + stop_reason: 'baz', + stop_sequence: 'foobar', + type: 'message', + usage: 'foobarba' + } + end - it 'does not yield the event to the block' do - stub_http_request(:post, url).to_return(status: 200, body:) - send_request - expect(events).to be_empty - end + it 'returns the response body' do + stub_http_request(:post, url).and_return(status: 200, body:) + expect(send_request).to eq(Anthropic::Client::Response.new('success', event)) end + end + end - context 'when event type error' do - let(:body) do - 'data: {"bar":"foo","type":"error"}' - end + describe '.post_as_stream' do + subject(:send_request) { described_class.post_as_stream(url, body) { |event| events << event } } - it 'does not yield the event to the block' do - stub_http_request(:post, url).to_return(status: 200, body:) - send_request - expect(events).to be_empty - end - end + let(:url) { 'https://foo.bar/baz' } + let(:body) { "data: #{event.to_json}" } + let(:events) { [] } + + include_examples 'handles errors from the API' - context 'when all other event types' do - it 'yields the response to the block' do - stub_http_request(:post, url).to_return(status: 200, body:) - send_request - expect(events).to eq([{ bar: 'foo' }]) - end + context 'when event type is ping' do + let(:event) { { type: 'ping' } } + + it 'does not yield the event to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to be_empty end - # rubocop:enable RSpec/NestedGroups end - context 'with 400 response' do - it 'raises an Anthropic::InvalidRequestError' do - stub_http_request(:post, url).and_return(status: 400, body:) - expect { send_request }.to raise_error(Anthropic::Errors::InvalidRequestError) + context 'when event type is error' do + let(:event) do + { + type: 'error', + error: { + type: 'invalid_request_error', + message: 'There was an issue with the format or content of your request.' + } + } end - end - context 'with 401 response' do - it 'raises an Anthropic::AuthenticationError' do - stub_http_request(:post, url).and_return(status: 401, body:) - expect { send_request }.to raise_error(Anthropic::Errors::AuthenticationError) + it 'does not yield the event to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to be_empty end end - context 'with 403 response' do - it 'raises an Anthropic::PermissionError' do - stub_http_request(:post, url).and_return(status: 403, body:) - expect { send_request }.to raise_error(Anthropic::Errors::PermissionError) + context 'when event type is message_start' do + let(:event) do + { + type: 'message_start', + message: { + id: 'msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-3-opus-20240229', + stop_reason: nil, + stop_sequence: nil + }, + usage: { + input_tokens: 25, + output_tokens: 1 + } + } end - end - context 'with 404 response' do - it 'raises an Anthropic::NotFoundError' do - stub_http_request(:post, url).and_return(status: 404, body:) - expect { send_request }.to raise_error(Anthropic::Errors::NotFoundError) + it 'yields the response to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to eq([Anthropic::Client::Response.new('success', event)]) end end - context 'with 409 response' do - it 'raises an Anthropic::ConflictError' do - stub_http_request(:post, url).and_return(status: 409, body:) - expect { send_request }.to raise_error(Anthropic::Errors::ConflictError) + context 'when event type is content_block_start' do + let(:event) do + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text: '' + } + } + end + + it 'yields the response to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to eq([Anthropic::Client::Response.new('success', event)]) end end - context 'with 422 response' do - it 'raises an Anthropic::UnprocessableEntityError' do - stub_http_request(:post, url).and_return(status: 422, body:) - expect { send_request }.to raise_error(Anthropic::Errors::UnprocessableEntityError) + context 'when event type is content_block_delta' do + let(:event) do + { + type: 'content_block_delta', + index: 0, + delta: { + type: 'text_delta', + text: 'Hello' + } + } + end + + it 'yields the response to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to eq([Anthropic::Client::Response.new('success', event)]) end end - context 'with 429 response' do - it 'raises an Anthropic::RateLimitError' do - stub_http_request(:post, url).and_return(status: 429, body:) - expect { send_request }.to raise_error(Anthropic::Errors::RateLimitError) + context 'when event type is content_block_stop' do + let(:event) { { type: 'content_block_stop', index: 0 } } + + it 'yields the response to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to eq([Anthropic::Client::Response.new('success', event)]) end end - context 'with 500 response' do - it 'raises an Anthropic::ApiError' do - stub_http_request(:post, url).and_return(status: 500, body:) - expect { send_request }.to raise_error(Anthropic::Errors::ApiError) + context 'when event type is message_delta' do + let(:event) do + { + type: 'message_delta', + delta: { + stop_reason: 'end_turn', + stop_sequence: nil + }, + usage: { + output_tokens: 15 + } + } + end + + it 'yields the response to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to eq([Anthropic::Client::Response.new('success', event)]) end end - context 'with 529 response' do - it 'raises an Anthropic::OverloadedError' do - stub_http_request(:post, url).and_return(status: 529, body:) - expect { send_request }.to raise_error(Anthropic::Errors::OverloadedError) + context 'when event type is message_stop' do + let(:event) { { type: 'message_stop' } } + + it 'yields the response to the block' do + stub_http_request(:post, url).to_return(status: 200, body:) + send_request + expect(events).to eq([Anthropic::Client::Response.new('success', event)]) end end end