mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user