diff --git a/lib/openai/http.rb b/lib/openai/http.rb index 2f60eb0b..644e692d 100644 --- a/lib/openai/http.rb +++ b/lib/openai/http.rb @@ -7,47 +7,52 @@ module HTTP include HTTPHeaders def get(path:, parameters: nil) - parse_jsonl(conn.get(uri(path: path), parameters) do |req| + parse_json(conn.get(uri(path: path), parameters) do |req| req.headers = headers end&.body) end def post(path:) - parse_jsonl(conn.post(uri(path: path)) do |req| + parse_json(conn.post(uri(path: path)) do |req| req.headers = headers end&.body) end def json_post(path:, parameters:, query_parameters: {}) - conn.post(uri(path: path)) do |req| + parse_json(conn.post(uri(path: path)) do |req| configure_json_post_request(req, parameters) req.params = req.params.merge(query_parameters) - end&.body + end&.body) end def multipart_post(path:, parameters: nil) - conn(multipart: true).post(uri(path: path)) do |req| + parse_json(conn(multipart: true).post(uri(path: path)) do |req| req.headers = headers.merge({ "Content-Type" => "multipart/form-data" }) req.body = multipart_parameters(parameters) - end&.body + end&.body) end def delete(path:) - conn.delete(uri(path: path)) do |req| + parse_json(conn.delete(uri(path: path)) do |req| req.headers = headers - end&.body + end&.body) end private - def parse_jsonl(response) + def parse_json(response) return unless response return response unless response.is_a?(String) - # Convert a multiline string of JSON objects to a JSON array. - response = response.gsub("}\n{", "},{").prepend("[").concat("]") + original_response = response.dup + if response.include?("}\n{") + # Attempt to convert what looks like a multiline string of JSON objects to a JSON array. + response = response.gsub("}\n{", "},{").prepend("[").concat("]") + end JSON.parse(response) + rescue JSON::ParserError + original_response end # Given a proc, returns an outer proc that can be used to iterate over a JSON stream of chunks. diff --git a/spec/fixtures/cassettes/files_fetch_image.yml b/spec/fixtures/cassettes/files_fetch_image.yml new file mode 100644 index 00000000..8aa6686a --- /dev/null +++ b/spec/fixtures/cassettes/files_fetch_image.yml @@ -0,0 +1,68 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.openai.com/v1/files/file-K3gwyzsbpt6RXuAfWzgUeh/content + 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: 200 + message: OK + headers: + Date: + - Fri, 14 Feb 2025 14:55:39 GMT + Content-Type: + - application/octet-stream + Content-Length: + - '249' + Connection: + - keep-alive + Content-Disposition: + - attachment; filename="image.png" + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-jxm65ijkzc1qrfhc0ij8moic + X-Request-Id: + - req_a5152835bd870146f9ab8757b5da7dfd + Openai-Processing-Ms: + - '146' + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=OU9._V0EmSRhM4s0ZTGsIS9Cl9p96JNMkYRPUhp_5Zc-1739544939-1.0.1.1-HSKYH1OiIdlBJAoRJSe1_nhJYAhmMyJF753RZNSM_nP06JvyNTPFSeedU_dF4DsuIfp9SL3sepk9Twcxme7NPg; + path=/; expires=Fri, 14-Feb-25 15:25:39 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=3UUB2JFeoIxCEm1YHTZn.YuCUyNpuMcLz4KkvNW1aLE-1739544939273-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 911de67c9f9d0038-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: !binary |- + iVBORw0KGgoAAAANSUhEUgAAAEQAAABECAYAAAA4E5OyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACOSURBVHgB7dBBDcAgAAAxNnGYRCz8SDgNrYR+c+09uP7BQ0gICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAiJA8NoA2dv2YpOAAAAAElFTkSuQmCC + recorded_at: Fri, 14 Feb 2025 14:55:39 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/files_fetch_image_retrieve.yml b/spec/fixtures/cassettes/files_fetch_image_retrieve.yml new file mode 100644 index 00000000..9e46dcdc --- /dev/null +++ b/spec/fixtures/cassettes/files_fetch_image_retrieve.yml @@ -0,0 +1,75 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.openai.com/v1/files/file-K3gwyzsbpt6RXuAfWzgUeh + 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: 200 + message: OK + headers: + Date: + - Fri, 14 Feb 2025 14:55:38 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-jxm65ijkzc1qrfhc0ij8moic + X-Request-Id: + - req_a69c47545eca3821335b1d969f38d351 + Openai-Processing-Ms: + - '50' + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=4U9Wp6hNHG.yfESAELNnI6tyERcXSoY5aN2p1feHKu8-1739544938-1.0.1.1-R_DKDjENdsn6knyIPBKNwe7uxrHniHogLQeR4RxWASaS9XShfRDjb8N7M9Ztwlcpu62UJ8Ehd2Wy9p64ahzfjg; + path=/; expires=Fri, 14-Feb-25 15:25:38 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=A2rCOmzJfrjtBXJbQPfBD6VQjiJc1kCzo75fumzx4qs-1739544938927-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 911de67b29f3bed7-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "object": "file", + "id": "file-K3gwyzsbpt6RXuAfWzgUeh", + "purpose": "vision", + "filename": "image.png", + "bytes": 249, + "created_at": 1739544938, + "status": "processed", + "status_details": null + } + recorded_at: Fri, 14 Feb 2025 14:55:38 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/files_fetch_image_upload.yml b/spec/fixtures/cassettes/files_fetch_image_upload.yml new file mode 100644 index 00000000..0cec8a22 --- /dev/null +++ b/spec/fixtures/cassettes/files_fetch_image_upload.yml @@ -0,0 +1,78 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/files + body: + encoding: ASCII-8BIT + string: !binary |- + LS0tLS0tLS0tLS0tLVJ1YnlNdWx0aXBhcnRQb3N0LWI0ODhhNWJhYWY0NTBkODE1MmY1M2ZlYTkwOWJjZDM1DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZpbGUiOyBmaWxlbmFtZT0iaW1hZ2UucG5nIg0KQ29udGVudC1MZW5ndGg6IDI0OQ0KQ29udGVudC1UeXBlOiANCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJpbmFyeQ0KDQqJUE5HDQoaCgAAAA1JSERSAAAARAAAAEQIBgAAADgTk7IAAAAJcEhZcwAACxMAAAsTAQCanBgAAAABc1JHQgCuzhzpAAAABGdBTUEAALGPC/xhBQAAAI5JREFUeAHt0EENwCAAADE2cZhELPxIOA2thH5z7T24/sFDSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICIkDw2gDZ2/Zik4AAAAASUVORK5CYIINCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iNDg4YTViYWFmNDUwZDgxNTJmNTNmZWE5MDliY2QzNQ0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJwdXJwb3NlIg0KDQp2aXNpb24NCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iNDg4YTViYWFmNDUwZDgxNTJmNTNmZWE5MDliY2QzNS0tDQo= + headers: + Content-Type: + - multipart/form-data; boundary=-----------RubyMultipartPost-b488a5baaf450d8152f53fea909bcd35 + Authorization: + - Bearer + Content-Length: + - '647' + 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: + - Fri, 14 Feb 2025 14:55:38 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-jxm65ijkzc1qrfhc0ij8moic + X-Request-Id: + - req_6075462c500e86d26593dfea7915eb55 + Openai-Processing-Ms: + - '213' + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=CwwusKBX0wkDw87d2t3u_Z.vLMcriSzf2Ek1pvKaalE-1739544938-1.0.1.1-zNjyPqormxZJCEUxKCJdV.z90dq3TRpfLVcvWzA_jPJanQcdk.H7M4KZXzktgoAl.qFK8bs_vpX4rxGxRtiLow; + path=/; expires=Fri, 14-Feb-25 15:25:38 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=Of90_9phKYsAfXNizMnfU17SZKEvzxfV2EZA5ZqUZsk-1739544938691-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 911de678ad1f0038-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "object": "file", + "id": "file-K3gwyzsbpt6RXuAfWzgUeh", + "purpose": "vision", + "filename": "image.png", + "bytes": 249, + "created_at": 1739544938, + "status": "processed", + "status_details": null + } + recorded_at: Fri, 14 Feb 2025 14:55:38 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_error_response.yml b/spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_error_response.yml new file mode 100644 index 00000000..0a05bd58 --- /dev/null +++ b/spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_error_response.yml @@ -0,0 +1,29 @@ +--- +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: 500 + message: Internal Server Error + headers: + Date: + - Mon, 14 Aug 2023 15:02:13 GMT + recorded_at: Mon, 14 Aug 2023 15:02:13 GMT + +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_json_error_response.yml b/spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_json_error_response.yml new file mode 100644 index 00000000..2bb10218 --- /dev/null +++ b/spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_json_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/mocks/http_get_with_error_response.yml b/spec/fixtures/cassettes/mocks/http_get_with_error_response.yml new file mode 100644 index 00000000..bfc3f5a8 --- /dev/null +++ b/spec/fixtures/cassettes/mocks/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 88780f00..002f02df 100644 --- a/spec/openai/client/chat_spec.rb +++ b/spec/openai/client/chat_spec.rb @@ -129,7 +129,7 @@ def call(chunk) end context "with an error response with a JSON body" do - let(:cassette) { "#{model} streamed chat with json error response".downcase } + let(:cassette) { "mocks/#{model} streamed chat with json error response".downcase } it "raises an HTTP error with the parsed body" do VCR.use_cassette(cassette, record: :none) do @@ -151,7 +151,7 @@ def call(chunk) end context "with an error response without a JSON body" do - let(:cassette) { "#{model} streamed chat with error response".downcase } + let(:cassette) { "mocks/#{model} streamed chat with error response".downcase } it "raises an HTTP error" do VCR.use_cassette(cassette, record: :none) do diff --git a/spec/openai/client/files_spec.rb b/spec/openai/client/files_spec.rb index 3ccee1b6..411b725b 100644 --- a/spec/openai/client/files_spec.rb +++ b/spec/openai/client/files_spec.rb @@ -128,5 +128,40 @@ end end end + + describe "#fetch_image" do + let(:cassette) { "files fetch_image" } + let(:upload_cassette) { "#{cassette} upload" } + let(:retrieve_cassette) { "#{cassette} retrieve" } + let(:filename) { "image.png" } + let(:file) { File.join(RSPEC_ROOT, "fixtures/files", filename) } + let(:upload_purpose) { "vision" } + let(:response) { OpenAI::Client.new.files.content(id: upload_id) } + + before do + # We need to check the file has been processed by OpenAI + # before we can delete it. + retrieved = VCR.use_cassette(retrieve_cassette) do + OpenAI::Client.new.files.retrieve(id: upload_id) + end + tries = 0 + until retrieved["status"] == "processed" + raise "File not processed after 10 tries" if tries > 10 + + sleep(1) + retrieved = VCR.use_cassette(retrieve_cassette, record: :all) do + OpenAI::Client.new.files.retrieve(id: upload_id) + end + tries += 1 + end + end + + it "succeeds in uploading and retrieving an image" do + VCR.use_cassette(cassette) do + expect(response).to be_a(String) + expect(response.size).to be > 0 + end + end + end end end diff --git a/spec/openai/client/http_spec.rb b/spec/openai/client/http_spec.rb index ec0ca7a3..39261518 100644 --- a/spec/openai/client/http_spec.rb +++ b/spec/openai/client/http_spec.rb @@ -106,7 +106,7 @@ describe ".get" do context "with an error response" do - let(:cassette) { "http get with error response".downcase } + let(:cassette) { "mocks/http get with error response".downcase } it "raises an HTTP error" do VCR.use_cassette(cassette, record: :none) do @@ -189,13 +189,22 @@ end end - describe ".parse_jsonl" do + describe ".parse_json" do context "with a jsonl string" do let(:body) { "{\"prompt\":\":)\"}\n{\"prompt\":\":(\"}\n" } - let(:parsed) { OpenAI::Client.new.send(:parse_jsonl, body) } + let(:parsed) { OpenAI::Client.new.send(:parse_json, body) } it { expect(parsed).to eq([{ "prompt" => ":)" }, { "prompt" => ":(" }]) } end + + context "with a non-json string containing newline-brace pattern" do + let(:body) { "Hello}\n{World" } + let(:parsed) { OpenAI::Client.new.send(:parse_json, body) } + + it "returns the original string when JSON parsing fails" do + expect(parsed).to eq("Hello}\n{World") + end + end end describe ".uri" do @@ -285,7 +294,7 @@ end describe "logging errors" do - let(:cassette) { "http get with error response".downcase } + let(:cassette) { "mocks/http get with error response".downcase } before do @original_stdout = $stdout