Skip to content

feat(files): copy the files a provider's code produces into Otari's store - #1518

Closed
daavoo wants to merge 1 commit into
mainfrom
feat/copy-provider-produced-files
Closed

daavoo wants to merge 1 commit into
mainfrom
feat/copy-provider-produced-files

Conversation

@daavoo

@daavoo daavoo commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

Closes #1475.

A provider-native code execution keeps what it wrote in the provider's own container and answers with the provider's file id. #1366 recorded that as a file_objects row with no bytes and streamed them from the provider on demand, which works right up until the provider stops holding them. OpenAI discards a container twenty minutes after its last use, together with everything in it, so a caller who comes back to the conversation the next day holds an id for a chart nobody can serve and GET /v1/files/{id}/content answers 502. The same row is invisible to the content normalizer, which skips a file with no storage_ref, so the chart cannot be handed back to a model either.

This copies the bytes into Otari's store as the run is recorded. A produced file becomes an ordinary Otari file: it downloads after the container is gone, and it can be attached to a later turn. The provider's id stays the row id, so a client echoing the turn back still names a file Otari knows.

What a reviewer should know

  • The copy is best effort, and that decides the shape of the rest. A copy that fails leaves the row exactly as it was before this change: no storage_ref, the provider named, the bytes proxied on demand. Losing a copy must not cost the caller the response it already has.
  • The proxy stays, rather than being removed as the issue suggested. Deployments already hold rows written before copying existed, and removing the on-demand path would break every one of them. What changed is the order: the download route prefers a local copy and falls back to the proxy, instead of treating any provider row as remote. That fallback is also what serves a row whose copy failed.
  • It streams, and it takes the two caps sandbox outputs already get. files_output_max_files bounds how many files are copied per response and files_output_max_bytes how many bytes across it. A file past either is still recorded, so its id resolves; it just serves by proxy. The byte count is checked as the chunks arrive rather than from Content-Length, because what a run writes is untrusted.
  • The copy uses the store the app built, threaded through RequestContext, rather than building one from config. Those agree in production and not where a store is injected, and bytes written to the wrong store are bytes the download route cannot find. This was a real bug in the first draft, caught by a test.
  • A failed copy discards its partial blob, so a proxy row never shadows a truncated one.

Timing

The issue left this open. The copy runs where recording already runs. The streaming path records after the stream settles, so it costs nothing there. The non-streaming path records before the reply returns, so the copy is added to the reply, bounded by the two caps above. Inline is what makes the file present the moment its id is announced; a background copy would leave a window where the id is public and the bytes are not, and the fallback during that window would be the proxy this change exists to stop relying on.

How to test it locally

Point a deployment at a provider that runs code natively, ask for a chart, and note the file_id in the reply. GET /api/v1/files/{id} shows a non-zero bytes, which a proxied row never had. Wait past the provider's container lifetime (twenty minutes on OpenAI) and GET /api/v1/files/{id}/content still serves the chart. Attaching the same id to a later request now reaches the model rather than being skipped.

What automated checks cover

Four tests against the issue's acceptance criteria, in tests/integration/test_provider_file_download.py: a copied file still downloads once the provider refuses it, a produced file attached to a later request reaches the model, the count cap leaves the file past it recorded but uncopied, and a failed copy still serves by proxy. The existing two recording tests now assert the copy landed rather than asserting storage_ref is None; their fixture fakes the provider's content call, without which a copy would fail the way a real outage does and those assertions would pass for the wrong reason.

make lint, make typecheck, make openapi-check and make postman-check pass, as do the files, content-normalizer and provider-produced-file suites (83 tests) and the wider integration slice matching file/sandbox/code_execution/messages (269 tests).

PR Type

  • New Feature

Relevant issues

Closes #1475. Part of #1470.

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).
  • If this changes a rule in ARCHITECTURE.md or scripts/check_architecture.py, the description names the rule and says why.

AI Usage

  • This is fully AI-generated.

AI Model/Tool used: Claude Code

Any additional AI details you'd like to share:

