Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
? new Set(parsed.options.toolChoice.allowedTools)
: undefined;
const tools = allowed
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
: parsed.context.tools;
if (tools.length === 0) return undefined;
const converted = tools.map(t => ({
Expand Down
7 changes: 4 additions & 3 deletions src/adapters/command-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { opendir } from "node:fs/promises";
import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types";
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice, toolChoiceAliases } from "../types";
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
import type { TranslatorBudget } from "../lib/translator-budget";
import { readBoundedResponseBody } from "../lib/bounded-body";
Expand Down Expand Up @@ -157,10 +157,11 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] {
const tools = parsed.context.tools ?? [];
if (isAllowedToolChoice(choice)) {
const allowed = new Set(choice.allowedTools);
return tools.filter(tool => toolAllowedByChoice(tool, allowed));
return tools.filter(tool => toolAllowedByChoice(tool, allowed, tools));
}
if (choice && typeof choice !== "string") {
return tools.filter(tool => toolChoiceAliases(tool).includes(choice.name));
const selected = resolveToolChoiceWireName(tools, choice.name);
return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected);
}
return tools;
}
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
? new Set(parsed.options.toolChoice.allowedTools)
: undefined;
const tools = allowed
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools))
: parsed.context.tools;
if (tools.length === 0) return undefined;
return [{
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,7 +1192,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown

function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice));
const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
if (tools.length === 0) return undefined;
const xaiTarget = isXaiSchemaTarget(provider);
const formatted = tools.flatMap(t => {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/tool-catalog-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
toolChoice?: OcxRequestOptions["toolChoice"],
toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
): string | undefined {
const visible = tools?.filter(toolChoiceToolPredicate(toolChoice));
const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools));
const visibleNames = visible?.map(toWireName);
// Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing
// to wire names first throws away the only thing that distinguishes Codex's JavaScript
Expand Down
16 changes: 11 additions & 5 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
export function bridgeToResponsesSSE(
events: AsyncIterable<AdapterEvent>,
modelId: string,
toolNsMap?: Map<string, { namespace: string; name: string }>,
toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>,
freeformToolNames?: Set<string>,
toolSearchToolNames?: Set<string>,
onCancel?: () => void,
Expand Down Expand Up @@ -1049,7 +1049,9 @@ export function bridgeToResponsesSSE(
}
const ns = mapped?.namespace;
const toolSearch = toolSearchToolNames?.has(realName) ?? false;
const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
const freeform = !toolSearch && (mapped
? mapped.freeform === true
: (freeformToolNames?.has(realName) ?? false));
const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
const item = toolSearch
? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
Expand Down Expand Up @@ -1439,7 +1441,7 @@ function buildResponseJSONWithBudget(
modelId: string,
options?: {
hideThinkingSummary?: boolean;
toolNsMap?: Map<string, { namespace: string; name: string }>;
toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>;
/** Request-visible tool names. When present, an upstream call outside this set fails closed. */
declaredToolNames?: ReadonlySet<string>;
/** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */
Expand Down Expand Up @@ -1620,7 +1622,9 @@ function buildResponseJSONWithBudget(
const realName = mapped?.name ?? currentToolCallName;
const ns = mapped?.namespace;
const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
const freeform = !toolSearch && (mapped
? mapped.freeform === true
: (options?.freeformToolNames?.has(realName) ?? false));
// #1611: same integral-float repair as the streaming path. Keyed by the wire name
// the request declared, which is the pre-namespace-mapping `currentToolCallName`.
const coercedArgs = coerceIntegerToolArguments(
Expand Down Expand Up @@ -1784,7 +1788,9 @@ function buildResponseJSONWithBudget(
const mapped = options?.toolNsMap?.get(currentToolCallName);
const realName = mapped?.name ?? currentToolCallName;
const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
const freeform = !toolSearch && (mapped
? mapped.freeform === true
: (options?.freeformToolNames?.has(realName) ?? false));
if (!freeform && !toolSearch) {
flushToolCall("incomplete");
errorEvent = {
Expand Down
15 changes: 11 additions & 4 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,13 +676,20 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}
}

const toolNsMap = new Map<string, { namespace: string; name: string }>();
const toolNsMap = new Map<string, { namespace: string; name: string; freeform?: true }>();
const freeform = new Set<string>();
const toolSearch = new Set<string>();
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
for (const t of parsed.context.tools ?? []) {
const requestedTools = parsed.context.tools ?? [];
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools);
for (const t of requestedTools) {
if (!toolAllowed(t)) continue;
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
if (t.namespace) {
toolNsMap.set(namespacedToolName(t.namespace, t.name), {
namespace: t.namespace,
name: t.name,
...(t.freeform ? { freeform: true } : {}),
});
}
if (t.freeform) freeform.add(t.name);
if (t.toolSearch) toolSearch.add(t.name);
}
Expand Down
21 changes: 19 additions & 2 deletions src/responses/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
OcxToolCall,
OcxReasoningReplayScopeRef,
} from "../types";
import { namespacedToolName } from "../types";
import { namespacedToolName, toolChoiceCandidates } from "../types";
import { responsesRequestSchema } from "./schema";
import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
import { lookupReplayThoughtSignature } from "./thought-signature-replay";
Expand Down Expand Up @@ -203,7 +203,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined;
for (const inner of t.tools as unknown[]) {
if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns);
else if (builtinFunctions && isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner);
else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns);
}
}
else if (t.type === "custom" && typeof t.name === "string") {
Expand Down Expand Up @@ -691,6 +691,15 @@ export function parseRequest(
const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
const loadedTools = buildTools(loadedToolSpecs) ?? [];
const loadedToolNames = new Set(loadedTools.map(t => namespacedToolName(t.namespace, t.name)));
const wireOwners = new Map<string, OcxTool>();
for (const tool of [...declaredTools, ...loadedTools]) {
const wireName = namespacedToolName(tool.namespace, tool.name);
const previous = wireOwners.get(wireName);
if (previous && (previous.namespace !== tool.namespace || previous.name !== tool.name || previous.freeform !== tool.freeform || previous.toolSearch !== tool.toolSearch)) {
throw new Error(`ambiguous tool catalog: multiple logical tools map to wire name ${wireName}`);
}
wireOwners.set(wireName, tool);
}
const seenTools = new Set<string>();
const mergedTools = [...declaredTools, ...loadedTools]
.filter(t => {
Expand All @@ -716,6 +725,14 @@ export function parseRequest(
options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop;
}
const tc = mapToolChoice(data.tool_choice);
if (tc && typeof tc === "object") {
const selectors = "allowedTools" in tc ? tc.allowedTools : [tc.name];
for (const selector of selectors) {
if (toolChoiceCandidates(mergedTools, selector).length > 1) {
throw new Error(`ambiguous tool_choice name: ${selector}`);
}
}
}
if (tc !== undefined) options.toolChoice = tc;
if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;
// Upstream codex-rs converts "ultra" to "max" at the inference boundary (core/src/client.rs
Expand Down
36 changes: 30 additions & 6 deletions src/server/responses/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,23 @@ import type { TranslatorBudget } from "../../lib/translator-budget";


export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): {
toolNsMap: Map<string, { namespace: string; name: string }>;
toolNsMap: Map<string, { namespace: string; name: string; freeform?: true }>;
declaredToolNames: Set<string>;
/** Declared parameter schema per request-visible tool name (#1611 integer repair). */
toolParameterSchemas: Map<string, Record<string, unknown>>;
freeformToolNames: Set<string>;
toolSearchToolNames: Set<string>;
} {
const toolNsMap = new Map<string, { namespace: string; name: string }>();
const toolNsMap = new Map<string, { namespace: string; name: string; freeform?: true }>();
const declaredToolNames = new Set<string>();
const toolParameterSchemas = new Map<string, Record<string, unknown>>();
const freeformToolNames = new Set<string>();
const toolSearchToolNames = new Set<string>();
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
for (const t of parsed.context.tools ?? []) {
const requestedTools = parsed.context.tools ?? [];
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools);
const authorizedTools = requestedTools.filter(toolAllowed);
for (const t of authorizedTools) {
// Upstream output is untrusted: only restore calls for tools the caller authorized.
if (!toolAllowed(t)) continue;
const wireName = namespacedToolName(t.namespace, t.name);
budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" });
declaredToolNames.add(wireName);
Expand All @@ -125,7 +126,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(wireName, t.parameters);
if (t.namespace) {
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
toolNsMap.set(wireName, { namespace: t.namespace, name: t.name });
toolNsMap.set(wireName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
}
if (t.freeform) {
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
Expand All @@ -136,6 +137,29 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
toolSearchToolNames.add(t.name);
}
}
// Some routed providers echo a bare tool_choice selector instead of the flattened catalog
// name. Accept only selectors the client actually sent and only when the full request catalog
// contains one tool with that logical name.
const choice = parsed.options.toolChoice;
const bareChoiceNames = new Set(
choice && typeof choice === "object"
? ("allowedTools" in choice ? choice.allowedTools : [choice.name])
: [],
);
const bareNameCounts = new Map<string, number>();
for (const t of requestedTools) {
bareNameCounts.set(t.name, (bareNameCounts.get(t.name) ?? 0) + 1);
}
for (const t of authorizedTools) {
if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
declaredToolNames.add(t.name);
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
if (t.parameters && typeof t.parameters === "object") {
toolParameterSchemas.set(t.name, t.parameters);
}
}
return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames };
}

Expand Down
61 changes: 55 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,59 @@ export function toolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): st
return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName];
}

export function toolAllowedByChoice(tool: Pick<OcxTool, "namespace" | "name">, allowedTools: ReadonlySet<string>): boolean {
return toolChoiceAliases(tool).some(name => allowedTools.has(name));
function sameToolIdentity(
left: Pick<OcxTool, "namespace" | "name">,
right: Pick<OcxTool, "namespace" | "name">,
): boolean {
return left.namespace === right.namespace && left.name === right.name;
}

/**
* All tools that could be selected by one client-facing name. Bare logical names are included
* here because they are a compatibility selector for namespaced tools, while wire and dotted
* aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid.
*/
export function toolChoiceCandidates(
tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
name: string,
): Pick<OcxTool, "namespace" | "name">[] {
if (!tools) return [];
const candidates: Pick<OcxTool, "namespace" | "name">[] = [];
for (const tool of tools) {
if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue;
if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool);
}
return candidates;
}

/**
* Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that
* shorthand only when the request contains one tool with the logical name, so an ambiguous name
* cannot authorize a tool from an unintended namespace.
*/
export function toolAllowedByChoice(
tool: Pick<OcxTool, "namespace" | "name">,
allowedTools: ReadonlySet<string>,
tools?: readonly Pick<OcxTool, "namespace" | "name">[],
): boolean {
if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name));
for (const name of [...toolChoiceAliases(tool), tool.name]) {
if (!allowedTools.has(name)) continue;
const candidates = toolChoiceCandidates(tools, name);
if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true;
}
return false;
}

