Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/blob-eve-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@vercel/blob": minor
---

Add `@vercel/blob/eve` Eve extension with tools for listing, reading, uploading, deleting, copying, renaming, and creating folders in a Blob store.

The destructive tools `del` and `rename` are gated on human approval (`approval: always()`) and pause the run until a person responds. Consumers can relax this with a directory mount override.

`zod` is now an optional peer dependency (`^4`) rather than a runtime dependency, alongside the existing optional `eve` peer. Both must be installed to use the `@vercel/blob/eve` subpath; neither is needed by the core SDK.

`CreateFolderCommandOptions` is widened to include the `storeId` and `oidcToken` auth options, so `createFolder` now declares the same Vercel OIDC authentication options as the other Blob commands. These options were already forwarded at runtime; this is a type-only, backward-compatible change.
6 changes: 5 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v5

# Pinned ahead of `.node-version` (lts/iron) on purpose: publint validates
# the published artifact, and building `@vercel/blob`'s `./eve` extension
# entry points requires Node >=24. On older versions that build is skipped,
# which would leave publint checking exports that were never emitted.
- uses: actions/setup-node@v6
with:
node-version-file: ".node-version"
node-version: 24
cache: "pnpm"

- name: Install dependencies
Expand Down
69 changes: 69 additions & 0 deletions packages/blob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,75 @@ We have examples on the vercel.com documentation, there are two ways to upload f
1. [Server uploads](https://vercel.com/docs/vercel-blob/server-upload): This is the most common way to upload files. The file is first sent to your server and then to Vercel Blob. It's straightforward to implement, but you are limited to the request body your server can handle. Which in case of a Vercel-hosted website is 4.5 MB. **This means you can't upload files larger than 4.5 MB on Vercel when using this method.**
2. [Client uploads](https://vercel.com/docs/vercel-blob/client-upload): This is a more advanced solution for when you need to upload larger files. The file is securely sent directly from the client (a browser for example) to Vercel Blob. This requires a bit more work to implement, but it allows you to upload files up to 5 TB.

## Eve extension

Mount Vercel Blob tools in an [Eve](https://eve.dev) agent.

`eve` and `zod` are optional peer dependencies: they are only needed for the
`@vercel/blob/eve` subpath, and the consuming agent supplies both.

```sh
pnpm add @vercel/blob eve zod
```

```ts
// agent/extensions/blob.ts
import blob from '@vercel/blob/eve';

export default blob({
token: process.env.BLOB_READ_WRITE_TOKEN,
});
```

The extension declares a config schema, so its default export is a mount
factory that must be called — even when every field is left out. Pass an empty
object to let the tools fall back to `BLOB_READ_WRITE_TOKEN`, `BLOB_STORE_ID`,
and `VERCEL_OIDC_TOKEN` from the environment:

```ts
// agent/extensions/blob.ts
import blob from '@vercel/blob/eve';

export default blob({});
```

The bare `export { default } from '@vercel/blob/eve'` form only works for
extensions that declare no config, and will not type-check here.

Mounted tools include `list`, `head`, `get`, `put`, `del`, `copy`, `rename`, and `create_folder`.

### Approval for destructive tools

`del` and `rename` ship with `approval: always()`, so each call pauses the run
and waits for a person before it executes. To relax that, use a directory mount
and override the tool in a same-named slot:

```
agent/extensions/blob/
extension.ts
tools/del.ts
```

```ts
// agent/extensions/blob/extension.ts
import blob from '@vercel/blob/eve';

export default blob({});
```

```ts
// agent/extensions/blob/tools/del.ts
import { del } from '@vercel/blob/eve/tools';
import { defineTool } from 'eve/tools';
import { never } from 'eve/tools/approval';

export default defineTool({ ...del, approval: never() });
```

The directory name is still the mount namespace, so the tool stays `blob__del`.
See [Eve extensions](https://eve.dev/docs/extensions) for mount namespaces and overrides,
and [human-in-the-loop](https://eve.dev/docs/human-in-the-loop) for the approval helpers.

## Releasing

Make sure to include a changeset in your PR. You can do this by running:
Expand Down
243 changes: 243 additions & 0 deletions packages/blob/eve/__tests__/get.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/**
* Branching in the `get` tool: what gets inlined into the model's context,
* what gets omitted, and how misses / conditional requests are reported.
*/

import type { Interceptable, MockAgent } from 'undici';
import {
BLOB_STORE_BASE_URL,
getTool,
setupMockAgent,
toolContext,
} from './harness';

const LAST_MODIFIED = 'Thu, 04 May 2023 15:12:07 GMT';
const MAX_INLINE_TEXT_BYTES = 64 * 1024;

type GetOutput = Awaited<ReturnType<typeof getTool.execute>> &
Record<string, unknown>;

describe('get tool', () => {
let mockAgent: MockAgent;
let storeClient: Interceptable;

beforeEach(() => {
mockAgent = setupMockAgent();
storeClient = mockAgent.get(BLOB_STORE_BASE_URL);
});

function replyWith(
body: string,
contentType: string,
{ size = body.length }: { size?: number } = {},
): void {
storeClient
.intercept({ path: '/foo.txt', method: 'GET' })
.reply(200, body, {
headers: {
'content-type': contentType,
'content-length': String(size),
'last-modified': LAST_MODIFIED,
etag: '"abc123"',
'cache-control': 'public, max-age=31536000',
'content-disposition': 'inline; filename="foo.txt"',
},
});
}

async function run(
input: Partial<Parameters<typeof getTool.execute>[0]> = {},
): Promise<GetOutput> {
return (await getTool.execute(
{
urlOrPathname: `${BLOB_STORE_BASE_URL}/foo.txt`,
access: 'public',
...input,
},
toolContext(),
)) as GetOutput;
}

it('inlines small text payloads', async () => {
replyWith('hello world', 'text/plain');

const result = await run();

expect(result.text).toBe('hello world');
expect(result.inlineTextOmitted).toBeUndefined();
expect(result.statusCode).toBe(200);
expect(result.contentType).toBe('text/plain');
expect(result.size).toBe(11);
expect(result.url).toBe(`${BLOB_STORE_BASE_URL}/foo.txt`);
expect(result.downloadUrl).toBe(
`${BLOB_STORE_BASE_URL}/foo.txt?download=1`,
);
expect(result.pathname).toBe('foo.txt');
expect(result.etag).toBe('"abc123"');
// eve tool output has to be JSON-serialisable, so this must be a string.
expect(typeof result.uploadedAt).toBe('string');
expect(result.uploadedAt).toBe(new Date(LAST_MODIFIED).toISOString());
});

it('inlines small JSON payloads', async () => {
replyWith('{"a":1}', 'application/json');

const result = await run();

expect(result.text).toBe('{"a":1}');
expect(result.inlineTextOmitted).toBeUndefined();
});

// A content-type header may carry parameters and is case-insensitive, so the
// media type has to be matched on its own rather than compared verbatim.
it.each([
['application/json; charset=utf-8', '{"a":1}'],
['application/json;charset=UTF-8', '{"a":1}'],
['text/plain; charset=utf-8', 'hello'],
['Application/JSON', '{"a":1}'],
['TEXT/PLAIN', 'hello'],
['application/ld+json', '{"@id":"x"}'],
])('inlines %s', async (contentType, body) => {
replyWith(body, contentType);

const result = await run();

expect(result.text).toBe(body);
expect(result.inlineTextOmitted).toBeUndefined();
});

it.each([
['image/png'],
['application/octet-stream'],
['application/json-seq'],
])('still omits %s', async (contentType) => {
replyWith('not inlined', contentType);

const result = await run();

expect(result.text).toBeUndefined();
expect(result.inlineTextOmitted).toBe(true);
});

it('inlines a text payload sitting exactly on the 64 KiB ceiling', async () => {
const body = 'a'.repeat(MAX_INLINE_TEXT_BYTES);
replyWith(body, 'text/plain');

const result = await run();

expect(result.size).toBe(MAX_INLINE_TEXT_BYTES);
expect(result.text).toBe(body);
expect(result.inlineTextOmitted).toBeUndefined();
});

/** Replies without a `content-length` header, as a chunked response would. */
function replyWithoutContentLength(body: string, contentType: string): void {
storeClient
.intercept({ path: '/foo.txt', method: 'GET' })
.reply(200, body, {
headers: {
'content-type': contentType,
'last-modified': LAST_MODIFIED,
etag: '"abc123"',
'cache-control': 'public, max-age=31536000',
'content-disposition': 'inline; filename="foo.txt"',
},
});
}

// Regression: `blob.size` is populated from `content-length`, which `get()`
// reports as 0 when the header is absent. Gating inlining on that value alone
// let a body of any size straight into the model's context.
it('omits an oversized body that arrives without content-length', async () => {
replyWithoutContentLength('a'.repeat(200 * 1024), 'text/plain');

const result = await run();

expect(result.size).toBe(0);
expect(result.text).toBeUndefined();
expect(result.inlineTextOmitted).toBe(true);
});

it('still inlines a small body that arrives without content-length', async () => {
replyWithoutContentLength('hello world', 'text/plain');

const result = await run();

expect(result.size).toBe(0);
expect(result.text).toBe('hello world');
expect(result.inlineTextOmitted).toBeUndefined();
});

it('counts multi-byte characters as bytes, not code units', async () => {
// 'é' is 2 bytes in UTF-8, so this is ~128 KiB on the wire while
// `String.length` reports only 64 KiB worth of code units.
replyWithoutContentLength('é'.repeat(MAX_INLINE_TEXT_BYTES), 'text/plain');

const result = await run();

expect(result.text).toBeUndefined();
expect(result.inlineTextOmitted).toBe(true);
});

it('omits text one byte over the 64 KiB ceiling', async () => {
const body = 'a'.repeat(MAX_INLINE_TEXT_BYTES + 1);
replyWith(body, 'text/plain');

const result = await run();

expect(result.size).toBe(MAX_INLINE_TEXT_BYTES + 1);
expect(result.text).toBeUndefined();
expect(result.inlineTextOmitted).toBe(true);
// Metadata still comes back so the model can hand off the URL.
expect(result.url).toBe(`${BLOB_STORE_BASE_URL}/foo.txt`);
expect(result.contentType).toBe('text/plain');
});

it('omits text for a non-inlineable content type even when small', async () => {
replyWith('‰PNG fake bytes', 'image/png');

const result = await run();

expect(result.text).toBeUndefined();
expect(result.inlineTextOmitted).toBe(true);
expect(result.contentType).toBe('image/png');
expect(result.size).toBeLessThan(MAX_INLINE_TEXT_BYTES);
});

it('reports a miss as found: false rather than throwing', async () => {
storeClient.intercept({ path: '/foo.txt', method: 'GET' }).reply(404, '');

await expect(run()).resolves.toEqual({ found: false });
});

it('reports a 304 as notModified with the etag and no body', async () => {
storeClient.intercept({ path: '/foo.txt', method: 'GET' }).reply(304, '', {
headers: {
etag: '"abc123"',
'last-modified': LAST_MODIFIED,
},
});

const result = await run({ ifNoneMatch: '"abc123"' });

expect(result).toEqual({
statusCode: 304,
notModified: true,
etag: '"abc123"',
});
});

it('sends the If-None-Match header supplied by the model', async () => {
let sentHeaders: Record<string, string> = {};
storeClient
.intercept({ path: '/foo.txt', method: 'GET' })
.reply(200, (req) => {
sentHeaders = req.headers as Record<string, string>;
return 'hello';
});

await run({ ifNoneMatch: '"etag-from-model"' });

expect(sentHeaders['If-None-Match']).toBe('"etag-from-model"');
});
});
Loading
Loading