[BUG] Converted attachments are not uploaded again after a provider deletes the file
Maintainers usually reply within 1 day
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 55/100
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
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 (
mainatb8b939d3, the commit taggedv2.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
- 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.
More from crmne/ruby_llm
-
[BUG] Chat replies are lost when a usage row cannot be linked to its messagePossibly taken @yorzi claimed this 1 day ago. Openbug
Difficulty 3/5 1-2 days Newbie friendliness 68/100
Maintainers usually reply within 1 day
Similar issues
-
ActionText::Content#to_markdown: <br> in a table cell and empty <tr> produce malformed Markdown tablesPossibly taken @azmi2409 claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
JetBrains/homebrew-utils#21 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
ublue-os/homebrew-experimental-tap#774 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
r-uby-dev/llm#251 · 2 comments ·
Maintainers usually reply within 1 day
-
accessibility
Difficulty 1/5 Under an hour Newbie friendliness 72/100
slovensko-digital/ops-portal#212 ·
Maintainers usually reply within 1 day