export function resolveToolChoiceWireName(tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined, name: string): string {
const match = tools?.find(tool => toolChoiceAliases(tool).includes(name));
return match ? namespacedToolName(match.namespace, match.name) : name;
const candidates = toolChoiceCandidates(tools, name);
if (candidates.length === 1) {
const match = candidates[0];
return namespacedToolName(match.namespace, match.name);
}
// Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The
// catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors.
return name;
}

/**
Expand Down Expand Up @@ -281,14 +327,17 @@ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is
/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */
export function toolChoiceToolPredicate(
choice: OcxToolChoice | undefined,
tools?: readonly Pick<OcxTool, "namespace" | "name">[],
): (tool: Pick<OcxTool, "namespace" | "name">) => boolean {
if (!choice || choice === "auto" || choice === "required") return () => true;
if (choice === "none") return () => false;
if (isAllowedToolChoice(choice)) {
const allowed = new Set(choice.allowedTools);
return tool => toolAllowedByChoice(tool, allowed);
return tool => toolAllowedByChoice(tool, allowed, tools);
}
return tool => toolChoiceAliases(tool).includes(choice.name);
if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name);
const candidates = toolChoiceCandidates(tools, choice.name);
return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool);
}

export interface OcxRequestOptions {
Expand Down
15 changes: 11 additions & 4 deletions src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,13 +741,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
throw e;
}

const toolNsMap = new Map<string, { namespace: string; name: string }>();
const toolNsMap = new Map<string, { namespace: string; name: string; freeform?: true }>();
const freeform = new Set<string>();
const toolSearch = new Set<string>();
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
for (const t of parsed.context.tools ?? []) {
const requestedTools = parsed.context.tools ?? [];
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools);
for (const t of requestedTools) {
if (!toolAllowed(t)) continue;
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
if (t.namespace) {
toolNsMap.set(namespacedToolName(t.namespace, t.name), {
namespace: t.namespace,
name: t.name,
...(t.freeform ? { freeform: true } : {}),
});
}
if (t.freeform) freeform.add(t.name);
if (t.toolSearch) toolSearch.add(t.name);
}
Expand Down
Loading
Loading