Skip to content
Merged
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
14 changes: 9 additions & 5 deletions src/core/messages-responses-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,18 @@ function imageUrl(source: unknown): string | undefined {
return undefined;
}

function inputParts(content: unknown, location = 'message'): JsonObject[] {
if (typeof content === 'string') return [{ type: 'input_text', text: content }];
function inputParts(content: unknown, location = 'message', role = 'user'): JsonObject[] {
// Responses requires assistant-role message text to be `output_text`;
// `input_text` is only valid for user/system. Emitting input_text under
// role:"assistant" (any replayed assistant turn) is a 400.
const textType = role === 'assistant' ? 'output_text' : 'input_text';
if (typeof content === 'string') return [{ type: textType, text: content }];
if (!Array.isArray(content)) invalidRequest(`${location} content must be a string or an array`);
const out: JsonObject[] = [];
for (const raw of content) {
const part = object(raw);
if (part?.type === 'text' && typeof part.text === 'string') {
out.push({ type: 'input_text', text: part.text });
out.push({ type: textType, text: part.text });
} else if (part?.type === 'image') {
const image_url = imageUrl(part.source);
if (!image_url) invalidRequest(`Unsupported ${location} image source`);
Expand Down Expand Up @@ -120,7 +124,7 @@ export function anthropicMessagesToOpenAIResponses(body: Uint8Array): Uint8Array
}
const content = message.content;
if (!Array.isArray(content)) {
const ordinary = inputParts(content, `${String(message.role)} message`);
const ordinary = inputParts(content, `${String(message.role)} message`, String(message.role));
if (ordinary.length) input.push({ role: message.role, content: ordinary });
continue;
}
Expand Down Expand Up @@ -149,7 +153,7 @@ export function anthropicMessagesToOpenAIResponses(body: Uint8Array): Uint8Array
output: functionOutput(part.content, part.is_error === true),
});
} else {
ordinary.push(...inputParts([rawPart], `${String(message.role)} message`));
ordinary.push(...inputParts([rawPart], `${String(message.role)} message`, String(message.role)));
}
}
flushOrdinary();
Expand Down
20 changes: 20 additions & 0 deletions tests/responses-bridge-roles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,26 @@ describe('anthropicMessagesToOpenAIResponses — message roles', () => {
]);
});

it('encodes assistant text as output_text (Responses rejects input_text under role:assistant)', () => {
const out = toResponses({
model: 'm',
messages: [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: [{ type: 'text', text: 'Hello!' }] },
{ role: 'assistant', content: 'plain string reply' },
{ role: 'user', content: 'continue' },
],
});
const assistant = out.input.filter((item: any) => item.role === 'assistant');
expect(assistant).toEqual([
{ role: 'assistant', content: [{ type: 'output_text', text: 'Hello!' }] },
{ role: 'assistant', content: [{ type: 'output_text', text: 'plain string reply' }] },
]);
// User text stays input_text.
const user = out.input.filter((item: any) => item.role === 'user');
expect(user[0].content[0].type).toBe('input_text');
});

it('drops empty system-role messages and rejects unknown roles', () => {
const out = toResponses({
model: 'm',
Expand Down