[codex] fix responses lifecycle parity and codex session serialization (#278)
* fix responses lifecycle parity * remove websocket test debug logging * fix responses proxy stream typecheck * update compact upstream response expectations * add community and deepwiki badges * update linuxdo banner link * fix responses review follow-ups * preserve whitespace in responses terminal deltas * fix responses stream reconciliation edges * fix responses lifecycle parity and codex session serialization * fix review follow-up edge cases --------- Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
@@ -437,6 +437,67 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops blank tool calls before proxying Responses requests while preserving follow-up tool outputs', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_blank',
|
||||
name: ' ',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_blank',
|
||||
output: '{"temp":22}',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_missing',
|
||||
output: 'orphaned output',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_blank',
|
||||
output: '{"temp":22}',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_missing',
|
||||
output: 'orphaned output',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
@@ -763,6 +824,45 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops tool outputs that do not have a matching prior tool call when converting to OpenAI-compatible messages', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_missing',
|
||||
output: 'orphaned tool result',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => {
|
||||
const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
|
||||
const firstName = `mcp__alpha_workspace__${sharedSuffix}`;
|
||||
@@ -840,6 +940,63 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
name: toolNames[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: ' ',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
name: 'lookup_weather',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops all-invalid function tools instead of forwarding blank names into Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: ' ',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
@@ -881,6 +1038,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
file: {
|
||||
filename: 'paper.pdf',
|
||||
file_data: Buffer.from('%PDF-demo').toString('base64'),
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1461,6 +1619,31 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops blank native function tools and tool choices when converting to OpenAI-compatible fallback bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
tools: [{
|
||||
type: 'function',
|
||||
name: ' ',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
},
|
||||
}],
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: ' ',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
expect(result.tool_choice).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives Responses reasoning config from chat-style reasoning request fields', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
|
||||
@@ -352,7 +352,7 @@ function convertOpenAiToolsToResponses(
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
return converted;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(
|
||||
@@ -384,6 +384,49 @@ function normalizeResponsesBodyForCompatibility(
|
||||
};
|
||||
}
|
||||
|
||||
const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set([
|
||||
'function_call',
|
||||
'custom_tool_call',
|
||||
]);
|
||||
|
||||
const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set([
|
||||
'function_call_output',
|
||||
'custom_tool_call_output',
|
||||
]);
|
||||
|
||||
function stripOrphanedResponsesToolOutputs(input: unknown): unknown {
|
||||
if (!Array.isArray(input)) return input;
|
||||
|
||||
const seenToolCallIds = new Set<string>();
|
||||
const sanitized: unknown[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!isRecord(item)) {
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (callId) seenToolCallIds.add(callId);
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (!callId || !seenToolCallIds.has(callId)) continue;
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push(item);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
||||
return normalizeResponsesMessageContentBlocks(role, content);
|
||||
}
|
||||
@@ -614,6 +657,9 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
||||
delete body.tool_choice;
|
||||
}
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(
|
||||
normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }),
|
||||
@@ -723,28 +769,30 @@ function toOpenAiMessageContent(content: unknown): string | Array<string | Recor
|
||||
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
return rawTools.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
return rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'custom' || type === 'image_generation') return item;
|
||||
if (type !== 'function') return item;
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
|
||||
if (type === 'custom' || type === 'image_generation') return item;
|
||||
if (type !== 'function') return item;
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
}
|
||||
|
||||
function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
|
||||
@@ -787,11 +835,13 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
stream: boolean,
|
||||
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const normalizedBody = normalizeResponsesRequestFieldParity(body, {
|
||||
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
const normalizedBody = normalizeResponsesBodyForCompatibility(
|
||||
normalizeResponsesRequestFieldParity(body, {
|
||||
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||
}),
|
||||
);
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = normalizedBody.input;
|
||||
const input = stripOrphanedResponsesToolOutputs(normalizedBody.input);
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
@@ -928,6 +978,9 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
|
||||
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
||||
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
||||
if (Array.isArray(payload.tools) && payload.tools.length === 0) {
|
||||
delete payload.tool_choice;
|
||||
}
|
||||
if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier;
|
||||
if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls;
|
||||
if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key;
|
||||
|
||||
@@ -8,6 +8,16 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
const RESPONSES_TOOL_CALL_ITEM_TYPES = new Set([
|
||||
'function_call',
|
||||
'custom_tool_call',
|
||||
]);
|
||||
|
||||
const RESPONSES_TOOL_OUTPUT_ITEM_TYPES = new Set([
|
||||
'function_call_output',
|
||||
'custom_tool_call_output',
|
||||
]);
|
||||
|
||||
const ALLOWED_RESPONSES_INPUT_STATUSES = new Set([
|
||||
'in_progress',
|
||||
'completed',
|
||||
@@ -135,7 +145,7 @@ function normalizeResponsesContentItem(
|
||||
return fileBlock ? toResponsesInputFileBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -166,10 +176,71 @@ function toResponsesInputMessageFromText(text: string): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesToolLifecycleItem(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) && !RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
return withNormalizedResponsesInputStatus(item);
|
||||
}
|
||||
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (!callId) return null;
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
...item,
|
||||
call_id: callId,
|
||||
};
|
||||
|
||||
if (typeof item.id === 'string') {
|
||||
const id = asTrimmedString(item.id);
|
||||
if (id) normalized.id = id;
|
||||
else delete normalized.id;
|
||||
}
|
||||
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, 'name')) {
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
normalized.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
return withNormalizedResponsesInputStatus(normalized);
|
||||
}
|
||||
|
||||
function sanitizeResponsesInputToolLifecycle(items: unknown[]): unknown[] {
|
||||
const sanitized: unknown[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!isRecord(item)) {
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) {
|
||||
const normalized = normalizeResponsesToolLifecycleItem(item);
|
||||
if (!normalized) continue;
|
||||
sanitized.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
const normalized = normalizeResponsesToolLifecycleItem(item);
|
||||
if (!normalized) continue;
|
||||
sanitized.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push(item);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return withNormalizedResponsesInputStatus(item);
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
return normalizeResponsesToolLifecycleItem(item) ?? item;
|
||||
}
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
@@ -211,7 +282,7 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.flatMap((item) => {
|
||||
const normalized = input.flatMap((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
return normalized ? [toResponsesInputMessageFromText(normalized)] : [];
|
||||
@@ -219,10 +290,11 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
||||
if (!isRecord(item)) return [item];
|
||||
return [normalizeResponsesMessageItem(item)];
|
||||
});
|
||||
return sanitizeResponsesInputToolLifecycle(normalized);
|
||||
}
|
||||
|
||||
if (isRecord(input)) {
|
||||
return [normalizeResponsesMessageItem(input)];
|
||||
return sanitizeResponsesInputToolLifecycle([normalizeResponsesMessageItem(input)]);
|
||||
}
|
||||
|
||||
return input;
|
||||
|
||||
Reference in New Issue
Block a user