Skip to content

File upload + reference

Pass a file path (or a Blob) as an attachment to complete(). The SDK uploads it to the provider’s file storage on first use, caches the remote id on the FileAttachment object, and sends a reference in subsequent calls — no repeated byte transfers. For providers without file storage, the file is inlined as base64 automatically.

Provider file APIs let you upload once and reuse across many requests. The wins:

  • Reduced token cost — file content bytes are not retransmitted per request; only a file id is sent.
  • Large files — some providers accept files up to 2 GB via the Files API but cap inline base64 at a few MB.
  • Multi-turn document Q&A — upload a PDF once, ask many questions without resending.

The raw problem: OpenAI requires a multipart files.create() call then { type: 'input_file', file_id } in the message content. Google uses uploadFile() and a fileUri. Anthropic has its own Files API endpoint with source: { type: 'file', file_id } in content blocks. Three upload methods, three reference shapes, three expiry policies.

The simplest usage — pass a file path string in attachments:

import { complete } from '@combycode/llm-sdk';
const { text } = await complete({
model: process.env.LLM_MODEL!,
apiKey: process.env.LLM_API_KEY,
prompt: 'What word is in this file? Reply with just the word.',
attachments: ['./banana.txt'],
maxTokens: 32,
});
console.log(text.trim().toLowerCase()); // 'banana'

The attachments array accepts:

  • A string path (Node/Bun — resolved relative to cwd)
  • A Uint8Array (raw bytes — treated as image by MIME detection)
  • A ContentPart (used as-is — full control)

When a file attachment is resolved before the request is sent, the FilesRegistry applies a FileStrategy decision:

ConditionDecision
Provider has no registered file adapterinline (base64 in message body)
File already uploaded and not expiredUse cached provider_ref (the stored remote id)
File is expiredreupload (re-upload, get a new id)
File is a URL and provider supports direct URLs (OpenAI, xAI)url (send the URL directly)
File is smaller than 50 KBinline (base64 — skip the upload round-trip)
File is 50 KB or largerupload (upload once, cache the remote id)
File exceeds provider max sizeskip (replace with a text placeholder)
Provider does not support the MIME typeskip

The inline threshold (50 KB) is the default. You can override it by passing a custom FileStrategy when building the engine.

Step 3 — Upload a Blob (browser or Node)

Section titled “Step 3 — Upload a Blob (browser or Node)”
import { complete, FileAttachment } from '@combycode/llm-sdk';
// From a browser <input type="file">
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const blob = input.files![0];
const attachment = FileAttachment.fromBlob(blob);
const { text } = await complete({
model: 'openai/gpt-4o',
prompt: 'Describe the contents of this file.',
attachments: [attachment], // ContentPart-compatible
maxTokens: 512,
});

FileAttachment.fromBlob(blob) reads blob.name, blob.type, and blob.size automatically.

FileAttachment tracks upload state per provider via uploads: Map<string, FileUploadState>:

import { FileAttachment } from '@combycode/llm-sdk';
const file = new FileAttachment({
filename: 'report.pdf',
mimeType: 'application/pdf',
sizeBytes: 204800,
content: { type: 'path', mimeType: 'application/pdf', path: './report.pdf' },
});
// After complete() has run -- state is stored on the file object:
console.log(file.isAvailable('openai')); // true after first call
console.log(file.getRef('openai')); // 'file-abc123' (OpenAI remote id)
const state = file.uploads.get('openai');
console.log(state?.status); // 'uploaded' | 'pending' | 'expired' | 'deleted' | 'error'
console.log(state?.remoteId); // provider's file id
console.log(state?.expiresAt); // Unix ms timestamp or null
ProviderMax file sizeExpiry
OpenAI512 MB (Responses API)Persistent until deleted
Anthropic— (Files API)30 days
Google2 GB (File API)48 hours
xAIProvider-dependent

The strategy checks expiresAt against Date.now() before every request. If expired, the file is re-uploaded automatically. file.uploads.get(provider)?.status transitions from 'uploaded' to 'expired' when the TTL passes.

Step 6 — Use DataSource: 'file' in content parts (advanced)

Section titled “Step 6 — Use DataSource: 'file' in content parts (advanced)”

