Skip to content

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.

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.

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.

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.

POST /files takes a multipart body, the same shape as OpenAI’s Files API.

Terminal window
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.

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.

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 set user=customer-4821.
  • Omit user on the upload, and omit it on the completion.
  • A mismatch returns 404 with the code file_not_found, the same as an id that does not exist. One end user cannot learn that another user’s file exists.
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:

Terminal window
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.

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.

Terminal window
# 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.

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.

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.