Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

[BUG] Converted attachments are not uploaded again after a provider deletes the file

Open
#1,046 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

@yorzi is already working on this.

Since Oct 8, 2026.

  • #1047 by @yorzi — open

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
55/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
ruby
Domain
api, backend

Research direction

Start with Chat#discard_missing_uploads in lib/ruby_llm/chat.rb:1473 and the existing provider-upload coverage in spec/ruby_llm/chat_provider_uploads_spec.rb; trace how converted attachment replacements and their upload memo are handled. Reproduce the plain and converted cases from the issue, then verify that a deleted converted upload is retried without rerunning the converter and that unrelated uploads are not discarded on a named-file 404.

Written by the indexing model from the issue text.

Description

bug
Basic checks
  • I searched existing issues - this hasn't been reported
  • I can reproduce this consistently
  • This is a RubyLLM bug, not my application code
What's broken?

When a provider deletes the file RubyLLM uploaded for a converted attachment, the next request raises, and so does every request after it on that chat. The same deletion on an attachment that needed no conversion is repaired: RubyLLM uploads the file again and runs the request once more.

Chat#discard_missing_uploads (lib/ruby_llm/chat.rb:1473) hands the provider messages, and the transcript holds the original attachments. For a converted attachment the upload is memoized on the replacement, which only @attachment_replacements holds. Protocol#missing_uploads therefore finds nothing to forget, discard_missing_uploads(e).empty? is true, and replacing_missing_uploads raises again. The stale memo stays on the replacement, so the chat keeps sending the deleted file id.

It is the gap between two recent commits. 14bf8641 ("Upload again when a provider deletes a reused file") walks the attachments in the transcript. 2dbed9ac ("Convert each unsupported attachment once per chat") then kept the replacement, and its upload memo, outside the transcript. The guides promise both halves:

  • files.md: "If the provider deletes an upload, the request that fails over it uploads the file again and runs once more, unless part of the reply already streamed."
  • attachments.md: "A chat converts each attachment once and reuses the replacement, including its provider upload, on later requests."

There is a second effect when the failing request also carries an uploaded attachment that needed no conversion and the error is a 404. The named file is not among the uploads the cleanup can see, so the rule for a 404 that names no file applies: RubyLLM forgets the unrelated upload, uploads that file again, and the retry fails with the same error.

Affected: a chat that uses convert_unsupported_attachments with a replacement large enough to be uploaded, when the provider drops that file while the chat object is alive, for example from the provider's dashboard. That is rare. When it happens the chat object is unusable from then on, the state 14bf8641 describes as "a plain Ruby chat failed for the rest of its life". Streamed requests fail the same way before anything streams. I reproduced it on Anthropic, where the upload threshold is 24 MiB. The code is shared by every protocol that uploads large attachments, and the threshold is lower elsewhere: 7 MiB on Vertex AI and 4.5 MB for Bedrock Converse documents. A persisted chat loaded in another process converts again, so it is affected only within one process.

How to reproduce

From the repo root on main, with no API key. It runs three turns twice, once with a plain attachment and once with a converted one. The provider deletes the uploaded file after the first turn. The 404 body is the shape spec/ruby_llm/chat_provider_uploads_spec.rb already uses.

require 'bundler/setup'
require 'ruby_llm'
require 'webmock'
require 'stringio'

WebMock.enable!
RubyLLM.configure { |config| config.anthropic_api_key = 'test' }

MODEL = 'claude-haiku-4-5'
SIZE = (24 * 1024 * 1024) + 1 # one byte past Anthropic's inline limit, so RubyLLM uploads the file

def json(body, status: 200)
  { status:, body: body.to_json, headers: { 'Content-Type' => 'application/json' } }
end

def three_turns(label)
  uploads = []
  deleted = false
  WebMock.reset!
  WebMock.stub_request(:post, 'https://api.anthropic.com/v1/files').to_return do
    uploads << (uploads.empty? ? 'file_old' : 'file_new')
    json({ id: uploads.last, type: 'file', filename: 'report.txt', mime_type: 'text/plain', size_bytes: SIZE,
           created_at: '2026-10-01T09:00:00Z', downloadable: false })
  end
  WebMock.stub_request(:post, 'https://api.anthropic.com/v1/messages').to_return do |request|
    if deleted && request.body.include?('"file_id":"file_old"')
      json({ type: 'error', error: { type: 'not_found_error', message: 'File not found: file_old' } }, status: 404)
    else
      json({ id: 'msg_1', type: 'message', role: 'assistant', model: MODEL, stop_reason: 'end_turn',
             content: [{ type: 'text', text: 'Hi' }], usage: { input_tokens: 9, output_tokens: 2 } })
    end
  end

  chat = RubyLLM.chat(model: MODEL, provider: :anthropic)
  attachment = yield chat
  turns = 3.times.map do |turn|
    deleted = turn.positive?
    (turn.zero? ? chat.ask('Read this', with: attachment) : chat.ask('Continue')).content
  rescue RubyLLM::Error => e
    "#{e.class}: #{e.message}"
  end
  puts "#{label}: #{turns.join(' | ')} (uploads: #{uploads.join(', ')})"
end

large_text = -> { RubyLLM::Attachment.new(StringIO.new('x' * SIZE), filename: 'report.txt') }

three_turns('plain    ') { large_text.call }

three_turns('converted') do |chat|
  converted = large_text.call
  chat.convert_unsupported_attachments { converted }
  RubyLLM::Attachment.new(StringIO.new('Office document'), filename: 'report.docx')
end
Expected behavior

The converted attachment recovers like the plain one. RubyLLM uploads the cached replacement again, runs the failed request once more, and does not call the converter a second time:

plain    : Hi | Hi | Hi (uploads: file_old, file_new)
converted: Hi | Hi | Hi (uploads: file_old, file_new)
What actually happened
plain    : Hi | Hi | Hi (uploads: file_old, file_new)
converted: Hi | RubyLLM::Error: File not found: file_old | RubyLLM::Error: File not found: file_old (uploads: file_old)
Environment
  • Ruby version: ruby 3.3.5
  • RubyLLM version: 2.1.0 (main at b8b939d3, the commit tagged v2.1.0)
  • Provider: Anthropic, stubbed with WebMock. No live requests were made.
  • OS: macOS
Dominant language
Ruby
Stars
4.4k
Forks
510
Avg merge
13h 57m
Merged PRs (30d)
49

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.

More from crmne/ruby_llm

All issues in crmne/ruby_llm

Similar issues

More Ruby issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.