Two things were found by writing the tests rather than by reading the code. The download route branched on record.provider is not None before looking at storage_ref, so a copied file was still fetched from the provider and the whole point of the change was lost; that is why the branch is now ordered on the copy. And record_provider_files built its own FileStore from config, which passes in production and writes to a store the route never reads in any deployment that injects one.

🤖 Generated with Claude Code

Summary

Provider-produced files are now copied into Otari’s file store while the run is recorded. This allows files to remain available after provider containers expire and supports their use in later requests.

The change:

  • Applies file-count and byte-size limits during copying.
  • Preserves the provider file ID.
  • Uses local storage when available and provider proxying as a fallback.
  • Treats copying as best effort and removes incomplete blobs.
  • Passes the configured file store through RequestContext.
  • Adds integration coverage for persistence, fallback behavior, limits, and later-turn attachments.

…tore

A provider-native code execution keeps what it wrote in the provider's own
container and answers with the provider's file id. #1366 recorded that as a
`file_objects` row with no bytes and streamed them from the provider on demand,
which works right up until the provider stops holding them. OpenAI discards a
container twenty minutes after its last use, and everything in it, so a caller
returning to the conversation the next day held an id for a chart nobody could
serve, and `GET /v1/files/{id}/content` answered 502. The same row was invisible
to the content normalizer, which skips a file with no `storage_ref`, so the
chart could not be handed back to a model either.

The bytes are copied into Otari's store as the run is recorded, so a produced
file is an ordinary Otari file: it downloads after the container is gone, and it
can be attached to a later turn. The provider's id stays the row id, so a client
echoing the turn back still names a file Otari knows. The copy streams rather
than buffering, and takes the two caps a sandbox run's outputs already get:
`files_output_max_files` bounds how many are copied, `files_output_max_bytes`
how many bytes across the response, and a file past either is still recorded so
its id resolves.

Copying is best effort, which is what decides the shape of the rest. A copy that
fails leaves the row exactly as it was before this change: no `storage_ref`, the
provider named, the bytes proxied on demand. So the proxy stays rather than
being removed as the issue suggested, because deployments already hold rows
written before copying existed and removing it would break them. The download
route now prefers a local copy and falls back to the proxy, rather than treating
any provider row as remote.

Two things the copy needed that were not there. It streams into the store the
app built, threaded through `RequestContext`, rather than building one from
config: those agree in production but not where a store is injected, and bytes
written to the wrong store are bytes the download route cannot find. And a
failed copy discards its partial blob, so a proxy row never shadows a truncated
one.

Timing, which the issue left open: the copy runs where recording already runs.
The streaming path records after the stream settles, so it costs nothing there.
The non-streaming path records before the reply returns, so it adds the copy to
the reply, bounded by the two caps. Inline is what makes the file present the
moment its id is announced.

Closes #1475

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@daavoo
daavoo deployed to integration-tests September 22, 2026 16:08 — with GitHub Actions Active
@daavoo
daavoo deployed to integration-tests September 22, 2026 16:08 — with GitHub Actions Active
@daavoo
daavoo deployed to integration-tests September 22, 2026 16:08 — with GitHub Actions Active
@daavoo
daavoo deployed to integration-tests September 22, 2026 16:08 — with GitHub Actions Active
@daavoo
daavoo requested review from a team, peteski22 and tbille and removed request for a team September 22, 2026 16:08
@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository: mozilla-ai/otari/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 70333c67-b83e-4436-bf00-4d83bb709d8a

📥 Commits

Reviewing files that changed from the base of the PR and between cc617f3 and fd12c2f.

📒 Files selected for processing (6)
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/files.py
  • src/gateway/repositories/files/provider_file_repository.py
  • src/gateway/services/content_normalizer.py
  • src/gateway/services/files/provider_files.py
  • tests/integration/test_provider_file_download.py
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@peteski22

Copy link
Copy Markdown
Contributor

Same PR/Duplicate of #1517

@peteski22 peteski22 closed this Sep 22, 2026

This branch was successfully deployed

1 active deployment
integration-tests — fd12c2f5 Deployed Sep 22, 2026 by daavoo via test-integration (3/4) #2488
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Copy the files a provider's code produces into Otari's store, so they outlive the provider's container

2 participants