diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md index 88c4fd83f7..0152933d69 100644 --- a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md +++ b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md @@ -11,7 +11,7 @@ adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate. | #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 + hardening 2cdfba24d (train-stacked) | 3 | yes | green | MERGED to train; grok blocker fixed; re-verdict PASS; landing on dev | | #2289 | fix(service): restart existing installs w/o re-register | 2df92a270 + locale sync 174f03b60 (train-stacked) | 2 | yes | green incl. Service lifecycle | MERGED to train; grok P2 fixed; re-verdict PASS; Closes #2287 | | #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev | -| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 (fix/apply-patch-routed-lowering) | 48 | yes | Ingwannu: two CHANGES_REQUESTED resolved on this head; third review says no remaining technical blocker | Linux shards green | +| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 + pin ec32a8d52 (train-stacked) | merged into train | yes | MERGED to train; grok P2 fixed; re-verdict PASS | Linux shards green; lidge full suite green | | #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed (fix/claude-code-thought-signature-replay) | 50 | no (review-ready + hygiene-blocked label) | BLOCKED state | CodeRabbit minor: normalize promptCacheKey via anthropicSessionKeyFromParts before storing as clientThreadId (core.ts ~1888-1896); lidge-jun review priority 63/80 confirms repro | | #2296 | fix(codex): bind Desktop reconnects to one pool account | e672b0fd0 + scope fix 698228e40 (train-stacked) | 2 | yes | green | MERGED to train; grok major fixed; re-verdict PASS; landing on dev | diff --git a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md index 26fa79e3db..aaab6ea8bf 100644 --- a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md +++ b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md @@ -2,3 +2,35 @@ 48 behind; single rebase onto now-stable dev. Preserve the !isCanonicalOpenAiForwardProvider boundary (already on head 398b7ade4; maintainer review r3 found no remaining technical blocker). Review: supportsResponsesCustomTools capability plumbing (registry/derive/types), compaction-body-last reorder invariant, byte-identical non-compaction pin test. Fork head (olddonkey/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Pre-merge: dismiss stale CHANGES_REQUESTED (converged per reviewer's own head-398b7ade4 comment) or record fresh APPROVE. Verify on REBASED head BEFORE merge: bun test tests/custom-tool-compat.test.ts tests/namespace-tool-compat.test.ts tests/openai-responses-passthrough.test.ts tests/responses-custom-tool-repair.test.ts, bun run typecheck, FULL SUITE (shared routing/adapter surface; ssh lidge if local env-limited). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head 398b7ade4 — 4 commits over base 7881319e, ~50 behind dev) + +The fork branch is not directly fetchable as a remote ref (fork: olddonkey); +use the PR ref. The branch carries its own rebase history — do NOT rebase the +fork branch; merge the PR ref into the TRAIN and let the train carry it. +Fork push only needed if we stack new commits on the PR itself. Steps: +1. Merge pr/2270 into train, resolve conflicts there. +2. Adversarial review (inherited model): supportsResponsesCustomTools + plumbing, compaction-body-last reorder invariant, byte-identical + non-compaction pin, !isCanonicalOpenAiForwardProvider boundary. +3. Focused custom-tool tests + typecheck + privacy locally at merged head; + lidge full suite; land via train PR to dev; dismiss stale review state via + merge admin path. + +## Review (Bohr, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS + +Clean: capability plumbing consistent (undefined/true = passthrough, false = +lowering, explicit-override precedence tested); all consumption sites behind +the exact-base-URL canonical gate; reorder fixes the real latent bug +(compaction replayed custom_tool_call reached strict upstreams unlowered) +with byte-identical non-compaction pin intact; response restoration +fail-closed via buildToolBridgeMaps; no privacy/logging regressions. + +P2 fixed (commit ec32a8d52): negative pin proving the canonical Codex forward +surface ignores supportsResponsesCustomTools:false. Re-verdict: PASS. +Accepted residuals (P3): composed registry-to-handleResponses e2e, +namespace-child deny dedup coverage, tool_choice + lowered apply_patch case. + +Gates at train head ec32a8d52: focused tests 138/138 (+ pin 100/100), +typecheck pass, privacy:scan pass, lidge r9 full suite 14233 pass / 0 fail +exit 0 at 668512a58 + pin-only delta after. diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d3b3e3de12..eeeb38c386 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1731,17 +1731,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // that already recorded a single-query web_search_call replays it every turn, and // a strict parser rejects the whole request over it (#930). outBody = backfillWebSearchQueries(outBody); - // Same predicate as the routedCompaction gate in handleResponses(): an - // authMode check would let a noncanonical custom forward provider skip this - // rewrite while the server still routes it as a summarizer turn (#422). - if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { - outBody = buildRoutedCompactionBody(outBody); - } if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { - const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + const rewritten = rewriteRoutedCustomToolsForUpstream( + outBody, + provider.supportsResponsesCustomTools, + ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } @@ -1770,6 +1767,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } + // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would + // let a noncanonical custom forward provider skip this rewrite while the server still routes + // it as a summarizer turn (#422). The compaction body build removes the tool surface and must + // therefore be the last routed transform: anything before it may depend on the declarations; + // anything after it cannot. + if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { + outBody = buildRoutedCompactionBody(outBody); + } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( outBody, diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c00df10bee..63cd1c9388 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -483,6 +483,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) { prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields; } + if (prov.supportsResponsesCustomTools === undefined && entry.supportsResponsesCustomTools !== undefined) { + prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools; + } if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 32a65a7af5..3fa208456f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -225,6 +225,8 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for OpenAI extended hosted web_search field support. */ supportsOpenAiWebSearchToolFields?: boolean; + /** Registry default for native Responses custom-tool support. */ + supportsResponsesCustomTools?: boolean; /** Registry default for exact model service-tier capability; explicit config keys win. */ modelSupportsServiceTier?: Record; /** @@ -1047,6 +1049,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ oauthId: "xai", jawcodeBundle: "xai", supportsOpenAiWebSearchToolFields: false, + // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting + // the otherwise-identical request after the custom tool is lowered to a function. + supportsResponsesCustomTools: false, note: "Log in with your Grok account", // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index e7db3c32a6..d5d4e93b30 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -4,6 +4,13 @@ import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function routedCustomToolPassesThrough( + name: string, + supportsResponsesCustomTools: boolean | undefined, +): boolean { + return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name); +} + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -34,7 +41,10 @@ export function routedCustomToolWireName(value: unknown): string | undefined { * Names of converted custom declarations after namespace lowering. Restoration uses these exact * wire identities so same-named function and custom children in different namespaces stay distinct. */ -function collectRoutedCustomToolWireNames(body: unknown): Set { +function collectRoutedCustomToolWireNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const groups = collectResponsesToolGroups(body); const bareWireNames = new Set(); @@ -54,7 +64,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { if ( tool.type === "custom" && typeof tool.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name) + && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) ) { names.add(tool.name); continue; @@ -67,7 +77,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { isPlainObject(child) && child.type === "custom" && typeof child.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name) + && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); } @@ -81,7 +91,10 @@ export function customToolItemId(id: unknown): unknown { return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; } -export function collectRoutedCustomToolNames(body: unknown): Set { +export function collectRoutedCustomToolNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -92,7 +105,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set { if ( value.type === "custom" && typeof value.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools) ) { names.add(value.name); } @@ -184,12 +197,15 @@ function rewriteForUpstream( return changed ? next : value; } -export function rewriteRoutedCustomToolsForUpstream(body: unknown): { +export function rewriteRoutedCustomToolsForUpstream( + body: unknown, + supportsResponsesCustomTools?: boolean, +): { body: unknown; names: Set; } { - const conversionNames = collectRoutedCustomToolNames(body); - const names = collectRoutedCustomToolWireNames(body); + const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); + const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); if (conversionNames.size === 0) return { body, names }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3f6cd42ea2..cbc90db605 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -268,9 +268,8 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { const groups = collectResponsesToolGroups(body); const plan = buildRewritePlan(groups); - // Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays - // call items carrying a private `namespace`, and the routed compaction turn strips the whole tool - // surface before this runs. + // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still + // replay call items carrying a private `namespace`. const emitted = new Set(); const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools; diff --git a/src/router.ts b/src/router.ts index 35e34d75ca..47a604d77c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -366,6 +366,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.supportsOpenAiWebSearchToolFields !== undefined ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } : {}), + ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined + ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools } + : {}), ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), diff --git a/src/types/provider.ts b/src/types/provider.ts index b4044050d5..3dfca58ddc 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -352,6 +352,12 @@ export interface OcxProviderConfig { * passthrough compatibility for OpenAI and unclassified gateways. */ supportsOpenAiWebSearchToolFields?: boolean; + /** + * Whether the Responses upstream accepts native custom tools and custom_tool_call items. + * Set false only for a provider whose native contract rejects them; absence preserves + * apply_patch passthrough compatibility for OpenAI and unclassified gateways. + */ + supportsResponsesCustomTools?: boolean; /** * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical * fields or closing events (#893). Disabled by default and applied only to client-facing diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1e2f4e6192..733aa7acc1 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -44,7 +44,7 @@ Responses-compatible streaming output. - 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. - 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. - 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. -- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form. +- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form. - 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. - 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. @@ -69,10 +69,11 @@ Two coordinates that lower to the same wire name are treated as one tool when th a `functions` child of the same name are the duplicate the parser already tolerates — and the one `promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. -Replayed call items are lowered whether or not this turn declares the group they name. A routed -compaction turn strips the whole tool surface before the boundary runs, and a catalog can change -mid-session, but the client is still replaying items this layer's own response restoration stamped -with a private `namespace`. Only `tool_choice` resolves a bare name through the catalog: a history +Replayed call items are lowered whether or not this turn declares the group they name. A catalog can +be absent or change mid-session, but the client is still replaying items this layer's own response +restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing +the tool surface so request-local aliases remain available for response restoration. Only +`tool_choice` resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 4d2a500857..d04535581d 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -14,6 +14,67 @@ function convertedInputDescription(name: string): string | undefined { } describe("routed custom-tool compatibility", () => { + test.each([ + ["absent", undefined], + ["true", true], + ] as const)("keeps apply_patch byte-identical when custom-tool support is %s", (_label, support) => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + const before = JSON.stringify(raw); + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, support); + + expect(rewritten.body).toBe(raw); + expect(JSON.stringify(rewritten.body)).toBe(before); + expect(rewritten.names).toEqual(new Set()); + }); + + test("lowers apply_patch declarations and replay items on an explicit capability denial", () => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, false); + const body = rewritten.body as typeof raw; + + expect(rewritten.names).toEqual(new Set(["apply_patch"])); + expect(body.tools[0]).toMatchObject({ + type: "function", + name: "apply_patch", + parameters: { required: ["input"] }, + }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(body.input[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch", + output: "done", + }); + }); + + test.each([undefined, true, false])("keeps lowering other custom tools when support is %p", support => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ type: "custom", name: "review_patch", description: "Review", format: { type: "text" } }], + }, support); + const body = rewritten.body as { tools: Array> }; + + expect(body.tools[0]).toMatchObject({ type: "function", name: "review_patch" }); + expect(rewritten.names).toEqual(new Set(["review_patch"])); + }); + test("converted exec preserves the JavaScript input contract", () => { const description = convertedInputDescription("exec"); expect(description).toContain("JavaScript"); diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 45a4157808..83367a8ed8 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -198,9 +198,8 @@ describe("Responses namespace tool compatibility", () => { expect(flatten([functionsGroup], [bare])).toEqual([bare]); }); - // The routed compaction turn strips the whole tool surface before this runs, and a catalog can - // change mid-session — but the client is still replaying items this layer's own restoration - // stamped with a private `namespace`. + // A catalog can be absent or change mid-session, but the client can still replay items this + // layer's own restoration stamped with a private `namespace`. test("lowers replayed calls even when this turn declares no namespace", () => { const body = rewriteRoutedNamespaceToolsForUpstream({ input: [ diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 1c95c49833..27254db95f 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3,6 +3,7 @@ import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterP import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routeModel } from "../src/router"; import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { encodeCompactionSummary, @@ -248,6 +249,369 @@ describe("DeepSeek Responses endpoint contract", () => { }); }); +describe("Responses custom-tool destination capability", () => { + test("xAI explicitly denies native custom tools and registry enrichment preserves an override", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.supportsResponsesCustomTools).toBe(false); + + const inherited = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + } as Parameters[1]; + enrichProviderFromRegistry("xai", inherited); + expect(inherited.supportsResponsesCustomTools).toBe(false); + + const explicit = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + supportsResponsesCustomTools: true, + } as Parameters[1]; + enrichProviderFromRegistry("xai", explicit); + expect(explicit.supportsResponsesCustomTools).toBe(true); + + const routed = routeModel({ + port: 0, + defaultProvider: "xai", + providers: { + xai: { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" }, + }, + } as OcxConfig, "xai/grok-4.6"); + expect(routed.provider.supportsResponsesCustomTools).toBe(false); + }); + + test("noncanonical forward destinations that deny custom tools lower apply_patch", () => { + const rawBody = { + model: "routed-model", + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "routed-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(request.headers.authorization).toBe("Bearer provider-static"); + expect(body.tools[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect([...(request.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + }); + + test("the canonical Codex forward surface never lowers custom tools, even with an explicit denial", () => { + const rawBody = { + model: "gpt-5.6-sol", + stream: true, + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + // Exact canonical Codex forward base URL: isCanonicalOpenAiForwardProvider is true, + // so the lowering gate must be unreachable regardless of the capability flag. + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(body.tools[0]).toMatchObject({ type: "custom", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ type: "custom_tool_call", call_id: "c1", name: "apply_patch" }); + expect(request.convertedRoutedCustomToolNames ?? []).toEqual([]); + }); +}); + +describe("routed compaction lowering order", () => { + const baseInput = [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + { type: "custom_tool_call", call_id: "c1", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "c1", output: "ok" }, + { + type: "tool_search_call", + call_id: "c2", + execution: "client", + arguments: { query: "database" }, + }, + { + type: "tool_search_output", + call_id: "c2", + execution: "client", + status: "completed", + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + { + type: "function_call", + call_id: "c3", + namespace: "collaboration", + name: "spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + { + type: "additional_tools", + role: "developer", + tools: [{ + type: "function", + name: "extra", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + ]; + const rawBody = (compaction: boolean) => ({ + model: "routed-model", + stream: false, + input: [ + ...baseInput, + ...(compaction ? [{ type: "compaction_trigger" }] : []), + ], + tools: [ + { + type: "custom", + name: "apply_patch", + description: "Apply patch", + format: { type: "text" }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "tool_search", + execution: "client", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: { type: "object" } }], + }, + ], + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + }); + const loweredReplay = [ + { + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + { + type: "function_call", + call_id: "c2", + name: "opencodex_tool_search", + arguments: JSON.stringify({ query: "database" }), + }, + { + type: "function_call_output", + call_id: "c2", + output: JSON.stringify({ + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + status: "completed", + }), + }, + { + type: "function_call", + call_id: "c3", + name: "collaboration__spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + ]; + const loweredTools = [ + { + type: "function", + name: "apply_patch", + description: "Apply patch", + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Raw input for this client-executed custom tool.", + }, + }, + required: ["input"], + additionalProperties: false, + }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "function", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + name: "opencodex_tool_search", + }, + { + type: "function", + name: "collaboration__spawn_agent", + parameters: { type: "object" }, + }, + { type: "function", name: "loaded_tool", parameters: { type: "object" } }, + ]; + + function build(compaction: boolean) { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "test-key", + supportsResponsesCustomTools: false, + }); + return adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: rawBody(compaction), + ...(compaction ? { _compactionRequest: true } : {}), + }, { headers: new Headers() }); + } + + test("lowers replayed calls before removing the compaction tool surface", () => { + const built = build(true); + const body = JSON.parse(built.body) as Record & { + input: Array>; + }; + + expect(body.input.slice(0, -1)).toEqual([ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_text", text: "[image omitted for compaction]" }, + ], + }, + ...loweredReplay, + ]); + expect(body.input.at(-1)).toEqual({ + type: "message", + role: "user", + content: [{ + type: "input_text", + text: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION"), + }], + }); + + expect(body).not.toHaveProperty("tools"); + expect(body).not.toHaveProperty("tool_choice"); + expect(body).not.toHaveProperty("parallel_tool_calls"); + expect(body).not.toHaveProperty("text"); + expect(body.input.some(item => item.type === "compaction_trigger")).toBe(false); + expect(body.input.some(item => item.type === "additional_tools")).toBe(false); + expect(JSON.stringify(body)).not.toContain("input_image"); + expect(JSON.stringify(body)).not.toContain("data:image/png"); + expect(body.input.find(item => item.call_id === "c3")).not.toHaveProperty("namespace"); + + expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]); + expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + }); + + test("leaves the non-compaction serialized body byte-identical", () => { + const built = build(false); + expect(built.body).toBe(JSON.stringify({ + model: "routed-model", + stream: false, + input: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + ...loweredReplay, + { + type: "additional_tools", + role: "developer", + tools: [{ type: "function", name: "extra", parameters: { type: "object" } }], + }, + ], + tools: loweredTools, + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + })); + }); +}); + describe("OpenAI Responses passthrough sanitization", () => { const deferredToolBody = { model: "routed-model", diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index a5fdafabee..923d52af44 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -528,6 +528,210 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses lowers and restores apply_patch when the destination denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses lowers apply_patch for a noncanonical forward destination that denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + let outboundAuthorization: string | null = null; + let outboundUrl = ""; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (input, init) => { + outboundUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + outboundBody = JSON.parse(String(init?.body)) as Record; + outboundAuthorization = new Headers(init?.headers).get("authorization"); + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-secret" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundUrl).toBe("https://provider.example/v1/responses"); + expect(outboundAuthorization).toBe("Bearer provider-static"); + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { const savedFetch = globalThis.fetch; const outboundBodies: Array> = [];