diff --git a/src/adapters/base.ts b/src/adapters/base.ts index faeea0e959..3f3f3d06b6 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -68,10 +68,12 @@ export interface AdapterRequest { method: string; headers: Record; body: string; - /** Custom-tool names actually lowered to upstream function calls while building this request. */ + /** Final upstream wire names of custom tools lowered to functions while building this request. */ convertedRoutedCustomToolNames?: ReadonlySet; /** Client tool-search names actually lowered to upstream function calls for this request. */ convertedRoutedToolSearchNames?: ReadonlySet; + /** Upstream-only aliases for namespace tools flattened in this request. */ + convertedRoutedNamespaceToolAliases?: ReadonlyMap; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b85252b142..0a24684395 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2,16 +2,17 @@ import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; import { catalogModelSupportsReasoningSummaries } from "../codex/catalog"; -import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction"; +import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction"; import { collectResponsesToolGroups } from "../responses/tool-groups"; import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; import { decodeServerSentEvents } from "../lib/sse-decoder"; -import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, destinationDecodesNativeCompactionBlob, isCanonicalOpenAiForwardProvider, isOpenAiOperatedResponsesDestination } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; +import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { createAdapterTierMetadata, @@ -41,7 +42,11 @@ export const FORWARD_HEADERS = [ export function sanitizeReasoningInputContent( body: unknown, - opts?: { preserveRawReasoningContent?: boolean }, + opts?: { + preserveRawReasoningContent?: boolean; + dropNullContentChannel?: boolean; + stripEncryptedContent?: boolean; + }, ): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const raw = body as Record; @@ -56,14 +61,42 @@ export function sanitizeReasoningInputContent( // ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native // backend cannot decrypt them and would reject the request. Strip regardless of content shape. const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX); - if (!hasRawContent && !hasOcxEnvelope) return item; - if (hasOcxEnvelope) { - changed = true; - const next: Record = { ...rec }; - delete next.encrypted_content; - if (!opts?.preserveRawReasoningContent) next.content = []; - return next; + const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status"); + const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content"); + const stripEncryptedContent = hasOcxEnvelope + || (opts?.stripEncryptedContent === true && hasEncryptedContent); + const retainsEncryptedContent = hasEncryptedContent && !stripEncryptedContent; + // Codex serializes an absent reasoning content channel as `"content": null`. The field is + // optional and null carries nothing, but a strict gateway rejects the item on its declared type + // — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content` + // rather than the field it actually refused, which is why this reads as a blob failure. Drop the + // key so the item matches the shape the upstream issued. + // + // Gated to routed destinations. An OpenAI-operated backend binds the blob to the item's exact + // shape, so deleting a field there invalidates it (`The encrypted content ... could not be + // verified`); the two requirements are exactly opposed, and a live regression proved it. That + // gate is also why this drop may touch an item that keeps its blob, which the status invariant + // below forbids: xAI demonstrably accepts its own blob without the null channel, and the + // destinations that bind blobs to item shape never reach this branch. + const dropNullContentChannel = opts?.dropNullContentChannel === true + && "content" in rec && !Array.isArray(rec.content); + // Invariant for fields newly stripped by this cross-backend layer: an item whose + // encrypted_content is forwarded keeps status because OpenAI-operated backends bind opaque + // reasoning blobs to the item shape. Content blanking predates this invariant and remains + // required by ChatGPT's input contract; a native blob plus raw content is a known unresolved + // shape conflict, not an oversight to resolve by preserving content here. + const stripOutputStatus = hasOutputStatus && !retainsEncryptedContent; + const blankContent = !dropNullContentChannel + && !opts?.preserveRawReasoningContent + && (hasRawContent || hasOcxEnvelope); + if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) { + return item; } + changed = true; + const next: Record = { ...rec }; + if (dropNullContentChannel) delete next.content; + if (stripOutputStatus) delete next.status; + if (stripEncryptedContent) delete next.encrypted_content; // Routed models can produce raw `reasoning_text` output items. Codex echoes those in later // native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty // `content`; keep summaries/ids and drop the raw content so native passthrough does not 400. @@ -71,9 +104,8 @@ export function sanitizeReasoningInputContent( // guide merges reasoning items into the adjacent assistant message), so providers flagged // `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks // continuations after tool calls (issue #875 family). - if (opts?.preserveRawReasoningContent) return item; - changed = true; - return { ...rec, content: [] }; + if (blankContent) next.content = []; + return next; }); return changed ? { ...raw, input } : body; @@ -120,6 +152,66 @@ function stripInvalidItemIds(body: unknown): unknown { return changed ? { ...body, input } : body; } +/** + * Codex-private tool fields that only the ChatGPT backend understands. + * + * A third-party Responses gateway validates its schema and rejects the whole request before + * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at + * the noncanonical boundary while the tool and every public option stay. + * + * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip + * with its own traversal, and the traversals disagreed about which containers they covered; a new + * one should be a row here instead. `toolTypes` omitted means the field is private on any tool. + */ +const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet }[] = [ + // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone. + { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]) }, + // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output` + // already loaded, so a still-deferred declaration — including one promoted out of a namespace + // group — otherwise reaches the wire carrying it. + { field: "defer_loading" }, +]; + +function stripCanonicalOnlyToolFields(body: unknown): unknown { + if (!isPlainObject(body)) return body; + + const rewriteTools = (tools: unknown[]): unknown[] => { + let changed = false; + const rewritten = tools.map(tool => { + if (!isPlainObject(tool)) return tool; + let next = tool; + for (const { field, toolTypes } of CANONICAL_ONLY_TOOL_FIELDS) { + if (!Object.hasOwn(next, field)) continue; + if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; + const { [field]: _private, ...rest } = next; + next = rest; + } + if (next === tool) return tool; + changed = true; + return next; + }); + return changed ? rewritten : tools; + }; + + let rewrittenBody = body; + if (Array.isArray(body.tools)) { + const tools = rewriteTools(body.tools); + if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools }; + } + if (!Array.isArray(body.input)) return rewrittenBody; + + let input: unknown[] | undefined; + for (let index = 0; index < body.input.length; index += 1) { + const item = body.input[index]; + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue; + const tools = rewriteTools(item.tools); + if (tools === item.tools) continue; + input ??= [...body.input]; + input[index] = { ...item, tools }; + } + return input ? { ...rewrittenBody, input } : rewrittenBody; +} + /** * When `store` is false, the upstream API does not persist response items. Any item ID * forwarded in `input` is then interpreted as a reference to a stored item that does not @@ -143,25 +235,41 @@ function stripItemIdsWhenUnstored(body: unknown): unknown { } /** - * Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain - * user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not - * OpenAI encryption — the native backend cannot decrypt it and would reject the request. Real - * OpenAI-encrypted compaction items are forwarded untouched. + * Normalize replayed compaction items for the destination backend. + * + * A compaction item carries an `encrypted_content` blob the client replays verbatim on every later + * turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are + * transparent base64 rather than encryption, so no upstream can read them and they always become + * plain user messages. Native blobs have multiple possible minters, so a destination's ability to + * decode its own blobs does not make a blob from a previous serving identity portable. On a known + * identity mismatch the blob degrades to the same note the bridged parser uses, even when the + * destination normally accepts native blobs. Without a known mismatch, the destination capability + * keeps the existing behavior. + * + * A bare `context_compaction` marker carries no blob and is forwarded untouched. */ -function scrubOcxCompactionItems(body: unknown): unknown { +function scrubOcxCompactionItems( + body: unknown, + destinationDecodesNativeBlob: boolean, + threadServingIdentityChanged: boolean, +): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { - if (!isPlainObject(item)) return item; - if (item.type !== "compaction" && item.type !== "compaction_summary" && item.type !== "context_compaction") return item; - const decoded = typeof item.encrypted_content === "string" ? decodeCompactionSummary(item.encrypted_content) : null; - if (decoded === null) return item; + if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item; + const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined; + if (encrypted === undefined) return item; + if ( + decodeCompactionSummary(encrypted) === null + && destinationDecodesNativeBlob + && !threadServingIdentityChanged + ) return item; changed = true; return { type: "message", role: "user", - content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${decoded}` }], + content: [{ type: "input_text", text: compactionItemToText(encrypted) }], }; }); @@ -1506,6 +1614,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; + let convertedRoutedNamespaceToolAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1572,7 +1681,26 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedToolSearchNames = rewritten.names; } - const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); + if (!isCanonicalOpenAiForwardProvider(provider)) { + // 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); + outBody = rewritten.body; + convertedRoutedNamespaceToolAliases = rewritten.aliases; + // Last, so promoted namespace children are also cleared of Codex-private fields. + outBody = stripCanonicalOnlyToolFields(outBody); + } + const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; + const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( + outBody, + destinationDecodesNativeCompactionBlob(provider), + threadServingIdentityChanged, + ), { + preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), + stripEncryptedContent: threadServingIdentityChanged, + }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), provider, @@ -1600,6 +1728,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), + ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/config.ts b/src/config.ts index 60178f3d4f..dcf34313a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -715,6 +715,7 @@ const providerConfigSchema = z.object({ supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), + decodesNativeCompactionBlobs: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), // The management API accepts `null` as "clear this", so a config written before the POST // canonicalization below can hold one on disk. Rejecting it here would send the operator diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index f1156cb447..0896e4e0e1 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -54,6 +54,32 @@ export function supportsNativeResponsesCompactEndpoint( && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; } +/** +/** + * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex + * surface or the official OpenAI API. + * + * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not + * receive the caller's credentials (see the forward-header gate in the Responses adapter), so + * forward auth says nothing about which backend is on the other end. + */ +export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean { + if (isCanonicalOpenAiForwardProvider(provider)) return true; + return provider.adapter === "openai-responses" + && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; +} + +/** + * Whether this destination can decode a native (non-`ocx1:`) compaction blob. + * + * Only the backend that minted a blob can decode it, so this is the OpenAI-operated set above plus + * any destination whose operator explicitly opts in for a relay that genuinely fronts OpenAI. + */ +export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean { + return isOpenAiOperatedResponsesDestination(provider) + || provider.decodesNativeCompactionBlobs === true; +} + export interface OpenAiTierMigrationProjection { config: OcxConfig; changed: boolean; diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index df31069557..f3fba7a033 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -33,6 +33,24 @@ export const SUMMARY_PREFIX = "Another language model started to solve this prob export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]"; +/** + * Item types in the compact wire family. Each carries an `encrypted_content` blob the client + * replays verbatim on every later turn, and the minting backend verifies it is unmodified. + * + * Keep this the only enumeration: a copy that listed just `compaction` let the response-side + * field backfill synthesize ids into the other two, which the client then replayed as "modified + * from the compact response". + */ +const COMPACTION_ITEM_TYPES: ReadonlySet = new Set([ + "compaction", + "compaction_summary", + "context_compaction", +]); + +export function isCompactionItemType(type: unknown): boolean { + return typeof type === "string" && COMPACTION_ITEM_TYPES.has(type); +} + export function encodeCompactionSummary(summary: string): string { return OCX_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64"); } diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 83ddc1be19..e7db3c32a6 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -1,4 +1,8 @@ +import { namespacedToolName } from "../types"; +import { collectResponsesToolGroups } from "./tool-groups"; + const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -13,6 +17,65 @@ function customToolInput(argumentsText: unknown): string { return argumentsText; } +function customToolWireName(namespace: string | undefined, name: string): string { + return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); +} + +/** Final upstream identity of a call, including a namespace restored by an earlier rewrite. */ +export function routedCustomToolWireName(value: unknown): string | undefined { + if (!isPlainObject(value) || typeof value.name !== "string") return undefined; + return customToolWireName( + typeof value.namespace === "string" ? value.namespace : undefined, + value.name, + ); +} + +/** + * 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 { + const names = new Set(); + const groups = collectResponsesToolGroups(body); + const bareWireNames = new Set(); + for (const group of groups) { + for (const tool of group) { + if ( + isPlainObject(tool) + && tool.type !== "namespace" + && typeof tool.name === "string" + ) bareWireNames.add(tool.name); + } + } + + for (const group of groups) { + for (const tool of group) { + if (!isPlainObject(tool)) continue; + if ( + tool.type === "custom" + && typeof tool.name === "string" + && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name) + ) { + names.add(tool.name); + continue; + } + if (tool.type !== "namespace" || typeof tool.name !== "string" || !Array.isArray(tool.tools)) { + continue; + } + for (const child of tool.tools) { + if ( + isPlainObject(child) + && child.type === "custom" + && typeof child.name === "string" + && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name) + && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) + ) names.add(customToolWireName(tool.name, child.name)); + } + } + } + return names; +} + export function customToolItemId(id: unknown): unknown { if (typeof id !== "string") return id; return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; @@ -125,11 +188,12 @@ export function rewriteRoutedCustomToolsForUpstream(body: unknown): { body: unknown; names: Set; } { - const names = collectRoutedCustomToolNames(body); - if (names.size === 0) return { body, names }; + const conversionNames = collectRoutedCustomToolNames(body); + const names = collectRoutedCustomToolWireNames(body); + if (conversionNames.size === 0) return { body, names }; const callIds = new Set(); - collectConvertedCallIds(body, names, callIds); - return { body: rewriteForUpstream(body, names, callIds), names }; + collectConvertedCallIds(body, conversionNames, callIds); + return { body: rewriteForUpstream(body, conversionNames, callIds), names }; } export function restoreRoutedCustomCalls( @@ -155,7 +219,8 @@ export function restoreRoutedCustomCalls( changed ||= result.changed; } - if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) { + const wireName = routedCustomToolWireName(value); + if (value.type === "function_call" && wireName !== undefined && names.has(wireName)) { restored.type = "custom_tool_call"; restored.id = customToolItemId(value.id); restored.input = customToolInput(value.arguments); diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts new file mode 100644 index 0000000000..3f6cd42ea2 --- /dev/null +++ b/src/responses/namespace-tool-compat.ts @@ -0,0 +1,356 @@ +import { namespacedToolName } from "../types"; +import { collectResponsesToolGroups } from "./tool-groups"; + +export interface RoutedNamespaceToolIdentity { + namespace: string; + name: string; +} + +export type RoutedNamespaceToolAliases = ReadonlyMap; + +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function namespaceIdentity(namespace: string, name: string): string { + return `${namespace}\u0000${name}`; +} + +/** + * A name that can become a wire tool name. Control characters are rejected because the identity + * key below joins namespace and name with NUL: a name carrying one could otherwise forge another + * tool's identity and silently take over its wire name. + */ +function isRepresentableName(name: unknown): name is string { + if (typeof name !== "string" || name.length === 0) return false; + for (let index = 0; index < name.length; index += 1) { + const code = name.charCodeAt(index); + // C0 controls and DEL, written as code points so this source never carries one itself. + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +type NamespaceGroup = { + namespace: string; + /** Children that can be lowered to a flat declaration; unrepresentable ones are omitted. */ + children: Record[]; +}; + +/** + * Read a private namespace group, or return undefined when the value is not one. + * + * Children that cannot be expressed as a flat declaration — a nested group, a missing name, a + * control character in the name — are dropped, and a group left with no children is dropped whole + * by the rewrite. Preserving the private `namespace` shape instead would lose every tool in the + * request rather than one: the strict gateways this layer exists for reject that tool type before + * inference, which is the failure the layer was written to prevent. + */ +function parseNamespaceGroup(tool: unknown): NamespaceGroup | undefined { + if ( + !isPlainObject(tool) + || tool.type !== "namespace" + || !isRepresentableName(tool.name) + || !Array.isArray(tool.tools) + ) return undefined; + const children: Record[] = []; + for (const child of tool.tools) { + if (!isPlainObject(child) || child.type === "namespace" || !isRepresentableName(child.name)) continue; + children.push(child); + } + return { namespace: tool.name, children }; +} + +/** + * Wire identity of a lowered tool. A `functions` child and an identical top-level declaration share + * one identity because they denote the same logical tool: `buildTools` flattens the reserved group + * without a namespace, so the parser already treats them as one and tolerates the duplicate. + */ +function loweredIdentity(namespace: string, name: string): string { + return namespace === BUILTIN_FUNCTIONS_NAMESPACE + ? namespaceIdentity(BUILTIN_FUNCTIONS_NAMESPACE, name) + : namespaceIdentity(namespace, name); +} + +function loweredWireName(namespace: string, name: string): string { + return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); +} + +function addSelector( + selectors: Map, + selector: string, + wireName: string, +): void { + const current = selectors.get(selector); + if (current === undefined) selectors.set(selector, wireName); + else if (current !== wireName) selectors.set(selector, null); +} + +type NamespaceRewritePlan = { + aliases: Map; + bareWireNames: Set; + identities: Map; + selectors: Map; +}; + +/** Two distinct logical tools would occupy one wire name; the caller maps this to a 400. */ +export class NamespaceToolCollisionError extends Error {} + +function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan { + const aliases = new Map(); + const bareWireNames = new Set(); + const identities = new Map(); + const selectors = new Map(); + const wireOwners = new Map(); + + for (const group of groups) { + for (const tool of group) { + if (isPlainObject(tool) && tool.type !== "namespace" && isRepresentableName(tool.name)) { + // A bare declaration is the reserved group's flattened form, so it claims that identity: + // declaring the same tool both ways is the duplicate the parser already tolerates, not a + // collision, and `promoteClientLoadedTools` produces exactly that shape. + wireOwners.set(tool.name, loweredIdentity(BUILTIN_FUNCTIONS_NAMESPACE, tool.name)); + bareWireNames.add(tool.name); + addSelector(selectors, tool.name, tool.name); + } + } + } + + for (const group of groups) { + for (const tool of group) { + const parsed = parseNamespaceGroup(tool); + if (!parsed) continue; + for (const child of parsed.children) { + const childName = child.name as string; + const identity = loweredIdentity(parsed.namespace, childName); + const wireName = loweredWireName(parsed.namespace, childName); + const owner = wireOwners.get(wireName); + if (owner !== undefined && owner !== identity) { + throw new NamespaceToolCollisionError( + `namespace tool wire-name collision for "${wireName}"; rename one of the colliding tools`, + ); + } + wireOwners.set(wireName, identity); + identities.set(identity, wireName); + addSelector(selectors, wireName, wireName); + addSelector(selectors, `${parsed.namespace}.${childName}`, wireName); + addSelector(selectors, childName, wireName); + if (parsed.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) { + aliases.set(wireName, { namespace: parsed.namespace, name: childName }); + } + } + } + } + + return { aliases, bareWireNames, identities, selectors }; +} + +/** + * Lower every namespace group in one tool container. `emitted` is shared across the whole body so + * a tool declared both bare and under `functions` is written once rather than twice. + * + * No `type: "namespace"` value survives this pass, including a group this layer cannot read: + * relaying the private shape is what the strict gateway rejects. + */ +function rewriteToolList( + tools: unknown[], + plan: NamespaceRewritePlan, + emitted: Set, +): unknown[] { + let changed = false; + const rewritten: unknown[] = []; + for (const tool of tools) { + if (isPlainObject(tool) && tool.type === "namespace") { + changed = true; + const parsed = parseNamespaceGroup(tool); + if (!parsed) continue; + for (const child of parsed.children) { + const wireName = plan.identities.get(loweredIdentity(parsed.namespace, child.name as string)); + // A bare declaration is the canonical representation of a `functions` child. Decide that + // from the complete catalog rather than whichever container happens to be rewritten first. + if ( + wireName === undefined + || (parsed.namespace === BUILTIN_FUNCTIONS_NAMESPACE && plan.bareWireNames.has(wireName)) + || emitted.has(wireName) + ) continue; + emitted.add(wireName); + rewritten.push(wireName === child.name ? child : { ...child, name: wireName }); + } + continue; + } + if (isPlainObject(tool) && isRepresentableName(tool.name)) { + if (emitted.has(tool.name)) { + changed = true; + continue; + } + emitted.add(tool.name); + } + rewritten.push(tool); + } + return changed ? rewritten : tools; +} + +/** + * Resolve one `{namespace?, name}` reference to its wire name and drop the private `namespace` key. + * + * `bareFallback` is for tool_choice, where a bare name is a selector the caller expects resolved + * against the catalog. Replayed call items pass `false`: a history item records which tool actually + * ran, so resolving a bare name through a same-named namespace child would rewrite history on a + * coincidence rather than translate it. + * + * An explicit namespace is always lowered, even when this turn's catalog no longer declares that + * group — a compaction turn drops the whole catalog, and a catalog can change mid-session. Leaving + * the key in place ships a Codex-private field to a gateway that rejects unknown fields, which is + * the failure this layer exists to prevent, and this layer's own response restoration is what put + * the key on the item. + */ +function rewriteNamedSelector( + value: unknown, + plan: NamespaceRewritePlan, + bareFallback: boolean, +): unknown { + if (!isPlainObject(value) || typeof value.name !== "string") return value; + if (typeof value.namespace !== "string") { + if (!bareFallback) return value; + const wireName = plan.selectors.get(value.name) ?? undefined; + return wireName === undefined || wireName === value.name ? value : { ...value, name: wireName }; + } + const { namespace, ...rest } = value; + const wireName = plan.identities.get(loweredIdentity(namespace, value.name)) + ?? loweredWireName(namespace, value.name); + return { ...rest, name: wireName }; +} + +function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown { + if (!isPlainObject(value)) return value; + if ((value.type === "function" || value.type === "custom") && typeof value.name === "string") { + return rewriteNamedSelector(value, plan, true); + } + if (value.type !== "allowed_tools" || !Array.isArray(value.tools)) return value; + let changed = false; + const tools = value.tools.map(tool => { + if (!isPlainObject(tool) || typeof tool.name !== "string") return tool; + const rewritten = rewriteNamedSelector(tool, plan, true); + changed ||= rewritten !== tool; + return rewritten; + }); + return changed ? { ...value, tools } : value; +} + +function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set): unknown { + if (!isPlainObject(item)) return item; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + const tools = rewriteToolList(item.tools, plan, emitted); + return tools === item.tools ? item : { ...item, tools }; + } + if ( + (item.type === "function_call" || item.type === "custom_tool_call") + && typeof item.name === "string" + ) return rewriteNamedSelector(item, plan, false); + return item; +} + +/** + * Lower Codex's private Responses namespace declarations for public/third-party gateways. + * + * Codex 0.147 groups ordinary tools under the reserved `functions` namespace; those children + * become bare top-level declarations. Other namespaces use the same collision-checked + * `__` 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): { + body: unknown; + aliases: Map; +} { + if (!isPlainObject(body)) return { body, aliases: new Map() }; + 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. + const emitted = new Set(); + const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools; + + let input = body.input; + if (Array.isArray(body.input)) { + let inputChanged = false; + const rewrittenInput = body.input.map(item => { + const next = rewriteInputItem(item, plan, emitted); + if (next !== item) inputChanged = true; + return next; + }); + if (inputChanged) input = rewrittenInput; + } + + const toolChoice = rewriteToolChoice(body.tool_choice, plan); + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + aliases: plan.aliases, + }; +} + +export function restoreRoutedNamespaceCalls( + value: unknown, + aliases: RoutedNamespaceToolAliases, +): { value: unknown; changed: boolean } { + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(entry => { + const result = restoreRoutedNamespaceCalls(entry, aliases); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isPlainObject(value)) return { value, changed: false }; + + let changed = false; + const restored: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = restoreRoutedNamespaceCalls(entry, aliases); + restored[key] = result.value; + changed ||= result.changed; + } + + if ( + (value.type === "function_call" || value.type === "custom_tool_call") + && typeof value.name === "string" + ) { + const identity = aliases.get(value.name); + if (identity) { + restored.name = identity.name; + restored.namespace = identity.namespace; + changed = true; + } + } + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +export function restoreRoutedNamespaceCallsInJson( + text: string, + aliases: RoutedNamespaceToolAliases, +): string { + if (aliases.size === 0) return text; + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const restored = restoreRoutedNamespaceCalls(payload, aliases); + return restored.changed ? JSON.stringify(restored.value) : text; +} + +export function createRoutedNamespaceCallRestoreRewrite( + aliases: RoutedNamespaceToolAliases, +): (payload: string) => string { + return payload => restoreRoutedNamespaceCallsInJson(payload, aliases); +} diff --git a/src/responses/parser.ts b/src/responses/parser.ts index ed7e83588c..de07832a40 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -15,7 +15,7 @@ import { namespacedToolName, toolChoiceCandidates } from "../types"; import { responsesRequestSchema } from "./schema"; import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata"; import { lookupReplayThoughtSignature } from "./thought-signature-replay"; -import { compactionItemToText } from "./compaction"; +import { compactionItemToText, isCompactionItemType } from "./compaction"; import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; @@ -434,7 +434,7 @@ export function parseRequest( continue; } - if (effectiveType === "compaction" || effectiveType === "compaction_summary" || effectiveType === "context_compaction") { + if (isCompactionItemType(effectiveType)) { // A stored summary from a previous compaction. Decode our ocx1 envelope into plain text so // the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note. // `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker; diff --git a/src/responses/reasoning-replay-cache.ts b/src/responses/reasoning-replay-cache.ts index 7f1d67c18c..c438f380c6 100644 --- a/src/responses/reasoning-replay-cache.ts +++ b/src/responses/reasoning-replay-cache.ts @@ -52,8 +52,16 @@ interface CacheEntry { at: number; } +interface ServingIdentityEntry { + identity: string; + bytes: number; + at: number; +} + const entries = new Map(); +const servingIdentities = new Map(); let totalBytes = 0; +let servingIdentityTotalBytes = 0; let clockForTests: (() => number) | null = null; const now = (): number => clockForTests?.() ?? Date.now(); @@ -62,28 +70,118 @@ function nonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } -function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined { - const identity = scope?.current; +type ReasoningReplayIdentityTuple = readonly [string, string, string, string, string]; + +function tupleForIdentity( + identity: Readonly | undefined, +): ReasoningReplayIdentityTuple | undefined { if ( - !nonEmpty(callId) - || !nonEmpty(scope?.clientThreadId) - || !nonEmpty(identity?.providerName) + !nonEmpty(identity?.providerName) || !nonEmpty(identity?.providerDestinationIdentity) || !nonEmpty(identity?.adapterName) || !nonEmpty(identity?.modelId) || !nonEmpty(identity?.credentialIdentity) ) return undefined; - return JSON.stringify([ - scope.clientThreadId, + return [ identity.providerName, identity.providerDestinationIdentity, identity.adapterName, identity.modelId, identity.credentialIdentity, + ]; +} + +function tupleForServingIdentity( + identity: Readonly | undefined, +): ReasoningReplayIdentityTuple | undefined { + if ( + !nonEmpty(identity?.providerName) + || !nonEmpty(identity?.providerDestinationDurableIdentity) + || !nonEmpty(identity?.adapterName) + || !nonEmpty(identity?.modelId) + || !nonEmpty(identity?.credentialDurableIdentity) + ) return undefined; + return [ + identity.providerName, + identity.providerDestinationDurableIdentity, + identity.adapterName, + identity.modelId, + identity.credentialDurableIdentity, + ]; +} + +function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined { + const identity = tupleForIdentity(scope?.current); + if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined; + return JSON.stringify([ + scope.clientThreadId, + ...identity, callId, ]); } +function deleteServingIdentity(threadId: string): void { + const entry = servingIdentities.get(threadId); + if (!entry) return; + servingIdentities.delete(threadId); + servingIdentityTotalBytes -= entry.bytes; +} + +function sweepExpiredServingIdentities(at: number): void { + for (const [threadId, entry] of servingIdentities) { + if (at - entry.at >= TTL_MS) deleteServingIdentity(threadId); + } +} + +/** + * Compare this request's route with the last route recorded for its client thread, then + * record the current route. A live mismatch means replayed opaque reasoning was minted by + * another backend and must not be forwarded to this one. + * + * Serving provenance uses restart-stable destination and credential dimensions so token + * generations and other volatile credential material cannot create false route changes. Missing + * durable identity, expired, or evicted state is deliberately unknown rather than a mismatch. + * This store is process-local, so a backend switch spanning a proxy restart is not detected. + */ +export function updateReasoningReplayServingIdentity( + scope: OcxReasoningReplayScopeRef | undefined, +): boolean { + const threadId = scope?.clientThreadId; + const identityTuple = tupleForServingIdentity(scope?.current); + if (!nonEmpty(threadId) || !identityTuple) return false; + const identity = JSON.stringify(identityTuple); + + const at = now(); + sweepExpiredServingIdentities(at); + const previous = servingIdentities.get(threadId); + const changed = previous !== undefined && previous.identity !== identity; + const bytes = Buffer.byteLength(JSON.stringify([threadId, identity]), "utf8"); + if (bytes > MAX_TOTAL_BYTES) { + deleteServingIdentity(threadId); + return false; + } + + if (previous) deleteServingIdentity(threadId); + servingIdentities.set(threadId, { identity, bytes, at }); + servingIdentityTotalBytes += bytes; + while ( + (servingIdentityTotalBytes > MAX_TOTAL_BYTES || servingIdentities.size > MAX_ENTRIES) + && servingIdentities.size > 1 + ) { + let oldestThreadId: string | undefined; + let oldestAt = Infinity; + for (const [candidateThreadId, entry] of servingIdentities) { + if (entry.at < oldestAt) { + oldestAt = entry.at; + oldestThreadId = candidateThreadId; + } + } + if (oldestThreadId === undefined) break; + deleteServingIdentity(oldestThreadId); + } + return changed; +} + function processLocalIdentity(domain: string, material: string): string { return createHmac("sha256", replayIdentityKey) .update(domain) @@ -303,6 +401,8 @@ export function peekReasoningForCall( /** Test-only: reset the cache and optionally pin the clock. */ export function clearReasoningReplayCacheForTests(clock?: (() => number) | null): void { entries.clear(); + servingIdentities.clear(); totalBytes = 0; + servingIdentityTotalBytes = 0; clockForTests = clock ?? null; } diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index c3c40b134d..1aaa16c73e 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -2,6 +2,7 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { customToolItemId, restoreRoutedCustomCalls, + routedCustomToolWireName, unwrapRoutedCustomToolArguments, } from "../responses/custom-tool-compat"; import { @@ -181,7 +182,8 @@ export function createRoutedCustomToolRestoreBlockRewrite( && typeof parsed.item.name === "string" ) { const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; - const routed = names.has(parsed.item.name); + const wireName = routedCustomToolWireName(parsed.item); + const routed = wireName !== undefined && names.has(wireName); if (upstreamItemId) { if (routed) { itemNames.set(upstreamItemId, parsed.item.name); diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 21a8b6a5bf..55c6d8ae7b 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -34,6 +34,13 @@ function reasoningTextOf(item: Record): string { /** Move a reasoning item's content channel into the summary channel. */ function reasoningItemToSummaryShape(item: Record): Record { if (item.type !== "reasoning") return item; + // `encrypted_content` is opaque, state-bearing provider data, so the entire item must retain its + // upstream shape unless that backend has an explicit replay contract permitting a rewrite. This + // defensively protects content-channel backends that do issue blobs when the client replays the + // stored item. The delta rewrite can still provide the expandable trace for the live turn. + // DeepSeek — the provider this rewrite was verified against — is `statelessResponses` and issues + // no blob, so it is unaffected. + if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; const text = reasoningTextOf(item); // Items that already use the summary channel (or carry no content text at // all) are left untouched: rewriting them could clear a valid summary. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c7773e4e4..81445a457a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -21,6 +21,7 @@ import { durableReplayCredentialIdentity, reasoningReplayKeyCredentialIdentity, reasoningReplayOAuthCredentialIdentity, + updateReasoningReplayServingIdentity, } from "../../responses/reasoning-replay-cache"; import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; @@ -275,6 +276,12 @@ import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-comp import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; +import { + createRoutedNamespaceCallRestoreRewrite, + NamespaceToolCollisionError, + restoreRoutedNamespaceCallsInJson, + type RoutedNamespaceToolAliases, +} from "../../responses/namespace-tool-compat"; import { collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, @@ -407,6 +414,11 @@ function bindRouteReasoningReplayScope(args: { } : undefined, ); + // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal + // after the first mismatch, but it cannot make history minted by the prior route decodable. + if (updateReasoningReplayServingIdentity(parsed._reasoningReplayScope)) { + parsed._stripReasoningEncryptedContent = true; + } } function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined { @@ -2501,6 +2513,7 @@ async function handleResponsesInner( : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); const routedCustomToolNames = new Set(); const routedToolSearchNames = new Set(); + let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY @@ -2527,11 +2540,21 @@ async function handleResponsesInner( request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); } catch (error) { releaseCodexAuthContextProbeLease(authCtx); + // A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and + // the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing + // it here escaped every catch up to the Bun handler, so the same request produced an + // unstructured 500 — and no request log — depending only on whether a rotation ran first. + if (error instanceof NamespaceToolCollisionError) { + return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); + } throw error; } if (route.provider.authMode !== "forward") { for (const name of request.convertedRoutedCustomToolNames ?? []) { - if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name); + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolNames.add(name); } } for (const name of request.convertedRoutedToolSearchNames ?? []) { @@ -2540,6 +2563,7 @@ async function handleResponsesInner( // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. routedToolSearchNames.add(name); } + routedNamespaceToolAliases = request.convertedRoutedNamespaceToolAliases ?? routedNamespaceToolAliases; // #1700: the bridged paths refuse a call to a tool the request never declared // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested @@ -3074,6 +3098,9 @@ async function handleResponsesInner( // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ createImageGenCallRestoreRewrite(imageGenCallAliases), + routedNamespaceToolAliases.size > 0 + ? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases) + : undefined, hasResponsesItemIdRepair(repairConfig) ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, @@ -3290,8 +3317,12 @@ async function handleResponsesInner( const text = bounded.text; inspectResponseLogJson(logCtx, text); const clientJson = (() => { - const restored = restoreRoutedCustomCallsInJson( + const restoredNamespace = restoreRoutedNamespaceCallsInJson( restoreImageGenCallsInJson(text, imageGenCallAliases), + routedNamespaceToolAliases, + ); + const restored = restoreRoutedCustomCallsInJson( + restoredNamespace, routedCustomToolNames, ); const restoredToolSearch = restoreRoutedToolSearchCallsInJson( diff --git a/src/server/responses/responses-field-backfill.ts b/src/server/responses/responses-field-backfill.ts index 32cf727891..48019670f2 100644 --- a/src/server/responses/responses-field-backfill.ts +++ b/src/server/responses/responses-field-backfill.ts @@ -24,6 +24,7 @@ import { sseDataPayload, type SseBlockRewrite, } from "../sse-payload-rewrite"; +import { isCompactionItemType } from "../../responses/compaction"; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -119,16 +120,6 @@ function backfillContentArray(content: unknown): unknown { return changed ? repaired : content; } -/** - * Item types that are NOT Responses output items and must be returned byte-for-byte. - * - * `compaction` is the `/v1/responses/compact` wire format, not a Responses output item. It has - * no `id` in that contract, so synthesizing one changes a response body the client compares - * exactly. The backfill exists to satisfy strict Responses decoders; a shape those decoders - * never see is outside its remit. - */ -const NON_RESPONSES_ITEM_TYPES: ReadonlySet = new Set(["compaction"]); - /** * Walk an output item and backfill output_text parts in its content. * Also backfills a missing required id on the item itself. @@ -136,7 +127,12 @@ const NON_RESPONSES_ITEM_TYPES: ReadonlySet = new Set(["compaction"]); */ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown { if (!isPlainObject(item)) return item; - if (typeof item.type === "string" && NON_RESPONSES_ITEM_TYPES.has(item.type)) return item; + // The compact wire family is the `/v1/responses/compact` format, not a Responses output item. + // Those items have no `id` in that contract, so synthesizing one changes a response body the + // client compares exactly — and the client replays the item on every later turn, where the + // minting backend rejects it as modified. The backfill exists to satisfy strict Responses + // decoders; a shape those decoders never see is outside its remit. + if (isCompactionItemType(item.type)) return item; const content = item.content; const repaired = backfillContentArray(content); const withId = backfillItemId(item, slot); diff --git a/src/types/provider.ts b/src/types/provider.ts index 72fbc10033..20ce2c0330 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -204,6 +204,11 @@ export interface OcxProviderConfig { * `ocxr1` envelopes are still stripped because no upstream can decrypt them. */ preserveResponsesReasoningContent?: boolean; + /** + * Explicit opt-in for a relay that genuinely fronts OpenAI and can decode native + * compaction blobs. Absent or false degrades foreign blobs to an opaque note. + */ + decodesNativeCompactionBlobs?: boolean; /** * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918, * link-local, or unique-local upstreams. Metadata endpoints remain blocked. diff --git a/src/types/request.ts b/src/types/request.ts index c01d6d3614..b6a5d5e6fe 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -65,6 +65,8 @@ export interface OcxParsedRequest { _clientThreadId?: string; /** Provider/account/model-bound namespace for process-local raw-reasoning replay. */ _reasoningReplayScope?: OcxReasoningReplayScopeRef; + /** A known in-process route switch requires opaque Responses reasoning blobs to be dropped. */ + _stripReasoningEncryptedContent?: boolean; /** * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. * When absent (single-operator local proxy), derivation stays local-scoped. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index efd1c87dbb..d3d20e0262 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -39,6 +39,37 @@ Responses-compatible streaming output. - 다른 대안 대신 이 방식을 선택한 이유: Provider-specific workarounds fragment the contract, while unconditional restoration could turn an untrusted ordinary function call into a privileged client discovery action. - 장점, 단점 및 영향: Strict third-party Responses gateways can start and continue deferred discovery without changing native ChatGPT behavior; ordinary same-named functions remain distinct, and the proxy performs a capped SSE lifecycle rewrite only when the request actually required compatibility translation. +[Decision Log] +- 목적과 의도: Keep Codex 0.147 namespace tool catalogs usable after a routed provider adopts native Responses but implements only the public flat tool variants. +- 기존 구현 및 제약 조건: Chat translation already flattened namespace children, while native Responses passthrough forwarded the private `namespace` variant unchanged. xAI therefore rejected Grok requests before inference after its OAuth Grok 4.5/4.6 route moved to Responses. +- 검토한 주요 대안: Move Grok back to Chat; special-case only xAI or the reserved `functions` group; flatten every complete namespace on noncanonical Responses and restore request-authorized aliases on return. +- 선택한 방식: Noncanonical Responses lowers `functions` children to their bare top-level names and every other complete namespace to collision-checked `__` aliases after custom/tool-search conversion. It rewrites matching replay calls and tool selectors, records the aliases on the built request, and restores only those aliases in JSON/SSE call items before custom/tool-search lifecycle repair. Canonical OpenAI forward preserves native namespace shapes. +- 다른 대안 대신 이 방식을 선택한 이유: A transport regression should not discard Responses streaming or create a provider-specific fork, and restoration without request-local authorization could reinterpret an unrelated upstream function as a client namespace call. +- 장점, 단점 및 영향: Grok and other public-schema Responses gateways accept current Codex catalogs while Codex still receives explicit namespace routing. No `type: "namespace"` value survives the boundary: a group the layer cannot express — empty, nested, or with an unusable child name — is dropped along with the children it cannot represent, because relaying the private shape costs the whole request rather than one tool. Genuinely ambiguous wire names still fail closed, now as a 400 rather than an unstructured 500. + +Two coordinates that lower to the same wire name are treated as one tool when they denote one: +`buildTools` flattens the reserved `functions` group without a namespace, so a bare declaration and +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 +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. + +Codex-private tool fields are removed at the same boundary from one table +(`CANONICAL_ONLY_TOOL_FIELDS`) rather than one bespoke pass each: `external_web_access` on either +web-search variant, and `defer_loading` on any declaration, which `activateDeferredTool` clears only +for tools a `tool_search_output` already loaded. A new private bit is a row there. + +The same noncanonical boundary strips ChatGPT's private `external_web_access` bit from routed +`web_search` declarations. The public tool remains enabled and all other options remain intact; +canonical OpenAI forwarding preserves the bit. xAI's public Responses schema enables browsing by +the presence of `web_search` and rejects the private argument, so forwarding it made the first +post-namespace request fail with HTTP 400. + The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool @@ -70,6 +101,43 @@ alone never opt a gateway in. and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, +and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent +base64, so they always lower to plain user messages. A native blob is relayed only when there is no +known serving-identity mismatch and the destination is known to decode native blobs — the canonical +ChatGPT forward surface, the official OpenAI API, or a provider with the explicit +`decodesNativeCompactionBlobs` capability. The destination gate alone is insufficient because more +than one backend, including OpenAI and xAI, mints native blobs: a destination can decode its own blob +without being able to decode the previous backend's. The same serving-identity mismatch signal +therefore strips reasoning `encrypted_content` and degrades native compaction blobs through the +existing opaque-note path. When the thread has no recorded identity, the destination-only behavior +is deliberately unchanged. Forward auth alone is not evidence: noncanonical forward providers +receive no caller credentials and may point at any backend. On any other routed destination the blob +also degrades to the same opaque note the bridged parser uses, because forwarding it there fails the +turn and the item outlives the failure in the client transcript, repeating on every later turn +including the compaction turn the proxy itself drives. With `store: false`, request sanitization +strips ids from every input item, including compact-wire items, matching codex-rs +(`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. + +[Decision Log] +- 목적과 의도: Keep a session usable after its history crosses backends, instead of wedging it on a + compaction blob the current upstream cannot decode. +- 기존 구현 및 제약 조건: Compaction handling was binary — `ocx1:` envelopes were ours, everything + else was treated as a native blob and gated only by the destination, even though multiple backends + mint mutually incompatible blobs. Response-side field backfill exempted only `compaction`, so its + two sibling types received synthesized ids the client then replayed. +- 검토한 주요 대안: Tag every compaction item with its minting provider/credential/model identity; + drop compaction items on any route change; gate relay on the destination that would decode them. +- 선택한 방식: Reuse the thread's recorded serving identity to degrade native blobs after a known + route change; otherwise retain the destination capability gate, and treat the compact wire family + as one enumeration so id-bearing passes cannot diverge per type. +- 다른 대안 대신 이 방식을 선택한 이유: Full per-item provenance tagging is unnecessary when the + existing thread identity proves a route change, while dropping the item would silently discard + compacted context and widening unknown-identity behavior needs a separate decision. +- 장점, 단점 및 영향: A cross-backend session degrades one compaction summary to a note instead of + failing every later turn. A self-hosted OpenAI relay keeps its blobs only when explicitly opted in; + other routed gateways see a note because routed compaction produces an `ocx1:` envelope. + ### Mixed-wire provider defaults Registry `modelWireDefaults` select an evidence-backed upstream protocol for an exact model without @@ -222,9 +290,11 @@ items restore `{ namespace: "image_gen", name: "" }` so Codex can di extension. When item-id repair is also enabled, both transforms compose in one SSE parse/stringify pass (`src/server/sse-payload-rewrite.ts`) rather than chaining separate JS pull wrappers. Inspection and continuation-cache branches keep the raw upstream alias, allowing stored -replays to return upstream without leaking a client-only namespace shape. Malformed, empty, and -unrelated namespaces remain untouched. ChatGPT forward mode preserves the private namespace and -hosted tool because that backend understands their native semantics. +replays to return upstream without leaking a client-only namespace shape. The image-gen layer itself +leaves malformed and empty image-gen namespaces untouched, but on a noncanonical route the general +namespace boundary above runs after it and lowers whatever remains, so no private group reaches the +wire. ChatGPT forward mode preserves the private namespace and hosted tool because that backend +understands their native semantics. Per-model `modelReasoningSummaryDelivery` is a narrow compatibility layer for `openai-responses` gateways whose summary capability is real but whose accepted delivery enum @@ -479,6 +549,41 @@ replays are explicit and receive the same repair. These compatibility guards are covered by focused tests and should stay close to the adapters that need them. +Responses passthrough keeps output-only `status` on any `reasoning` input item that retains opaque +`encrypted_content` because OpenAI-operated backends may bind the blob to that field. The established +raw-`content` rule remains separate: ChatGPT accepts reasoning input only with empty `content`, so a +native blob plus raw content keeps the blob and `status` but still blanks `content`. That shape is a +known unresolved contract conflict, not evidence that either existing rule is safe to broaden. The +blob is kept unless the in-process thread record proves that the current provider, destination, +adapter, model, or credential differs from the route recorded for the prior request on that client +thread. On a proven change the blob and `status` are removed while the reasoning item and its summary +survive; `status` is also removed from blobless reasoning items. Missing, expired, or evicted identity +state is unknown. The comparison uses the durable destination and credential identities with the +provider, adapter, and model, so OAuth token-generation refreshes do not look like backend changes; +when either durable dimension is unavailable it refuses to record rather than falling back to a +volatile identity. The record is deliberately process-local, so a backend switch spanning a proxy +restart is not detected and may still be rejected upstream. + +A combo target rotation between turns legitimately changes that serving identity, so the following +turn drops blobs minted by the prior target. This is correct because the new target cannot decode +them, but it is intentionally unobvious to the client: `pickComboTarget` keys selection state only by +combo id, without a conversation dimension, and the SSE model-name rewrite preserves the requested +combo name instead of exposing the concrete target switch. A user can therefore observe a reasoning +cache drop with no visible model change. + +The image and web-search auxiliary loops consume `_reasoningReplayScope` for bridge-level replay but +never call `bindRouteReasoningReplayScope`, so their internal small-model requests do not update the +serving-identity record. That omission is intentional: binding those routes would poison the main +conversation's last-serving identity and cause a later main-model turn to strip valid blobs. + +[Decision Log] +- 목적과 의도: Keep same-backend opaque reasoning replay while preventing backend-private blobs and output-only fields from breaking the first turn after a route change. +- 기존 구현 및 제약 조건: Reasoning-input sanitation already handled raw content and `ocxr1:` envelopes; the replay cache already supplied a bounded, thread-scoped physical-route identity, but no record connected that identity to native `encrypted_content` provenance. +- 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, retry after an upstream 4xx, or compare and strip before the first outbound request only when an in-process record proves a route change. +- 선택한 방식: Preserve `status` whenever its blob is forwarded without changing the pre-existing raw-`content` blanking rule; otherwise remove output-only `status`, compare and update a 64-entry/256 KiB/one-hour in-process serving-identity record using durable destination and credential dimensions at request time, and pass the proven-change decision into the Responses adapter to remove foreign `encrypted_content`. +- 다른 대안 대신 이 방식을 선택한 이유: Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and a deterministic pre-flight decision avoids a second paid or stateful upstream attempt. +- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning, known cross-route replay keeps the reasoning item without its undecodable blob, and switches spanning a proxy restart remain an explicit coverage gap. + DeepSeek's stateless Responses compatibility pass normalizes only unambiguous tool-call batches. Calls emitted before the first matched output stay together as one assistant batch, followed by their outputs in call order; hook-injected messages that split the batch move after it without being @@ -797,6 +902,16 @@ Codex app, so tool cells group like native models — while the text still round `content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: `devlog/_fin/260709_native_response_pattern/`. +The content-to-summary channel rewrite skips any reasoning item that carries a native +`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must +round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. +This defensively protects providers that issue blobs and later join the route through +`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is +`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits +summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite +does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry +no blob and still route to the summary channel, so the live expandable trace is unchanged. + The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts new file mode 100644 index 0000000000..45a4157808 --- /dev/null +++ b/tests/namespace-tool-compat.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test"; +import { + createRoutedNamespaceCallRestoreRewrite, + restoreRoutedNamespaceCalls, + restoreRoutedNamespaceCallsInJson, + rewriteRoutedNamespaceToolsForUpstream, +} from "../src/responses/namespace-tool-compat"; + +describe("Responses namespace tool compatibility", () => { + test("flattens builtin and routed namespaces across declarations, selectors, and replay", () => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + model: "routed-model", + tools: [ + { + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec", description: "run" }], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: {} }], + }, + ], + input: [ + { + type: "function_call", + namespace: "collaboration", + name: "spawn_agent", + call_id: "call_spawn", + arguments: "{}", + }, + { + type: "custom_tool_call", + namespace: "functions", + name: "exec", + call_id: "call_exec", + input: "text(true)", + }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", namespace: "collaboration", name: "spawn_agent" }, + { type: "custom", namespace: "functions", name: "exec" }, + ], + }, + }); + const body = rewritten.body as { + tools: Array<{ type: string; name: string }>; + input: Array<{ namespace?: string; name: string }>; + tool_choice: { tools: Array<{ namespace?: string; name: string }> }; + }; + + expect(body.tools).toEqual([ + { type: "custom", name: "exec", description: "run" }, + { type: "function", name: "collaboration__spawn_agent", parameters: {} }, + ]); + expect(body.input[0]).toMatchObject({ name: "collaboration__spawn_agent", call_id: "call_spawn" }); + expect(body.input[0]).not.toHaveProperty("namespace"); + expect(body.input[1]).toMatchObject({ name: "exec", call_id: "call_exec" }); + expect(body.input[1]).not.toHaveProperty("namespace"); + expect(body.tool_choice.tools).toEqual([ + { type: "function", name: "collaboration__spawn_agent" }, + { type: "custom", name: "exec" }, + ]); + expect([...rewritten.aliases]).toEqual([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + }); + + test("rewrites a unique bare selector but leaves an ambiguous one unchanged", () => { + const unique = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ + type: "namespace", + name: "one", + tools: [{ type: "function", name: "read" }], + }], + tool_choice: { type: "function", name: "read" }, + }).body as { tool_choice: { name: string } }; + expect(unique.tool_choice.name).toBe("one__read"); + + const ambiguous = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "namespace", name: "one", tools: [{ type: "function", name: "read" }] }, + { type: "namespace", name: "two", tools: [{ type: "function", name: "read" }] }, + ], + tool_choice: { type: "function", name: "read" }, + }).body as { tool_choice: { name: string } }; + expect(ambiguous.tool_choice.name).toBe("read"); + + const directCollision = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "function", name: "read" }, + { type: "namespace", name: "workspace", tools: [{ type: "function", name: "read" }] }, + ], + tool_choice: { type: "function", name: "read" }, + }).body as { + tools: Array<{ name: string }>; + tool_choice: { name: string }; + }; + expect(directCollision.tools.map(tool => tool.name)).toEqual(["read", "workspace__read"]); + expect(directCollision.tool_choice.name).toBe("read"); + }); + + test("fails closed when flattening would collide with a declared wire name", () => { + expect(() => rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "function", name: "workspace__read" }, + { type: "namespace", name: "workspace", tools: [{ type: "function", name: "read" }] }, + ], + })).toThrow('namespace tool wire-name collision for "workspace__read"'); + }); + + // Relaying `type: "namespace"` is what the strict gateway rejects, and it rejects the request + // rather than the tool — so a group this layer cannot represent costs every tool in the turn. + // Dropping what cannot be expressed costs only that. + test("lowers every namespace group rather than relaying the private shape", () => { + const body = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "namespace", name: "empty", tools: [] }, + { + type: "namespace", + name: "partial", + tools: [ + { type: "namespace", name: "nested", tools: [] }, + { type: "function", name: "", parameters: {} }, + { type: "function", name: "ok", parameters: {} }, + ], + }, + ], + }).body as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "function", name: "partial__ok", parameters: {} }]); + expect(body.tools.some(tool => tool.type === "namespace")).toBe(false); + }); + + // The identity key joins namespace and name with NUL, so a name carrying one could otherwise + // forge another tool's identity and silently take over its wire name. + test("drops children whose names cannot become a wire name", () => { + const NUL = String.fromCharCode(0); + const body = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "namespace", name: "a", tools: [{ type: "function", name: `b${NUL}c` }] }, + { type: "namespace", name: `a${NUL}b`, tools: [{ type: "function", name: "c" }] }, + { type: "namespace", name: "ok", tools: [{ type: "function", name: "run" }] }, + ], + }).body as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "function", name: "ok__run" }]); + }); + + // `buildTools` flattens the reserved group without a namespace, so the parser treats these as one + // logical tool and tolerates the duplicate; `promoteClientLoadedTools` produces exactly this shape. + test("treats a bare declaration and a functions child of the same name as one tool", () => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + tools: [ + { type: "function", name: "exec", parameters: {} }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "exec", parameters: {} }] }, + ], + }); + const body = rewritten.body as { tools: Array> }; + + expect(body.tools).toEqual([{ type: "function", name: "exec", parameters: {} }]); + expect([...rewritten.aliases]).toEqual([]); + }); + + test("chooses the bare declaration regardless of which tool container comes first", () => { + const bare = { + type: "function", + name: "exec", + description: "canonical bare declaration", + parameters: { type: "object", properties: { input: { type: "string" } } }, + }; + const functionsGroup = { + type: "namespace", + name: "functions", + tools: [{ + type: "function", + name: "exec", + description: "namespace duplicate", + parameters: { type: "object", properties: {} }, + }], + }; + const flatten = (bodyTools: unknown[], additionalTools: unknown[]) => { + const rewritten = rewriteRoutedNamespaceToolsForUpstream({ + tools: bodyTools, + input: [{ type: "additional_tools", role: "developer", tools: additionalTools }], + }).body as { + tools: Array>; + input: Array<{ tools: Array> }>; + }; + return [...rewritten.tools, ...rewritten.input[0]!.tools]; + }; + + expect(flatten([bare], [functionsGroup])).toEqual([bare]); + 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`. + test("lowers replayed calls even when this turn declares no namespace", () => { + const body = rewriteRoutedNamespaceToolsForUpstream({ + input: [ + { type: "function_call", namespace: "collaboration", name: "spawn_agent", call_id: "c1", arguments: "{}" }, + { type: "custom_tool_call", namespace: "functions", name: "exec", call_id: "c2", input: "run" }, + ], + }).body as { input: Array> }; + + expect(body.input[0]).toEqual({ + type: "function_call", + name: "collaboration__spawn_agent", + call_id: "c1", + arguments: "{}", + }); + expect(body.input[1]).toEqual({ + type: "custom_tool_call", + name: "exec", + call_id: "c2", + input: "run", + }); + expect(JSON.stringify(body)).not.toContain("namespace"); + }); + + // A history item records which tool actually ran. Resolving its bare name through a same-named + // namespace child would rewrite that record on a coincidence rather than translate it. + test("does not re-point a replayed bare-named call at a namespace child", () => { + const body = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ type: "namespace", name: "workspace", tools: [{ type: "function", name: "read" }] }], + input: [{ type: "function_call", name: "read", call_id: "c1", arguments: "{}" }], + tool_choice: { type: "function", name: "read" }, + }).body as { input: Array>; tool_choice: { name: string } }; + + expect(body.input[0].name).toBe("read"); + expect(body.tool_choice.name).toBe("workspace__read"); + }); + + test("restores only aliases authorized by this request in JSON and SSE payloads", () => { + const aliases = new Map([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + const payload = { + type: "response.completed", + response: { + output: [ + { type: "function_call", name: "collaboration__spawn_agent", call_id: "call_1" }, + { type: "function_call", name: "untrusted__tool", call_id: "call_2" }, + ], + }, + }; + + expect(restoreRoutedNamespaceCalls(payload, aliases).value).toMatchObject({ + response: { + output: [ + { type: "function_call", namespace: "collaboration", name: "spawn_agent" }, + { type: "function_call", name: "untrusted__tool" }, + ], + }, + }); + const text = JSON.stringify(payload); + expect(JSON.parse(restoreRoutedNamespaceCallsInJson(text, aliases))).toMatchObject({ + response: { output: [ + { namespace: "collaboration", name: "spawn_agent" }, + { name: "untrusted__tool" }, + ] }, + }); + expect(JSON.parse(createRoutedNamespaceCallRestoreRewrite(aliases)(text))).toMatchObject({ + response: { output: [ + { namespace: "collaboration", name: "spawn_agent" }, + { name: "untrusted__tool" }, + ] }, + }); + expect(restoreRoutedNamespaceCallsInJson("not-json", aliases)).toBe("not-json"); + }); +}); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 52ed4cf3d1..0e969c8a1b 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3,8 +3,14 @@ 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 { sanitizeEncryptedContentInPlace } from "../src/server/responses"; +import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; +import { + encodeCompactionSummary, + OPAQUE_COMPACTION_NOTE, + SUMMARY_PREFIX, +} from "../src/responses/compaction"; import { createTranslatorBudget } from "../src/lib/translator-budget"; +import type { OcxConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createResponsesPassthroughAdapter = (...args: Parameters) => @@ -336,15 +342,15 @@ describe("OpenAI Responses passthrough sanitization", () => { }>; }; - const namespace = body.tools.find(tool => tool.type === "namespace" && tool.name === "workspace"); - expect(namespace?.tools?.map(tool => tool.name)).toEqual([ - "upfront_read", - "declared_deferred_read", - "deferred_read", + expect(body.tools.some(tool => tool.type === "namespace")).toBe(false); + expect(body.tools.filter(tool => tool.name?.startsWith("workspace__")).map(tool => tool.name)).toEqual([ + "workspace__upfront_read", + "workspace__declared_deferred_read", + "workspace__deferred_read", ]); - expect(namespace?.tools?.find(tool => tool.name === "declared_deferred_read")) + expect(body.tools.find(tool => tool.name === "workspace__declared_deferred_read")) .not.toHaveProperty("defer_loading"); - expect(namespace?.tools?.find(tool => tool.name === "deferred_read")) + expect(body.tools.find(tool => tool.name === "workspace__deferred_read")) .not.toHaveProperty("defer_loading"); expect(body.tools.find(tool => tool.name === "tool_search")).toMatchObject({ type: "function", @@ -419,11 +425,11 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools).toBeUndefined(); const additionalTools = body.input.find(item => item.type === "additional_tools")?.tools; - const namespace = additionalTools?.find(tool => tool.type === "namespace" && tool.name === "workspace"); - expect(namespace?.tools?.map(tool => tool.name)).toEqual([ - "upfront_read", - "declared_deferred_read", - "deferred_read", + expect(additionalTools?.some(tool => tool.type === "namespace")).toBe(false); + expect(additionalTools?.filter(tool => tool.name?.startsWith("workspace__")).map(tool => tool.name)).toEqual([ + "workspace__upfront_read", + "workspace__declared_deferred_read", + "workspace__deferred_read", ]); expect(additionalTools?.find(tool => tool.name === "tool_search")) .toMatchObject({ type: "function", name: "tool_search" }); @@ -735,6 +741,7 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(storedBody.input.map(item => item.id)).toEqual(["msg_abc", "fc_xyz", "rs_123"]); }); + test("drops raw reasoning input content before native GPT passthrough", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ @@ -774,6 +781,154 @@ describe("OpenAI Responses passthrough sanitization", () => { }); }); + test("keeps a blob-bearing reasoning item byte-identical when the route is unchanged", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const reasoningItem = { + type: "reasoning", + id: "rs_same_backend", + status: "completed", + summary: [{ type: "summary_text", text: "summary" }], + encrypted_content: "backend-minted-blob", + content: [], + }; + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + store: true, + input: [reasoningItem], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(JSON.stringify(body.input[0])).toBe(JSON.stringify(reasoningItem)); + }); + + test("keeps a native blob while blanking its raw reasoning content", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "native-backend-blob", + content: [{ type: "reasoning_text", text: "raw routed reasoning" }], + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input[0]).toEqual({ + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "native-backend-blob", + content: [], + }); + }); + + test("keeps encrypted reasoning content without a proven route switch", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "reasoning", + summary: [], + encrypted_content: "same-backend-blob", + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input[0]).toEqual({ + type: "reasoning", + summary: [], + encrypted_content: "same-backend-blob", + }); + }); + + test("strips encrypted reasoning content after a known route switch but keeps the item", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _stripReasoningEncryptedContent: true, + _rawBody: { + model: "gpt-5.6-sol", + input: [ + { + type: "reasoning", + id: "rs_foreign_backend", + status: "completed", + summary: [{ type: "summary_text", text: "still useful" }], + encrypted_content: "foreign-backend-blob", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input).toEqual([ + { + type: "reasoning", + id: "rs_foreign_backend", + summary: [{ type: "summary_text", text: "still useful" }], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }, + ]); + }); + + test("strips status from a reasoning item that has no encrypted content", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.6-sol", + input: [{ + type: "reasoning", + id: "rs_without_blob", + status: "completed", + summary: [{ type: "summary_text", text: "summary" }], + }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { input: Record[] }; + + expect(body.input[0]).toEqual({ + type: "reasoning", + id: "rs_without_blob", + summary: [{ type: "summary_text", text: "summary" }], + }); + }); + test("strips image_generation hosted tool for codex-spark passthrough", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ @@ -816,6 +971,102 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toMatchObject({ type: "image_generation" }); }); + test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const request = adapter.buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "grok-4.6", + input: [{ + type: "additional_tools", + tools: [{ type: "web_search", external_web_access: true, search_context_size: "medium" }], + }], + tools: [{ type: "web_search", external_web_access: false, filters: { allowed_domains: ["example.com"] } }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Record[]; + input: Array<{ type: string; tools: Record[] }>; + }; + + expect(body.tools).toEqual([{ + type: "web_search", + filters: { allowed_domains: ["example.com"] }, + }]); + expect(body.input[0]?.tools).toEqual([{ + type: "web_search", + search_context_size: "medium", + }]); + }); + + test("preserves external_web_access on the canonical OpenAI forward route", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "gpt-5.5", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "gpt-5.5", + input: [], + tools: [{ type: "web_search", external_web_access: true }], + }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const body = JSON.parse(request.body) as { tools: Record[] }; + + expect(body.tools).toEqual([{ type: "web_search", external_web_access: true }]); + }); + + // `activateDeferredTool` clears `defer_loading` only for tools a `tool_search_output` already + // loaded, so the first turn of a deferred catalog — and any child promoted out of a namespace + // group — otherwise carries the private field to a gateway that rejects unknown arguments. + test("drops Codex-private tool fields from routed declarations", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }); + const request = adapter.buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "grok-4.6", + tools: [ + { type: "web_search_preview", external_web_access: true }, + { + type: "namespace", + name: "workspace", + tools: [{ type: "function", name: "read", defer_loading: true, parameters: {} }], + }, + ], + input: [{ + type: "additional_tools", + tools: [{ type: "function", name: "loose", defer_loading: true, parameters: {} }], + }], + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Record[]; + input: Array<{ tools: Record[] }>; + }; + + expect(body.tools[0]).toEqual({ type: "web_search_preview" }); + expect(body.tools[1]).toMatchObject({ type: "function", name: "workspace__read" }); + expect(body.tools[1]).not.toHaveProperty("defer_loading"); + expect(body.input[0].tools[0]).not.toHaveProperty("defer_loading"); + }); + test("preserves prompt_cache_key in the raw Responses passthrough body", () => { const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ @@ -1488,7 +1739,7 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(JSON.parse(secondRequest.body)).toEqual(firstBody); }); - test("keyed platform preserves unrelated and malformed namespaces", () => { + test("keyed platform flattens complete namespaces and drops ones it cannot express", () => { const adapter = createResponsesPassthroughAdapter(keyedProvider); const request = adapter.buildRequest({ modelId: "gpt-5.6-sol", @@ -1515,12 +1766,13 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { tool_choice: { type: string; name: string }; }; + // The empty `image_gen` group declares nothing, and relaying `type: "namespace"` is the shape a + // strict gateway rejects for the whole request. expect(body.tools).toEqual([ - { type: "namespace", name: "image_gen", tools: [] }, { - type: "namespace", - name: "web", - tools: [{ type: "function", name: "run", parameters: {} }], + type: "function", + name: "web__run", + parameters: { type: "object" }, }, { type: "image_generation" }, ]); @@ -1894,10 +2146,8 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, meta); const body = JSON.parse(request.body) as { tools: Array> }; - expect(body.tools).toEqual([ - { type: "namespace", name: "image_gen", tools: [] }, - { type: "image_generation" }, - ]); + // The empty namespace group is lowered away; only the hosted tool reaches the wire. + expect(body.tools).toEqual([{ type: "image_generation" }]); }); test("hosted-tool preference uses the exact model id", () => { @@ -1921,10 +2171,8 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }, meta); const body = JSON.parse(request.body) as { tools: Array> }; - expect(body.tools).toEqual([ - { type: "namespace", name: "image_gen", tools: [] }, - { type: "image_generation" }, - ]); + // The empty namespace group is lowered away; only the hosted tool reaches the wire. + expect(body.tools).toEqual([{ type: "image_generation" }]); }); test("hosted-tool preference honors an OpenAI virtual model's selected id", () => { @@ -2069,6 +2317,196 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { }); }); +describe("routed namespace and custom-tool identity", () => { + const customNamespace = "custom_catalog"; + const functionNamespace = "function_catalog"; + const rawTools = [ + { + type: "namespace", + name: customNamespace, + tools: [{ + type: "custom", + name: "read", + description: "Read freeform input", + format: { type: "text" }, + }], + }, + { + type: "namespace", + name: functionNamespace, + tools: [{ + type: "function", + name: "read", + description: "Read structured input", + parameters: { type: "object", properties: {} }, + }], + }, + ]; + const customUpstreamItem = { + type: "function_call", + id: "fc_custom_read", + call_id: "call_custom_read", + name: `${customNamespace}__read`, + arguments: JSON.stringify({ input: "freeform payload" }), + status: "completed", + }; + const functionUpstreamItem = { + type: "function_call", + id: "fc_function_read", + call_id: "call_function_read", + name: `${functionNamespace}__read`, + arguments: "{}", + status: "completed", + }; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + const frame = (event: string, payload: Record): string => + `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + + test("round-trips same-named namespaced custom and function calls through JSON and SSE", async () => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const built = adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "routed-model", input: "read", tools: rawTools }, + }, { headers: new Headers() }); + const builtBody = JSON.parse(built.body) as { tools: Array> }; + + expect(builtBody.tools.map(tool => ({ type: tool.type, name: tool.name }))).toEqual([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual([ + `${customNamespace}__read`, + ]); + + const savedFetch = globalThis.fetch; + const outboundBodies: Array> = []; + globalThis.fetch = (async (_input, init) => { + const outbound = JSON.parse(String(init?.body)) as Record; + outboundBodies.push(outbound); + if (outbound.stream === true) { + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...customUpstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: customUpstreamItem.id, + arguments: customUpstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: customUpstreamItem }), + frame("response.output_item.added", { + output_index: 1, + item: { ...functionUpstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 1, + item_id: functionUpstreamItem.id, + arguments: functionUpstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 1, item: functionUpstreamItem }), + frame("response.completed", { + response: { + id: "resp_stream", + status: "completed", + output: [customUpstreamItem, functionUpstreamItem], + }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + } + return new Response(JSON.stringify({ + id: "resp_json", + status: "completed", + output: [customUpstreamItem, functionUpstreamItem], + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const requestBody = (stream: boolean) => ({ + model: "fixture/routed-model", + stream, + input: [{ role: "user", content: [{ type: "input_text", text: "read both" }] }], + tools: rawTools, + }); + + try { + const jsonResponse = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody(false)), + }), config, { model: "", provider: "" }); + const json = await jsonResponse.json() as { output: Array> }; + expect(json.output[0]).toMatchObject({ + type: "custom_tool_call", + namespace: customNamespace, + name: "read", + input: "freeform payload", + }); + expect(json.output[0]).not.toHaveProperty("arguments"); + expect(json.output[1]).toMatchObject({ + type: "function_call", + namespace: functionNamespace, + name: "read", + arguments: "{}", + }); + + const sseResponse = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody(true)), + }), config, { model: "", provider: "" }); + const clientSse = await sseResponse.text(); + const payloads = clientSse + .split(/\r?\n/) + .filter(line => line.startsWith("data:") && line.slice(5).trim() !== "[DONE]") + .map(line => JSON.parse(line.slice(5).trim()) as Record); + const completed = payloads.find(payload => payload.type === "response.completed") as { + response: { output: Array> }; + } | undefined; + expect(completed?.response.output[0]).toMatchObject({ + type: "custom_tool_call", + namespace: customNamespace, + name: "read", + input: "freeform payload", + }); + expect(completed?.response.output[1]).toMatchObject({ + type: "function_call", + namespace: functionNamespace, + name: "read", + arguments: "{}", + }); + expect(payloads.some(payload => payload.type === "response.custom_tool_call_input.done")).toBe(true); + expect(payloads.some(payload => payload.type === "response.function_call_arguments.done")).toBe(true); + + for (const outbound of outboundBodies) { + const tools = outbound.tools as Array>; + expect(tools.map(tool => ({ type: tool.type, name: tool.name }))).toEqual([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + } + } finally { + globalThis.fetch = savedFetch; + } + }); +}); + describe("OpenAI Responses forward-mode unsupported param stripping", () => { const meta = { headers: new Headers({ authorization: "Bearer token" }) }; const rawBody = { @@ -2135,6 +2573,126 @@ describe("OpenAI Responses forward-mode unsupported param stripping", () => { }); }); +describe("replayed compaction blobs", () => { + type PassthroughProvider = Parameters[0]; + + // Shaped like a blob minted by an OpenAI-operated backend: opaque, no `ocx1:` envelope. + const NATIVE_BLOB = "gAAAAAB-openai-minted-compaction-blob"; + const routedProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "xai-test", + }; + const openaiKeyedProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-test", + }; + // Forward auth alone says nothing about the backend. Noncanonical providers receive no caller + // credentials, so this relay cannot be assumed to understand OpenAI's native blob. + const forwardRelayProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://relay.example/backend-api/codex", + authMode: "forward", + }; + const optedInRelayProvider: PassthroughProvider = { + adapter: "openai-responses", + baseUrl: "https://openai-relay.example/v1", + authMode: "key", + apiKey: "relay-test", + decodesNativeCompactionBlobs: true, + }; + + function forwardedInput( + target: PassthroughProvider, + input: unknown[], + threadServingIdentityChanged = false, + ): Record[] { + const request = createResponsesPassthroughAdapter(target).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", store: false, input }, + ...(threadServingIdentityChanged ? { _stripReasoningEncryptedContent: true } : {}), + }, { headers: new Headers({ authorization: "Bearer token" }) }); + return (JSON.parse(request.body) as { input: Record[] }).input; + } + + // Forwarding a blob to a backend that did not mint it fails the turn, and because the item lives + // in the client transcript the failure repeats on every later turn — including the compaction turn + // — so the session cannot recover until its history is cleared. + test("degrades a foreign blob to a note on a destination that cannot decode it", () => { + for (const target of [routedProvider, forwardRelayProvider]) { + for (const type of ["compaction", "compaction_summary", "context_compaction"]) { + const forwarded = forwardedInput(target, [ + { type, encrypted_content: NATIVE_BLOB }, + ]); + expect(forwarded[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + expect(JSON.stringify(forwarded)).not.toContain(NATIVE_BLOB); + } + } + }); + + test("forwards a foreign blob untouched to destinations known to decode it", () => { + const item = { type: "compaction", encrypted_content: NATIVE_BLOB }; + for (const target of [provider, openaiKeyedProvider, optedInRelayProvider]) { + expect(forwardedInput(target, [item])[0]).toEqual(item); + } + }); + + test("a known serving-identity change overrides the native-blob destination gate", () => { + const before = { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }; + const after = { type: "message", role: "user", content: [{ type: "input_text", text: "after" }] }; + + expect(forwardedInput(openaiKeyedProvider, [ + before, + { type: "compaction", encrypted_content: NATIVE_BLOB }, + after, + ], true)).toEqual([ + before, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }, + after, + ]); + }); + + // The proxy's own envelope is transparent base64, so no upstream can read it anywhere. + test("lowers proxy-minted ocx1 envelopes on every destination", () => { + const item = { type: "compaction", encrypted_content: encodeCompactionSummary("prior work") }; + for (const target of [provider, openaiKeyedProvider, routedProvider]) { + for (const threadServingIdentityChanged of [false, true]) { + expect(forwardedInput(target, [item], threadServingIdentityChanged)[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\nprior work` }], + }); + } + } + }); + + // A bare marker carries no blob, so there is nothing to mis-route. + test("leaves compaction items without encrypted_content alone", () => { + for (const target of [provider, routedProvider]) { + for (const type of ["compaction", "context_compaction"]) { + for (const threadServingIdentityChanged of [false, true]) { + const item = { type }; + expect(forwardedInput(target, [item], threadServingIdentityChanged)[0]).toEqual(item); + } + } + } + }); +}); + describe("openaiResponsesUrl", () => { test("does not strip mid-path /v1 or a non-endpoint responses suffix", () => { expect(openaiResponsesUrl("https://proxy.example.com/v1/relay")).toBe( @@ -2145,3 +2703,95 @@ describe("openaiResponsesUrl", () => { ); }); }); + +describe("reasoning input content channel", () => { + const routed = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + }; + + function forwarded(item: Record): Record { + const request = createResponsesPassthroughAdapter(routed).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", store: false, input: [item] }, + }, { headers: new Headers() }); + return (JSON.parse(request.body) as { input: Record[] }).input[0]; + } + + // Codex serializes an absent reasoning content channel as `"content": null`. xAI rejects the item + // and blames the sibling blob (`Could not decode the compaction blob`), so this reads as an + // encrypted_content failure; dropping the null key is what actually fixes it. Verified against a + // captured failing request: removing only this key turned the 400 into a 200. + test("drops a null content channel while keeping the replayable blob", () => { + const out = forwarded({ + type: "reasoning", + content: null, + summary: [{ type: "summary_text", text: "thinking" }], + encrypted_content: "upstream-issued-blob", + }); + expect(out).not.toHaveProperty("content"); + expect(out.encrypted_content).toBe("upstream-issued-blob"); + expect(out.summary).toEqual([{ type: "summary_text", text: "thinking" }]); + }); + + // An OpenAI-operated backend binds the blob to the item's exact shape, so deleting a field there + // invalidates it: `The encrypted content ... could not be verified`. Caught in live traffic after + // an ungated first version of this fix shipped locally — the two backends want opposite things. + test("keeps a null content channel on OpenAI-operated destinations", () => { + const item = { + type: "reasoning", + content: null, + summary: [], + encrypted_content: "openai-issued-blob", + }; + for (const target of [ + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "key" as const, apiKey: "sk-t" }, + ]) { + const request = createResponsesPassthroughAdapter(target).buildRequest({ + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "gpt-5.6-sol", store: false, input: [item] }, + }, { headers: new Headers({ authorization: "Bearer token" }) }); + const out = (JSON.parse(request.body) as { input: Record[] }).input[0]; + expect(out).toHaveProperty("content"); + expect(out.content).toBeNull(); + expect(out.encrypted_content).toBe("openai-issued-blob"); + } + }); + + // A noncanonical forward gateway does not receive the caller's credentials, so forward auth says + // nothing about which backend answers; it is routed and must get the strip. + test("strips a null content channel on a noncanonical forward relay", () => { + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://relay.example/backend-api/codex", + authMode: "forward", + }).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", store: false, input: [{ type: "reasoning", content: null, encrypted_content: "b" }] }, + }, { headers: new Headers() }); + const out = (JSON.parse(request.body) as { input: Record[] }).input[0]; + expect(out).not.toHaveProperty("content"); + }); + + test("leaves an array content channel to the existing sanitizer", () => { + const out = forwarded({ + type: "reasoning", + content: [{ type: "reasoning_text", text: "raw" }], + encrypted_content: "upstream-issued-blob", + }); + expect(out.content).toEqual([]); + expect(out.encrypted_content).toBe("upstream-issued-blob"); + }); +}); diff --git a/tests/reasoning-replay-identity.test.ts b/tests/reasoning-replay-identity.test.ts index 80cdd49aaa..8f204b8141 100644 --- a/tests/reasoning-replay-identity.test.ts +++ b/tests/reasoning-replay-identity.test.ts @@ -10,6 +10,7 @@ import { reasoningReplayKeyCredentialIdentity, reasoningReplayOAuthCredentialIdentity, rememberReasoningForCall, + updateReasoningReplayServingIdentity, } from "../src/responses/reasoning-replay-cache"; import type { AdapterEvent, OcxReasoningReplayScopeRef } from "../src/types"; @@ -25,9 +26,11 @@ function scope( current: { providerName: "provider-a", providerDestinationIdentity: "destination:provider-a", + providerDestinationDurableIdentity: "destination:durable-provider-a", adapterName: "openai-chat", modelId: "deepseek-v4-flash", credentialIdentity: "key:physical-a", + credentialDurableIdentity: "credential:durable-slot-a", ...overrides, }, }; @@ -69,6 +72,95 @@ describe("reasoning replay provider and credential identity", () => { } }); + test("serving identity ignores credential generation but reports durable route changes", () => { + expect(updateReasoningReplayServingIdentity(scope({ + credentialIdentity: "oauth:slot-a-generation-a", + }))).toBe(false); + expect(updateReasoningReplayServingIdentity(scope({ + credentialIdentity: "oauth:slot-a-generation-b", + }))).toBe(false); + + const changedModel = scope({ + modelId: "deepseek-v4", + credentialIdentity: "oauth:slot-a-generation-b", + }); + expect(updateReasoningReplayServingIdentity(changedModel)).toBe(true); + expect(updateReasoningReplayServingIdentity(changedModel)).toBe(false); + + const changedCredential = scope({ + modelId: "deepseek-v4", + credentialIdentity: "oauth:slot-b-generation-a", + credentialDurableIdentity: "credential:durable-slot-b", + }); + expect(updateReasoningReplayServingIdentity(changedCredential)).toBe(true); + expect(updateReasoningReplayServingIdentity(changedCredential)).toBe(false); + + const changedDestination = scope({ + modelId: "deepseek-v4", + providerDestinationIdentity: "destination:provider-b", + providerDestinationDurableIdentity: "destination:durable-provider-b", + credentialIdentity: "oauth:slot-b-generation-a", + credentialDurableIdentity: "credential:durable-slot-b", + }); + expect(updateReasoningReplayServingIdentity(changedDestination)).toBe(true); + expect(updateReasoningReplayServingIdentity(changedDestination)).toBe(false); + + expect(updateReasoningReplayServingIdentity(undefined)).toBe(false); + expect(updateReasoningReplayServingIdentity({ clientThreadId: "thread-unknown" })).toBe(false); + }); + + test("serving identity refuses to record when durable dimensions are unavailable", () => { + const clientThreadId = "thread-without-durable-identity"; + expect(updateReasoningReplayServingIdentity({ + ...scope({ credentialDurableIdentity: undefined }), + clientThreadId, + })).toBe(false); + expect(updateReasoningReplayServingIdentity({ + ...scope({ modelId: "different-model" }), + clientThreadId, + })).toBe(false); + + const destinationThreadId = "thread-without-durable-destination"; + expect(updateReasoningReplayServingIdentity({ + ...scope({ providerDestinationDurableIdentity: undefined }), + clientThreadId: destinationThreadId, + })).toBe(false); + expect(updateReasoningReplayServingIdentity({ + ...scope({ modelId: "different-model" }), + clientThreadId: destinationThreadId, + })).toBe(false); + }); + + test("expired serving identity is unknown rather than a backend change", () => { + let clock = 1_000; + clearReasoningReplayCacheForTests(() => clock); + expect(updateReasoningReplayServingIdentity(scope())).toBe(false); + + clock += 60 * 60 * 1000 + 1; + expect(updateReasoningReplayServingIdentity(scope({ modelId: "deepseek-v4" }))).toBe(false); + }); + + test("repeated identity changes do not grow the thread store beyond 64 entries", () => { + const servingScope = ( + threadId: string, + modelId: string, + ): OcxReasoningReplayScopeRef => ({ + ...scope({ modelId }), + clientThreadId: threadId, + }); + + for (let i = 0; i < 64; i++) { + expect(updateReasoningReplayServingIdentity(servingScope(`thread-${i}`, "model-a"))).toBe(false); + } + for (let i = 0; i < 70; i++) { + expect(updateReasoningReplayServingIdentity(servingScope("thread-63", `model-change-${i}`))).toBe(true); + } + + expect(updateReasoningReplayServingIdentity(servingScope("thread-64", "model-a"))).toBe(false); + expect(updateReasoningReplayServingIdentity(servingScope("thread-1", "model-b"))).toBe(true); + expect(updateReasoningReplayServingIdentity(servingScope("thread-0", "model-b"))).toBe(false); + }); + test("incomplete, unscoped, and legacy thread-only namespaces fail closed", () => { const incomplete: OcxReasoningReplayScopeRef[] = [ { clientThreadId: THREAD }, diff --git a/tests/responses-compaction.test.ts b/tests/responses-compaction.test.ts index 23a7668e9c..5cc02d2e56 100644 --- a/tests/responses-compaction.test.ts +++ b/tests/responses-compaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { CODEX_FORWARD_BASE_URL } from "../src/providers/openai-tiers"; import { parseRequest } from "../src/responses/parser"; import { COMPACT_PROMPT, @@ -178,10 +179,19 @@ describe("forward-path ocx1 compaction scrub", () => { authMode: "forward" as const, }; - function forwardedBody(rawBody: Record): { input: Array> } { - const adapter = createResponsesPassthroughAdapter(provider as never); + function forwardedBody( + rawBody: Record, + target = provider, + threadServingIdentityChanged = false, + ): { input: Array> } { + const adapter = createResponsesPassthroughAdapter(target as never); const request = adapter.buildRequest({ - modelId: "gpt-5.5", context: { messages: [] }, stream: true, options: {}, _rawBody: rawBody, + modelId: "gpt-5.5", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + ...(threadServingIdentityChanged ? { _stripReasoningEncryptedContent: true } : {}), }, { headers: new Headers() }); return JSON.parse(request.body as string) as { input: Array> }; } @@ -218,10 +228,45 @@ describe("forward-path ocx1 compaction scrub", () => { const body = forwardedBody({ model: "gpt-5.5", input: [{ type: "compaction", encrypted_content: "gAAAAA-real-openai-blob" }], - }); + }, { ...provider, baseUrl: CODEX_FORWARD_BASE_URL }); expect(body.input[0].type).toBe("compaction"); expect(body.input[0].encrypted_content).toBe("gAAAAA-real-openai-blob"); }); + + test("known serving-identity changes degrade native blobs before OpenAI forwarding", () => { + const before = { type: "message", role: "user", content: [{ type: "input_text", text: "before" }] }; + const after = { type: "message", role: "user", content: [{ type: "input_text", text: "after" }] }; + const body = forwardedBody({ + model: "gpt-5.5", + input: [ + before, + { type: "compaction", encrypted_content: "xai-native-compaction-blob" }, + after, + ], + }, { ...provider, baseUrl: CODEX_FORWARD_BASE_URL }, true); + + expect(body.input).toEqual([ + before, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }, + after, + ]); + }); + + test("noncanonical forward providers degrade OpenAI-encrypted compaction items", () => { + const body = forwardedBody({ + model: "gpt-5.5", + input: [{ type: "compaction", encrypted_content: "gAAAAA-real-openai-blob" }], + }, provider); + expect(body.input[0]).toEqual({ + type: "message", + role: "user", + content: [{ type: "input_text", text: OPAQUE_COMPACTION_NOTE }], + }); + }); }); describe("remote compaction v1 helpers (260707 Design-B sweep)", () => { diff --git a/tests/responses-field-backfill.test.ts b/tests/responses-field-backfill.test.ts index f4ac338b69..c5ec73e690 100644 --- a/tests/responses-field-backfill.test.ts +++ b/tests/responses-field-backfill.test.ts @@ -433,21 +433,25 @@ describe("responses-field-backfill", () => { expect(new Set(ids).size).toBe(2); }); - // `compaction` is the /v1/responses/compact wire format, not a Responses output item: it - // carries no id in that contract, and clients compare the body exactly. Synthesizing an id - // here changed a response that had nothing to do with strict Responses decoding — a defect - // that only appeared once this backfill and the compact endpoint were on the same tree. - test("a compaction item is returned byte-for-byte", () => { - const response = { - id: "resp_1", - object: "response", - status: "completed", - output: [{ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }], - }; - const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { - output: Record[]; - }; - expect(result.output[0]).toEqual({ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }); - expect(result.output[0]).not.toHaveProperty("id"); + // The compact wire family is the /v1/responses/compact format, not Responses output items: they + // carry no id in that contract, clients compare the body exactly, and the client replays the item + // on every later turn where the minting backend rejects a modified one. Synthesizing an id here + // changed a response that had nothing to do with strict Responses decoding — a defect that only + // appeared once this backfill and the compact endpoint were on the same tree. It originally + // covered `compaction` alone, so the sibling types kept receiving synthesized ids. + test("every compact wire item type is returned byte-for-byte", () => { + for (const type of ["compaction", "compaction_summary", "context_compaction"]) { + const item = { type, encrypted_content: "gAAAAAB-test-opaque" }; + const response = { id: "resp_1", object: "response", status: "completed", output: [item] }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { + output: Record[]; + }; + expect(result.output[0]).toEqual(item); + expect(result.output[0]).not.toHaveProperty("id"); + + const streamed = parseData(apply(sseBlock({ type: "response.output_item.done", output_index: 0, item }))); + expect(streamed[0].item).toEqual(item); + expect(streamed[0].item).not.toHaveProperty("id"); + } }); }); diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index 09b8e1bad7..4ba42acf91 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -209,6 +209,52 @@ describe("responses reasoning summary channel rewrite", () => { expect(rewrite("not json")).toBe("not json"); expect(rewrite("[1,2]")).toBe("[1,2]"); }); + + // `encrypted_content` is opaque, state-bearing provider data, so preserve the complete item + // shape defensively when the client replays it. This rewrite's round-trip was verified against + // DeepSeek, which is stateless and issues no blob; providers that do issue one joined later + // through `preserveReasoningContentModels`. + describe("items carrying encrypted_content", () => { + const blobItem = { + type: "reasoning", + id: "rs_1", + status: "completed", + encrypted_content: "gAAAAAB-upstream-issued-blob", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }; + + test("are returned byte-for-byte on output_item.done", () => { + const payload = { type: "response.output_item.done", output_index: 0, item: blobItem }; + expect(apply(payload)).toEqual(payload); + }); + + test("are returned byte-for-byte inside response.completed output", () => { + const payload = { + type: "response.completed", + response: { id: "resp_1", output: [blobItem] }, + }; + expect(apply(payload)).toEqual(payload); + }); + + test("are returned byte-for-byte through the non-streaming document rewrite", () => { + const doc = { id: "resp_1", object: "response", output: [blobItem] }; + expect(rewriteReasoningSummaryInJson(doc)).toBe(doc); + const json = JSON.stringify(doc); + expect(rewriteReasoningSummaryInJsonString(json)).toBe(json); + }); + + // Only the stored item is protected: the live trace Codex renders comes from the delta events, + // which carry no blob and are still routed to the summary channel. + test("do not disable the delta rewrite that renders the live trace", () => { + expect(apply({ + type: "response.reasoning_text.delta", + delta: "think", + item_id: "rs_1", + output_index: 0, + })).toMatchObject({ type: "response.reasoning_summary_text.delta", delta: "think" }); + }); + }); }); describe("routeUsesContentChannelReasoning", () => { diff --git a/tests/server-xai-responses-streaming.test.ts b/tests/server-xai-responses-streaming.test.ts index 7195436a60..19800234a7 100644 --- a/tests/server-xai-responses-streaming.test.ts +++ b/tests/server-xai-responses-streaming.test.ts @@ -220,4 +220,188 @@ describe("xAI OAuth Responses streaming", () => { await server.stop(true); } }, 10_000); + + test("lowers Codex namespaces for xAI and restores routed calls on the client stream", async () => { + let outboundBody: Record | undefined; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + outboundBody = JSON.parse(String(init?.body)) as Record; + const call = { + id: "fc_spawn", + type: "function_call", + status: "completed", + name: "collaboration__spawn_agent", + call_id: "call_spawn", + arguments: "{}", + }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(sse({ + type: "response.created", + sequence_number: 0, + response: { id: "resp_namespace", object: "response", status: "in_progress", model: "grok-4.6", output: [] }, + })); + controller.enqueue(sse({ + type: "response.output_item.added", + sequence_number: 1, + output_index: 0, + item: call, + })); + controller.enqueue(sse({ + type: "response.output_item.done", + sequence_number: 2, + output_index: 0, + item: call, + })); + controller.enqueue(sse({ + type: "response.completed", + sequence_number: 3, + response: { + id: "resp_namespace", + object: "response", + status: "completed", + model: "grok-4.6", + output: [call], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })); + controller.close(); + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + stream: true, + store: false, + tools: [{ type: "web_search", external_web_access: true }], + input: [ + { + type: "additional_tools", + role: "developer", + tools: [ + { + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec", description: "run code", format: { type: "text" } }], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", description: "spawn", parameters: {} }], + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "delegate" }] }, + ], + }), + }); + expect(response.status).toBe(200); + const clientText = await response.text(); + + const outboundInput = outboundBody?.input as Array<{ + type: string; + tools?: Array<{ type: string; name?: string }>; + }> | undefined; + const outboundTools = outboundInput?.find(item => item.type === "additional_tools")?.tools; + expect(outboundTools?.some(tool => tool.type === "namespace")).toBe(false); + expect(outboundTools?.find(tool => tool.name === "exec")?.type).toBe("function"); + expect(outboundTools?.find(tool => tool.name === "collaboration__spawn_agent")?.type).toBe("function"); + expect(outboundBody?.tools).toEqual([{ type: "web_search" }]); + + const payloads = clientText + .split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6)) as Record); + const added = payloads.find(payload => payload.type === "response.output_item.added") as { + item?: Record; + } | undefined; + expect(added?.item).toMatchObject({ + type: "function_call", + namespace: "collaboration", + name: "spawn_agent", + call_id: "call_spawn", + }); + const completed = payloads.find(payload => payload.type === "response.completed") as { + response?: { output?: Array> }; + } | undefined; + expect(completed?.response?.output?.[0]).toMatchObject({ + namespace: "collaboration", + name: "spawn_agent", + }); + } finally { + await server.stop(true); + } + }, 10_000); + + test("restores routed namespace calls in a non-streaming xAI JSON response", async () => { + let outboundBody: Record | undefined; + const call = { + id: "fc_spawn_json", + type: "function_call", + status: "completed", + name: "collaboration__spawn_agent", + call_id: "call_spawn_json", + arguments: "{}", + }; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + outboundBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ + id: "resp_namespace_json", + object: "response", + status: "completed", + model: "grok-4.6", + output: [call], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + stream: false, + store: false, + tools: [{ + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", description: "spawn", parameters: {} }], + }], + input: "delegate", + }), + }); + expect(response.status).toBe(200); + + const outboundTools = outboundBody?.tools as Array<{ type: string; name?: string }> | undefined; + expect(outboundTools).toEqual([expect.objectContaining({ + type: "function", + name: "collaboration__spawn_agent", + })]); + const clientBody = await response.json() as { output?: Array> }; + expect(clientBody.output?.[0]).toMatchObject({ + type: "function_call", + namespace: "collaboration", + name: "spawn_agent", + call_id: "call_spawn_json", + }); + } finally { + await server.stop(true); + } + }, 10_000); });