[OpenAI Bug] File upload - OpenAI multipart uploads

Closed
#1,261 0 comments 1 reaction 0 assignees View on GitHub

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
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
ruby
Domain
api, backend

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

bug

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.

  1. Build a 65,112-byte file (example content: repeat "# doc\n\n").

  2. 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")
    
  3. Loop if needed; on failure, capture x-request-id from RubyLLM::BadRequestError#response response 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 optional expires_after, etc.) should not put a large file body before purpose on the wire.
  • Matches OpenAI’s common examples (purpose then file) 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

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Similar issues

More Ruby issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.