Skip to content

Commit d18bbfb

Browse files
committed
add image tool
1 parent 5a9cb4b commit d18bbfb

File tree

6 files changed

+319
-65
lines changed

6 files changed

+319
-65
lines changed

src/helpers/beta/zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { infer as zodInfer, ZodType } from 'zod/v4';
22
import * as z from 'zod/v4';
33
import type { BetaRunnableTool, Promisable } from '../../lib/beta/BetaRunnableTool';
4-
import type { FunctionTool } from '../../resources/beta';
4+
import type { ChatCompletionContentPart } from '../../resources';
55

66
/**
77
* Creates a tool using the provided Zod schema that can be passed
@@ -13,7 +13,7 @@ export function betaZodFunctionTool<InputSchema extends ZodType>(options: {
1313
name: string;
1414
parameters: InputSchema;
1515
description: string;
16-
run: (args: zodInfer<InputSchema>) => Promisable<string | Array<FunctionTool>>; // TODO: I changed this but double check
16+
run: (args: zodInfer<InputSchema>) => Promisable<string | ChatCompletionContentPart[]>;
1717
}): BetaRunnableTool<zodInfer<InputSchema>> {
1818
const jsonSchema = z.toJSONSchema(options.parameters, { reused: 'ref' });
1919

src/lib/beta/BetaRunnableTool.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { FunctionTool } from '../../resources/beta';
2-
import type { ChatCompletionTool } from '../../resources';
1+
import type { ChatCompletionContentPart, ChatCompletionTool } from '../../resources';
32

43
export type Promisable<T> = T | Promise<T>;
54

65
// this type is just an extension of BetaTool with a run and parse method
76
// that will be called by `toolRunner()` helpers
87
export type BetaRunnableTool<Input = any> = ChatCompletionTool & {
9-
run: (args: Input) => Promisable<string | Array<FunctionTool>>;
8+
run: (args: Input) => Promisable<string | ChatCompletionContentPart[]>;
109
parse: (content: unknown) => Input;
1110
};

tests/lib/tools/BetaToolRunner.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from 'openai/resources';
1212
import type { Fetch } from 'openai/internal/builtin-types';
1313
import type { BetaToolRunnerParams } from 'openai/lib/beta/BetaToolRunner';
14+
import { betaZodFunctionTool } from 'openai/helpers/beta/zod';
1415

1516
const weatherTool: BetaRunnableTool<{ location: string }> = {
1617
type: 'function',
@@ -1007,6 +1008,63 @@ describe('ToolRunner', () => {
10071008
expect(runner.params.messages[2]).toMatchObject(getWeatherToolResult('Paris', 'tool_1'));
10081009
await expectDone(iterator);
10091010
});
1011+
1012+
it('allows you to use non-string returning custom tools', async () => {
1013+
const customTool: BetaRunnableTool<{ location: string }> = {
1014+
type: 'function',
1015+
function: {
1016+
name: 'getWeather',
1017+
description: 'Get the weather in a given location',
1018+
parameters: {
1019+
type: 'object',
1020+
properties: {
1021+
location: { type: 'string' },
1022+
},
1023+
},
1024+
},
1025+
run: async ({ location }) => {
1026+
return [
1027+
{
1028+
type: 'image_url' as const,
1029+
image_url: {
1030+
url: `https://example.com/weather-${location}.jpg`,
1031+
},
1032+
},
1033+
];
1034+
},
1035+
parse: (input: unknown) => input as { location: string },
1036+
};
1037+
1038+
const { runner, handleAssistantMessage } = setupTest({
1039+
messages: [{ role: 'user', content: 'Test done method' }],
1040+
tools: [customTool],
1041+
});
1042+
1043+
const iterator = runner[Symbol.asyncIterator]();
1044+
1045+
// Assistant requests the custom tool
1046+
handleAssistantMessage([getWeatherToolUse('Paris')]);
1047+
await expectEvent(iterator, (message) => {
1048+
expect(message?.choices[0]?.message?.tool_calls).toMatchObject([getWeatherToolUse('Paris')]);
1049+
});
1050+
1051+
// Verify generateToolResponse returns the custom tool result
1052+
const toolResponse = await runner.generateToolResponse();
1053+
expect(toolResponse).toMatchObject([
1054+
{
1055+
role: 'tool',
1056+
tool_call_id: 'tool_1',
1057+
content: JSON.stringify([
1058+
{
1059+
type: 'image_url',
1060+
image_url: {
1061+
url: 'https://example.com/weather-Paris.jpg',
1062+
},
1063+
},
1064+
]),
1065+
},
1066+
]);
1067+
});
10101068
});
10111069

10121070
describe('.runUntilDone()', () => {

tests/lib/tools/BetaToolRunnerE2E.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { OpenAI } from '../../../src';
22
import { betaZodFunctionTool } from '../../../src/helpers/beta/zod';
3-
import * as z from 'zod';
3+
import { z } from 'zod/v4';
44
import nock from 'nock';
55
import { gunzipSync } from 'zlib';
66
import { RequestInfo } from 'openai/internal/builtin-types';
7+
import * as fs from 'node:fs/promises';
78

89
describe('toolRunner integration tests', () => {
910
let client: OpenAI;
@@ -272,4 +273,47 @@ describe('toolRunner integration tests', () => {
272273
expect(params.messages).toEqual([{ role: 'user', content: 'Updated message' }]);
273274
});
274275
});
276+
277+
describe('Non string returning tools', () => {
278+
it('should handle non-string returning tools', async () => {
279+
const exampleImageBuffer = await fs.readFile(__dirname + '/logo.png');
280+
const exampleImageBase64 = exampleImageBuffer.toString('base64');
281+
const exampleImageUrl = `data:image/png;base64,${exampleImageBase64}`;
282+
283+
const tool = betaZodFunctionTool({
284+
name: 'cool_logo_getter_tool',
285+
description: 'query for a company logo',
286+
parameters: z.object({
287+
name: z.string().min(1).max(100).describe('the name of the company whose logo you want'),
288+
}),
289+
run: async () => {
290+
return [
291+
{
292+
type: 'image_url' as const,
293+
image_url: {
294+
url: exampleImageUrl,
295+
},
296+
},
297+
];
298+
},
299+
});
300+
301+
const runner = client.beta.chat.completions.toolRunner({
302+
model: 'gpt-4o',
303+
max_tokens: 1000,
304+
messages: [
305+
{
306+
role: 'user',
307+
content:
308+
'what is the dominant colour of the logo of the company "Stainless"? One word response nothing else',
309+
},
310+
],
311+
tools: [tool],
312+
});
313+
314+
const finalMessage = await runner.runUntilDone();
315+
const color = finalMessage.content?.toLowerCase();
316+
expect(['blue', 'black', 'gray', 'grey']).toContain(color); // ai is bad at colours apparently
317+
});
318+
});
275319
});

tests/lib/tools/logo.png

36.9 KB
Loading

0 commit comments

Comments
 (0)