🐛 fix: Normalize output_text blocks in Responses API input conversion (#11835)

* 🐛 fix: Normalize `output_text` blocks in Responses API input conversion

Treat `output_text` content blocks the same as `input_text` when
converting Responses API input to internal message format. Previously,
assistant messages containing `output_text` blocks fell through to the
default handler, producing `{ type: 'output_text' }` without a `text`
field, which caused downstream provider adapters (e.g. Bedrock) to fail
with "Unsupported content block type: output_text".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: Remove ChatModelStreamHandler from OpenAI and Responses controllers

Eliminated the ChatModelStreamHandler from both OpenAIChatCompletionController and createResponse functions to streamline event handling. This change simplifies the code by relying on existing handlers for message deltas and reasoning deltas, enhancing maintainability and reducing complexity in the agent's event processing logic.

* feat: Enhance input conversion in Responses API

Updated the `convertInputToMessages` function to handle additional content types, including `input_file` and `refusal` blocks, ensuring they are converted to appropriate message formats. Implemented null filtering for content arrays and default values for missing fields, improving robustness. Added comprehensive unit tests to validate these changes and ensure correct behavior across various input scenarios.

* fix: Forward upstream provider status codes in error responses

Updated error handling in OpenAIChatCompletionController and createResponse functions to forward upstream provider status codes (e.g., Anthropic 400s) instead of masking them as 500. This change improves error reporting by providing more accurate status codes and error types, enhancing the clarity of error responses for clients.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Danny Avila 2026-02-17 22:34:19 -05:00 committed by GitHub
parent 3bf715e05e
commit 5ea59ecb2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 573 additions and 65 deletions

View file

@ -20,7 +20,6 @@ jest.mock('@librechat/agents', () => ({
getMessageId: jest.fn(),
ToolEndHandler: jest.fn(),
handleToolCalls: jest.fn(),
ChatModelStreamHandler: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({

View file

@ -30,9 +30,6 @@ jest.mock('@librechat/agents', () => ({
messages: [],
indexTokenCountMap: {},
}),
ChatModelStreamHandler: jest.fn().mockImplementation(() => ({
handle: jest.fn(),
})),
}));
jest.mock('@librechat/api', () => ({

View file

@ -34,9 +34,6 @@ jest.mock('@librechat/agents', () => ({
messages: [],
indexTokenCountMap: {},
}),
ChatModelStreamHandler: jest.fn().mockImplementation(() => ({
handle: jest.fn(),
})),
}));
jest.mock('@librechat/api', () => ({

View file

@ -1,12 +1,7 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const { EModelEndpoint, ResourceType, PermissionBits } = require('librechat-data-provider');
const {
Callback,
ToolEndHandler,
formatAgentMessages,
ChatModelStreamHandler,
} = require('@librechat/agents');
const {
writeSSE,
createRun,
@ -325,18 +320,8 @@ const OpenAIChatCompletionController = async (req, res) => {
}
};
// Built-in handler for processing raw model stream chunks
const chatModelStreamHandler = new ChatModelStreamHandler();
// Event handlers for OpenAI-compatible streaming
const handlers = {
// Process raw model chunks and dispatch message/reasoning deltas
on_chat_model_stream: {
handle: async (event, data, metadata, graph) => {
await chatModelStreamHandler.handle(event, data, metadata, graph);
},
},
// Text content streaming
on_message_delta: createHandler((data) => {
const content = data?.delta?.content;
@ -577,7 +562,14 @@ const OpenAIChatCompletionController = async (req, res) => {
writeSSE(res, '[DONE]');
res.end();
} else {
sendErrorResponse(res, 500, errorMessage, 'server_error');
// Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
const statusCode =
typeof error?.status === 'number' && error.status >= 400 && error.status < 600
? error.status
: 500;
const errorType =
statusCode >= 400 && statusCode < 500 ? 'invalid_request_error' : 'server_error';
sendErrorResponse(res, statusCode, errorMessage, errorType);
}
}
};

View file

@ -1,13 +1,8 @@
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const { EModelEndpoint, ResourceType, PermissionBits } = require('librechat-data-provider');
const {
Callback,
ToolEndHandler,
formatAgentMessages,
ChatModelStreamHandler,
} = require('@librechat/agents');
const {
createRun,
buildToolSet,
@ -410,9 +405,6 @@ const createResponse = async (req, res) => {
// Collect usage for balance tracking
const collectedUsage = [];
// Built-in handler for processing raw model stream chunks
const chatModelStreamHandler = new ChatModelStreamHandler();
// Artifact promises for processing tool outputs
/** @type {Promise<import('librechat-data-provider').TAttachment | null>[]} */
const artifactPromises = [];
@ -443,11 +435,6 @@ const createResponse = async (req, res) => {
// Combine handlers
const handlers = {
on_chat_model_stream: {
handle: async (event, data, metadata, graph) => {
await chatModelStreamHandler.handle(event, data, metadata, graph);
},
},
on_message_delta: responsesHandlers.on_message_delta,
on_reasoning_delta: responsesHandlers.on_reasoning_delta,
on_run_step: responsesHandlers.on_run_step,
@ -570,8 +557,6 @@ const createResponse = async (req, res) => {
} else {
const aggregatorHandlers = createAggregatorEventHandlers(aggregator);
const chatModelStreamHandler = new ChatModelStreamHandler();
// Collect usage for balance tracking
const collectedUsage = [];
@ -596,11 +581,6 @@ const createResponse = async (req, res) => {
};
const handlers = {
on_chat_model_stream: {
handle: async (event, data, metadata, graph) => {
await chatModelStreamHandler.handle(event, data, metadata, graph);
},
},
on_message_delta: aggregatorHandlers.on_message_delta,
on_reasoning_delta: aggregatorHandlers.on_reasoning_delta,
on_run_step: aggregatorHandlers.on_run_step,
@ -727,7 +707,13 @@ const createResponse = async (req, res) => {
writeDone(res);
res.end();
} else {
sendResponsesErrorResponse(res, 500, errorMessage, 'server_error');
// Forward upstream provider status codes (e.g., Anthropic 400s) instead of masking as 500
const statusCode =
typeof error?.status === 'number' && error.status >= 400 && error.status < 600
? error.status
: 500;
const errorType = statusCode >= 400 && statusCode < 500 ? 'invalid_request' : 'server_error';
sendResponsesErrorResponse(res, statusCode, errorMessage, errorType);
}
}
};

View file

@ -0,0 +1,193 @@
#!/usr/bin/env bash
#
# Live integration tests for the Responses API endpoint.
# Sends curl requests to a running LibreChat server to verify
# multi-turn conversations with output_text / refusal blocks work.
#
# Usage:
# ./responses-api.live.test.sh <BASE_URL> <API_KEY> <AGENT_ID>
#
# Example:
# ./responses-api.live.test.sh http://localhost:3080 sk-abc123 agent_xyz
set -euo pipefail
BASE_URL="${1:?Usage: $0 <BASE_URL> <API_KEY> <AGENT_ID>}"
API_KEY="${2:?Usage: $0 <BASE_URL> <API_KEY> <AGENT_ID>}"
AGENT_ID="${3:?Usage: $0 <BASE_URL> <API_KEY> <AGENT_ID>}"
ENDPOINT="${BASE_URL}/v1/responses"
PASS=0
FAIL=0
# ── Helpers ───────────────────────────────────────────────────────────
post_json() {
local label="$1"
local body="$2"
local stream="${3:-false}"
echo "──────────────────────────────────────────────"
echo "TEST: ${label}"
echo "──────────────────────────────────────────────"
local http_code
local response
if [ "$stream" = "true" ]; then
# For streaming, just check we get a 200 and some SSE data
response=$(curl -s -w "\n%{http_code}" \
-X POST "${ENDPOINT}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${API_KEY}" \
-d "${body}" \
--max-time 60)
else
response=$(curl -s -w "\n%{http_code}" \
-X POST "${ENDPOINT}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${API_KEY}" \
-d "${body}" \
--max-time 60)
fi
http_code=$(echo "$response" | tail -1)
local body_out
body_out=$(echo "$response" | sed '$d')
if [ "$http_code" = "200" ]; then
echo " ✓ HTTP 200"
PASS=$((PASS + 1))
else
echo " ✗ HTTP ${http_code}"
echo " Response: ${body_out}"
FAIL=$((FAIL + 1))
fi
# Print truncated response for inspection
echo " Response (first 300 chars): ${body_out:0:300}"
echo ""
# Return the body for chaining
echo "$body_out"
}
extract_response_id() {
# Extract "id" field from JSON response
echo "$1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4
}
# ── Test 1: Basic single-turn request ─────────────────────────────────
RESP1=$(post_json "Basic single-turn request" "$(cat <<EOF
{
"model": "${AGENT_ID}",
"input": "Say hello in exactly 5 words.",
"stream": false
}
EOF
)")
# ── Test 2: Multi-turn with output_text assistant blocks ──────────────
post_json "Multi-turn with output_text blocks (the original bug)" "$(cat <<EOF
{
"model": "${AGENT_ID}",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "What is 2+2?"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "2+2 equals 4.", "annotations": [], "logprobs": []}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "And what is 3+3?"}]
}
],
"stream": false
}
EOF
)" > /dev/null
# ── Test 3: Multi-turn with refusal blocks ────────────────────────────
post_json "Multi-turn with refusal blocks" "$(cat <<EOF
{
"model": "${AGENT_ID}",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Do something bad"}]
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "refusal", "refusal": "I cannot help with that."}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "OK, just say hello then."}]
}
],
"stream": false
}
EOF
)" > /dev/null
# ── Test 4: Streaming request ─────────────────────────────────────────
post_json "Streaming single-turn request" "$(cat <<EOF
{
"model": "${AGENT_ID}",
"input": "Say hi in one word.",
"stream": true
}
EOF
)" "true" > /dev/null
# ── Test 5: Back-and-forth using previous_response_id ─────────────────
RESP5=$(post_json "First turn for previous_response_id chain" "$(cat <<EOF
{
"model": "${AGENT_ID}",
"input": "Remember this number: 42. Just confirm you got it.",
"stream": false
}
EOF
)")
RESP5_ID=$(extract_response_id "$RESP5")
if [ -n "$RESP5_ID" ]; then
echo " Extracted response ID: ${RESP5_ID}"
post_json "Follow-up using previous_response_id" "$(cat <<EOF
{
"model": "${AGENT_ID}",
"input": "What number did I ask you to remember?",
"previous_response_id": "${RESP5_ID}",
"stream": false
}
EOF
)" > /dev/null
else
echo " ⚠ Could not extract response ID, skipping follow-up test"
FAIL=$((FAIL + 1))
fi
# ── Summary ───────────────────────────────────────────────────────────
echo "══════════════════════════════════════════════"
echo "RESULTS: ${PASS} passed, ${FAIL} failed"
echo "══════════════════════════════════════════════"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi

