Repository navigation
Replies: 1 comment
|
Moved to #1261. Generated by Copilot Triage using |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
OpenAI Files API: intermittent 400 on upload (~65 KB file bodies)
This is NOT a bug in RubyLLM file upload, it is an OpenAI bug. However it is something that can be worked around in RubyLLM; we have reproduced the same type of failures with other clients (curl, etc.) anytime the file multipart part is sent before purpose on
POST /v1/filesand you have the affected file size (most reliably at 65,112 bytes of file content).We suspect OpenAI's parser breaks into 64kb chunks and somehow misaligns. The net effect is that a valid
purposeargument (e.g.user_data) is read as invalid (usually something like'user_data\r'with a trailing\r). OpenAI then returns a 400 and reports that as an invalidpurpose. We’ve reproduced this intermittently and reported it to OpenAI support (withx-request-ids and hexdumps); we’re still waiting on some sort of engineering response from the OpenAI team 🤞.Workaround: send
purposebeforefileon the wire (e.g. curl-F purpose=...then-F file=@...). RubyLLM 2.x currently builds OpenAI uploads asfilethenpurposeviaProtocols::Files#multipart_payload, which can hit this OpenAI behavior. We can provide a small (few lines) PR for OpenAI-only multipart order override if you are interested.Summary
When uploading to OpenAI via
RubyLLM.upload(..., provider: :openai, purpose: "user_data"),Protocols::Files#multipart_payloadbuilds the Faraday multipart hash with the file part before scalar fields ({ file: ... }.merge(fields)). On the wire, the file part precedes purpose.For certain file sizes (we consistently repro around 65,112 bytes of file content with a markdown-like body), OpenAI
POST /v1/filescan return HTTP 400 with a purpose validation error where the message shows'user_data\r'(trailing CR) even thoughuser_datais valid. The same bytes and purpose often succeed whenpurposeis sent before the file (same client stack, same endpoint).We have production volume (hundreds of failures) when using the default multipart order, and test correlation with OpenAI support (request IDs + hexdump showing the client sends
\r\n\r\nuser_data\r\nwith no\rinside the purpose value). OpenAI support is supposed to be escalating but no confirmation on this; regardless, sendingpurposebeforefileavoids the failure altogether.Certainly RubyLLM 2.x should not rely on multipart part order for correctness on file uploads in general, but a workaround for OpenAI Files uploads is not a bad idea.
RubyLLM behavior (root cause)
Base implementation (
lib/ruby_llm/protocols/files.rb):Faraday multipart encodes hash insertion order, so
fileis always first on the wire, thenpurpose(and any other fields).OpenAI’s
Protocols::OpenAI::Filesusesmultipart_payloadfrom the upload path (render_upload_payload→multipart_payload(attachment, purpose: ...)).Symptom
Endpoint:
POST /v1/filesPurpose:
user_data(valid per OpenAI docs)HTTP: 400
Typical error message:
'user_data\r' is not one of ['fine-tune', 'assistants', 'batch', 'user_data', 'vision', 'evals'] - 'purpose'Intermittent: Same payload can return 200 or 400 in repeated attempts (likely boundary/size sensitive on the server); production saw many failures with file-first ordering.
Reproduction (minimal)
Environment:
ruby_llm2.1.x,faraday~2.15,faraday-multipart~1.2,multipart-post~2.4, real OpenAI API key.Build a 65,112-byte file (example content: repeat
"# doc\n\n").Upload with RubyLLM using default OpenAI files multipart (file before purpose on the wire):
Loop if needed; on failure, capture
x-request-idfromRubyLLM::BadRequestError#responseresponse headers.Control (succeeds with same bytes): POST the same multipart with
purposebeforefile(e.g. Faraday hash{ purpose: "user_data", file: FilePart(...) }, or provider POST with{ purpose:, file: }).Evidence the client sends a clean
purposevalueFaraday-encoded body (file before purpose) at the purpose part looks like:
Hex before the literal
user_data:... 22 0d 0a 0d 0a 75 73 65 72 5f 64 61 74 61("\r\n\r\nuser_data"), not0d 75...(no CR immediately beforeuser_datain the field value).Example correlated failure (lab):
2026-10-20T03:57:17Zx-request-idreq_903d5fc6ff434751973fdeff01a6abb5Content-Typeboundary (same request)-----------RubyMultipartPost-671a74ea00edcdd37561b65aee38791dProposed fix
Override
multipart_payloadinRubyLLM::Protocols::OpenAI::Filesso scalar fields precede the file part (same fields, different order):Rationale:
{ file: }.merge(fields)if that order is correct for them.purpose(and optionalexpires_after, etc.) should not put a large file body beforepurposeon the wire.purposethenfile) and avoids the'user_data\r'400 class in production and lab.Test: Unit spec stubbing the connection and asserting multipart payload key order (purpose before file), plus optional live smoke test at ~65112 bytes (may be flaky).
PR offer
We have a branch with this override + a unit spec (
OpenAI::Filesmultipart key order) and can open a PR if maintainers want it. We have not re-recorded all OpenAI VCR cassettes; happy to runrake vcr:record[openai]or adjust CI if multipart order affects recorded requests.Test Environment (repro)
ruby_llmfaradayfaraday-multipartmultipart-postMiddleware:
Faraday::Multipart::Middleware(viafaraday-multipart).Thanks for your consideration and the amazing project!
All reactions