Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
Expand Up @@ -10,3 +10,21 @@ MODIFY existing native Responses repair and namespace tests, or register new dom
UPDATE structure/11_compatibility-contracts.md and guides/codex-integration.md with completion parity boundary and inventory.

C: standalone synthetic imports with stub tools (no real execution) and remote focused tests/typecheck; all stack PR exact-head hosted CI must pass before merge. Keep original pi-filter/owned-refresh/responses-patch terminal criteria unchanged and satisfy them at final D with PR heads/CI/merge ancestry. Register native GitHub stack, merge approved prefix using async REST and SHA guard, wait for actual merged status, fetch dev and prove all merged SHAs ancestors. No release/deploy/local suites. Stop only verified DONE or actual external blocking evidence. Resource bounds inherited from000.

## P revalidation at b477b731e

030 lifecycle+raw payload boundaries are independently reviewed and55 remote tests pass. The existing bridge streams ordinary function argument previews then uses coerceIntegerToolArguments at authoritative arguments.done/item.done;040 mirrors that contract. Unlike executable exec source compilation, numeric representation repair does not require withholding previews. Do not synthesize corrected deltas; correct the authoritative completion events and JSON snapshots, and verify downstream Chat collector consumes those finals. Buffer only early identity-less completion events if correlation needs them; all retention remains budgeted.

Implementation interfaces locked for disjoint delegation: function-call-compat.ts exports collectFunctionCallRepairSchemas(body), repairFunctionCalls(value, schemas): {value,changed}, repairFunctionCallsInJson(text,schemas). responses-function-tool-repair.ts exports createResponsesFunctionToolRepairBlockRewrite(schemas,budget?). The collector reads only original current-turn ordinary declarations and honors original function-kind/namespace selector restrictions; it can reuse pure namespace lowering to resolve selectors while preserving original schema values. Native forward routing receives an empty repair map. Empty ordinary completed arguments become{}; custom/native wrappers never enter.

Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to new responses/tool-name-aliases.ts, guard import updates, core integration, namespace tests, layout registration, docs. Worker owns only the two new function repair modules and one new tests/responses/responses-function-tool-repair.test.ts. No shared write paths, no worker commits/FSM/local suites. Core captures schemas after successful adapter buildRequest from the previously captured clientToolAuthorizationBody, then uses same pure repair in remembered continuation and clientJSON, and block repair after custom/tool-search restores before final declaration guard. Each attempt receives fresh block state.

## Implementation observations

Namespace aliases are built after custom lowering (openai-responses.ts2410-2432), so an original custom tool can carry lowered kind=function. Preserve that existing namespace restoration kind behavior; only original-schema function repair enforces ordinary function kind. Dotted restoration adds spelling parity, not a new kind conversion. Explicit conflicting namespaces stay untouched. Reserved functions children participate in the shared collision inventory as bare names. The existing namespace tests are updated for additional alias entries rather than weakening their authorization assertions.

Review-size exception: keep original-schema collection, native SSE/JSON/replay wiring and their end-to-end regressions in one layer because they jointly define the completion contract. Roughly half the added lines are focused regressions; the alias inventory is moved, not reimplemented. Prior catalog and patch concerns are already separate PRs. Additional Aside profile work remains separate future cycles.

## Review synthesis, round1

Accept three medium findings: (1) sparse JSON receives inferred completion status after the new repair, so normalize snapshot/required fields before function repair and reuse that normalization for stored replay; (2) an index-only early completion can be correlated but still lacks item_id, so attach the known id even if arguments stay unchanged; (3) current-turn tool_search_output declarations are promoted by the adapter but absent from the original-schema collector, so include their original definitions in collector/selector resolution after the replay-prefix cut. Do not broaden collectResponsesToolGroups globally or include historical loaded declarations. Main owns normalization order/replay regression; existing worker owns early-frame id and loaded-declaration collector fixes/tests. Original authorization and preservation constraints remain.
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,12 @@ Codex. Native custom calls and converted function calls use the same completion
patch previews are held while their executable form is unresolved. JavaScript that merely
contains patch text and unrelated native custom payloads stay unchanged.

Ordinary routed Responses function calls also use the original declared parameter schema at
completion: integral floats in integer fields and integral numbers in string-only fields are
normalized, while fractions and numeric unions stay unchanged. An explicitly empty completed
argument string becomes `{}`. Final events and locally stored continuation history agree.
Unambiguous dotted namespace spellings are restored to the declared namespace and tool name.

The selected provider must support function/tool calling. A text-only provider without tool-call
support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool
mode unchanged.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@
"responses-context-overflow.test.ts": "responses",
"responses-custom-tool-guidance.test.ts": "responses",
"responses-custom-tool-repair.test.ts": "responses",
"responses-function-tool-repair.test.ts": "responses",
"responses-fetch-helpers-boundary.test.ts": "responses",
"responses-field-backfill.test.ts": "responses",
"responses-forward-dangling-call.test.ts": "responses",
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2429,7 +2429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// Codex 0.147 emits private namespace tool groups, while public/third-party Responses
// gateways accept only flat tool variants. Run after custom/tool-search lowering so
// namespace children already carry their final public kind before they are promoted.
const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody);
const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames);
outBody = rewritten.body;
convertedRoutedNamespaceToolAliases = rewritten.aliases;
// Preserve xAI's cached-only fail-closed semantics and image-search mapping before the
Expand Down
173 changes: 173 additions & 0 deletions src/responses/function-call-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { coerceIntegerToolArguments } from "../lib/tool-argument-integers";
import { namespacedToolName } from "../types/tools";
import { rewriteRoutedNamespaceToolsForUpstream } from "./namespace-tool-compat";
import { collectResponsesToolGroups } from "./tool-groups";

