fix(responses-bridge): encode assistant text as output_text, not input_text (#129)

When a Claude Code /v1/messages request is bridged to a GPT model on the
Responses path (bridgedGptMessages), prior assistant text turns were lowered
to content parts of type input_text. The OpenAI Responses API requires
assistant-role message content to be output_text; input_text under
role:assistant is rejected with 400, breaking every multi-turn session to a
Responses-routed GPT model after the first assistant reply. The bridge's read
side already maps assistant text to/from output_text — only the write side was
wrong. Thread the role into inputParts and pick the text type accordingly;
user/system stay input_text.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Shubham Srivastava
2026-07-21 07:44:18 +05:30
committed by GitHub
parent b754d95295
commit fbe2c251b8
2 changed files with 29 additions and 5 deletions
+9 -5
View File
@@ -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`);
@@ -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;
}
@@ -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();
+20
View File
@@ -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',