diff --git a/README.md b/README.md index 14fb3975..ab370a31 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,9 @@ client.models.retrieve(id: "text-ada-001") - text-babbage-001 - text-curie-001 -### ChatGPT +### Chat -ChatGPT is a model that can be used to generate text in a conversational style. 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): +GPT is a model that can be used to generate text in a conversational style. 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): ```ruby response = client.chat( @@ -176,11 +176,11 @@ puts response.dig("choices", 0, "message", "content") # => "Hello! How may I assist you today?" ``` -### Streaming ChatGPT +### Streaming Chat -[Quick guide to streaming ChatGPT with Rails 7 and Hotwire](https://gist.github.com/alexrudall/cb5ee1e109353ef358adb4e66631799d) +[Quick guide to streaming Chat with Rails 7 and Hotwire](https://gist.github.com/alexrudall/cb5ee1e109353ef358adb4e66631799d) -You can stream from the API in realtime, which can be much faster and used to create a more engaging user experience. Pass a [Proc](https://ruby-doc.org/core-2.6/Proc.html) (or any object with a `#call` method) to the `stream` parameter to receive the stream of text chunks as they are generated. Each time one or more chunks is received, the proc will be called once with each chunk, parsed as a Hash. If OpenAI returns an error, `ruby-openai` will pass that to your proc as a Hash. +You can stream from the API in realtime, which can be much faster and used to create a more engaging user experience. Pass a [Proc](https://ruby-doc.org/core-2.6/Proc.html) (or any object with a `#call` method) to the `stream` parameter to receive the stream of completion chunks as they are generated. Each time one or more chunks is received, the proc will be called once with each chunk, parsed as a Hash. If OpenAI returns an error, `ruby-openai` will raise a Faraday error. ```ruby client.chat( @@ -195,7 +195,7 @@ client.chat( # => "Anna is a young woman in her mid-twenties, with wavy chestnut hair that falls to her shoulders..." ``` -Note: the API docs state that token usage is included in the streamed chat chunk objects, but this doesn't currently appear to be the case. To count tokens while streaming, try `OpenAI.rough_token_count` or [tiktoken_ruby](https://github.com/IAPark/tiktoken_ruby). +Note: OpenAPI currently does not report token usage for streaming responses. To count tokens while streaming, try `OpenAI.rough_token_count` or [tiktoken_ruby](https://github.com/IAPark/tiktoken_ruby). We think that each call to the stream proc corresponds to a single token, so you can also try counting the number of calls to the proc to get the completion token count. ### Functions @@ -455,6 +455,18 @@ puts response["text"] # => "Transcription of the text" ``` +#### Errors + +HTTP errors can be caught like this: + +``` + begin + OpenAI::Client.new.models.retrieve(id: "text-ada-001") + rescue Faraday::Error => e + raise "Got a Faraday error: #{e}" + end +``` + ## 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/lib/openai/http.rb b/lib/openai/http.rb index b00c0207..bc06de9d 100644 --- a/lib/openai/http.rb +++ b/lib/openai/http.rb @@ -50,8 +50,6 @@ def to_json(string) # For each chunk, the inner user_proc is called giving it the JSON object. The JSON object could # be a data object or an error object as described in the OpenAI API documentation. # - # If the JSON object for a given data or error message is invalid, it is ignored. - # # @param user_proc [Proc] The inner proc to call for each JSON object in the chunk. # @return [Proc] An outer proc that iterates over a raw stream, converting it to JSON. def to_json_stream(user_proc:) @@ -59,25 +57,21 @@ def to_json_stream(user_proc:) proc do |chunk, _bytes, env| if env && env.status != 200 - emit_json(json: chunk, user_proc: user_proc) - else - parser.feed(chunk) do |_type, data| - emit_json(json: data, user_proc: user_proc) unless data == "[DONE]" - end + raise_error = Faraday::Response::RaiseError.new + raise_error.on_complete(env.merge(body: JSON.parse(chunk))) end - end - end - def emit_json(json:, user_proc:) - user_proc.call(JSON.parse(json)) - rescue JSON::ParserError - # Ignore invalid JSON. + parser.feed(chunk) do |_type, data| + user_proc.call(JSON.parse(data)) unless data == "[DONE]" + end + end end def conn(multipart: false) Faraday.new do |f| f.options[:timeout] = @request_timeout f.request(:multipart) if multipart + f.response :raise_error end end diff --git a/spec/fixtures/cassettes/finetune_completions_i_love_mondays.yml b/spec/fixtures/cassettes/finetune_completions_i_love_mondays.yml deleted file mode 100644 index fb0b2b0a..00000000 --- a/spec/fixtures/cassettes/finetune_completions_i_love_mondays.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https://api.openai.com/v1/completions - body: - encoding: UTF-8 - string: '{"model":"ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52","prompt":"I - love Mondays"}' - headers: - Content-Type: - - application/json - Authorization: - - Bearer - 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: - Date: - - Wed, 26 Apr 2023 10:09:28 GMT - Content-Type: - - application/json - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Access-Control-Allow-Origin: - - "*" - Cache-Control: - - no-cache, must-revalidate - Openai-Model: - - ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52 - Openai-Organization: - - user-jxm65ijkzc1qrfhc0ij8moic - Openai-Processing-Ms: - - '505' - Openai-Version: - - '2020-10-01' - Strict-Transport-Security: - - max-age=15724800; includeSubDomains - X-Ratelimit-Limit-Requests: - - '3000' - X-Ratelimit-Limit-Tokens: - - '250000' - X-Ratelimit-Remaining-Requests: - - '2999' - X-Ratelimit-Remaining-Tokens: - - '249983' - X-Ratelimit-Reset-Requests: - - 20ms - X-Ratelimit-Reset-Tokens: - - 3ms - X-Request-Id: - - b051193af1c4fbf86b789b6103134d70 - Cf-Cache-Status: - - DYNAMIC - Server: - - cloudflare - Cf-Ray: - - 7bde07c2ae3d4ad1-CGK - Alt-Svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: '{"id":"cmpl-79WNTFdjpCCVZljOg9XhP1TacLq7t","object":"text_completion","created":1682503767,"model":"ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52","choices":[{"text":" - without kids and I love one out of every four days of being held in a","index":0,"logprobs":null,"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":16,"total_tokens":19}} - - ' - recorded_at: Wed, 26 Apr 2023 10:09:28 GMT -- request: - method: post - uri: https://api.openai.com/v1/completions - body: - encoding: UTF-8 - string: '{"model":"ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52","prompt":"I - love Mondays"}' - headers: - Content-Type: - - application/json - Authorization: - - Bearer - Test: - - X-Default - 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: - Date: - - Mon, 14 Aug 2023 15:02:53 GMT - Content-Type: - - application/json - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Access-Control-Allow-Origin: - - "*" - Cache-Control: - - no-cache, must-revalidate - Openai-Model: - - ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52 - Openai-Organization: - - user-jxm65ijkzc1qrfhc0ij8moic - Openai-Processing-Ms: - - '116' - Openai-Version: - - '2020-10-01' - Strict-Transport-Security: - - max-age=15724800; includeSubDomains - X-Ratelimit-Limit-Requests: - - '3000' - X-Ratelimit-Limit-Tokens: - - '250000' - X-Ratelimit-Limit-Tokens-Usage-Based: - - '250000' - X-Ratelimit-Remaining-Requests: - - '2999' - X-Ratelimit-Remaining-Tokens: - - '249983' - X-Ratelimit-Remaining-Tokens-Usage-Based: - - '249983' - X-Ratelimit-Reset-Requests: - - 20ms - X-Ratelimit-Reset-Tokens: - - 3ms - X-Ratelimit-Reset-Tokens-Usage-Based: - - 3ms - X-Request-Id: - - 55b2a15f27c0ffd867978c0b447a95e9 - Cf-Cache-Status: - - DYNAMIC - Server: - - cloudflare - Cf-Ray: - - 7f6a14d46b634136-LHR - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: | - { - "id": "cmpl-7nTNl18qWMK7AUe0FD6dMbceH4Jk7", - "object": "text_completion", - "created": 1692025373, - "model": "ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52", - "choices": [ - { - "text": ". It screams every Thursday to come. I love the Sunday blues, too.\"", - "index": 0, - "logprobs": null, - "finish_reason": "length" - } - ], - "usage": { - "prompt_tokens": 3, - "completion_tokens": 16, - "total_tokens": 19 - } - } - recorded_at: Mon, 14 Aug 2023 15:02:53 GMT -recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/finetune_completions_i_love_mondays_create.yml b/spec/fixtures/cassettes/finetune_completions_i_love_mondays_create.yml deleted file mode 100644 index aa39f278..00000000 --- a/spec/fixtures/cassettes/finetune_completions_i_love_mondays_create.yml +++ /dev/null @@ -1,125 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https://api.openai.com/v1/fine-tunes - body: - encoding: UTF-8 - string: '{"training_file":"file-M1RDS8Ezce8dgwbBReH7rZjI","model":"ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52"}' - headers: - Content-Type: - - application/json - Authorization: - - Bearer - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - response: - status: - code: 400 - message: Bad Request - headers: - Date: - - Wed, 26 Apr 2023 10:09:27 GMT - Content-Type: - - application/json - Content-Length: - - '330' - Connection: - - keep-alive - Access-Control-Allow-Origin: - - "*" - Openai-Version: - - '2020-10-01' - X-Request-Id: - - 34830a91068469e97ce329304c20005e - Openai-Processing-Ms: - - '46' - Strict-Transport-Security: - - max-age=15724800; includeSubDomains - Cf-Cache-Status: - - DYNAMIC - Server: - - cloudflare - Cf-Ray: - - 7bde07bfbf4ebeac-CGK - Alt-Svc: - - h3=":443"; ma=86400, h3-29=":443"; ma=86400 - body: - encoding: UTF-8 - string: | - { - "error": { - "message": "Invalid base model: ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52 (we don't support fine-tuning from this model. However, we will support fine-tuning from all new models fine-tuned from this point onwards.)", - "type": "invalid_request_error", - "param": null, - "code": null - } - } - recorded_at: Wed, 26 Apr 2023 10:09:27 GMT -- request: - method: post - uri: https://api.openai.com/v1/fine-tunes - body: - encoding: UTF-8 - string: '{"training_file":"file-vdxufGUKXK9TYCB9M1itajjD","model":"ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52"}' - headers: - Content-Type: - - application/json - Authorization: - - Bearer - Test: - - X-Default - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - User-Agent: - - Ruby - response: - status: - code: 400 - message: Bad Request - headers: - Date: - - Mon, 14 Aug 2023 15:02:52 GMT - Content-Type: - - application/json - Content-Length: - - '330' - Connection: - - keep-alive - Access-Control-Allow-Origin: - - "*" - Openai-Version: - - '2020-10-01' - X-Request-Id: - - 72e691fc5f995b39d3841e8788dfac8a - Openai-Processing-Ms: - - '36' - Strict-Transport-Security: - - max-age=15724800; includeSubDomains - Cf-Cache-Status: - - DYNAMIC - Server: - - cloudflare - Cf-Ray: - - 7f6a14d27b4c7773-LHR - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: UTF-8 - string: | - { - "error": { - "message": "Invalid base model: ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52 (we don't support fine-tuning from this model. However, we will support fine-tuning from all new models fine-tuned from this point onwards.)", - "type": "invalid_request_error", - "param": null, - "code": null - } - } - recorded_at: Mon, 14 Aug 2023 15:02:52 GMT -recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/gpt-3_5-turbo_streamed_chat_with_error_response.yml b/spec/fixtures/cassettes/gpt-3_5-turbo_streamed_chat_with_error_response.yml new file mode 100644 index 00000000..2bb10218 --- /dev/null +++ b/spec/fixtures/cassettes/gpt-3_5-turbo_streamed_chat_with_error_response.yml @@ -0,0 +1,42 @@ +--- +http_interactions: + - request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello!"}],"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Mon, 14 Aug 2023 15:02:13 GMT + Content-Type: + - application/json + body: + encoding: UTF-8 + string: |+ + { + "error": { + "message": "Test error", + "type": "test_error", + "param": null, + "code": "test" + } + } + recorded_at: Mon, 14 Aug 2023 15:02:13 GMT + +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/http_get_with_error_response.yml b/spec/fixtures/cassettes/http_get_with_error_response.yml new file mode 100644 index 00000000..bfc3f5a8 --- /dev/null +++ b/spec/fixtures/cassettes/http_get_with_error_response.yml @@ -0,0 +1,41 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.openai.com/v1/models/text-ada-001 + body: + encoding: US-ASCII + string: '' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Mon, 14 Aug 2023 15:02:13 GMT + Content-Type: + - application/json + body: + encoding: UTF-8 + string: |+ + { + "error": { + "message": "Test error", + "type": "test_error", + "param": null, + "code": "test" + } + } + recorded_at: Mon, 14 Aug 2023 15:02:13 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/openai/client/chat_spec.rb b/spec/openai/client/chat_spec.rb index 4dc2f701..272b28da 100644 --- a/spec/openai/client/chat_spec.rb +++ b/spec/openai/client/chat_spec.rb @@ -72,6 +72,28 @@ def call(chunk) end end end + + context "with an error response" do + let(:cassette) { "#{model} streamed chat with error response".downcase } + + it "raises an HTTP error" do + VCR.use_cassette(cassette) do + response + rescue Faraday::BadRequestError => e + expect(e.response).to include(status: 400) + expect(e.response[:body]).to eq({ + "error" => { + "message" => "Test error", + "type" => "test_error", + "param" => nil, + "code" => "test" + } + }) + else + raise "Expected to raise Faraday::BadRequestError" + end + end + end end end diff --git a/spec/openai/client/finetunes_spec.rb b/spec/openai/client/finetunes_spec.rb index 6eaccfcb..7289e75d 100644 --- a/spec/openai/client/finetunes_spec.rb +++ b/spec/openai/client/finetunes_spec.rb @@ -81,9 +81,9 @@ # It takes too long to fine-tune a model so we can delete it when running the test suite # against the live API. Instead, we just check that the API returns an error. - it "returns an error" do + it "raises an error" do VCR.use_cassette(cassette) do - expect(response.dig("error", "message")).to include("does not exist") + expect { response }.to raise_error(Faraday::ResourceNotFound) end end @@ -95,25 +95,5 @@ end end end - - describe "#completions" do - let(:prompt) { "I love Mondays" } - let(:cassette) { "finetune completions #{prompt}".downcase } - let(:model) { "ada:ft-user-jxm65ijkzc1qrfhc0ij8moic-2021-12-11-20-11-52" } - let(:response) do - OpenAI::Client.new.completions( - parameters: { - model: model, - prompt: prompt - } - ) - end - - it "succeeds" do - VCR.use_cassette(cassette) do - expect(response["model"]).to eq(model) - end - end - end end end diff --git a/spec/openai/client/http_spec.rb b/spec/openai/client/http_spec.rb index d12b027a..5149ed5f 100644 --- a/spec/openai/client/http_spec.rb +++ b/spec/openai/client/http_spec.rb @@ -96,6 +96,22 @@ end end + describe ".get" do + context "with an error response" do + let(:cassette) { "http get with error response".downcase } + + it "raises an HTTP error" do + VCR.use_cassette(cassette) do + OpenAI::Client.new.models.retrieve(id: "text-ada-001") + rescue Faraday::Error => e + expect(e.response).to include(status: 400) + else + raise "Expected to raise Faraday::BadRequestError" + end + end + end + end + describe ".to_json_stream" do context "with a proc" do let(:user_proc) { proc { |x| x } } @@ -144,51 +160,12 @@ CHUNK end - it "does not raise an error" do - expect(user_proc).to receive(:call).with(JSON.parse('{"foo": "bar"}')) - - expect do - stream.call(chunk) - end.not_to raise_error - end - end - - context "wehn called with string containing Obie's invalid JSON" do - let(:chunk) do - <<~CHUNK - data: { "foo": "bar" } - - data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":123,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":null,"fu - - # - CHUNK - end - - it "does not raise an error" do + it "raise an error" do expect(user_proc).to receive(:call).with(JSON.parse('{"foo": "bar"}')) expect do stream.call(chunk) - end.not_to raise_error - end - end - - context "when OpenAI returns an HTTP error" do - let(:chunk) { "{\"error\":{\"message\":\"A bad thing has happened!\"}}" } - let(:env) { Faraday::Env.new(status: 500) } - - it "does not raise an error and calls the user proc with the error parsed as JSON" do - expect(user_proc).to receive(:call).with( - { - "error" => { - "message" => "A bad thing has happened!" - } - } - ) - - expect do - stream.call(chunk, 0, env) - end.not_to raise_error + end.to raise_error(JSON::ParserError) end end