[OpenAI Bug] File upload - OpenAI multipart uploads
Maintainers usually reply within 1 day
A pull request for this has already been merged.
- #1301 by @crmne — merged
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 55/100
Research direction
Read lib/ruby_llm/protocols/files.rb and the OpenAI files upload path that calls multipart_payload; inspect the existing OpenAI Files specs for multipart payload assertions. Confirm that the generated field order places purpose before file and add coverage for it. Done when the OpenAI upload payload keeps scalar fields before the file part and the relevant specs pass.
Written by the indexing model from the issue text.
Description
Moved from https://github.com/crmne/ruby_llm/discussions/1260, opened by @spovich.
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/files and 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 purpose argument (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 invalid purpose. We’ve reproduced this intermittently and reported it to OpenAI support (with x-request-ids and hexdumps); we’re still waiting on some sort of engineering response from the OpenAI team 🤞.
Workaround: send purpose before file on the wire (e.g. curl -F purpose=... then -F file=@...). RubyLLM 2.x currently builds OpenAI uploads as file then purpose via Protocols::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_payload builds 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/files can return HTTP 400 with a purpose validation error where the message shows 'user_data\r' (trailing CR) even though user_data is valid. The same bytes and purpose often succeed when purpose is 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\n with no \r inside the purpose value). OpenAI support is supposed to be escalating but no confirmation on this; regardless, sending purpose before file avoids 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):
def multipart_payload(attachment, **fields)
{ file: file_part(attachment) }.merge(fields.compact)
end
Faraday multipart encodes hash insertion order, so file is always first on the wire, then purpose (and any other fields).
OpenAI’s Protocols::OpenAI::Files uses multipart_payload from the upload path (render_upload_payload → multipart_payload(attachment, purpose: ...)).
Symptom
-
Endpoint:
POST /v1/files -
Purpose:
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_llm 2.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):
RubyLLM.configure { |c| c.openai_api_key = ENV["OPENAI_API_KEY"] } size = 65_112 chunk = "# doc\n\n" body = (chunk * (size / chunk.bytesize + 1)).byteslice(0, size) file = Tempfile.new(["repro", ".md"]) file.binmode file.write(body) file.flush # Optional: ActiveStorage-style path override (closer to Rails uploads) file.define_singleton_method(:path) { "999888777-cleaned.md" } # Force stock wire order if testing against a fork that already fixed OpenAI::Files: # Broken = Module.new do # def multipart_payload(attachment, **fields) # { file: file_part(attachment) }.merge(fields.compact) # end # end # RubyLLM::Protocols::OpenAI::Files.prepend(Broken) RubyLLM.upload(file, provider: :openai, purpose: "user_data", filename: "999888777-cleaned.md") -
Loop if needed; on failure, capture
x-request-idfromRubyLLM::BadRequestError#responseresponse headers.
Control (succeeds with same bytes): POST the same multipart with purpose before file (e.g. Faraday hash { purpose: "user_data", file: FilePart(...) }, or provider POST with { purpose:, file: }).
Evidence the client sends a clean purpose value
Faraday-encoded body (file before purpose) at the purpose part looks like:
... name="purpose"\r\n\r\nuser_data\r\n--<boundary>--\r\n
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"), not 0d 75... (no CR immediately before user_data in the field value).
Example correlated failure (lab):
| Field | Value |
|---|---|
| UTC | 2026-10-20T03:57:17Z |
x-request-id |
req_903d5fc6ff434751973fdeff01a6abb5 |
Content-Type boundary (same request) |
-----------RubyMultipartPost-671a74ea00edcdd37561b65aee38791d |
Proposed fix
Override multipart_payload in RubyLLM::Protocols::OpenAI::Files so scalar fields precede the file part (same fields, different order):
def multipart_payload(attachment, **fields)
fields.compact.merge(file: file_part(attachment))
end
Rationale:
- Other providers can keep
{ file: }.merge(fields)if that order is correct for them. - OpenAI Files uploads with
purpose(and optionalexpires_after, etc.) should not put a large file body beforepurposeon the wire. - Matches OpenAI’s common examples (
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::Files multipart key order) and can open a PR if maintainers want it. We have not re-recorded all OpenAI VCR cassettes; happy to run rake vcr:record[openai] or adjust CI if multipart order affects recorded requests.
Test Environment (repro)
| Gem | Version |
|---|---|
ruby_llm |
2.1.x |
faraday |
2.15.0 |
faraday-multipart |
1.2.0 |
multipart-post |
2.4.1 |
Middleware: Faraday::Multipart::Middleware (via faraday-multipart).
Thanks for your consideration and the amazing project!
- Dominant language
- Ruby
- Stars
- 4.5k
- Forks
- 528
- Avg merge
- 11h 46m
- Merged PRs (30d)
- 306
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Similar issues
-
allow favicon to be served from CDN/CORS (i.e. treat it the same as the avatar)Possibly taken @enkaskal claimed this today. Openenhancement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
cotes2020/jekyll-theme-chirpy#2811 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
-
Add a dark modeOpenaccessibility enhancement good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
samiromer2/Ruby-Consent-Portal#35 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
bug detail
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
keygen-sh/keygen-api#1161 ·
Maintainers usually reply within 1 day