When building messages manually, reference a FileAttachment via a file data source:

import { complete, FileAttachment } from '@combycode/llm-sdk';
const file = new FileAttachment({
filename: 'data.pdf',
mimeType: 'application/pdf',
sizeBytes: 10000,
content: { type: 'path', mimeType: 'application/pdf', path: './data.pdf' },
});
const { text } = await complete({
model: 'anthropic/claude-opus-4.8',
prompt: [
{
role: 'user',
content: [
{
type: 'document',
source: { type: 'file', fileId: file.id },
},
{ type: 'text', text: 'Summarise this document in two sentences.' },
],
},
],
maxTokens: 256,
});

The onMessageResolve hook fires before the request is sent and replaces the file source with either a provider_ref (uploaded) or base64 (inlined) source.

attachments in CompleteOptions:

Value typeBehaviour
string (path)File read from disk (Node/Bun). MIME type inferred from extension.
string (http(s)://)Treated as a URL source. Provider-specific: OpenAI/xAI send the URL directly; others fetch and inline.
Uint8ArrayTreated as image data; MIME type defaults to image/jpeg.
ContentPartUsed as-is — full control over type and source.
FileAttachmentManaged file — upload state tracked, remote id reused automatically.

FileContent types (for FileAttachment constructor):

typeWhen to use
'path'Node/Bun — file on disk. Read lazily when needed.
'buffer'Raw Uint8Array bytes already in memory.
'blob'Browser Blob or File object.
'base64'Already-encoded base64 string. Skips the encoding step.
'url'Remote URL. Sent directly to providers that accept URLs; fetched + inlined for others.

FileDecision actions (what the strategy decides):

ActionMeaning
'upload'Upload the file, cache the remote id, send provider_ref.
'reupload'Previous upload expired; upload again, get a new id.
'inline'Encode as base64 and embed directly in the message body.
'url'Send the file URL directly (provider fetches it).
'skip'Provider does not support this file type or the file is too large; replaced with a text placeholder.

Custom strategy:

import { DefaultFileStrategy, type FileStrategyContext, type FileDecision } from '@combycode/llm-sdk';
class AlwaysInlineStrategy extends DefaultFileStrategy {
decide(ctx: FileStrategyContext): FileDecision {
return { action: 'inline', reason: 'always inline for this engine' };
}
}

Pass the custom strategy when building a FilesRegistry or engine.

import { complete } from '@combycode/llm-sdk';

// File grounding via the unified attachments API. (Official openai uploads via
// files.create then references input_file by id; google inlines / uses the File
// API.) Here a single attachments entry should carry the document through.
const t0 = performance.now();
const { text } = await complete({
  model: process.env.LLM_MODEL!,
  apiKey: process.env.LLM_API_KEY,
  prompt: 'What word is in this file? Reply with just the word.',
  attachments: ['../../official-samples/_fixtures/banana.txt'],
  maxTokens: 32,
});
console.log(JSON.stringify({ result: text.trim() || 'empty', ms: Math.round(performance.now() - t0) }));

OpenAI’s SDK requires calling files.create() with a ReadStream or File object, storing the returned file_id, then referencing it as { type: 'input_file', file_id } in message content — three separate steps per file per provider. Google and Anthropic each have their own upload methods and reference shapes. ORXA’s FilesRegistry handles upload, caching, expiry detection, and reference injection automatically. The FileAttachment object persists upload state across calls, so the same file object can be reused in a conversation loop without re-uploading.

Files are tracked per FileAttachment instance, not by path. Two FileAttachment objects created from the same path are independent — each will upload separately. Reuse the same object to benefit from upload caching.

Small files (under 50 KB) are inlined, not uploaded. If you pass a 30 KB PDF, it will be base64-encoded in the request body on every call, not uploaded to the Files API. This is intentional (avoids upload latency for small files). Override the threshold with a custom strategy if needed.

Google file expiry is 48 hours. If you store a FileAttachment in a database and restore it after 48 hours, file.isAvailable('google') will return false (expired) and the SDK will re-upload automatically.

attachments sugar is for one-shot calls. For multi-turn conversations where the same file is referenced across many messages, build the FileAttachment explicitly and include it in the message content manually — this gives you full control over the upload lifecycle.

Next steps: