diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 103f9f3236..8bd711f9c6 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -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', () => ({ diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 03a280b545..8592c79a2d 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -30,9 +30,6 @@ jest.mock('@librechat/agents', () => ({ messages: [], indexTokenCountMap: {}, }), - ChatModelStreamHandler: jest.fn().mockImplementation(() => ({ - handle: jest.fn(), - })), })); jest.mock('@librechat/api', () => ({ diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 25e048f2fa..e16ca394b2 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -34,9 +34,6 @@ jest.mock('@librechat/agents', () => ({ messages: [], indexTokenCountMap: {}, }), - ChatModelStreamHandler: jest.fn().mockImplementation(() => ({ - handle: jest.fn(), - })), })); jest.mock('@librechat/api', () => ({ diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index d4dc82174d..b334580eb1 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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); } } }; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3cd1dff5eb..afdb96be9f 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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[]} */ 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); } } }; diff --git a/packages/api/src/agents/responses/__tests__/responses-api.live.test.sh b/packages/api/src/agents/responses/__tests__/responses-api.live.test.sh new file mode 100755 index 0000000000..657e64c8e5 --- /dev/null +++ b/packages/api/src/agents/responses/__tests__/responses-api.live.test.sh @@ -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 +# +# Example: +# ./responses-api.live.test.sh http://localhost:3080 sk-abc123 agent_xyz + +set -euo pipefail + +BASE_URL="${1:?Usage: $0 }" +API_KEY="${2:?Usage: $0 }" +AGENT_ID="${3:?Usage: $0 }" + +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 < /dev/null + +# ── Test 3: Multi-turn with refusal blocks ──────────────────────────── + +post_json "Multi-turn with refusal blocks" "$(cat < /dev/null + +# ── Test 4: Streaming request ───────────────────────────────────────── + +post_json "Streaming single-turn request" "$(cat < /dev/null + +# ── Test 5: Back-and-forth using previous_response_id ───────────────── + +RESP5=$(post_json "First turn for previous_response_id chain" "$(cat < /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 diff --git a/packages/api/src/agents/responses/__tests__/service.test.ts b/packages/api/src/agents/responses/__tests__/service.test.ts new file mode 100644 index 0000000000..b9b64d21ee --- /dev/null +++ b/packages/api/src/agents/responses/__tests__/service.test.ts @@ -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?' }] }, + ]); + }); +}); diff --git a/packages/api/src/agents/responses/service.ts b/packages/api/src/agents/responses/service.ts index 842db86679..2e49b1b979 100644 --- a/packages/api/src/agents/responses/service.ts +++ b/packages/api/src/agents/responses/service.ts @@ -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 => part != null); } else { content = ''; }