mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-18 16:38:10 +01:00
🐛 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:
parent
3bf715e05e
commit
5ea59ecb2b
8 changed files with 573 additions and 65 deletions
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ jest.mock('@librechat/agents', () => ({
|
|||
messages: [],
|
||||
indexTokenCountMap: {},
|
||||
}),
|
||||
ChatModelStreamHandler: jest.fn().mockImplementation(() => ({
|
||||
handle: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ jest.mock('@librechat/agents', () => ({
|
|||
messages: [],
|
||||
indexTokenCountMap: {},
|
||||
}),
|
||||
ChatModelStreamHandler: jest.fn().mockImplementation(() => ({
|
||||
handle: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
193
packages/api/src/agents/responses/__tests__/responses-api.live.test.sh
Executable file
193
packages/api/src/agents/responses/__tests__/responses-api.live.test.sh
Executable 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
|
||||
333
packages/api/src/agents/responses/__tests__/service.test.ts
Normal file
333
packages/api/src/agents/responses/__tests__/service.test.ts
Normal 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?' }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue