From 7059abcca66813f9643ec0e0d7727fdba1e7a042 Mon Sep 17 00:00:00 2001 From: dex0shubham Date: Mon, 20 Jul 2026 15:00:59 +0100 Subject: [PATCH] fix(responses-bridge): encode assistant text as output_text, not input_text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/messages-responses-bridge.ts | 14 +++++++++----- tests/responses-bridge-roles.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/core/messages-responses-bridge.ts b/src/core/messages-responses-bridge.ts index 8708a68..aaa1a7f 100644 --- a/src/core/messages-responses-bridge.ts +++ b/src/core/messages-responses-bridge.ts @@ -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(); diff --git a/tests/responses-bridge-roles.test.ts b/tests/responses-bridge-roles.test.ts index 597c4e3..96e5924 100644 --- a/tests/responses-bridge-roles.test.ts +++ b/tests/responses-bridge-roles.test.ts @@ -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',