Skip to content

Commit b3148a2

Browse files
ertembiyikseratch
andauthored
fix: non-OpenAI models w/ OpenRouter send an empty string when calling tools (#499)
Co-authored-by: Kazuhiro Sera <[email protected]>
1 parent 3b51969 commit b3148a2

File tree

3 files changed

+169
-4
lines changed

3 files changed

+169
-4
lines changed

.changeset/quick-frogs-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openai/agents-extensions': patch
3+
---
4+
5+
Fix open ai compatible models misuse '' in tools arguments call when an empty object is the valid option

packages/agents-extensions/src/aiSdk.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,36 @@ function convertLegacyToolOutputContent(
357357
);
358358
}
359359

360+
function schemaAcceptsObject(schema: JSONSchema7 | undefined): boolean {
361+
if (!schema) {
362+
return false;
363+
}
364+
const schemaType = schema.type;
365+
if (Array.isArray(schemaType)) {
366+
if (schemaType.includes('object')) {
367+
return true;
368+
}
369+
} else if (schemaType === 'object') {
370+
return true;
371+
}
372+
return Boolean(schema.properties || schema.additionalProperties);
373+
}
374+
375+
function expectsObjectArguments(
376+
tool: SerializedTool | SerializedHandoff | undefined,
377+
): boolean {
378+
if (!tool) {
379+
return false;
380+
}
381+
if ('toolName' in tool) {
382+
return schemaAcceptsObject(tool.inputJsonSchema as JSONSchema7 | undefined);
383+
}
384+
if (tool.type === 'function') {
385+
return schemaAcceptsObject(tool.parameters as JSONSchema7 | undefined);
386+
}
387+
return false;
388+
}
389+
360390
/**
361391
* Maps the protocol-level structured outputs into the Language Model V2 result primitives.
362392
* The AI SDK expects either plain text or content parts (text + media), so we merge multiple
@@ -630,15 +660,41 @@ export class AiSdkModel implements Model {
630660
(c: any) => c && c.type === 'tool-call',
631661
);
632662
const hasToolCalls = toolCalls.length > 0;
663+
664+
const toolsNameToToolMap = new Map<
665+
string,
666+
SerializedTool | SerializedHandoff
667+
>(request.tools.map((tool) => [tool.name, tool] as const));
668+
669+
for (const handoff of request.handoffs) {
670+
toolsNameToToolMap.set(handoff.toolName, handoff);
671+
}
633672
for (const toolCall of toolCalls) {
673+
const requestedTool =
674+
typeof toolCall.toolName === 'string'
675+
? toolsNameToToolMap.get(toolCall.toolName)
676+
: undefined;
677+
678+
if (!requestedTool && toolCall.toolName) {
679+
this.#logger.warn(
680+
`Received tool call for unknown tool '${toolCall.toolName}'.`,
681+
);
682+
}
683+
684+
let toolCallArguments: string;
685+
if (typeof toolCall.input === 'string') {
686+
toolCallArguments =
687+
toolCall.input === '' && expectsObjectArguments(requestedTool)
688+
? JSON.stringify({})
689+
: toolCall.input;
690+
} else {
691+
toolCallArguments = JSON.stringify(toolCall.input ?? {});
692+
}
634693
output.push({
635694
type: 'function_call',
636695
callId: toolCall.toolCallId,
637696
name: toolCall.toolName,
638-
arguments:
639-
typeof toolCall.input === 'string'
640-
? toolCall.input
641-
: JSON.stringify(toolCall.input ?? {}),
697+
arguments: toolCallArguments,
642698
status: 'completed',
643699
providerData: hasToolCalls ? result.providerMetadata : undefined,
644700
});

packages/agents-extensions/test/aiSdk.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,110 @@ describe('AiSdkModel.getResponse', () => {
462462
]);
463463
});
464464

465+
test('normalizes empty string tool input for object schemas', async () => {
466+
const model = new AiSdkModel(
467+
stubModel({
468+
async doGenerate() {
469+
return {
470+
content: [
471+
{
472+
type: 'tool-call',
473+
toolCallId: 'call-1',
474+
toolName: 'objectTool',
475+
input: '',
476+
},
477+
],
478+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
479+
providerMetadata: { meta: true },
480+
response: { id: 'id' },
481+
finishReason: 'tool-calls',
482+
warnings: [],
483+
} as any;
484+
},
485+
}),
486+
);
487+
488+
const res = await withTrace('t', () =>
489+
model.getResponse({
490+
input: 'hi',
491+
tools: [
492+
{
493+
type: 'function',
494+
name: 'objectTool',
495+
description: 'accepts object',
496+
parameters: {
497+
type: 'object',
498+
properties: {},
499+
additionalProperties: false,
500+
},
501+
} as any,
502+
],
503+
handoffs: [],
504+
modelSettings: {},
505+
outputType: 'text',
506+
tracing: false,
507+
} as any),
508+
);
509+
510+
expect(res.output).toHaveLength(1);
511+
expect(res.output[0]).toMatchObject({
512+
type: 'function_call',
513+
arguments: '{}',
514+
});
515+
});
516+
517+
test('normalizes empty string tool input for handoff schemas', async () => {
518+
const model = new AiSdkModel(
519+
stubModel({
520+
async doGenerate() {
521+
return {
522+
content: [
523+
{
524+
type: 'tool-call',
525+
toolCallId: 'handoff-call',
526+
toolName: 'handoffTool',
527+
input: '',
528+
},
529+
],
530+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
531+
providerMetadata: { meta: true },
532+
response: { id: 'id' },
533+
finishReason: 'tool-calls',
534+
warnings: [],
535+
} as any;
536+
},
537+
}),
538+
);
539+
540+
const res = await withTrace('t', () =>
541+
model.getResponse({
542+
input: 'hi',
543+
tools: [],
544+
handoffs: [
545+
{
546+
toolName: 'handoffTool',
547+
toolDescription: 'handoff accepts object',
548+
inputJsonSchema: {
549+
type: 'object',
550+
properties: {},
551+
additionalProperties: false,
552+
},
553+
strictJsonSchema: true,
554+
} as any,
555+
],
556+
modelSettings: {},
557+
outputType: 'text',
558+
tracing: false,
559+
} as any),
560+
);
561+
562+
expect(res.output).toHaveLength(1);
563+
expect(res.output[0]).toMatchObject({
564+
type: 'function_call',
565+
arguments: '{}',
566+
});
567+
});
568+
465569
test('forwards toolChoice to AI SDK (generate)', async () => {
466570
const seen: any[] = [];
467571
const model = new AiSdkModel(

0 commit comments

Comments
 (0)