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
5 changes: 5 additions & 0 deletions .changeset/vercel-sandbox-user-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-sandbox-vercel': patch
---

Vercel sandbox requests now append a `@tanstack/ai` token to the `user-agent`.
16 changes: 14 additions & 2 deletions packages/ai-sandbox-vercel/src/provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { APIError, Sandbox } from '@vercel/sandbox'
import { VERCEL_CAPS, VercelHandle } from './handle'
import { withSandboxUserAgent } from './user-agent'
import type {
SandboxCapabilities,
SandboxCreateInput,
Expand Down Expand Up @@ -67,6 +68,8 @@ export function isDirAlreadyExistsError(error: unknown): boolean {
class VercelProvider implements SandboxProvider {
readonly name = 'vercel'

private readonly fetch = withSandboxUserAgent()

constructor(private readonly config: VercelSandboxConfig) {}

capabilities(): SandboxCapabilities {
Expand Down Expand Up @@ -101,6 +104,7 @@ class VercelProvider implements SandboxProvider {
async create(input: SandboxCreateInput): Promise<SandboxHandle> {
const sandbox = await Sandbox.create({
...this.auth(),
fetch: this.fetch,
runtime: this.config.runtime ?? DEFAULT_RUNTIME,
...(this.config.timeout !== undefined
? { timeout: this.config.timeout }
Expand Down Expand Up @@ -133,7 +137,11 @@ class VercelProvider implements SandboxProvider {

async resume(input: SandboxResumeInput): Promise<SandboxHandle | null> {
try {
const sandbox = await Sandbox.get({ name: input.id, ...this.auth() })
const sandbox = await Sandbox.get({
name: input.id,
...this.auth(),
fetch: this.fetch,
})
return new VercelHandle({
sandbox,
workdir: this.workdir,
Expand All @@ -147,7 +155,11 @@ class VercelProvider implements SandboxProvider {

async destroy(input: SandboxDestroyInput): Promise<void> {
try {
const sandbox = await Sandbox.get({ name: input.id, ...this.auth() })
const sandbox = await Sandbox.get({
name: input.id,
...this.auth(),
fetch: this.fetch,
})
await sandbox.stop()
} catch {
// Already stopped / gone.
Expand Down
33 changes: 33 additions & 0 deletions packages/ai-sandbox-vercel/src/user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* User-agent tagging for Vercel Sandbox traffic.
*
* The `@vercel/sandbox` SDK sends a `user-agent` of
* `vercel/sandbox/<sdk-version> (…)` on every API request. We wrap the SDK's
* `fetch` so each request also carries a `@tanstack/ai` token.
*/

export const USER_AGENT_TOKEN = '@tanstack/ai'

/**
* Wraps a `fetch` so every request's `user-agent` contains the package name
* (e.g. `vercel/sandbox/2.2.1 (…) @tanstack/ai`). When no
* `user-agent` is present, the token becomes the whole header.
*/
export function withSandboxUserAgent(
inner: typeof globalThis.fetch = globalThis.fetch,
): typeof globalThis.fetch {
return (input, init) => {
const headers = new Headers(
init?.headers ??
(typeof input === 'object' && 'headers' in input
? input.headers
: undefined),
)
const existing = headers.get('user-agent')
headers.set(
'user-agent',
existing ? `${existing} ${USER_AGENT_TOKEN}` : USER_AGENT_TOKEN,
)
return inner(input, { ...init, headers })
}
}
73 changes: 73 additions & 0 deletions packages/ai-sandbox-vercel/tests/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { USER_AGENT_TOKEN, withSandboxUserAgent } from '../src/user-agent'

function userAgentOf(init: RequestInit | undefined): string | null {
return new Headers(init?.headers).get('user-agent')
}

describe('withSandboxUserAgent', () => {
it('appends the token to an existing user-agent (as the SDK sends)', async () => {
const inner = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(new Response())
const wrapped = withSandboxUserAgent(inner)

await wrapped('https://api.vercel.com/v2/sandboxes', {
headers: { 'user-agent': 'vercel/sandbox/2.2.1' },
})

const [, init] = inner.mock.calls[0]!
expect(userAgentOf(init)).toBe(`vercel/sandbox/2.2.1 ${USER_AGENT_TOKEN}`)
})

it('sets the token as the user-agent when none is present', async () => {
const inner = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(new Response())
const wrapped = withSandboxUserAgent(inner)

await wrapped('https://api.vercel.com/v2/sandboxes')

const [, init] = inner.mock.calls[0]!
expect(userAgentOf(init)).toBe(USER_AGENT_TOKEN)
})

it('preserves other request init fields (method, body, signal)', async () => {
const inner = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(new Response())
const wrapped = withSandboxUserAgent(inner)
const signal = new AbortController().signal

await wrapped('https://api.vercel.com/v2/sandboxes', {
method: 'POST',
body: '{"ok":true}',
signal,
})

const [, init] = inner.mock.calls[0]!
expect(init?.method).toBe('POST')
expect(init?.body).toBe('{"ok":true}')
expect(init?.signal).toBe(signal)
})

it('reads the user-agent off a Request input when no init is given', async () => {
const inner = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValue(new Response())
const wrapped = withSandboxUserAgent(inner)

await wrapped(
new Request('https://api.vercel.com/v2/sandboxes', {
headers: { 'user-agent': 'vercel/sandbox/2.2.1' },
}),
)

const [, init] = inner.mock.calls[0]!
expect(userAgentOf(init)).toBe(`vercel/sandbox/2.2.1 ${USER_AGENT_TOKEN}`)
})

it('defaults to globalThis.fetch when no inner fetch is supplied', () => {
expect(typeof withSandboxUserAgent()).toBe('function')
})
})