export interface FunctionCallRepairSchema {
name: string;
namespace?: string;
parameters?: Record<string, unknown>;
}

/** Keys are canonical original identities, never a bare-name fallback for a namespace. */
export type FunctionCallRepairSchemas = ReadonlyMap<string, FunctionCallRepairSchema>;

function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

/** JSON object member order is immaterial; array elements retain their exact order. */
function sameSchemaValue(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right) && left.length === right.length
&& left.every((value, index) => sameSchemaValue(value, right[index]));
}
if (!isObject(left) || !isObject(right)) return false;
const keys = Object.keys(left);
return keys.length === Object.keys(right).length
&& keys.every(key => Object.hasOwn(right, key) && sameSchemaValue(left[key], right[key]));
}

function namespaceOf(value: unknown): string | undefined {
return typeof value === "string" && value !== "functions" ? value : undefined;
}

function selectorAllows(
selector: unknown,
lowered: unknown,
wireName: string,
identity: FunctionCallRepairSchema,
): boolean {
if (!isObject(selector) || selector.type !== "function" || typeof selector.name !== "string") return false;
if ("namespace" in selector) {
if (typeof selector.namespace !== "string" || selector.namespace.length === 0) return false;
return namespaceOf(selector.namespace) === identity.namespace && selector.name === identity.name;
}
return isObject(lowered) && lowered.type === "function" && lowered.name === wireName;
}

/** Caller supplies currentTurnWireToolCatalogBody BEFORE provider schema lowering. */
export function collectFunctionCallRepairSchemas(body: unknown): Map<string, FunctionCallRepairSchema> {
const schemas = new Map<string, FunctionCallRepairSchema>();
if (!isObject(body)) return schemas;
const groups = collectResponsesToolGroups(body);
if (Array.isArray(body.input)) {
for (const entry of body.input) {
if (isObject(entry) && entry.type === "tool_search_output" && Array.isArray(entry.tools)) groups.push(entry.tools);
}
}
// Reuse namespace selector resolution, retaining schemas from the original objects below.
// This local catalog view includes loaded definitions without revisiting replay history or
// teaching the shared tool-group collector a new transport-wide interpretation.
const lowered = rewriteRoutedNamespaceToolsForUpstream({ ...body, tools: groups.flat(), input: [] }).body;
const choice = body.tool_choice;
const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined;
const occupied = new Map<string, { kind: unknown; identity: FunctionCallRepairSchema } | null>();
const register = (tool: unknown, namespace?: string): void => {
if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return;
const identity: FunctionCallRepairSchema = {
name: tool.name,
...(namespace ? { namespace } : {}),
...(isObject(tool.parameters) ? { parameters: tool.parameters } : {}),
};
const key = namespacedToolName(namespace, tool.name);
if (occupied.has(key)) {
const previous = occupied.get(key);
// Conflicting duplicate declarations cannot choose a schema by insertion order.
if (!previous || previous.kind !== tool.type
|| previous.identity.namespace !== namespace
|| previous.identity.name !== tool.name
|| !sameSchemaValue(previous.identity.parameters, identity.parameters)) {
occupied.set(key, null);
}
} else occupied.set(key, { kind: tool.type, identity });
};
for (const group of groups) {
for (const tool of group) {
if (!isObject(tool)) continue;
if (tool.type === "namespace") {
if (typeof tool.name !== "string" || !tool.name || !Array.isArray(tool.tools)) continue;
for (const child of tool.tools) register(child, namespaceOf(tool.name));
} else if (tool.type === "function" && isObject(tool.function)) {
register({ ...tool.function, type: "function" });
} else register(tool);
}
}
for (const [key, entry] of occupied) {
if (!entry || entry.kind !== "function") continue;
let allowed = choice === undefined || choice === "auto" || choice === "required";
if (isObject(choice)) {
if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
const selectors = isObject(loweredChoice) && Array.isArray(loweredChoice.tools) ? loweredChoice.tools : [];
allowed = choice.tools.some((selector, index) => selectorAllows(selector, selectors[index], key, entry.identity));
} else allowed = selectorAllows(choice, loweredChoice, key, entry.identity);
}
if (allowed) schemas.set(key, entry.identity);
}
return schemas;
}

