Skip to content
Merged
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/agent-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@upstash/box": patch
---

Add type-safe options to RunOptions and StreamOptions for passing SDK-specific options to Claude Code, Codex, and OpenCode agents
5 changes: 5 additions & 0 deletions .changeset/box-sizes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@upstash/box": patch
---

Add configurable box sizes (small, medium, large) to Box.create() and Box.fromSnapshot()
5 changes: 5 additions & 0 deletions .changeset/delete-boxes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@upstash/box": patch
---

Add Box.delete() static method for bulk box deletion
5 changes: 5 additions & 0 deletions .changeset/prompt-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@upstash/box": patch
---

Add multi-modal prompt files support for run and stream (file paths as multipart, base64 as JSON)
5 changes: 5 additions & 0 deletions .changeset/skills-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@upstash/box": patch
---

Add box.skills namespace for managing platform skills (add, remove, list)
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:

- run: pnpm build

- run: pnpm test:integration
- run: pnpm -r test:integration
env:
UPSTASH_BOX_API_KEY: ${{ secrets.UPSTASH_BOX_API_KEY }}
AGENT_API_KEY: ${{ secrets.AGENT_API_KEY }}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
"devDependencies": {
"@changesets/cli": "^2.29.4",
"dotenv": "^17.3.1",
"vitest": "^4.1.1"
"vitest": "^4.1.2"
}
}
25 changes: 25 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,31 @@ The `provider` field in agent config is optional — the SDK infers it from the
| `OpenRouterModel.O3` | `openrouter/openai/o3` |
| `OpenRouterModel.O4_Mini` | `openrouter/openai/o4-mini` |

## Box Sizes

Boxes have configurable resource sizes, set at creation time via the `size` option. Defaults to `"small"`.

| Size | CPU | Memory |
| -------- | ------- | ------ |
| `small` | 2 cores | 2 GB |
| `medium` | 4 cores | 8 GB |
| `large` | 8 cores | 16 GB |

```ts
const box = await Box.create({
size: "large",
agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_5 },
});

console.log(box.size); // "large"
```

Also supported in `Box.fromSnapshot()`:

```ts
const box = await Box.fromSnapshot("snap_abc123", { size: "medium" });
```

## Runtimes

`Runtime` is a string union type: `"node" | "python" | "golang" | "ruby" | "rust"`
Expand Down
171 changes: 169 additions & 2 deletions packages/sdk/src/__tests__/box-agent-run.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { z } from "zod/v3";
import { Agent } from "../types.js";
import type { Chunk } from "../types.js";
import { mockSSEResponse, mockResponse, createTestBox, mockSSEResponseChunked } from "./helpers.js";

Expand Down Expand Up @@ -213,7 +214,7 @@ describe("box.agent.run", () => {
},
});

expect(run.id).toBe("box-123");
expect(run.id).toEqual(expect.any(String));

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
Expand All @@ -240,7 +241,7 @@ describe("box.agent.run", () => {
webhook: { url: "https://example.com/hook" },
});

expect(run.id).toBe("box-123");
expect(run.id).toEqual(expect.any(String));

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
Expand Down Expand Up @@ -285,6 +286,123 @@ describe("box.agent.run", () => {
}),
).rejects.toThrow("bad request");
});

it("sends ClaudeCode agent_options in request body", async () => {
const { box, fetchMock } = await createTestBox<Agent.ClaudeCode>();

fetchMock.mockResolvedValueOnce(
mockSSEResponse([
{ event: "run_start", data: { run_id: "r1" } },
{ event: "done", data: { output: "ok" } },
]),
);

await box.agent.run({
prompt: "test",
options: {
maxTurns: 5,
effort: "max",
thinking: { type: "enabled", budgetTokens: 16000 },
},
});

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toEqual({
maxTurns: 5,
effort: "max",
thinking: { type: "enabled", budgetTokens: 16000 },
});
});

it("sends Codex agent_options in request body", async () => {
const { box, fetchMock } = await createTestBox<Agent.Codex>({ agent: Agent.Codex });

fetchMock.mockResolvedValueOnce(
mockSSEResponse([
{ event: "run_start", data: { run_id: "r1" } },
{ event: "done", data: { output: "ok" } },
]),
);

await box.agent.run({
prompt: "test",
options: {
modelReasoningEffort: "high",
personality: "pragmatic",
webSearch: true,
},
});

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toEqual({
model_reasoning_effort: "high",
personality: "pragmatic",
web_search: true,
});
});

it("sends OpenCode agent_options in request body", async () => {
const { box, fetchMock } = await createTestBox<Agent.OpenCode>();

fetchMock.mockResolvedValueOnce(
mockSSEResponse([
{ event: "run_start", data: { run_id: "r1" } },
{ event: "done", data: { output: "ok" } },
]),
);

await box.agent.run({
prompt: "test",
options: {
reasoningEffort: "high",
textVerbosity: "low",
reasoningSummary: "concise",
},
});

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toEqual({
reasoningEffort: "high",
textVerbosity: "low",
reasoningSummary: "concise",
});
});

