Skip to content

Commit eaf3666

Browse files
committed
Add openai container provider
1 parent 51932ae commit eaf3666

File tree

8 files changed

+419
-2
lines changed

8 files changed

+419
-2
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Example demonstrating the Code Execution Middleware with OpenAI.
3+
*
4+
* This middleware enables GPT models to execute code in a sandboxed container,
5+
* analyze uploaded files, and generate output files. The example demonstrates
6+
* multi-turn conversation with container and file state persistence.
7+
*/
8+
9+
import { OpenAIContainerProvider } from "@langchain/openai/middleware";
10+
import { ChatOpenAI } from "@langchain/openai";
11+
import { HumanMessage } from "@langchain/core/messages";
12+
import { MemorySaver } from "@langchain/langgraph";
13+
import {
14+
codeExecutionMiddleware,
15+
createAgent,
16+
MemoryFileProvider,
17+
} from "langchain";
18+
import fs from "node:fs/promises";
19+
import { join } from "node:path";
20+
21+
// Initial setup
22+
const model = new ChatOpenAI({
23+
model: "gpt-4.1",
24+
useResponsesApi: true,
25+
});
26+
27+
const middleware = codeExecutionMiddleware(
28+
new OpenAIContainerProvider(),
29+
new MemoryFileProvider()
30+
);
31+
32+
const agent = createAgent({
33+
model,
34+
middleware: [middleware],
35+
checkpointer: new MemorySaver(),
36+
});
37+
38+
const thread = {
39+
configurable: {
40+
thread_id: "test-123",
41+
},
42+
};
43+
44+
// Read and add the test data file
45+
const testDataPath = "test_data.csv";
46+
const fileContent = await fs.readFile(testDataPath);
47+
48+
// First invocation - should create container and analyze uploaded data
49+
const response1 = await agent.invoke(
50+
{
51+
messages: new HumanMessage("Filter to just widget A"),
52+
files: [await middleware.addFile(testDataPath, fileContent)],
53+
},
54+
thread
55+
);
56+
console.log("Response 1:", response1.messages);
57+
58+
// Second invocation - should reuse container and previous analysis
59+
const response2 = await agent.invoke(
60+
{
61+
messages: new HumanMessage(
62+
"Turn that into a graph of sales and units over time."
63+
),
64+
},
65+
thread
66+
);
67+
console.log("Response 2:", response2.messages);
68+
69+
// Extract and download generated files
70+
const generatedFiles = middleware
71+
.files(response2)
72+
.filter(({ type, path }) => type === "tool" && path.endsWith(".png"));
73+
74+
for (const file of generatedFiles) {
75+
const content = await file.getContent();
76+
const outputPath = join(".", file.path);
77+
await fs.writeFile(outputPath, content);
78+
console.log(`Downloaded generated file: ${outputPath}`);
79+
}