function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed: boolean): unknown {
if (!isObject(item) || item.type !== "function_call" || typeof item.name !== "string"
|| typeof item.arguments !== "string") return item;
if (item.status !== "completed" && !(item.status === undefined && completed)) return item;
if ("namespace" in item && (typeof item.namespace !== "string" || !item.namespace)) return item;
const namespace = namespaceOf(item.namespace);
const schema = schemas.get(namespacedToolName(namespace, item.name));
if (!schema) return item;
// An explicit namespace is an identity coordinate, not another spelling to guess at.
if ("namespace" in item && (schema.namespace !== namespace || schema.name !== item.name)) return item;
const raw = item.arguments;
if (raw !== "") {
try {
let unsafe = false;
JSON.parse(raw, (_key, value: unknown) => {
if (typeof value === "number" && (!Number.isFinite(value)
|| (Number.isInteger(value) && !Number.isSafeInteger(value)))) unsafe = true;
return value;
});
// Re-stringifying another repaired field must not round an unsafe sibling number.
if (unsafe) return item;
} catch { return item; }
}
const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name);
return argumentsText === raw ? item : { ...item, arguments: argumentsText };
}

/** Only executable completion slots are visited; metadata and custom input are opaque. */
export function repairFunctionCalls(
value: unknown,
schemas: FunctionCallRepairSchemas,
): { value: unknown; changed: boolean } {
if (schemas.size === 0 || !isObject(value)) return { value, changed: false };
if (typeof value.status === "string" && ["failed", "incomplete", "cancelled", "in_progress", "queued"].includes(value.status)) return { value, changed: false };
let next: unknown = value;
if (value.type === "function_call") next = repairItem(value, schemas, false);
else if (value.type === "response.output_item.done") {
const item = repairItem(value.item, schemas, true);
if (item !== value.item) next = { ...value, item };
} else if (value.type === "response.completed" && isObject(value.response)) {
const response = value.response;
if ((response.status === undefined || response.status === "completed") && Array.isArray(response.output)) {
const original = response.output;
const output = original.map(item => repairItem(item, schemas, true));
if (output.some((item, index) => item !== original[index])) next = { ...value, response: { ...response, output } };
}
} else if (typeof value.type !== "string" || !value.type.startsWith("response.")) {
if (Array.isArray(value.output)) {
const original = value.output;
const output = original.map(item => repairItem(item, schemas, value.status === "completed"));
if (output.some((item, index) => item !== original[index])) next = { ...value, output };
}
}
return { value: next, changed: next !== value };
}

export function repairFunctionCallsInJson(text: string, schemas: FunctionCallRepairSchemas): string {
if (schemas.size === 0) return text;
let payload: unknown;
try { payload = JSON.parse(text); } catch { return text; }
const repaired = repairFunctionCalls(payload, schemas);
return repaired.changed ? JSON.stringify(repaired.value) : text;
}
31 changes: 27 additions & 4 deletions src/responses/namespace-tool-compat.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { namespacedToolName } from "../types";
import { dottedToolName, namespacedToolName } from "../types";
import { collectResponsesToolGroups } from "./tool-groups";
import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases";

export interface RoutedNamespaceToolIdentity {
namespace: string;
Expand Down Expand Up @@ -340,7 +341,10 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se
* `<namespace>__<name>` wire identity as the chat adapters. The returned request-local aliases
* are the only names response restoration is allowed to expand.
*/
export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
export function rewriteRoutedNamespaceToolsForUpstream(
body: unknown,
convertedCustomToolNames?: ReadonlySet<string>,
): {
body: unknown;
aliases: Map<string, RoutedNamespaceToolIdentity>;
} {
Expand All @@ -365,14 +369,29 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
}

const toolChoice = rewriteToolChoice(body.tool_choice, plan);
const aliases = authorizedAliases(plan.aliases, toolChoice);
const ambiguousDotted = collectAmbiguousDottedAliases(groups);
// Authorize canonical identities first, then add only unambiguous spellings.
// Selection cannot hide a collision elsewhere in the original declaration set.
for (const identity of [...aliases.values()]) {
const dotted = dottedToolName(identity.namespace, identity.name);
if (dottedAliasIsUnambiguous(identity.namespace, identity.name)
&& !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted)
&& !aliases.has(dotted)) aliases.set(dotted, identity);
}
// The adapter lowers custom tools before namespaces. Preserve their declared
// kind only in already-authorized response aliases; wire selectors remain lowered.
for (const identity of aliases.values()) {
if (convertedCustomToolNames?.has(namespacedToolName(identity.namespace, identity.name))) identity.kind = "custom";
}
return {
body: {
...body,
...(tools !== body.tools ? { tools } : {}),
...(input !== body.input ? { input } : {}),
...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
},
aliases: authorizedAliases(plan.aliases, toolChoice),
aliases,
};
}

Expand Down Expand Up @@ -404,7 +423,11 @@ export function restoreRoutedNamespaceCalls(
&& typeof value.name === "string"
) {
const identity = aliases.get(value.name);
if (identity) {
if (identity
// Custom declarations may be lowered to function calls upstream, but an
// ordinary function declaration never authorizes a custom call payload.
&& (value.type !== "custom_tool_call" || identity.kind === "custom")
&& (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) {
Comment thread
lidge-jun marked this conversation as resolved.
restored.name = identity.name;
restored.namespace = identity.namespace;
changed = true;
Expand Down
Loading
Loading