View file

@ -0,0 +1,333 @@
import { convertInputToMessages } from '../service';
import type { InputItem } from '../types';
describe('convertInputToMessages', () => {
// ── String input shorthand ─────────────────────────────────────────
it('converts a string input to a single user message', () => {
const result = convertInputToMessages('Hello');
expect(result).toEqual([{ role: 'user', content: 'Hello' }]);
});
// ── Empty input array ──────────────────────────────────────────────
it('returns an empty array for empty input', () => {
const result = convertInputToMessages([]);
expect(result).toEqual([]);
});
// ── Role mapping ───────────────────────────────────────────────────
it('maps developer role to system', () => {
const input: InputItem[] = [
{ type: 'message', role: 'developer', content: 'You are helpful.' },
];
expect(convertInputToMessages(input)).toEqual([
{ role: 'system', content: 'You are helpful.' },
]);
});
it('maps system role to system', () => {
const input: InputItem[] = [{ type: 'message', role: 'system', content: 'System prompt.' }];
expect(convertInputToMessages(input)).toEqual([{ role: 'system', content: 'System prompt.' }]);
});
it('maps user role to user', () => {
const input: InputItem[] = [{ type: 'message', role: 'user', content: 'Hi' }];
expect(convertInputToMessages(input)).toEqual([{ role: 'user', content: 'Hi' }]);
});
it('maps assistant role to assistant', () => {
const input: InputItem[] = [{ type: 'message', role: 'assistant', content: 'Hello!' }];
expect(convertInputToMessages(input)).toEqual([{ role: 'assistant', content: 'Hello!' }]);
});
it('defaults unknown roles to user', () => {
const input = [
{ type: 'message', role: 'unknown_role', content: 'test' },
] as unknown as InputItem[];
expect(convertInputToMessages(input)[0].role).toBe('user');
});
// ── input_text content blocks ──────────────────────────────────────
it('converts input_text blocks to text blocks', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Hello world' }],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'user', content: [{ type: 'text', text: 'Hello world' }] }]);
});
// ── output_text content blocks (the original bug) ──────────────────
it('converts output_text blocks to text blocks', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'I can help!', annotations: [], logprobs: [] }],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{ role: 'assistant', content: [{ type: 'text', text: 'I can help!' }] },
]);
});
// ── refusal content blocks ─────────────────────────────────────────
it('converts refusal blocks to text blocks', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'assistant',
content: [{ type: 'refusal', refusal: 'I cannot do that.' }],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{ role: 'assistant', content: [{ type: 'text', text: 'I cannot do that.' }] },
]);
});
// ── input_image content blocks ─────────────────────────────────────
it('converts input_image blocks to image_url blocks', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_image', image_url: 'https://example.com/img.png', detail: 'high' },
],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'https://example.com/img.png', detail: 'high' },
},
],
},
]);
});
// ── input_file content blocks ──────────────────────────────────────
it('converts input_file blocks to text placeholders', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'user',
content: [{ type: 'input_file', filename: 'report.pdf', file_id: 'f_123' }],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{ role: 'user', content: [{ type: 'text', text: '[File: report.pdf]' }] },
]);
});
it('uses "unknown" for input_file without filename', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'user',
content: [{ type: 'input_file', file_id: 'f_123' }],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{ role: 'user', content: [{ type: 'text', text: '[File: unknown]' }] },
]);
});
// ── Null / undefined filtering ─────────────────────────────────────
it('filters out null elements in content arrays', () => {
const input = [
{
type: 'message',
role: 'user',
content: [null, { type: 'input_text', text: 'valid' }, undefined],
},
] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'user', content: [{ type: 'text', text: 'valid' }] }]);
});
// ── Missing text field defaults to empty string ────────────────────
it('defaults to empty string when text field is missing on input_text', () => {
const input = [
{
type: 'message',
role: 'user',
content: [{ type: 'input_text' }],
},
] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'user', content: [{ type: 'text', text: '' }] }]);
});
it('defaults to empty string when text field is missing on output_text', () => {
const input = [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text' }],
},
] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'assistant', content: [{ type: 'text', text: '' }] }]);
});
it('defaults to empty string when refusal field is missing on refusal block', () => {
const input = [
{
type: 'message',
role: 'assistant',
content: [{ type: 'refusal' }],
},
] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'assistant', content: [{ type: 'text', text: '' }] }]);
});
// ── Unknown block types are filtered out ───────────────────────────
it('filters out unknown content block types', () => {
const input = [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'keep me' },
{ type: 'some_future_type', data: 'ignore' },
],
},
] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'user', content: [{ type: 'text', text: 'keep me' }] }]);
});
// ── Mixed valid/invalid content in same array ──────────────────────
it('handles mixed valid and invalid content blocks', () => {
const input = [
{
type: 'message',
role: 'assistant',
content: [
{ type: 'output_text', text: 'Hello', annotations: [], logprobs: [] },
null,
{ type: 'unknown_type' },
{ type: 'refusal', refusal: 'No can do' },
],
},
] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([
{
role: 'assistant',
content: [
{ type: 'text', text: 'Hello' },
{ type: 'text', text: 'No can do' },
],
},
]);
});
// ── Non-array, non-string content defaults to empty string ─────────
it('defaults to empty string for non-array non-string content', () => {
const input = [{ type: 'message', role: 'user', content: 42 }] as unknown as InputItem[];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'user', content: '' }]);
});
// ── Function call items ────────────────────────────────────────────
it('converts function_call items to assistant messages with tool_calls', () => {
const input: InputItem[] = [
{
type: 'function_call',
id: 'fc_1',
call_id: 'call_abc',
name: 'get_weather',
arguments: '{"city":"NYC"}',
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_abc',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
},
],
},
]);
});
// ── Function call output items ─────────────────────────────────────
it('converts function_call_output items to tool messages', () => {
const input: InputItem[] = [
{
type: 'function_call_output',
call_id: 'call_abc',
output: '{"temp":72}',
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{
role: 'tool',
content: '{"temp":72}',
tool_call_id: 'call_abc',
},
]);
});
// ── Item references are skipped ────────────────────────────────────
it('skips item_reference items', () => {
const input: InputItem[] = [
{ type: 'item_reference', id: 'ref_123' },
{ type: 'message', role: 'user', content: 'Hello' },
];
const result = convertInputToMessages(input);
expect(result).toEqual([{ role: 'user', content: 'Hello' }]);
});
// ── Multi-turn conversation (the real-world scenario) ──────────────
it('handles a full multi-turn conversation with output_text blocks', () => {
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: 'You are a helpful assistant.' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'What is 2+2?' }],
},
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: '2+2 is 4.', annotations: [], logprobs: [] }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'And 3+3?' }],
},
];
const result = convertInputToMessages(input);
expect(result).toEqual([
{ role: 'system', content: [{ type: 'text', text: 'You are a helpful assistant.' }] },
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }] },
{ role: 'assistant', content: [{ type: 'text', text: '2+2 is 4.' }] },
{ role: 'user', content: [{ type: 'text', text: 'And 3+3?' }] },
]);
});
});