libs/providers/langchain-openai/package.json

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,27 @@
3535
"zod": "^3.25.76 || ^4"
3636
},
3737
"peerDependencies": {
38-
"@langchain/core": "^1.0.0-alpha.6"
38+
"@langchain/core": "^1.0.0-alpha.6",
39+
"langchain": "workspace:*"
40+
},
41+
"peerDependenciesMeta": {
42+
"langchain": {
43+
"optional": true
44+
}
3945
},
4046
"devDependencies": {
4147
"@azure/identity": "^4.2.1",
4248
"@cfworker/json-schema": "^4.1.1",
4349
"@langchain/core": "workspace:*",
4450
"@langchain/eslint": "workspace:*",
51+
"@langchain/langgraph-checkpoint": "^0.1.1",
4552
"@langchain/standard-tests": "workspace:*",
4653
"@tsconfig/recommended": "^1.0.10",
4754
"@vitest/coverage-v8": "^3.2.4",
4855
"dotenv": "^17.2.1",
4956
"dpdm": "^3.14.0",
5057
"eslint": "^9.34.0",
58+
"langchain": "workspace:*",
5159
"prettier": "^2.8.3",
5260
"rimraf": "^5.0.1",
5361
"typescript": "~5.8.3",
@@ -86,9 +94,20 @@
8694
},
8795
"input": "./src/index.ts"
8896
},
97+
"./middleware": {
98+
"import": {
99+
"types": "./dist/middleware/index.d.ts",
100+
"default": "./dist/middleware/index.js"
101+
},
102+
"require": {
103+
"types": "./dist/middleware/index.d.cts",
104+
"default": "./dist/middleware/index.cjs"
105+
},
106+
"input": "./src/middleware/index.ts"
107+
},
89108
"./package.json": "./package.json"
90109
},
91110
"files": [
92111
"dist/"
93112
]
94-
}
113+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
ContainerInfo,
3+
ContainerProvider,
4+
FileFromProvider,
5+
UploadFileToContainerOpts,
6+
UploadFileToContainerRet,
7+
type ModelRequest,
8+
} from "langchain";
9+
import OpenAI, { toFile } from "openai";
10+
import { extractGeneratedFilesOpenAI } from "./extractGeneratedFiles.js";
11+
12+
export class OpenAIContainerProvider implements ContainerProvider {
13+
tools = [{ type: "code_interpreter", name: "code_interpreter" }];
14+
15+
async startContainer(): Promise<ContainerInfo> {
16+
// Create a new container explicitly using OpenAI Containers API
17+
// Use a unique name for the container
18+
const container = await new OpenAI().containers.create({
19+
name: `langchain-code-execution-${crypto.randomUUID()}`,
20+
});
21+
22+
return {
23+
id: container.id,
24+
// OpenAI containers expire after 20 minutes of inactivity
25+
// Make it 19 minutes to be safe
26+
// FIXME: Better track lifecycle because it's actually after 20 minutes of inactivity
27+
// Can probably get last activity off of response metadata
28+
expiresAt: new Date(
29+
container.created_at +
30+
(container.expires_after?.minutes ?? 19) * 60 * 1000
31+
),
32+
};
33+
}
34+
35+
async uploadFileToContainer({
36+
containerId,
37+
path,
38+
providerId,
39+
getContent,
40+
}: UploadFileToContainerOpts): Promise<UploadFileToContainerRet> {
41+
// FIXME: It would be more efficient to pass the files to the container at creation time,
42+
// which is supported by the API, but our current interface doesn't support that
43+
const client = new OpenAI();
44+
45+
let newProviderId = providerId;
46+
if (newProviderId == null) {
47+
// Upload to OpenAI Files API
48+
const fileObject = await client.files.create({
49+
file: await toFile(await getContent(), path),
50+
purpose: "user_data",
51+
});
52+
53+
newProviderId = fileObject.id;
54+
}
55+
56+
if (containerId == null) {
57+
throw new Error("containerId is required to upload file to container");
58+
}
59+
60+
await client.containers.files.create(containerId, {
61+
file_id: newProviderId,
62+
});
63+
64+
return { providerId: newProviderId };
65+
}
66+
67+
async modifyModelRequest?<TState extends Record<string, unknown>, TContext>(
68+
containerId: string | undefined,
69+
_newFiles: string[],
70+
request: ModelRequest<TState, TContext>
71+
): Promise<ModelRequest<TState, TContext> | void> {
72+
// Build the code_interpreter tool configuration
73+
const codeInterpreterTool = {
74+
type: "code_interpreter" as const,
75+
container: containerId,
76+
};
77+
78+
return {
79+
...request,
80+
tools: [codeInterpreterTool],
81+
};
82+
}
83+
84+
async extractFilesFromModelResponse(
85+
messages: ModelRequest["messages"]
86+
): Promise<FileFromProvider[]> {
87+
// Extract file information from the last message's annotations
88+
const lastMessage = messages[messages.length - 1];
89+
const files = extractGeneratedFilesOpenAI({ messages: [lastMessage] });
90+
91+
const client = new OpenAI();
92+
93+
// Download each file and return file info
94+
return Promise.all(
95+
files.map(async (file) => {
96+
const response = await client.containers.files.content.retrieve(
97+
file.fileId,
98+
{
99+
container_id: file.containerId,
100+
}
101+
);
102+
103+
if (!response.body) {
104+
throw new Error(
105+
`No body in file download response for ${file.fileId}`
106+
);
107+
}
108+
109+
// Convert web ReadableStream to Buffer
110+
const chunks: Uint8Array[] = [];
111+
const reader = response.body.getReader();
112+
while (true) {
113+
const { done, value } = await reader.read();
114+
if (done) break;
115+
chunks.push(value);
116+
}
117+
const content = Buffer.concat(chunks);
118+
119+
return {
120+
providerId: file.fileId,
121+
content,
122+
path: file.filename,
123+
type: "tool" as const,
124+
};
125+
})
126+
);
127+
}
128+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Extract generated file information from an OpenAI code execution response.
3+
*
4+
* Parses the message annotations to find files created during code execution.
5+
* Returns file information including container_id, file_id, and filename.
6+
*
7+
* @param response - The OpenAI message response
8+
* @returns Array of file information objects
9+
*
10+
* @example
11+
* ```typescript
12+
* import { extractGeneratedFilesOpenAI, downloadFileOpenAI } from '@langchain/openai/middleware';
13+
*
14+
* const response = await agent.invoke({...});
15+
* const files = extractGeneratedFilesOpenAI(response);
16+
*
17+
* for (const file of files) {
18+
* await downloadFileOpenAI(client, file.containerId, file.fileId, file.filename);
19+
* }
20+
* ```
21+
*/
22+
export function extractGeneratedFilesOpenAI(response: any): Array<{
23+
fileId: string;
24+
containerId: string;
25+
filename: string;
26+
}> {
27+
const files: Array<{
28+
fileId: string;
29+
containerId: string;
30+
filename: string;
31+
}> = [];
32+
33+
// Handle both single message responses and response objects with messages array
34+
const messages = response.messages || [response];
35+
36+
for (const message of messages) {
37+
if (!message.content || !Array.isArray(message.content)) {
38+
continue;
39+
}
40+
41+
for (const contentItem of message.content) {
42+
if (
43+
contentItem.type === "text" &&
44+
Array.isArray(contentItem.annotations)
45+
) {
46+
for (const annotation of contentItem.annotations) {
47+
if (
48+
annotation.type === "container_file_citation" &&
49+
annotation.file_id &&
50+
annotation.container_id &&
51+
annotation.filename
52+
) {
53+
files.push({
54+
fileId: annotation.file_id,
55+
containerId: annotation.container_id,
56+
filename: annotation.filename,
57+
});
58+
}
59+
}
60+
}
61+
}
62+
}
63+
64+
return files;
65+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { OpenAIContainerProvider } from "./containerProvider.js";

0 commit comments

Comments
 (0)