Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(responses):** resolve bidirectional tool namespace identity and custom tool payload for Codex\n
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { translateNonStreamingResponse } from "../responseTranslator.ts";
import { extractToolSchemaMap } from "../../translator/response/openai-responses/toolSchemas.ts";
import { stripMarkdownCodeFence } from "../../utils/aiSdkCompat.ts";
import { normalizeOpenAIToolFinishReasons } from "./passthroughToolNames.ts";
import { resolveRequestToolIdentity } from "./requestToolIdentity.ts";
import {
cacheReasoningFromAssistantMessage,
requiresReasoningReplay,
Expand Down
2 changes: 2 additions & 0 deletions open-sse/handlers/chatCore/requestToolIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,5 @@ export function extractRequestToolIdentityMap(
delete translatedBody._toolNameMap;
return requestToolIdentityMap as Map<string, NamespaceIdentity> | null;
}

export { resolveRequestToolIdentity } from "../../translator/response/openai-responses/requestToolIdentity.ts";
12 changes: 10 additions & 2 deletions open-sse/translator/helpers/geminiToolsSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ function isSanitizedNameTaken(
): boolean {
if (!(toolNameMap instanceof Map)) return false;
const mappedOriginalName = toolNameMap.get(sanitizedName);
return typeof mappedOriginalName === "string" && mappedOriginalName !== originalName;
if (typeof mappedOriginalName !== "string" || mappedOriginalName === originalName) {
return false;
}
// If both original names normalize to the same name (e.g. functions__exec and functions_exec),
// they refer to the same underlying tool across turns — not an actual collision.
if (normalizeGeminiToolName(mappedOriginalName) === normalizeGeminiToolName(originalName)) {
return false;
}
return true;
}

export function sanitizeGeminiToolName(
Expand Down Expand Up @@ -126,7 +134,7 @@ export function sanitizeGeminiToolName(
}

if (isSanitizedNameTaken(toolNameMap, sanitizedName, name)) {
sanitizedName = buildHashedGeminiToolName("tool", `${name}:${Date.now()}`, 12);
sanitizedName = buildHashedGeminiToolName("tool", `${name}:collision:${conflictingOriginalName || ""}`, 12);
}

console.warn(
Expand Down
15 changes: 12 additions & 3 deletions open-sse/translator/request/openai-responses/additionalTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,21 @@ export function collectResponsesCustomToolNames(
inputItems: unknown[]
): Set<string> {
const names = new Set<string>();
const visit = (tools: unknown[]) => {
const visit = (tools: unknown[], ns = "") => {
for (const toolValue of tools) {
const tool = toRecord(toolValue);
const name = toolName(toolValue);
if (tool.type === "custom" && name) names.add(name);
if (tool.type === "namespace" && Array.isArray(tool.tools)) visit(tool.tools);
if (tool.type === "custom" && name) {
names.add(name);
if (ns) {
names.add(`${ns}__${name}`);
names.add(`${ns}_${name}`);
names.add(`${ns}.${name}`);
}
}
if (tool.type === "namespace" && Array.isArray(tool.tools)) {
visit(tool.tools, name || ns);
}
}
};
visit(collectResponsesTools(rootTools, inputItems));
Expand Down
21 changes: 9 additions & 12 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ import {
} from "./openai-responses/pureHelpers.ts";
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
import { buildResponsesToolCallItem } from "./responsesToolItem.ts";
import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts";
import {
resolveRequestToolIdentity,
resolveToolCustomStatus,
} from "./openai-responses/requestToolIdentity.ts";
import { resolveLocalToolCallIndex } from "./openai-responses/toolCallLocalIndex.ts";
import {
synthesizeCompletedToolCalls,
computeFinishReason,
withAssistantRoleOnFirstDelta,
} from "./openai-responses/synthesizeCompletedToolCalls.ts";

// normalizeUpstreamFailure is re-exported for external importers (tests).
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";

Expand Down Expand Up @@ -543,10 +547,7 @@ function emitToolCall(state, emit, tc) {
// unconditional `toolName === "apply_patch"` OR never actually implemented the carve-out.
const toolName = state.funcNames[tcIdx] || funcName || "";
const lowerName = toolName.toLowerCase();
const isCustomTool =
((lowerName === "apply_patch" || lowerName === "applypatch") &&
!state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true;
const { identity, isCustomTool } = resolveToolCustomStatus(state, toolName, lowerName);

if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId;
const callId = state.funcCallIds[tcIdx];
Expand All @@ -555,7 +556,6 @@ function emitToolCall(state, emit, tc) {
// #7936 — restore the codex-side `{namespace, name}` pair when the bare
// leaf on the Chat wire was flattened from a Responses namespace sub-tool.
// Codex dispatches from `namespace` independently of `name` (no `__` split).
const identity = resolveRequestToolIdentity(state.requestToolIdentityMap, toolName);
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: outputIndex,
Expand Down Expand Up @@ -613,13 +613,10 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
const normalizedIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, idx);
const args = state.funcArgsBuf[idx] || "{}";
const toolName = state.funcNames[idx] || "";
// See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the
// same classification independently for their respective add/close call sites).
// Shared with emitToolCall() via resolveToolCustomStatus() — must stay in sync
// (both call sites classify the same tool independently for add/close).
const lowerName = toolName.toLowerCase();
const isCustomTool =
((lowerName === "apply_patch" || lowerName === "applypatch") &&
!state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true;
const { isCustomTool } = resolveToolCustomStatus(state, toolName, lowerName);

let funcItem;
if (isCustomTool) {
Expand Down
120 changes: 108 additions & 12 deletions open-sse/translator/response/openai-responses/requestToolIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,27 +57,123 @@ function splitFlattenedNamespaceWireName(toolName: string): RequestToolIdentity
* fallback below.
*/
export function resolveRequestToolIdentity(identityMap: unknown, toolName: string) {
if (!toolName) return null;
if (!toolName || !identityMap) return null;

const direct =
identityMap instanceof Map
? identityMap.get(toolName)
: identityMap && typeof identityMap === "object" && !Array.isArray(identityMap)
? (identityMap as Record<string, unknown>)[toolName]
: undefined;
const getEntry = (key: string): unknown => {
if (identityMap instanceof Map) return identityMap.get(key);
if (typeof identityMap === "object" && !Array.isArray(identityMap)) {
return (identityMap as Record<string, unknown>)[key];
}
return undefined;
};

// 1. Direct hit
const direct = getEntry(toolName);
const directIdentity = asRequestToolIdentity(direct);
if (directIdentity) return directIdentity;

const candidates =
// 2. Chained alias hit: if direct value is a string alias (e.g. "functions__exec")
if (typeof direct === "string") {
const aliasedIdentity = asRequestToolIdentity(getEntry(direct));
if (aliasedIdentity) return aliasedIdentity;
}

// 3. Collect candidates
const rawCandidates =
identityMap instanceof Map
? identityMap.values()
: identityMap && typeof identityMap === "object" && !Array.isArray(identityMap)
? Array.from(identityMap.values())
: typeof identityMap === "object" && !Array.isArray(identityMap)
? Object.values(identityMap as Record<string, unknown>)
: [];
for (const candidate of candidates) {

const candidates: RequestToolIdentity[] = [];
for (const candidate of rawCandidates) {
const identity = asRequestToolIdentity(candidate);
if (identity && `${identity.namespace}.${identity.name}` === toolName) return identity;
if (identity) candidates.push(identity);
}

const toolLower = toolName.toLowerCase();

// 4. Check compound variations: single underscore, double underscore, dot, hyphen
for (const identity of candidates) {
const ns = identity.namespace;
const nm = identity.name;
const single = `${ns}_${nm}`;
const double = `${ns}__${nm}`;
const dot = `${ns}.${nm}`;
const hyphen = `${ns}-${nm}`;

if (toolName === single || toolName === double || toolName === dot || toolName === hyphen) {
return identity;
}
if (
toolLower === single.toLowerCase() ||
toolLower === double.toLowerCase() ||
toolLower === dot.toLowerCase() ||
toolLower === hyphen.toLowerCase()
) {
return identity;
}
}

// 5. Check exact or case-insensitive bare leaf name (only if unambiguous)
const leafMatches = candidates.filter(
(identity) => toolName === identity.name || toolLower === identity.name.toLowerCase()
);
if (leafMatches.length === 1) {
return leafMatches[0];
}

// 6. Check underscore-delimited suffix match (e.g. tool_<hash>_exec or prefix__exec, only
// if unambiguous). Dot notation is intentionally excluded here: a dotted name is meant to
// spell an exact `namespace.name` pair (already handled by step 4), so a dotted prefix that
// does not match any candidate's real namespace (e.g. "other.exec_command") must stay
// unresolved rather than being treated as an arbitrary hash-style prefix.
const suffixMatches = candidates.filter((identity) => {
const nmLower = identity.name.toLowerCase();
return (
toolName.endsWith(`_${identity.name}`) ||
toolName.endsWith(`__${identity.name}`) ||
toolLower.endsWith(`_${nmLower}`) ||
toolLower.endsWith(`__${nmLower}`)
);
});
if (suffixMatches.length === 1) {
return suffixMatches[0];
}

return splitFlattenedNamespaceWireName(toolName);
}

/**
* Shared classification used by both emitToolCall() and closeToolCall() in
* openai-responses.ts — kept here so the two call sites cannot drift apart
* (see the "must stay in sync" comment at both call sites).
*/
export function resolveToolCustomStatus(
state: {
requestToolIdentityMap?: unknown;
toolSchemas?: { has?: (key: string) => boolean };
customToolNames?: { has?: (key: string) => boolean };
},
toolName: string,
lowerName: string
): { identity: RequestToolIdentity | null; isCustomTool: boolean } {
const identity = resolveRequestToolIdentity(state.requestToolIdentityMap, toolName);
const resolvedLeaf = identity ? identity.name.toLowerCase() : lowerName;
// "exec" only carves out as a custom tool when it resolved through a namespace identity
// (the Codex `functions.exec`/`os.exec` sub-tool convention) — a bare, unnamespaced
// function literally named "exec" (e.g. a Gemma tool call) must stay a regular function_call.
const isCustomTool = Boolean(
((lowerName === "apply_patch" ||
lowerName === "applypatch" ||
resolvedLeaf === "apply_patch" ||
resolvedLeaf === "applypatch" ||
(identity !== null && resolvedLeaf === "exec")) &&
!state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true ||
(identity !== null && state.customToolNames?.has?.(identity.name) === true) ||
state.customToolNames?.has?.(resolvedLeaf) === true
);
return { identity, isCustomTool };
}
3 changes: 2 additions & 1 deletion open-sse/utils/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
import { createStreamFailureAborter } from "./streamFailureBoundary.ts";
import { recordToolLatency } from "../services/toolLatencyTracker.ts";
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
import { resolveRequestToolIdentity } from "../translator/response/openai-responses/requestToolIdentity.ts";
import {
generateSessionId,
markToolFinish,
Expand Down Expand Up @@ -256,7 +257,7 @@ function restoreResponsesPassthroughFunctionCallIdentity(
if (functionCall.type !== "function_call" || typeof functionCall.name !== "string")
return false;

const identity = requestToolIdentityMap.get(functionCall.name);
const identity = resolveRequestToolIdentity(requestToolIdentityMap, functionCall.name);
if (!identity) return false;

const changed =
Expand Down
Loading