Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/fix-output-object-tools-conflict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
75 changes: 75 additions & 0 deletions e2e/issues/issue-411-output-object-tools-conflict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Regression test for GitHub Issue #411
* https://github.com/OpenRouterTeam/ai-sdk-provider/issues/411
*
* Reported error: When using generateText with Output.object() and tools,
* the model returns tool call arguments as plain text instead of structured
* tool_calls.
*
* This test verifies that both response_format and tools are sent together
* in the request (matching @ai-sdk/openai behavior), and that models can
* produce structured tool_calls alongside Output.object().
*/
import { generateText, Output, tool } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod/v4';
import { createOpenRouter } from '@/src';

vi.setConfig({
testTimeout: 120_000,
});

describe('Issue #411: generateText with Output.object() + tools should return structured tool_calls', () => {
const provider = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
baseUrl: `${process.env.OPENROUTER_API_BASE}/api/v1`,
});

it('should produce structured tool calls when Output.object and tools are both present', async () => {
const model = provider('openai/gpt-4o-mini');

const lookupEmailTool = tool({
description: 'Look up information about an email address',
inputSchema: z.object({
email: z.string().describe('The email address to look up'),
}),
execute: async ({ email }) => ({
name: 'John Doe',
email,
}),
});

const result = await generateText({
model,
messages: [
{
role: 'user',
content:
'Look up the email for john@example.com and return the result.',
},
],
experimental_output: Output.object({
schema: z.object({
name: z.string(),
email: z.string(),
}),
}),
tools: {
lookupEmail: lookupEmailTool,
},
});

// The model should have made at least one step
expect(result.steps.length).toBeGreaterThan(0);

// Check that at least one step had a proper tool call
const toolCallSteps = result.steps.filter(
(step) => step.toolCalls && step.toolCalls.length > 0,
);
expect(toolCallSteps.length).toBeGreaterThan(0);

// Verify the tool call has the correct tool name (structured, not text)
const firstToolCall = toolCallSteps[0]!.toolCalls[0]!;
expect(firstToolCall.toolName).toBe('lookupEmail');
});
});
77 changes: 75 additions & 2 deletions src/chat/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,73 @@ describe('doGenerate', () => {
});
});

it('should send both response_format and tools when both are present', async () => {
prepareJsonResponse({ content: '' });

await model.doGenerate({
prompt: TEST_PROMPT,
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: { answer: { type: 'string' } },
required: ['answer'],
additionalProperties: false,
},
name: 'AnswerResponse',
},
tools: [
{
type: 'function',
name: 'lookup-email',
description: 'Look up information about an email address',
inputSchema: {
type: 'object',
properties: { email: { type: 'string' } },
required: ['email'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
],
});

const body = await server.calls[0]!.requestBodyJson;

// Both response_format and tools should be present (matching @ai-sdk/openai behavior)
expect(body).toHaveProperty('response_format');
expect(body).toHaveProperty('tools');
expect((body as Record<string, unknown>).response_format).toEqual({
type: 'json_schema',
json_schema: {
schema: {
type: 'object',
properties: { answer: { type: 'string' } },
required: ['answer'],
additionalProperties: false,
},
strict: true,
name: 'AnswerResponse',
},
});
expect((body as Record<string, unknown>).tools).toEqual([
{
type: 'function',
function: {
name: 'lookup-email',
description: 'Look up information about an email address',
parameters: {
type: 'object',
properties: { email: { type: 'string' } },
required: ['email'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
},
]);
});

it('should pass headers', async () => {
prepareJsonResponse({ content: '' });

Expand Down Expand Up @@ -2225,7 +2292,7 @@ describe('doStream', () => {
});
});

it('should pass responseFormat AND tools together', async () => {
it('should send both response_format and tools when both are present in streaming', async () => {
prepareStreamResponse({ content: ['{"name": "John", "age": 30}'] });

const testSchema: JSONSchema7 = {
Expand Down Expand Up @@ -2266,7 +2333,13 @@ describe('doStream', () => {
},
});

expect(await server.calls[0]!.requestBodyJson).toStrictEqual({
const body = await server.calls[0]!.requestBodyJson;

// Both response_format and tools should be present (matching @ai-sdk/openai behavior)
expect(body).toHaveProperty('response_format');
expect(body).toHaveProperty('tools');

expect(body).toStrictEqual({
stream: true,
stream_options: { include_usage: true },
model: 'anthropic/claude-3.5-sonnet',
Expand Down
Loading