fix(codex): preserve native Responses passthrough tools and history (#3107)

* fix(codex): preserve tool_search hosted tool

* fix(codex): preserve native custom tools

* fix(codex): preserve native assistant commentary history
This commit is contained in:
Aoxiong Yin
2026-06-04 05:12:54 +08:00
committed by GitHub
parent ed170229e7
commit 261a910820
3 changed files with 158 additions and 12 deletions

View File

@@ -156,9 +156,7 @@ export interface CodexQuotaSnapshot {
* x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at
* x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at
*/
export function parseCodexQuotaHeaders(
headers: Record<string, string>
): CodexQuotaSnapshot | null {
export function parseCodexQuotaHeaders(headers: Record<string, string>): CodexQuotaSnapshot | null {
const usage5h = headers["x-codex-5h-usage"] ?? null;
const limit5h = headers["x-codex-5h-limit"] ?? null;
const resetAt5h = headers["x-codex-5h-reset-at"] ?? null;
@@ -426,6 +424,7 @@ function repairMissingCodexFunctionCallOutputs(body: Record<string, unknown>): v
// `{ type: "namespace", name: "mcp__atlassian__", tools: [...] }` for MCP tool groups.
// Keep them through `normalizeCodexTools` so upstream can execute them.
const CODEX_HOSTED_TOOL_TYPES: ReadonlySet<string> = new Set([
"tool_search",
"image_generation",
"web_search",
"web_search_preview",
@@ -449,7 +448,7 @@ export function isCodexFreePlan(providerSpecificData: unknown): boolean {
export function normalizeCodexTools(
body: Record<string, unknown>,
options?: { dropImageGeneration?: boolean }
options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean }
): void {
if (!Array.isArray(body.tools)) return;
@@ -477,6 +476,18 @@ export function normalizeCodexTools(
return true;
}
// Native Codex clients send Responses API custom tools such as apply_patch as:
// { type: "custom", name, format }. Preserve those only on native passthrough;
// translated/non-native requests can still contain provider-specific "custom"
// shapes that the Codex backend would reject.
if (toolType === "custom" && options?.preserveCustomTools === true) {
const name = typeof tool.name === "string" ? tool.name.trim().slice(0, 128) : "";
if (!name) return false;
tool.name = name;
validToolNames.add(name);
return true;
}
if (toolType !== "function") {
const hasFunctionObject = tool.function && typeof tool.function === "object";
const hasName = typeof tool.name === "string";
@@ -533,6 +544,12 @@ export function normalizeCodexTools(
!Array.isArray(functionObject.parameters)
? functionObject.parameters
: { type: "object", properties: {} };
const strict =
typeof tool.strict === "boolean"
? tool.strict
: typeof functionObject?.strict === "boolean"
? functionObject.strict
: undefined;
// Rewrite in-place to Responses format
for (const key of Object.keys(tool)) {
@@ -542,6 +559,7 @@ export function normalizeCodexTools(
tool.name = name.slice(0, 128);
if (description) tool.description = description;
tool.parameters = parameters;
if (strict !== undefined) tool.strict = strict;
validToolNames.add(name);
return true;
@@ -1216,7 +1234,9 @@ export class CodexExecutor extends BaseExecutor {
}
if (Array.isArray(body.input)) {
body.input = sanitizeResponsesInputItems(body.input, false);
body.input = sanitizeResponsesInputItems(body.input, false, {
dropInternalAssistantMessages: !nativeCodexPassthrough,
});
}
repairMissingCodexFunctionCallOutputs(body);
@@ -1284,6 +1304,7 @@ export class CodexExecutor extends BaseExecutor {
// invalid upstream, and translation bugs can leave orphaned/empty tool_choice names.
normalizeCodexTools(body, {
dropImageGeneration: isCodexFreePlan(credentials?.providerSpecificData),
preserveCustomTools: nativeCodexPassthrough,
});
// Strip stored response item references (rs_, resp_, msg_ IDs) from input.

View File

@@ -1,4 +1,7 @@
type JsonRecord = Record<string, unknown>;
type SanitizeResponsesInputOptions = {
dropInternalAssistantMessages?: boolean;
};
const INTERNAL_ASSISTANT_PHASES = new Set(["commentary"]);
const SERVER_ITEM_ID_PREFIX_BY_TYPE: Record<string, string> = {
function_call: "fc_",
@@ -67,12 +70,17 @@ function sanitizeInputItem(item: unknown): unknown {
return next;
}
export function sanitizeResponsesInputItems(items: readonly unknown[], clone = true): unknown[] {
export function sanitizeResponsesInputItems(
items: readonly unknown[],
clone = true,
options: SanitizeResponsesInputOptions = {}
): unknown[] {
const dropInternalAssistantMessages = options.dropInternalAssistantMessages ?? true;
const sanitized: unknown[] = [];
for (const item of items) {
const record = toRecord(item);
if (record && isInternalAssistantMessage(record)) {
if (dropInternalAssistantMessages && record && isInternalAssistantMessage(record)) {
continue;
}

View File

@@ -450,7 +450,7 @@ test("CodexExecutor.transformRequest strips store from compact requests even whe
assert.equal(result.instructions, "keep this");
});
test("CodexExecutor.transformRequest strips raw internal assistant commentary without dropping useful Responses items", () => {
test("CodexExecutor.transformRequest preserves native assistant commentary history", () => {
const executor = new CodexExecutor();
const body = {
_nativeCodexPassthrough: true,
@@ -508,7 +508,7 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi
assert.equal(
result.input.some((item) => JSON.stringify(item).includes("Need maybe inspect tool output")),
false
true
);
assert.equal(
result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")),
@@ -540,6 +540,46 @@ test("CodexExecutor.transformRequest strips raw internal assistant commentary wi
);
});
test("CodexExecutor.transformRequest still strips assistant commentary outside native passthrough", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.5-low",
{
input: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Continue." }],
},
{
type: "message",
role: "assistant",
phase: "commentary",
content: [{ type: "output_text", text: "Internal progress note." }],
},
{
type: "message",
role: "assistant",
phase: "final_answer",
content: [{ type: "output_text", text: "Visible final answer." }],
},
],
stream: false,
},
false,
{ requestEndpointPath: "/responses" }
);
assert.equal(
result.input.some((item) => JSON.stringify(item).includes("Internal progress note")),
false
);
assert.equal(
result.input.some((item) => JSON.stringify(item).includes("Visible final answer")),
true
);
});
test("CodexExecutor.transformRequest inserts missing function_call_output items", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
@@ -587,7 +627,7 @@ test("CodexExecutor.transformRequest inserts missing function_call_output items"
});
});
test("CodexExecutor.transformRequest strips internal assistant commentary before mapping messages to input", () => {
test("CodexExecutor.transformRequest preserves native assistant commentary before mapping messages to input", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.5-low",
@@ -614,7 +654,7 @@ test("CodexExecutor.transformRequest strips internal assistant commentary before
assert.equal(
result.input.some((item) => JSON.stringify(item).includes("Need maybe update PR body")),
false
true
);
assert.equal(
result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")),
@@ -973,6 +1013,7 @@ test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted to
],
},
{ type: "image_generation", output_format: "png" },
{ type: "tool_search" },
{ type: "web_search" },
{ type: "unknown_hosted_tool" },
],
@@ -983,7 +1024,13 @@ test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted to
);
const types = (result.tools as Array<Record<string, unknown>>).map((tool) => tool.type);
assert.deepEqual(types, ["function", "namespace", "image_generation", "web_search"]);
assert.deepEqual(types, [
"function",
"namespace",
"image_generation",
"tool_search",
"web_search",
]);
const namespaceTool = (result.tools as Array<Record<string, unknown>>).find(
(tool) => tool.type === "namespace"
@@ -996,6 +1043,76 @@ test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted to
assert.deepEqual(result.tool_choice, { type: "function", name: "jira_get_issue" });
});
test("CodexExecutor.transformRequest preserves native Codex custom tools", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.5",
{
_nativeCodexPassthrough: true,
model: "gpt-5.5",
input: [],
tools: [
{
type: "custom",
name: "apply_patch",
description: "Use the apply_patch tool to edit files.",
format: {
type: "grammar",
syntax: "lark",
definition: "start: /.+/",
},
},
{
type: "function",
name: "exec_command",
description: "Runs a command.",
parameters: { type: "object", properties: {} },
strict: false,
},
],
},
true,
{ requestEndpointPath: "/responses" }
);
const tools = result.tools as Array<Record<string, unknown>>;
assert.equal(tools.length, 2);
assert.deepEqual(tools[0], {
type: "custom",
name: "apply_patch",
description: "Use the apply_patch tool to edit files.",
format: {
type: "grammar",
syntax: "lark",
definition: "start: /.+/",
},
});
assert.equal(tools[1].strict, false);
});
test("CodexExecutor.transformRequest still drops custom tools outside native passthrough", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.5",
{
model: "gpt-5.5",
input: [],
tools: [
{ type: "custom", name: "apply_patch", format: { type: "grammar" } },
{ type: "function", name: "exec_command", parameters: { type: "object" } },
],
},
true,
{ requestEndpointPath: "/responses" }
);
const tools = result.tools as Array<Record<string, unknown>>;
assert.deepEqual(
tools.map((tool) => tool.name),
["exec_command"]
);
});
test("CodexExecutor maps Codex websocket error events to response.failed SSE", () => {
const raw = JSON.stringify({
type: "error",