it("does not send agent_options when not provided", async () => {
const { box, fetchMock } = await createTestBox();

fetchMock.mockResolvedValueOnce(
mockSSEResponse([
{ event: "run_start", data: { run_id: "r1" } },
{ event: "done", data: { output: "ok" } },
]),
);

await box.agent.run({ prompt: "test" });

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toBeUndefined();
});

it("sends agent_options in webhook run", async () => {
const { box, fetchMock } = await createTestBox<Agent.ClaudeCode>();

fetchMock.mockResolvedValueOnce(mockResponse({ status: "accepted", box_id: "box-123" }));

await box.agent.run({
prompt: "test",
options: { maxTurns: 3 },
webhook: { url: "https://example.com/hook" },
});

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toEqual({ maxTurns: 3 });
});
});

describe("box.agent.stream", () => {
Expand Down Expand Up @@ -461,4 +579,53 @@ describe("box.agent.stream", () => {
expect(run.result).toBe("partial");
expect(run.cost.computeMs).toBeGreaterThanOrEqual(0);
});

it("sends agent_options in stream request body (OpenCode)", async () => {
const { box, fetchMock } = await createTestBox<Agent.OpenCode>();

fetchMock.mockResolvedValueOnce(
mockSSEResponse([
{ event: "run_start", data: { run_id: "r1" } },
{ event: "done", data: { output: "ok" } },
]),
);

const run = await box.agent.stream({
prompt: "test",
options: { reasoningEffort: "high", textVerbosity: "low" },
});
for await (const _ of run) {
// consume
}

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toEqual({ reasoningEffort: "high", textVerbosity: "low" });
});

it("sends agent_options in stream request body (Codex)", async () => {
const { box, fetchMock } = await createTestBox<Agent.Codex>({ agent: Agent.Codex });

fetchMock.mockResolvedValueOnce(
mockSSEResponse([
{ event: "run_start", data: { run_id: "r1" } },
{ event: "done", data: { output: "ok" } },
]),
);

const run = await box.agent.stream({
prompt: "test",
options: { modelReasoningEffort: "medium", personality: "friendly" },
});
for await (const _ of run) {
// consume
}

const [, runCall] = fetchMock.mock.calls;
const body = JSON.parse(runCall[1].body as string);
expect(body.agent_options).toEqual({
model_reasoning_effort: "medium",
personality: "friendly",
});
});
});
29 changes: 29 additions & 0 deletions packages/sdk/src/__tests__/box-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,35 @@ describe("Box.create", () => {
expect(box.networkPolicy).toEqual({ mode: "allow-all" });
});

it("sends size in body when provided", async () => {
const data = { ...TEST_BOX_DATA, status: "running", size: "large" };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data));

const box = await Box.create({ ...TEST_CONFIG, size: "large" });

const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string);
expect(body.size).toBe("large");
expect(box.size).toBe("large");
});

it("omits size from body when not provided", async () => {
const data = { ...TEST_BOX_DATA, status: "running" };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data));

await Box.create(TEST_CONFIG);

const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string);
expect(body.size).toBeUndefined();
});

it("defaults size to small when not in response", async () => {
const data = { ...TEST_BOX_DATA, status: "running" };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data));

const box = await Box.create(TEST_CONFIG);
expect(box.size).toBe("small");
});

it("throws on API error response", async () => {
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: "rate limited" }, 429));

Expand Down
58 changes: 58 additions & 0 deletions packages/sdk/src/__tests__/box-delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Box, BoxError } from "../client.js";
import { mockResponse, TEST_CONFIG } from "./helpers.js";

describe("Box.delete (static)", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
delete process.env.UPSTASH_BOX_API_KEY;
});
afterEach(() => vi.restoreAllMocks());

it("deletes a single box by ID", async () => {
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));

await Box.delete({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl, boxIds: "box-1" });

const [url, init] = vi.mocked(fetch).mock.calls[0]!;
expect(url).toBe(`${TEST_CONFIG.baseUrl}/v2/box`);
expect(init?.method).toBe("DELETE");
const body = JSON.parse(init?.body as string);
expect(body.ids).toEqual(["box-1"]);
});

it("deletes multiple boxes by ID", async () => {
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));

await Box.delete({
apiKey: TEST_CONFIG.apiKey,
baseUrl: TEST_CONFIG.baseUrl,
boxIds: ["box-1", "box-2", "box-3"],
});

const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string);
expect(body.ids).toEqual(["box-1", "box-2", "box-3"]);
});

it("throws when apiKey is missing", async () => {
await expect(Box.delete({ boxIds: "box-1" })).rejects.toThrow("apiKey is required");
});

it("uses env var for apiKey", async () => {
process.env.UPSTASH_BOX_API_KEY = "env-key";
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));

await Box.delete({ boxIds: "box-1" });

const [, init] = vi.mocked(fetch).mock.calls[0]!;
expect((init?.headers as Record<string, string>)["X-Box-Api-Key"]).toBe("env-key");
});

it("throws on API error", async () => {
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: "not found" }, 404));

await expect(
Box.delete({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl, boxIds: "bad-id" }),
).rejects.toThrow("not found");
});
});
Loading
Loading