-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(sdk): add E2B client to the downstream SDK packages #1772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+1,533
−63
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
dd506d6
feat(sdk): add E2B client to the downstream SDK packages
devin-ai-integration[bot] 537135a
refactor(sdk): bind the client configuration on the resource classes
devin-ai-integration[bot] 59fb825
fix(sdk): snapshot the bound header maps
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| '@e2b/code-interpreter': minor | ||
| '@e2b/code-interpreter-python': minor | ||
| '@e2b/desktop': minor | ||
| '@e2b/desktop-python': minor | ||
| e2b: patch | ||
| '@e2b/python-sdk': patch | ||
| --- | ||
|
|
||
| Add an `E2B` client that binds the connection configuration explicitly, so the API key and domain no longer have to come from the environment variables: `new E2B({ apiKey, domain }).Sandbox.create()` in JavaScript and `E2B(api_key=..., domain=...).Sandbox.create()` in Python. The client exposes the package's own `Sandbox` (and `AsyncSandbox` in the Code Interpreter Python SDK) together with the core `Volume`, `Template` and `Secret` resources, per-call options still take precedence, and multiple clients are isolated from each other and from the env-configured top-level exports. | ||
|
|
||
| Binding the configuration to a class now lives on the resource classes themselves (internally), so the core and the downstream clients share one implementation instead of each package generating bound subclasses on its own. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { ConnectionOpts, Secret, Template, Volume } from 'e2b' | ||
|
|
||
| import { Sandbox } from './sandbox' | ||
|
|
||
| /** | ||
| * Connection options bound to an {@link E2B} client. | ||
| * | ||
| * Same as {@link ConnectionOpts} without `signal`, which cancels a single | ||
| * request and therefore can only be passed per call. | ||
| */ | ||
| export type E2BClientOpts = Omit<ConnectionOpts, 'signal'> | ||
|
|
||
| /** | ||
| * E2B client with an explicitly bound connection configuration. | ||
| * | ||
| * The resources exposed by the client ({@link E2B.Sandbox}, | ||
| * {@link E2B.Volume}, {@link E2B.Template}, {@link E2B.Secret}) behave exactly | ||
| * like the top-level `Sandbox` / `Volume` / `Template` / `Secret` exports, | ||
| * except the options passed to the client are used as the defaults instead of | ||
| * the environment variables. | ||
| * Per-call options still take precedence over the client's options. | ||
| * | ||
| * Multiple clients are fully isolated from each other and from the top-level | ||
| * env-configured exports. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { E2B } from '@e2b/code-interpreter' | ||
| * | ||
| * const client = new E2B({ apiKey: 'e2b_...', domain: 'e2b.dev' }) | ||
| * | ||
| * const sandbox = await client.Sandbox.create() | ||
| * const execution = await sandbox.runCode('x = 1; x += 1; x') | ||
| * ``` | ||
| */ | ||
| export class E2B { | ||
| /** | ||
| * Code Interpreter `Sandbox` class bound to this client's connection | ||
| * configuration. | ||
| */ | ||
| readonly Sandbox: typeof Sandbox | ||
|
|
||
| /** | ||
| * `Volume` class bound to this client's connection configuration. | ||
| */ | ||
| readonly Volume: typeof Volume | ||
|
|
||
| /** | ||
| * `Template` bound to this client's connection configuration. Both the | ||
| * builder (`client.Template()`) and the statics | ||
| * (`client.Template.build(...)`, `client.Template.exists(...)`, …) work like | ||
| * the top-level `Template`. | ||
| */ | ||
| readonly Template: typeof Template | ||
|
|
||
| /** | ||
| * `Secret` class bound to this client's connection configuration. | ||
| */ | ||
| readonly Secret: typeof Secret | ||
|
|
||
| /** | ||
| * Create a new client with the connection options bound to it. | ||
| * | ||
| * @param opts connection options used as the defaults for every call made | ||
| * through this client's resource classes. | ||
| */ | ||
| constructor(opts?: E2BClientOpts) { | ||
| this.Sandbox = Sandbox.withOpts(opts) | ||
| this.Volume = Volume.withOpts(opts) | ||
| this.Template = Template.withOpts(opts) | ||
| this.Secret = Secret.withOpts(opts) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| import { | ||
| createServer, | ||
| IncomingMessage, | ||
| Server, | ||
| ServerResponse, | ||
| } from 'node:http' | ||
| import { AddressInfo } from 'node:net' | ||
| import { afterAll, assert, beforeAll, beforeEach, test } from 'vitest' | ||
|
|
||
| import { E2B, Sandbox, Volume } from '../src' | ||
|
|
||
| const API_KEY_A = `e2b_${'a'.repeat(40)}` | ||
| const API_KEY_B = `e2b_${'b'.repeat(40)}` | ||
| const ENV_API_KEY = `e2b_${'e'.repeat(40)}` | ||
|
|
||
| const DOMAIN_A = 'client-a.test' | ||
| const DOMAIN_B = 'client-b.test' | ||
| const DOMAIN_ENV = 'env.test' | ||
|
|
||
| interface RecordedRequest { | ||
| path: string | ||
| apiKey?: string | ||
| body: unknown | ||
| } | ||
|
|
||
| let requests: RecordedRequest[] = [] | ||
| let server: Server | ||
| let apiUrl: string | ||
|
|
||
| const lastRequest = () => requests[requests.length - 1] | ||
| const apiKeys = () => requests.map((request) => request.apiKey) | ||
|
|
||
| async function handler(req: IncomingMessage, res: ServerResponse) { | ||
| const chunks: Buffer[] = [] | ||
| for await (const chunk of req) { | ||
| chunks.push(chunk as Buffer) | ||
| } | ||
| const raw = Buffer.concat(chunks).toString() | ||
|
|
||
| requests.push({ | ||
| path: req.url ?? '', | ||
| apiKey: (req.headers['x-api-key'] as string | undefined) ?? undefined, | ||
| body: raw ? JSON.parse(raw) : undefined, | ||
| }) | ||
|
|
||
| const respond = (status: number, body: unknown) => { | ||
| res.writeHead(status, { 'Content-Type': 'application/json' }) | ||
| res.end(JSON.stringify(body)) | ||
| } | ||
|
|
||
| const path = req.url ?? '' | ||
| if (path.startsWith('/sandboxes')) { | ||
| respond(201, { | ||
| sandboxID: 'test-sandbox-id', | ||
| templateID: 'base', | ||
| envdVersion: '0.2.4', | ||
| }) | ||
| } else if (path.startsWith('/volumes')) { | ||
| respond(201, { | ||
| volumeID: 'test-volume-id', | ||
| name: 'test-volume', | ||
| token: 'test-volume-token', | ||
| }) | ||
| } else if (path.startsWith('/templates/aliases/')) { | ||
| respond(200, { aliases: [], templateID: 'test-template-id' }) | ||
| } else if (path.startsWith('/secrets')) { | ||
| respond(201, { | ||
| secretID: 'test-secret-id', | ||
| name: 'test-secret', | ||
| currentVersion: 1, | ||
| metadata: {}, | ||
| createdAt: new Date().toISOString(), | ||
| updatedAt: new Date().toISOString(), | ||
| }) | ||
| } else { | ||
| respond(404, { code: 404, message: 'not found' }) | ||
| } | ||
| } | ||
|
|
||
| const envBackup: Record<string, string | undefined> = {} | ||
| const envOverrides: Record<string, string | undefined> = { | ||
| E2B_API_KEY: ENV_API_KEY, | ||
| E2B_DOMAIN: DOMAIN_ENV, | ||
| E2B_API_URL: undefined, | ||
| E2B_SANDBOX_URL: undefined, | ||
| E2B_DEBUG: undefined, | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| for (const [key, value] of Object.entries(envOverrides)) { | ||
| envBackup[key] = process.env[key] | ||
| if (value === undefined) { | ||
| delete process.env[key] | ||
| } else { | ||
| process.env[key] = value | ||
| } | ||
| } | ||
|
|
||
| server = createServer(handler) | ||
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) | ||
| apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` | ||
| }) | ||
|
|
||
| afterAll(async () => { | ||
| await new Promise<void>((resolve, reject) => | ||
| server.close((err) => (err ? reject(err) : resolve())) | ||
| ) | ||
|
|
||
| for (const [key, value] of Object.entries(envBackup)) { | ||
| if (value === undefined) { | ||
| delete process.env[key] | ||
| } else { | ||
| process.env[key] = value | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| beforeEach(() => { | ||
| requests = [] | ||
| }) | ||
|
|
||
| test('client.Sandbox.create uses the client config instead of env vars', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
|
|
||
| const sandbox = await client.Sandbox.create() | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A]) | ||
| assert.equal(sandbox.sandboxDomain, DOMAIN_A) | ||
| // The Code Interpreter template is still the default. | ||
| assert.equal( | ||
| (lastRequest().body as { templateID: string }).templateID, | ||
| 'code-interpreter-v1' | ||
| ) | ||
| }) | ||
|
|
||
| test('client.Sandbox instances keep the Code Interpreter API', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
|
|
||
| assert.notEqual(client.Sandbox, Sandbox) | ||
| assert.isTrue(client.Sandbox.prototype instanceof Sandbox) | ||
|
|
||
| const sandbox = await client.Sandbox.create() | ||
|
|
||
| assert.instanceOf(sandbox, Sandbox) | ||
| assert.instanceOf(sandbox, client.Sandbox) | ||
| assert.isFunction(sandbox.runCode) | ||
| assert.isFunction(sandbox.createCodeContext) | ||
| }) | ||
|
|
||
| test('per-call options take precedence over the client config', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
|
|
||
| const sandbox = await client.Sandbox.create({ | ||
| apiKey: API_KEY_B, | ||
| domain: DOMAIN_B, | ||
| }) | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_B]) | ||
| assert.equal(sandbox.sandboxDomain, DOMAIN_B) | ||
| }) | ||
|
|
||
| test('per-call options explicitly set to undefined keep the client config', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
|
|
||
| const sandbox = await client.Sandbox.create({ | ||
| apiKey: undefined, | ||
| domain: undefined, | ||
| }) | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A]) | ||
| assert.equal(sandbox.sandboxDomain, DOMAIN_A) | ||
| }) | ||
|
|
||
| test('two clients with different configs stay isolated', async () => { | ||
| const clientA = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
| const clientB = new E2B({ apiKey: API_KEY_B, domain: DOMAIN_B, apiUrl }) | ||
|
|
||
| const sandboxA = await clientA.Sandbox.create() | ||
| const sandboxB = await clientB.Sandbox.create() | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_B]) | ||
| assert.equal(sandboxA.sandboxDomain, DOMAIN_A) | ||
| assert.equal(sandboxB.sandboxDomain, DOMAIN_B) | ||
| }) | ||
|
|
||
| test('mutating the options object does not change the bound config', async () => { | ||
| const opts = { apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl } | ||
| const client = new E2B(opts) | ||
| opts.apiKey = API_KEY_B | ||
|
|
||
| await client.Sandbox.create() | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A]) | ||
| }) | ||
|
|
||
| test('client.Sandbox can be rebound to a variable', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
| const S = client.Sandbox | ||
|
|
||
| const sandbox = await S.create() | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A]) | ||
| assert.equal(sandbox.sandboxDomain, DOMAIN_A) | ||
| }) | ||
|
|
||
| test('the core resources are bound to the client config as well', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
|
|
||
| const volume = await client.Volume.create('test-volume') | ||
| assert.instanceOf(volume, Volume) | ||
| assert.deepEqual(apiKeys(), [API_KEY_A]) | ||
|
|
||
| assert.isTrue(await client.Template.exists('test-template')) | ||
| await client.Secret.create('test-secret', 'value') | ||
|
|
||
| assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_A, API_KEY_A]) | ||
| }) | ||
|
|
||
| test('the top-level Sandbox keeps using the environment configuration', async () => { | ||
| const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }) | ||
| await client.Sandbox.create() | ||
|
|
||
| const sandbox = await Sandbox.create({ apiUrl }) | ||
|
|
||
| assert.equal(lastRequest().apiKey, ENV_API_KEY) | ||
| assert.equal(sandbox.sandboxDomain, DOMAIN_ENV) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.