Send files and images
A completion can carry files. Send an image for a vision model to look at, or a PDF for the model to read.
There are two ways in, and you can mix them in one request:
- Inline. Put the bytes in the message as a content part. This is what a stock OpenAI SDK sends, so it works with no change.
- By id. Upload the file once to
POST /files, then name its id. Use this when the same file appears in several requests, or when the file is large.
Urai sends the attachment to the model you chose and lets the provider decide what to do with it. There is no capability check. Images work on any vision model. PDFs are read by Gemini and Anthropic models.
An attachment is not a knowledge collection. An attachment belongs to one request or one thread, and the model reads it directly. A knowledge collection holds documents that your organization indexes once, and the model searches them. Use an attachment for a file the caller has now. Use a collection for a document library.
Send an image inline
Section titled “Send an image inline”Use the OpenAI image_url part with a base64 data: URL.
{ "model": "anthropic/claude-haiku-4-5", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "What is wrong with this chart?" }, { "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgoAAAA..." } } ] } ]}import base64
with open("chart.png", "rb") as f: data = base64.b64encode(f.read()).decode()
response = client.chat.completions.create( model="anthropic/claude-haiku-4-5", messages=[{ "role": "user", "content": [ {"type": "text", "text": "What is wrong with this chart?"}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{data}"}}, ], }],)Urai accepts OpenAI’s detail hint on the part and ignores it.
Send a document inline
Section titled “Send a document inline”Use the file part with file_data.
{ "type": "file", "file": { "filename": "invoice-2041.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQK..." }}file_data takes a data: URL or bare base64. With bare base64, Urai reads the
type from the extension in filename. Send a data: URL when you can, because
the type is then explicit.
filename is optional. Without it, Urai builds a name from the media type, so
image/png becomes image.png. Providers show the name to the model, so a real
name is better.
Remote URLs are not accepted
Section titled “Remote URLs are not accepted”Urai does not fetch http:// or https:// URLs in an image_url part. A
remote URL fails with 400 and the code invalid_attachment.
Two reasons:
- Fetching a URL that a caller names would turn this endpoint into a request forwarder for any address the service can reach.
- Passing a URL through unfetched behaves differently on each provider. OpenAI reads it, Anthropic drops it without a word, and Gemini takes only its own URLs. A clear error is better than an answer that quietly ignores the image.
Download the file yourself and send it inline, or upload it and send the id.
Upload a file
Section titled “Upload a file”POST /files takes a multipart body, the same shape as OpenAI’s Files API.
curl https://chat.app.urai.dev/api/openai/v1/files \ -H "Authorization: Bearer $URAI_API_KEY" \ -F "file=@invoice-2041.pdf" \ -F "purpose=user_data" \ -F "user=customer-4821"| Field | Required | Notes |
|---|---|---|
file |
yes | The file part. Urai reads the filename and the content type from it. |
purpose |
no | Stored and returned as you sent it. The default is user_data. |
user |
no | Urai extension. The end user the file belongs to. |
Urai returns 201 and the file object.
{ "id": "9d4c1f7e-...", "object": "file", "bytes": 184320, "created_at": 1710000000, "filename": "invoice-2041.pdf", "purpose": "user_data", "mime_type": "application/pdf"}mime_type is a Urai addition to OpenAI’s shape. Read it to learn what the
server detected before you decide whether your model can use the file.
Urai takes the content type from the upload, unless the client sends
application/octet-stream. Browsers and curl often send that for every file, so
in that case Urai reads the type from the filename extension instead.
Reference a file by id
Section titled “Reference a file by id”Two forms work, and you can mix them.
Inside a message, as a content part:
{ "role": "user", "content": [ { "type": "text", "text": "Total this invoice." }, { "type": "file", "file": { "file_id": "9d4c1f7e-..." } } ]}Or at the top level of the request body:
{ "model": "anthropic/claude-haiku-4-5", "messages": [{ "role": "user", "content": "Total this invoice." }], "attachments": ["9d4c1f7e-..."]}The top-level field exists for the same reason as
knowledge.
A typed OpenAI SDK builds content parts from a fixed shape, so a file_id part
is out of reach in some of them. Every client can set a root-level field.
response = client.chat.completions.create( model="anthropic/claude-haiku-4-5", messages=[{"role": "user", "content": "Total this invoice."}], user="customer-4821", extra_body={"attachments": ["9d4c1f7e-..."]},)Urai attaches every id in attachments to the last user message in the
request. A request with no user message fails with 400.
Files belong to a key and a user
Section titled “Files belong to a key and a user”A file is scoped to the API key that uploaded it and to the user value sent
with it. This is the same rule that
threads follow.
The consequence matters:
- Upload with
user=customer-4821, and the completion that references the file must also setuser=customer-4821. - Omit
useron the upload, and omit it on the completion. - A mismatch returns
404with the codefile_not_found, the same as an id that does not exist. One end user cannot learn that another user’s file exists.
Manage uploaded files
Section titled “Manage uploaded files”| Method and path | Purpose |
|---|---|
POST /files |
Upload a file. Returns 201 and the file object. |
GET /files |
List the 100 most recent files for this key and user. |
GET /files/{id} |
Read one file object. |
GET /files/{id}/content |
Download the bytes. |
DELETE /files/{id} |
Delete the file. |
Every read and delete takes the principal as a query parameter, because these requests have no body:
curl "https://chat.app.urai.dev/api/openai/v1/files?user=customer-4821" \ -H "Authorization: Bearer $URAI_API_KEY"DELETE returns {"id": "...", "object": "file", "deleted": true}.
Deleting a file does not change a completion that already used it. The bytes are copied into the conversation when the completion runs, so earlier turns keep their copy.
Files across turns of one thread
Section titled “Files across turns of one thread”Urai stores every attachment against the message it arrived on, including bytes you sent inline. A follow-up turn on the same thread therefore still sees the image or the document.
# First turn: send the image, and keep the thread id.curl https://chat.app.urai.dev/api/openai/v1/chat/completions \ -H "Authorization: Bearer $URAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "anthropic/claude-haiku-4-5", "messages": [...] }' -i
# Second turn: no image, and the model still has it.curl https://chat.app.urai.dev/api/openai/v1/chat/completions \ -H "Authorization: Bearer $URAI_API_KEY" \ -H "Content-Type: application/json" \ -H "x-thread-id: 0723cee0-3912-434e-8450-2c701e6f9713" \ -d '{ "model": "anthropic/claude-haiku-4-5", "messages": [{ "role": "user", "content": "And the second bar?" }] }'Replay is best effort. If the stored bytes are gone, that turn falls back to its text and the request still succeeds.
Where attachments are read
Section titled “Where attachments are read”| Message role | Behaviour |
|---|---|
user |
Attachments are sent to the model. The text goes first, then each file. |
system |
Attachments are ignored. No provider accepts a file on a system prompt. |
assistant |
Attachments are ignored. Every provider drops them. |
Urai ignores a content part whose type it does not recognise, rather than
failing the request. New OpenAI part types appear regularly, and a client that
sends one should not break.
Limits and errors
Section titled “Limits and errors”| Limit | Value |
|---|---|
| Attachments in one request | 16 |
| Size of one attachment | 25 MiB after decoding |
| Completion request body | 64 MiB. Base64 adds about one third to the size of the bytes. |
Files returned by GET /files |
The 100 most recent |
| Status | Code | Cause |
|---|---|---|
| 400 | invalid_attachment |
A remote URL, a bad data URL, invalid base64, or a file part with neither file_data nor file_id. |
| 400 | attachment_too_large |
One attachment is over 25 MiB. |
| 400 | too_many_attachments |
More than 16 attachments in one request. |
| 400 | invalid_multipart |
POST /files could not read the multipart body. |
| 400 | missing_file |
POST /files had no file part. |
| 400 | invalid_file |
The uploaded file is empty. |
| 404 | file_not_found |
Unknown id, or the id belongs to another user. |
Nothing is dropped without a word. An attachment that Urai cannot resolve fails the request.