From 3dab92d9e5cf02d16f5d8bab35ae24771d4709f6 Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 12:37:09 +0000 Subject: [PATCH 1/7] Add failing spec --- spec/fixtures/cassettes/files_fetch_image.yml | 68 ++++++++++++++++ .../cassettes/files_fetch_image_poll.yml | 75 ++++++++++++++++++ .../cassettes/files_fetch_image_upload.yml | 78 +++++++++++++++++++ spec/openai/client/files_spec.rb | 27 +++++++ 4 files changed, 248 insertions(+) create mode 100644 spec/fixtures/cassettes/files_fetch_image.yml create mode 100644 spec/fixtures/cassettes/files_fetch_image_poll.yml create mode 100644 spec/fixtures/cassettes/files_fetch_image_upload.yml diff --git a/spec/fixtures/cassettes/files_fetch_image.yml b/spec/fixtures/cassettes/files_fetch_image.yml new file mode 100644 index 00000000..3b0690eb --- /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-8wnPtwpUdezRLfXfcXFC2J/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 12:35:56 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_f7bbcccb27372dedad8032e719aec545 + Openai-Processing-Ms: + - '359' + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=WZVdVso53iY3aaIYfk9OJb_rrEu8T_nPYSlOmduFX9g-1739536556-1.0.1.1-S1LrJYDGm3qUvTy6n6cwPAZL6Hta77qz8gOSCeQa.4odwB9343lB1fu_HjW0IkS4VyWd4IBcRcUUQTzOBEnaBA; + path=/; expires=Fri, 14-Feb-25 13:05:56 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=kpXfoLfYYbUjYAvoEluLbo4qaz5vE42nEK1CintDPOQ-1739536556970-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 911d19d4bfc49520-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: !binary |- + iVBORw0KGgoAAAANSUhEUgAAAEQAAABECAYAAAA4E5OyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACOSURBVHgB7dBBDcAgAAAxNnGYRCz8SDgNrYR+c+09uP7BQ0gICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAiJA8NoA2dv2YpOAAAAAElFTkSuQmCC + recorded_at: Fri, 14 Feb 2025 12:35:56 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/fixtures/cassettes/files_fetch_image_poll.yml b/spec/fixtures/cassettes/files_fetch_image_poll.yml new file mode 100644 index 00000000..4125fa0c --- /dev/null +++ b/spec/fixtures/cassettes/files_fetch_image_poll.yml @@ -0,0 +1,75 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.openai.com/v1/files/file-8wnPtwpUdezRLfXfcXFC2J + 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 12:35:56 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-jxm65ijkzc1qrfhc0ij8moic + X-Request-Id: + - req_76f5aa4b42ff0fdde3c38a67cd603b1a + Openai-Processing-Ms: + - '58' + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=YcnhQZ2Mh_RjR2wQ5NRT.eyXtasawWuYt6tNUdMo0UM-1739536556-1.0.1.1-LSv2tX0O72tY2lUxo7X2eSqOtTwNZgrAPNPccJACfwRSMOxpdiS8ExkBOi630jF9sVtEIyiP4BI.z99ae4cjBg; + path=/; expires=Fri, 14-Feb-25 13:05:56 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=XTrpdCv50B2uW3PiGEQFfKue5a1gs1q2uwGCYEEUkXU-1739536556227-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 911d19d21e36d857-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "object": "file", + "id": "file-8wnPtwpUdezRLfXfcXFC2J", + "purpose": "vision", + "filename": "image.png", + "bytes": 249, + "created_at": 1739536555, + "status": "processed", + "status_details": null + } + recorded_at: Fri, 14 Feb 2025 12:35:56 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..a7f36596 --- /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 |- + LS0tLS0tLS0tLS0tLVJ1YnlNdWx0aXBhcnRQb3N0LWJkNGUyYmRmM2NmY2QzNTgyZDYyNWRmMGYxYmNiZDM2DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZpbGUiOyBmaWxlbmFtZT0iaW1hZ2UucG5nIg0KQ29udGVudC1MZW5ndGg6IDI0OQ0KQ29udGVudC1UeXBlOiANCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJpbmFyeQ0KDQqJUE5HDQoaCgAAAA1JSERSAAAARAAAAEQIBgAAADgTk7IAAAAJcEhZcwAACxMAAAsTAQCanBgAAAABc1JHQgCuzhzpAAAABGdBTUEAALGPC/xhBQAAAI5JREFUeAHt0EENwCAAADE2cZhELPxIOA2thH5z7T24/sFDSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICIkDw2gDZ2/Zik4AAAAASUVORK5CYIINCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iZDRlMmJkZjNjZmNkMzU4MmQ2MjVkZjBmMWJjYmQzNg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJwdXJwb3NlIg0KDQp2aXNpb24NCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iZDRlMmJkZjNjZmNkMzU4MmQ2MjVkZjBmMWJjYmQzNi0tDQo= + headers: + Content-Type: + - multipart/form-data; boundary=-----------RubyMultipartPost-bd4e2bdf3cfcd3582d625df0f1bcbd36 + 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 12:35:55 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - user-jxm65ijkzc1qrfhc0ij8moic + X-Request-Id: + - req_1a48daf6a46643c912d6632b4abeacf6 + Openai-Processing-Ms: + - '221' + Access-Control-Allow-Origin: + - "*" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=.Ry0xEQzGd2Epy1ImEivveotG_N1Z.sVH5ZTtDaH9Rs-1739536555-1.0.1.1-PasptjvMGNJZLcCsE2ftGVOjHUePPS5HFMULmnI8.tEjCa4EPfefjAIulN0WaRtcALIlJA_ivbtweH2.hOg8tA; + path=/; expires=Fri, 14-Feb-25 13:05:55 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=7PRb5QSLuZ3MqZ8gPqZqsu1UcNfEprahJWx8kMtSmtA-1739536555816-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 911d19ce3fbf9520-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "object": "file", + "id": "file-8wnPtwpUdezRLfXfcXFC2J", + "purpose": "vision", + "filename": "image.png", + "bytes": 249, + "created_at": 1739536555, + "status": "processed", + "status_details": null + } + recorded_at: Fri, 14 Feb 2025 12:35:55 GMT +recorded_with: VCR 6.1.0 diff --git a/spec/openai/client/files_spec.rb b/spec/openai/client/files_spec.rb index 3ccee1b6..643863c0 100644 --- a/spec/openai/client/files_spec.rb +++ b/spec/openai/client/files_spec.rb @@ -128,5 +128,32 @@ end end end + + describe "#fetch_image" do + let(:cassette) { "files fetch_image" } + let(:upload_cassette) { "#{cassette} upload" } + let(:filename) { "image.png" } + let(:file) { File.join(RSPEC_ROOT, "fixtures/files", filename) } + let(:upload_purpose) { "vision" } + + def poll_until_processed(max_attempts: 10) + VCR.use_cassette("#{cassette}_poll", record: :new_episodes) do + max_attempts.times do |attempt| + retrieved = OpenAI::Client.new.files.retrieve(id: upload_id) + return retrieved if retrieved["status"] == "processed" + raise "File not processed after #{max_attempts} attempts" if attempt == max_attempts - 1 + end + end + end + + it "succeeds in uploading and retrieving an image" do + VCR.use_cassette(cassette) do + poll_until_processed + response = OpenAI::Client.new.files.content(id: upload_id) + expect(response).to be_a(String) + expect(response.size).to be > 0 + end + end + end end end From 8e1d6a22614257fc701737c4b187c47b4377c182 Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 12:41:50 +0000 Subject: [PATCH 2/7] Add fix --- lib/openai/http.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/openai/http.rb b/lib/openai/http.rb index 2f60eb0b..e909530f 100644 --- a/lib/openai/http.rb +++ b/lib/openai/http.rb @@ -7,9 +7,10 @@ module HTTP include HTTPHeaders def get(path:, parameters: nil) - parse_jsonl(conn.get(uri(path: path), parameters) do |req| - req.headers = headers - end&.body) + response = conn.get(uri(path: path), parameters) { |req| req.headers = headers } + parse_jsonl(response.body) + rescue JSON::ParserError + response.body end def post(path:) From 2be9bf8eb37c8ca1a3c986ef59b44e68378d87fa Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 12:48:15 +0000 Subject: [PATCH 3/7] Improve fix --- lib/openai/http.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/openai/http.rb b/lib/openai/http.rb index e909530f..e254b0a6 100644 --- a/lib/openai/http.rb +++ b/lib/openai/http.rb @@ -7,10 +7,9 @@ module HTTP include HTTPHeaders def get(path:, parameters: nil) - response = conn.get(uri(path: path), parameters) { |req| req.headers = headers } - parse_jsonl(response.body) - rescue JSON::ParserError - response.body + parse_jsonl(conn.get(uri(path: path), parameters) do |req| + req.headers = headers + end&.body) end def post(path:) @@ -49,6 +48,8 @@ def parse_jsonl(response) response = response.gsub("}\n{", "},{").prepend("[").concat("]") JSON.parse(response) + rescue JSON::ParserError + response end # Given a proc, returns an outer proc that can be used to iterate over a JSON stream of chunks. From 2f7cfb74e51c868a57ed2f8b96efed0839abf9d1 Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 14:16:23 +0000 Subject: [PATCH 4/7] Separate mocked VCR cassettes --- ...urbo_streamed_chat_with_error_response.yml | 29 +++++++++++++ ...streamed_chat_with_json_error_response.yml | 42 +++++++++++++++++++ .../mocks/http_get_with_error_response.yml | 41 ++++++++++++++++++ spec/openai/client/chat_spec.rb | 4 +- spec/openai/client/http_spec.rb | 8 ++-- 5 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_error_response.yml create mode 100644 spec/fixtures/cassettes/mocks/gpt-3_5-turbo_streamed_chat_with_json_error_response.yml create mode 100644 spec/fixtures/cassettes/mocks/http_get_with_error_response.yml 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/http_spec.rb b/spec/openai/client/http_spec.rb index ec0ca7a3..abf1e686 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,10 +189,10 @@ 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 @@ -285,7 +285,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 From 6bd8a035cf00c942bb959302ba59cdd3cd1e01b9 Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 14:16:41 +0000 Subject: [PATCH 5/7] Try and parse everything from string to JSON --- lib/openai/http.rb | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/openai/http.rb b/lib/openai/http.rb index e254b0a6..f7c6b8f2 100644 --- a/lib/openai/http.rb +++ b/lib/openai/http.rb @@ -7,49 +7,49 @@ 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("]") + if response.include?("}\n{") + # Convert a multiline string of JSON objects to a JSON array. + response = response.gsub("}\n{", "},{").prepend("[").concat("]") + end - JSON.parse(response) - rescue JSON::ParserError - response + try_parse_json(response) end # Given a proc, returns an outer proc that can be used to iterate over a JSON stream of chunks. From e055041f46aa2797b8075f108ac54ae6f163de96 Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 14:36:06 +0000 Subject: [PATCH 6/7] Return the original response if attempt to parse JSONL file fails --- lib/openai/http.rb | 7 +++++-- spec/openai/client/http_spec.rb | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/openai/http.rb b/lib/openai/http.rb index f7c6b8f2..644e692d 100644 --- a/lib/openai/http.rb +++ b/lib/openai/http.rb @@ -44,12 +44,15 @@ def parse_json(response) return unless response return response unless response.is_a?(String) + original_response = response.dup if response.include?("}\n{") - # Convert a multiline string of JSON objects to a JSON array. + # Attempt to convert what looks like a multiline string of JSON objects to a JSON array. response = response.gsub("}\n{", "},{").prepend("[").concat("]") end - try_parse_json(response) + 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/openai/client/http_spec.rb b/spec/openai/client/http_spec.rb index abf1e686..39261518 100644 --- a/spec/openai/client/http_spec.rb +++ b/spec/openai/client/http_spec.rb @@ -196,6 +196,15 @@ 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 From 19bce2b623b1fe3efdd35aebdcb9bf5b06d4dbeb Mon Sep 17 00:00:00 2001 From: Alex Rudall Date: Fri, 14 Feb 2025 14:56:08 +0000 Subject: [PATCH 7/7] Refactor spec --- spec/fixtures/cassettes/files_fetch_image.yml | 18 +++++++------- ...oll.yml => files_fetch_image_retrieve.yml} | 22 ++++++++--------- .../cassettes/files_fetch_image_upload.yml | 24 +++++++++---------- spec/openai/client/files_spec.rb | 24 ++++++++++++------- 4 files changed, 48 insertions(+), 40 deletions(-) rename spec/fixtures/cassettes/{files_fetch_image_poll.yml => files_fetch_image_retrieve.yml} (69%) diff --git a/spec/fixtures/cassettes/files_fetch_image.yml b/spec/fixtures/cassettes/files_fetch_image.yml index 3b0690eb..8aa6686a 100644 --- a/spec/fixtures/cassettes/files_fetch_image.yml +++ b/spec/fixtures/cassettes/files_fetch_image.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: https://api.openai.com/v1/files/file-8wnPtwpUdezRLfXfcXFC2J/content + uri: https://api.openai.com/v1/files/file-K3gwyzsbpt6RXuAfWzgUeh/content body: encoding: US-ASCII string: '' @@ -23,7 +23,7 @@ http_interactions: message: OK headers: Date: - - Fri, 14 Feb 2025 12:35:56 GMT + - Fri, 14 Feb 2025 14:55:39 GMT Content-Type: - application/octet-stream Content-Length: @@ -37,9 +37,9 @@ http_interactions: Openai-Organization: - user-jxm65ijkzc1qrfhc0ij8moic X-Request-Id: - - req_f7bbcccb27372dedad8032e719aec545 + - req_a5152835bd870146f9ab8757b5da7dfd Openai-Processing-Ms: - - '359' + - '146' Access-Control-Allow-Origin: - "*" Strict-Transport-Security: @@ -47,22 +47,22 @@ http_interactions: Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=WZVdVso53iY3aaIYfk9OJb_rrEu8T_nPYSlOmduFX9g-1739536556-1.0.1.1-S1LrJYDGm3qUvTy6n6cwPAZL6Hta77qz8gOSCeQa.4odwB9343lB1fu_HjW0IkS4VyWd4IBcRcUUQTzOBEnaBA; - path=/; expires=Fri, 14-Feb-25 13:05:56 GMT; domain=.api.openai.com; HttpOnly; + - __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=kpXfoLfYYbUjYAvoEluLbo4qaz5vE42nEK1CintDPOQ-1739536556970-0.0.1.1-604800000; + - _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: - - 911d19d4bfc49520-LHR + - 911de67c9f9d0038-LHR Alt-Svc: - h3=":443"; ma=86400 body: encoding: ASCII-8BIT string: !binary |- iVBORw0KGgoAAAANSUhEUgAAAEQAAABECAYAAAA4E5OyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACOSURBVHgB7dBBDcAgAAAxNnGYRCz8SDgNrYR+c+09uP7BQ0gICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAiJA8NoA2dv2YpOAAAAAElFTkSuQmCC - recorded_at: Fri, 14 Feb 2025 12:35:56 GMT + 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_poll.yml b/spec/fixtures/cassettes/files_fetch_image_retrieve.yml similarity index 69% rename from spec/fixtures/cassettes/files_fetch_image_poll.yml rename to spec/fixtures/cassettes/files_fetch_image_retrieve.yml index 4125fa0c..9e46dcdc 100644 --- a/spec/fixtures/cassettes/files_fetch_image_poll.yml +++ b/spec/fixtures/cassettes/files_fetch_image_retrieve.yml @@ -2,7 +2,7 @@ http_interactions: - request: method: get - uri: https://api.openai.com/v1/files/file-8wnPtwpUdezRLfXfcXFC2J + uri: https://api.openai.com/v1/files/file-K3gwyzsbpt6RXuAfWzgUeh body: encoding: US-ASCII string: '' @@ -23,7 +23,7 @@ http_interactions: message: OK headers: Date: - - Fri, 14 Feb 2025 12:35:56 GMT + - Fri, 14 Feb 2025 14:55:38 GMT Content-Type: - application/json Transfer-Encoding: @@ -35,9 +35,9 @@ http_interactions: Openai-Organization: - user-jxm65ijkzc1qrfhc0ij8moic X-Request-Id: - - req_76f5aa4b42ff0fdde3c38a67cd603b1a + - req_a69c47545eca3821335b1d969f38d351 Openai-Processing-Ms: - - '58' + - '50' Access-Control-Allow-Origin: - "*" Strict-Transport-Security: @@ -45,17 +45,17 @@ http_interactions: Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=YcnhQZ2Mh_RjR2wQ5NRT.eyXtasawWuYt6tNUdMo0UM-1739536556-1.0.1.1-LSv2tX0O72tY2lUxo7X2eSqOtTwNZgrAPNPccJACfwRSMOxpdiS8ExkBOi630jF9sVtEIyiP4BI.z99ae4cjBg; - path=/; expires=Fri, 14-Feb-25 13:05:56 GMT; domain=.api.openai.com; HttpOnly; + - __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=XTrpdCv50B2uW3PiGEQFfKue5a1gs1q2uwGCYEEUkXU-1739536556227-0.0.1.1-604800000; + - _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: - - 911d19d21e36d857-LHR + - 911de67b29f3bed7-LHR Alt-Svc: - h3=":443"; ma=86400 body: @@ -63,13 +63,13 @@ http_interactions: string: | { "object": "file", - "id": "file-8wnPtwpUdezRLfXfcXFC2J", + "id": "file-K3gwyzsbpt6RXuAfWzgUeh", "purpose": "vision", "filename": "image.png", "bytes": 249, - "created_at": 1739536555, + "created_at": 1739544938, "status": "processed", "status_details": null } - recorded_at: Fri, 14 Feb 2025 12:35:56 GMT + 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 index a7f36596..0cec8a22 100644 --- a/spec/fixtures/cassettes/files_fetch_image_upload.yml +++ b/spec/fixtures/cassettes/files_fetch_image_upload.yml @@ -6,10 +6,10 @@ http_interactions: body: encoding: ASCII-8BIT string: !binary |- - LS0tLS0tLS0tLS0tLVJ1YnlNdWx0aXBhcnRQb3N0LWJkNGUyYmRmM2NmY2QzNTgyZDYyNWRmMGYxYmNiZDM2DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZpbGUiOyBmaWxlbmFtZT0iaW1hZ2UucG5nIg0KQ29udGVudC1MZW5ndGg6IDI0OQ0KQ29udGVudC1UeXBlOiANCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJpbmFyeQ0KDQqJUE5HDQoaCgAAAA1JSERSAAAARAAAAEQIBgAAADgTk7IAAAAJcEhZcwAACxMAAAsTAQCanBgAAAABc1JHQgCuzhzpAAAABGdBTUEAALGPC/xhBQAAAI5JREFUeAHt0EENwCAAADE2cZhELPxIOA2thH5z7T24/sFDSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICIkDw2gDZ2/Zik4AAAAASUVORK5CYIINCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iZDRlMmJkZjNjZmNkMzU4MmQ2MjVkZjBmMWJjYmQzNg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJwdXJwb3NlIg0KDQp2aXNpb24NCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iZDRlMmJkZjNjZmNkMzU4MmQ2MjVkZjBmMWJjYmQzNi0tDQo= + LS0tLS0tLS0tLS0tLVJ1YnlNdWx0aXBhcnRQb3N0LWI0ODhhNWJhYWY0NTBkODE1MmY1M2ZlYTkwOWJjZDM1DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZpbGUiOyBmaWxlbmFtZT0iaW1hZ2UucG5nIg0KQ29udGVudC1MZW5ndGg6IDI0OQ0KQ29udGVudC1UeXBlOiANCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJpbmFyeQ0KDQqJUE5HDQoaCgAAAA1JSERSAAAARAAAAEQIBgAAADgTk7IAAAAJcEhZcwAACxMAAAsTAQCanBgAAAABc1JHQgCuzhzpAAAABGdBTUEAALGPC/xhBQAAAI5JREFUeAHt0EENwCAAADE2cZhELPxIOA2thH5z7T24/sFDSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICAkhISSEhJAQEkJCSAgJISEkhISQEBJCQkgICSEhJISEkBASQkJICIkDw2gDZ2/Zik4AAAAASUVORK5CYIINCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iNDg4YTViYWFmNDUwZDgxNTJmNTNmZWE5MDliY2QzNQ0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJwdXJwb3NlIg0KDQp2aXNpb24NCi0tLS0tLS0tLS0tLS1SdWJ5TXVsdGlwYXJ0UG9zdC1iNDg4YTViYWFmNDUwZDgxNTJmNTNmZWE5MDliY2QzNS0tDQo= headers: Content-Type: - - multipart/form-data; boundary=-----------RubyMultipartPost-bd4e2bdf3cfcd3582d625df0f1bcbd36 + - multipart/form-data; boundary=-----------RubyMultipartPost-b488a5baaf450d8152f53fea909bcd35 Authorization: - Bearer Content-Length: @@ -26,7 +26,7 @@ http_interactions: message: OK headers: Date: - - Fri, 14 Feb 2025 12:35:55 GMT + - Fri, 14 Feb 2025 14:55:38 GMT Content-Type: - application/json Transfer-Encoding: @@ -38,9 +38,9 @@ http_interactions: Openai-Organization: - user-jxm65ijkzc1qrfhc0ij8moic X-Request-Id: - - req_1a48daf6a46643c912d6632b4abeacf6 + - req_6075462c500e86d26593dfea7915eb55 Openai-Processing-Ms: - - '221' + - '213' Access-Control-Allow-Origin: - "*" Strict-Transport-Security: @@ -48,17 +48,17 @@ http_interactions: Cf-Cache-Status: - DYNAMIC Set-Cookie: - - __cf_bm=.Ry0xEQzGd2Epy1ImEivveotG_N1Z.sVH5ZTtDaH9Rs-1739536555-1.0.1.1-PasptjvMGNJZLcCsE2ftGVOjHUePPS5HFMULmnI8.tEjCa4EPfefjAIulN0WaRtcALIlJA_ivbtweH2.hOg8tA; - path=/; expires=Fri, 14-Feb-25 13:05:55 GMT; domain=.api.openai.com; HttpOnly; + - __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=7PRb5QSLuZ3MqZ8gPqZqsu1UcNfEprahJWx8kMtSmtA-1739536555816-0.0.1.1-604800000; + - _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: - - 911d19ce3fbf9520-LHR + - 911de678ad1f0038-LHR Alt-Svc: - h3=":443"; ma=86400 body: @@ -66,13 +66,13 @@ http_interactions: string: | { "object": "file", - "id": "file-8wnPtwpUdezRLfXfcXFC2J", + "id": "file-K3gwyzsbpt6RXuAfWzgUeh", "purpose": "vision", "filename": "image.png", "bytes": 249, - "created_at": 1739536555, + "created_at": 1739544938, "status": "processed", "status_details": null } - recorded_at: Fri, 14 Feb 2025 12:35:55 GMT + recorded_at: Fri, 14 Feb 2025 14:55:38 GMT recorded_with: VCR 6.1.0 diff --git a/spec/openai/client/files_spec.rb b/spec/openai/client/files_spec.rb index 643863c0..411b725b 100644 --- a/spec/openai/client/files_spec.rb +++ b/spec/openai/client/files_spec.rb @@ -132,24 +132,32 @@ 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 - def poll_until_processed(max_attempts: 10) - VCR.use_cassette("#{cassette}_poll", record: :new_episodes) do - max_attempts.times do |attempt| - retrieved = OpenAI::Client.new.files.retrieve(id: upload_id) - return retrieved if retrieved["status"] == "processed" - raise "File not processed after #{max_attempts} attempts" if attempt == max_attempts - 1 + 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 - poll_until_processed - response = OpenAI::Client.new.files.content(id: upload_id) expect(response).to be_a(String) expect(response.size).to be > 0 end