View file

@ -6,11 +6,12 @@
*/
import type { Response as ServerResponse } from 'express';
import type {
ResponseRequest,
RequestValidationResult,
InputItem,
InputContent,
ResponseRequest,
ResponseContext,
InputContent,
ModelContent,
InputItem,
Response,
} from './types';
import {
@ -134,7 +135,7 @@ export function convertInputToMessages(input: string | InputItem[]): InternalMes
const messageItem = item as {
type: 'message';
role: string;
content: string | InputContent[];
content: string | (InputContent | ModelContent)[];
};
let content: InternalMessage['content'];
@ -142,21 +143,31 @@ export function convertInputToMessages(input: string | InputItem[]): InternalMes
if (typeof messageItem.content === 'string') {
content = messageItem.content;
} else if (Array.isArray(messageItem.content)) {
content = messageItem.content.map((part) => {
if (part.type === 'input_text') {
return { type: 'text', text: part.text };
}
if (part.type === 'input_image') {
return {
type: 'image_url',
image_url: {
url: (part as { image_url?: string }).image_url,
detail: (part as { detail?: string }).detail,
},
};
}
return { type: part.type };
});
content = messageItem.content
.filter((part): part is InputContent | ModelContent => part != null)
.map((part) => {
if (part.type === 'input_text' || part.type === 'output_text') {
return { type: 'text', text: (part as { text?: string }).text ?? '' };
}
if (part.type === 'refusal') {
return { type: 'text', text: (part as { refusal?: string }).refusal ?? '' };
}
if (part.type === 'input_image') {
return {
type: 'image_url',
image_url: {
url: (part as { image_url?: string }).image_url,
detail: (part as { detail?: string }).detail,
},
};
}
if (part.type === 'input_file') {
const filePart = part as { filename?: string };
return { type: 'text', text: `[File: ${filePart.filename ?? 'unknown'}]` };
}
return null;
})
.filter((part): part is NonNullable<typeof part> => part != null);
} else {
content = '';
}