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
5 changes: 5 additions & 0 deletions .changeset/from-chat-messages-tool-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openrouter/agent': patch
---

Fix `fromChatMessages` to convert assistant `toolCalls` to `function_call` items, and widen `callModel`'s `input` type to accept `models.InputsUnion`.
2 changes: 1 addition & 1 deletion packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ type BaseCallModelInput<
models.ResponsesRequest[K]
>;
} & {
input: FieldOrAsyncFunction<Item[]> | string;
input: FieldOrAsyncFunction<models.InputsUnion | Item[]>;
tools?: TTools;
/**
* Optional filter restricting which tools are exposed to the model for this
Expand Down
192 changes: 191 additions & 1 deletion packages/agent/src/lib/chat-compat.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { OpenRouterCore } from '@openrouter/sdk/core';
import type * as models from '@openrouter/sdk/models';

import { describe, expect, it } from 'vitest';
import { callModel } from '../inner-loop/call-model.js';
import { fromChatMessages, toChatMessage } from './chat-compat.js';

/**
Expand Down Expand Up @@ -203,6 +204,195 @@ describe('fromChatMessages', () => {
});
});

describe('assistant message tool calls conversion', () => {
it('converts assistant message with toolCalls and null content to function_call item', () => {
const messages: models.ChatMessages[] = [
{
role: 'user',
content: 'What is the weather?',
},
{
role: 'assistant',
content: null,
toolCalls: [
{
id: 'call_123',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"location":"Paris"}',
},
},
],
},
{
role: 'tool',
toolCallId: 'call_123',
content: '{"temperature": 20}',
},
];

const result = fromChatMessages(messages);

expect(result).toEqual([
{
role: 'user',
content: 'What is the weather?',
},
{
type: 'function_call',
callId: 'call_123',
name: 'get_weather',
arguments: '{"location":"Paris"}',
},
{
type: 'function_call_output',
callId: 'call_123',
output: '{"temperature": 20}',
},
]);
});

it('converts assistant message with both content and toolCalls', () => {
const messages: models.ChatMessages[] = [
{
role: 'assistant',
content: 'Let me check that for you.',
toolCalls: [
{
id: 'call_123',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"location":"Paris"}',
},
},
],
},
];

const result = fromChatMessages(messages);

expect(result).toEqual([
{
role: 'assistant',
content: 'Let me check that for you.',
},
{
type: 'function_call',
callId: 'call_123',
name: 'get_weather',
arguments: '{"location":"Paris"}',
},
]);
});

it('supports snake_case tool_calls and tool_call_id', () => {
const messages = [
{
role: 'assistant' as const,
content: null,
tool_calls: [
{
id: 'call_456',
type: 'function' as const,
function: {
name: 'search',
arguments: '{"query":"vitest"}',
},
},
],
},
{
role: 'tool' as const,
tool_call_id: 'call_456',
content: 'Found results',
},
];

const result = fromChatMessages(messages as unknown as models.ChatMessages[]);

expect(result).toEqual([
{
type: 'function_call',
callId: 'call_456',
name: 'search',
arguments: '{"query":"vitest"}',
},
{
type: 'function_call_output',
callId: 'call_456',
output: 'Found results',
},
]);
});

it('stringifies object arguments on tool calls', () => {
const messages = [
{
role: 'assistant' as const,
content: null,
toolCalls: [
{
id: 'call_789',
type: 'function' as const,
function: {
name: 'calculate',
arguments: {
a: 1,
b: 2,
} as unknown as string,
},
},
],
},
];

const result = fromChatMessages(messages as unknown as models.ChatMessages[]);

expect(result).toEqual([
{
type: 'function_call',
callId: 'call_789',
name: 'calculate',
arguments: JSON.stringify({
a: 1,
b: 2,
}),
},
]);
});

it('produces input accepted by callModel without type errors', () => {
const chatMessages: models.ChatMessages[] = [
{
role: 'system',
content: 'You are a helpful assistant.',
},
{
role: 'user',
content: 'Hello!',
},
{
role: 'assistant',
content: 'Hi there! How can I help you?',
},
{
role: 'user',
content: 'What is the weather like?',
},
];

const fakeClient = {} as OpenRouterCore;
const result = callModel(fakeClient, {
model: 'openai/gpt-5-nano',
input: fromChatMessages(chatMessages),
});

expect(result).toBeDefined();
});
});

describe('content array handling', () => {
it('stringifies array content for user messages', () => {
const messages: models.ChatMessages[] = [
Expand Down
64 changes: 53 additions & 11 deletions packages/agent/src/lib/chat-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,28 +80,70 @@ function contentToString(content: unknown): string {
* ```
*/
export function fromChatMessages(messages: models.ChatMessages[]): models.InputsUnion {
return messages.map((msg): models.EasyInputMessage | models.FunctionCallOutputItem => {
const result: (
| models.EasyInputMessage
| models.FunctionCallOutputItem
| models.OutputFunctionCallItem
)[] = [];

for (const msg of messages) {
if (isToolResponseMessage(msg)) {
return {
result.push({
type: 'function_call_output' as const,
callId: msg.toolCallId,
callId:
msg.toolCallId ??
(
msg as {
tool_call_id?: string;
}
).tool_call_id ??
'',
output: contentToString(msg.content),
};
});
continue;
}

if (isAssistantMessage(msg)) {
return {
role: mapChatRole('assistant'),
content: contentToString(msg.content),
};
const toolCalls =
msg.toolCalls ??
(
msg as {
tool_calls?: models.ChatToolCall[];
}
).tool_calls;
const content = contentToString(msg.content);

if (content.length > 0 || !toolCalls?.length) {
result.push({
role: mapChatRole('assistant'),
content,
});
}

if (toolCalls?.length) {
for (const tc of toolCalls) {
result.push({
type: 'function_call' as const,
callId: tc.id,
name: tc.function.name,
arguments:
typeof tc.function.arguments === 'string'
? tc.function.arguments
: JSON.stringify(tc.function.arguments),
});
}
}
continue;
}

// System, user, developer messages
return {
result.push({
role: mapChatRole(msg.role),
content: contentToString(msg.content),
};
});
});
}

return result;
}

/**
Expand Down