-
Notifications
You must be signed in to change notification settings - Fork 853
fix(responses): compare serving identity for compaction blobs too #2249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d6c6a31
e4508d0
add8a55
3d11f6f
bf84d17
d8426a6
6e86b18
6bede37
abc2b9f
10b68d2
c3d62b0
2dd6685
847cd8a
fbce515
4f9f728
26ed833
248a75c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<string, unknown>; | ||||||||||||||
|
|
@@ -56,24 +61,51 @@ 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<string, unknown> = { ...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<string, unknown> = { ...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. | ||||||||||||||
| // DeepSeek's Responses API instead ACCEPTS plaintext reasoning replay (its compatibility | ||||||||||||||
| // 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<string> }[] = [ | ||||||||||||||
| // 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<string> | undefined; | ||||||||||||||
| let convertedRoutedToolSearchNames: Set<string> | undefined; | ||||||||||||||
| let convertedRoutedNamespaceToolAliases: Map<string, { namespace: string; name: string }> | 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; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Document that Line 1694 reads a field whose name scopes it to reasoning replay, then feeds it into two independent decisions: The failure mode is concrete and quiet. A later change that narrows 📝 Proposed comment+ // One signal, two decisions. The recorded serving-identity mismatch is named for reasoning
+ // replay, but a native compaction blob has the same provenance problem: multiple backends
+ // mint them, so a proven route change makes the blob undecodable regardless of item type.
+ // Do not narrow this field to reasoning items without also re-deciding compaction.
const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| 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 } : {}), | ||||||||||||||
| }; | ||||||||||||||
| }, | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Reuse the exported alias instead of re-declaring the alias payload shape.
src/responses/namespace-tool-compat.tsalready exportsRoutedNamespaceToolAliases(ReadonlyMap<string, RoutedNamespaceToolIdentity>), andsrc/server/responses/core.tsline 2566 assigns this field directly into a variable of that type. The inline{ namespace: string; name: string }literal duplicates that contract in three places (src/adapters/base.ts,src/adapters/openai-responses.ts, and the compat module). IfRoutedNamespaceToolIdentitygains a field, the adapter contract silently drifts and only fails at the assignment site.Import the alias here so one declaration owns the wire identity.
♻️ Proposed contract consolidation
Add the type-only import at the top of
src/adapters/base.ts:📝 Committable suggestion
🤖 Prompt for AI Agents