From 61afaab15b5302dc10a954725bd2e14c69cee34e Mon Sep 17 00:00:00 2001 From: fliaping <269832017@qq.com> Date: Thu, 8 Oct 2026 03:42:02 -0700 Subject: [PATCH 1/7] [verified] feat(compaction): opt in custom Responses gateways to native protocols --- .../src/content/docs/reference/adapters.md | 29 ++ .../docs/reference/configuration/providers.md | 3 + .../content/docs/reference/proxy-formats.md | 6 + .../docs/zh-cn/reference/proxy-formats.md | 6 + .../docs/zh-tw/reference/proxy-formats.md | 6 + scripts/test-layout/layout.json | 1 + scripts/test-layout/seeds.json | 3 +- src/adapters/openai-responses/passthrough.ts | 140 +----- src/adapters/openai-responses/transport.ts | 81 ++++ src/config/schema/leaf-validators.ts | 2 + src/providers/model-rename-fields.ts | 2 + src/providers/openai-tiers-destination.ts | 11 +- src/providers/openai-tiers.ts | 2 +- src/server/auth-cors.ts | 2 + src/server/responses/compact.ts | 73 ++- src/server/responses/compaction-recovery.ts | 13 +- src/server/responses/core-auth.ts | 6 + src/server/responses/request-prepare.ts | 5 + src/server/responses/request-sidecar-auth.ts | 4 +- src/types/provider.ts | 4 + structure/config.md | 2 +- structure/providers-and-adapters.md | 4 +- structure/transports/responses-failover.md | 6 +- structure/transports/responses.md | 5 +- tests/fixtures/test-layout-expected.json | 1 + .../native-compaction-gateway.test.ts | 446 ++++++++++++++++++ 26 files changed, 709 insertions(+), 154 deletions(-) create mode 100644 src/adapters/openai-responses/transport.ts create mode 100644 tests/responses/native-compaction-gateway.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 892b9ccd878..49fdc1d1371 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -153,6 +153,35 @@ with `forwardClientHeaders`; provider `headers` win for this option, and credent names are refused. Canonical ChatGPT forward auth retains its separate fixed header allowlist. Only `originator`, `x-client-request-id`, `x-codex-app-version`, and `user-agent` are supported by `forwardClientHeaders`; arbitrary names are rejected on load/write and ignored at runtime. +### Native compaction on custom gateways + +Responses compatibility alone is not evidence of native compaction support. Custom gateways +continue using portable text summaries unless the operator declares the relevant capability: + +- `supportsNativeCompactionTrigger: true` preserves v2 `compaction_trigger` requests and native output. +- `supportsNativeCompactEndpoint: true` opts into the separate v1 compact endpoint. A gateway can + support v2 without implementing this endpoint; do not enable it just because v2 succeeds. +- `decodesNativeCompactionBlobs: true` preserves native blobs on subsequent turns when there is + no known serving-provider mismatch. Neither generation flag implies this replay capability. + +For a gateway whose **v2 generation and replay** have both been verified, merge these fields into +its existing provider configuration, retaining its real URL and configured authentication: + +```json +{ + "adapter": "openai-responses", + "supportsNativeCompactionTrigger": true, + "decodesNativeCompactionBlobs": true +} +``` + +These flags do not make the gateway an official OpenAI destination, forward caller credentials, +change account selection, or bypass normal compatibility sanitization. Validate an actual compact +response and continued conversation, not just HTTP 200. Keep blobs on the backend that minted them; +cross-provider compaction routing overrides still use portable summaries. V1 fallback summaries +remain portable even when the destination separately accepts v2 triggers. Native-capable routes +are excluded from portable emergency compaction recovery for their respective protocol. + Adapter selection does not select the upstream transport. Eligible requests can use the [upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses the diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b51c9897509..55c587cd78a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -250,6 +250,9 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an explicit all-zero user entry means a known-zero estimate; delete that model entry to restore automatic pricing. All-zero catalog metadata still falls through. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. | | `forwardClientHeaders?` | `string[]` | `openai-responses` only: opt in to copying named inbound client metadata headers onto inference requests when the provider has not already set that header. Credential and transport-owned names such as `Authorization`, cookies, API-key headers, `Content-Type`, `Content-Length`, `Host`, and `x-oai-attestation` are rejected and also filtered at runtime. Header names are case-insensitive and may not repeat. `headers` remains authoritative; the existing caller `User-Agent` fallback still applies when neither surface sets one. Supported names: `originator`, `x-client-request-id`, `x-codex-app-version`, `user-agent`; all other names are refused. | +| `supportsNativeCompactionTrigger?` | `boolean` | `openai-responses` only: declare support for native v2 `compaction_trigger` input items. Defaults off for noncanonical gateways; preserves native compaction rather than requesting a portable text summary. Independent of the v1 endpoint and blob replay capabilities. | +| `supportsNativeCompactEndpoint?` | `boolean` | `openai-responses` only: declare support for native v1 `POST /responses/compact`. Defaults off for custom gateways. Uses the provider's Responses URL plus `/compact` and its normal transport authentication. Does not enable v2 triggers or grant official OpenAI identity. | +| `decodesNativeCompactionBlobs?` | `boolean` | Declare that this destination can replay native, non-`ocx1:` compaction blobs. Independent of either generation capability; a known serving-provider mismatch still degrades foreign ciphertext. Enable only after testing compact and a subsequent turn on the same backend. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | | `vercelGatewayRouting?` | `VercelGatewayRouting` | Default Vercel AI Gateway `order`, `only`, and `sort` (`"cost"` \| `"ttft"` \| `"tps"`) preferences; valid only for canonical Vercel AI Gateway with `openai-chat`. | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index e16a7823988..3e4cf465aa7 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -662,8 +662,14 @@ conversation. | Route type | Behavior | | --- | --- | | Canonical ChatGPT or official OpenAI route | Tries the native `/responses/compact` endpoint with the resolved account and model authentication; HTTP 404 falls back to a regular Responses compaction turn | +| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint using the provider's normal Responses URL, authentication, and client headers; HTTP 404 uses portable fallback | | Other routed model | Runs an internal, non-streaming, no-tools compaction turn with a `compaction_trigger`; requires exactly one synthetic `compaction` item whose `encrypted_content` is an `ocx1:` envelope; decodes that summary into v1 replacement history | +The custom v1 endpoint capability is independent of v2 `supportsNativeCompactionTrigger` +and of `decodesNativeCompactionBlobs` for replay. None grants canonical OpenAI identity or +caller credential forwarding. Cross-provider overrides remain portable; v2 success alone +does not establish support for `/responses/compact`. See [provider configuration](/reference/configuration/providers/). + If the native compact endpoint returns HTTP 404, OpenCodex retries compaction through a regular Responses turn with the same model selector and session headers. Canonical ChatGPT fallback turns use upstream SSE; the compact caller still receives JSON. A completed native opaque diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 91ea424435d..f49a98425dc 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -249,8 +249,14 @@ Compaction 会为需要缩短长 Responses 会话的客户端返回替换历史 | 路由类型 | 行为 | | --- | --- | | Canonical ChatGPT 或官方 OpenAI 路由 | 将请求转发到原生 `/responses/compact` 端点,并使用已解析的账号和模型认证 | +| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint with the provider's normal Responses URL, authentication, and client headers; HTTP 404 uses portable fallback | | 其他路由模型 | 运行一次内部的、非流式、无工具的 compaction 轮次,并带 `compaction_trigger`;要求且只允许一个 synthetic `compaction` 项,其 `encrypted_content` 是一个 `ocx1:` 封装;把该摘要解码为 v1 替换历史 | +The custom v1 capability is independent of v2 `supportsNativeCompactionTrigger` and +`decodesNativeCompactionBlobs` for replay. None grants canonical identity or caller credential +forwarding; cross-provider overrides remain portable. See the canonical +[provider configuration](/reference/configuration/providers/) before opting in. + 原生 compact 响应会被缓冲,最大 32 MiB,即使其声明的 `Content-Length` 已经超过该限制也是如此。compact 专用失败包括: | 状态 | 类型或 code | 含义 | diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 5f1ca4e8a34..ab3fef852a2 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -226,8 +226,14 @@ Compaction 為需要縮短長 Responses 對話的客戶端回傳取代歷史。 | 路由型別 | 行為 | | --- | --- | | 規範 ChatGPT 或官方 OpenAI 路由 | 以解析的帳號與模型認證將請求轉發到原生 `/responses/compact` 端點 | +| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint with the provider's normal Responses URL, authentication, and client headers; HTTP 404 uses portable fallback | | 其他路由模型 | 執行一個內部、非串流、無工具的 compaction 回合,帶有 `compaction_trigger`;需要恰好一個合成的 `compaction` 項目,其 `encrypted_content` 為 `ocx1:` 封裝;將該摘要解碼為 v1 取代歷史 | +The custom v1 capability is independent of v2 `supportsNativeCompactionTrigger` and +`decodesNativeCompactionBlobs` for replay. None grants canonical identity or caller credential +forwarding; cross-provider overrides remain portable. See the canonical +[provider configuration](/reference/configuration/providers/) before opting in. + 原生 compact 回應以 32 MiB 上限緩衝,包含其宣告的 `Content-Length` 已超過限制的回應。Compact 專屬失敗包含: | 狀態 | 型別或代碼 | 意義 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8c9253b0c5..6648b1a04c4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1509,6 +1509,7 @@ "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", diff --git a/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json index 45792a9c4bc..f39b3cb95b1 100644 --- a/scripts/test-layout/seeds.json +++ b/scripts/test-layout/seeds.json @@ -32,7 +32,7 @@ "codex-integration": { "match": [ "^context-compat\\.test\\.ts$", - "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" + "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native(?!-compaction-gateway)|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" ] }, "server": { @@ -62,6 +62,7 @@ }, "responses": { "match": [ + "^native-compaction-gateway\\.test\\.ts$", "^openai-responses-summary-none\\.test\\.ts$", "^(?:apply|chat|citation|continuation|eventstream|legacy|namespace|passthrough|responses|sse|thought|ws)-" ] diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 00bf30c3adb..092a29036f5 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -7,14 +7,14 @@ import { applyGithubCopilotContextTier } from "../../providers/github-copilot-co import { Buffer } from "node:buffer"; import type { IncomingMeta, ProviderAdapter } from "../base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; -import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../../codex/forward-transport-headers"; +import { applyCodexRoutingHint, CODEX_ROUTING_HINT_HEADER } from "../../codex/forward-transport-headers"; import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType, transferCompactionCiphertextLease } from "../../responses/compaction"; import { decodeServerSentEvents } from "../../lib/sse-decoder"; import { - CODEX_FORWARD_BASE_URL, destinationDecodesNativeCompactionBlob, isCanonicalOpenAiForwardProvider, isOpenAiOperatedResponsesDestination, + supportsNativeResponsesCompactionTrigger, } from "../../providers/openai-tiers"; import type { TranslatorBudget } from "../../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream, validateFinalCustomToolCompatibility } from "../../responses/custom-tool-compat"; @@ -23,7 +23,8 @@ import { rewriteRoutedNamespaceToolsForUpstream } from "../../responses/namespac import { repairLegacyDottedToolCallNames } from "../../responses/legacy-dotted-tool-name-repair"; import { preparePlaintextV2AgentMessages } from "../../responses/plaintext-v2-agent-messages"; import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../../responses/muse-tool-name-alias"; -import { openaiResponsesUrl } from "../openai-responses-url"; +import { buildResponsesTransport } from "./transport"; +export { FORWARD_HEADERS } from "./transport"; import { normalizeResponsesCodeMode } from "../responses-code-mode"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "../xai-web-search"; import { @@ -46,7 +47,7 @@ import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuation import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; import { observeOutbound } from "../../usage/cache-diagnostic"; -import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers"; + import { normalizeMuseToolChoice } from "./muse-tool-choice"; /** @@ -62,55 +63,6 @@ export function requiresPlaintextReasoningReplay(provider: OcxProviderConfig): b && provider.requiresAdjacentResponsesToolResults === true; } -// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. -// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. -export const FORWARD_HEADERS = [ - "authorization", - "chatgpt-account-id", - "openai-beta", - "originator", - "session_id", - "session-id", - "thread-id", - "x-client-request-id", - "x-codex-beta-features", - "x-codex-installation-id", - "x-codex-parent-thread-id", - "x-codex-turn-metadata", - "x-codex-turn-state", - "x-codex-window-id", - "x-oai-attestation", - "x-openai-subagent", - "x-responsesapi-include-timing-metrics", - CODEX_RESPONSES_LITE_HEADER, -]; - -/** Preserve the caller fingerprint unless the provider explicitly owns that header. */ -function applyCallerUserAgentFallback( - headers: Record, - incoming: IncomingMeta, -): void { - if (Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) return; - const userAgent = incoming.headers.get("user-agent"); - if (userAgent) headers["User-Agent"] = userAgent; -} - -/** Copy only explicitly opted-in caller metadata; provider-owned headers remain authoritative. */ -function applyConfiguredClientHeaderForwarding( - headers: Record, - incoming: IncomingMeta, - provider: OcxProviderConfig, -): void { - if (!Array.isArray(provider.forwardClientHeaders)) return; - for (const rawName of provider.forwardClientHeaders.slice(0, 64)) { - const name = normalizeForwardedClientHeaderName(rawName); - if (name === null) continue; - if (Object.keys(headers).some(existing => existing.toLowerCase() === name)) continue; - const value = incoming.headers.get(name); - if (value) headers[name] = value; - } -} - /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); @@ -124,9 +76,9 @@ function stripInputImagesDeep(value: unknown): unknown { } /** - * Rewrite a compaction turn for an upstream that does not speak Codex's private - * `compaction_trigger` item: drop the trigger and the whole tool surface, and ask - * for the handoff summary in plain terms instead (#422). + * Rewrite a compaction turn without native v2 capability, or a forced portable + * handoff: drop the trigger and the whole tool surface, and ask for the handoff + * summary in plain terms instead (#422). Native v1 capability is independent. * * The adapter builds from `parsed._rawBody`, so the summarizer prompt that * handleResponses() pushed onto `parsed.context` never reaches the wire — it has to @@ -218,61 +170,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) { const translatorBudget = incoming.translatorBudget; - const headers: Record = { "Content-Type": "application/json" }; - let url: string; - - if (provider.authMode === "forward") { - const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); - // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1). - const baseUrl = mayForwardCallerCredentials - ? CODEX_FORWARD_BASE_URL - : provider.baseUrl.replace(/\/+$/, ""); - url = `${baseUrl}/responses`; - if (provider.headers) Object.assign(headers, provider.headers); // static headers first… - const runtimeProvider = provider as { - _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; - _codexAccountRequired?: boolean; - }; - if ( - mayForwardCallerCredentials - && runtimeProvider._codexAccountRequired - && !runtimeProvider._codexAccountOverride - ) { - throw new Error("Codex pool account auth is required but unavailable"); - } - if (mayForwardCallerCredentials) { - for (const h of FORWARD_HEADERS) { - const v = incoming?.headers.get(h); - if (v) { - if (h === CODEX_RESPONSES_LITE_HEADER) { - for (const name of Object.keys(headers)) { - if (name.toLowerCase() === h) delete headers[name]; - } - } - headers[h] = v; // …so genuine forwarded fields win. - } - } - } - const override = runtimeProvider._codexAccountOverride; - if (override && mayForwardCallerCredentials) { - headers["authorization"] = `Bearer ${override.accessToken}`; - headers["chatgpt-account-id"] = override.chatgptAccountId; - } - } else { - if (provider.responsesPath === undefined) { - url = openaiResponsesUrl(provider.baseUrl); - } else { - const base = provider.baseUrl.replace(/\/$/, ""); - url = `${base}${provider.responsesPath}`; - } - if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; - if (provider.headers) Object.assign(headers, provider.headers); - } - // Some Responses-compatible gateways select their Codex compatibility path from the real - // client fingerprint. Additional caller metadata is opt-in; static provider headers remain - // authoritative in either auth mode. - applyConfiguredClientHeaderForwarding(headers, incoming, provider); - applyCallerUserAgentFallback(headers, incoming); + const { url, headers } = buildResponsesTransport(provider, incoming); const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; @@ -397,12 +295,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } // The same class of private field, one level up, but keyed on the DESTINATION rather than // on the canonical surface alone. `src/server/responses/compact.ts` spreads the caller's - // raw body into the native `/responses/compact` request without passing through this - // adapter, and that endpoint is offered only to OpenAI-operated destinations - // (supportsNativeResponsesCompactEndpoint). Stripping on the canonical predicate here - // would make the two paths disagree for `openai-apikey`; stripping on the destination - // keeps every OpenAI-operated route byte-identical and removes the field exactly where it - // is known to break, which is a gateway this proxy does not operate. + // raw body into native `/responses/compact` without this adapter, and applies the same + // destination boundary for opted-in custom gateways. Native endpoint capability does not + // grant OpenAI-private fields. Stripping on the canonical predicate here would make the + // two paths disagree for `openai-apikey`; stripping on the destination keeps every + // OpenAI-operated route byte-identical and removes the field exactly where it is known + // to break, which is a gateway this proxy does not operate. // // Placed before the routed compaction body is built and before serialization, so the HTTP, // routed-compaction and WebSocket outbounds are all covered by this one call. @@ -459,13 +357,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!forward) { outBody = normalizeOpenCodeGoAdditionalTools(outBody, url, parsed._replayPrefixLen); } - // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would - // let a noncanonical custom forward provider skip this rewrite while the server still routes - // it as a summarizer turn (#422). The compaction body build removes the tool surface and must + // Same native-v2/portable predicate as the routedCompaction gate in handleResponses(): + // neither forward auth nor native v1 endpoint support implies trigger support. The + // compaction body build removes the tool surface and must // therefore be the last routed transform that may depend on those declarations. Structural // sanitizers below can still run after it. outBody = normalizeResponsesCodeMode(outBody, parsed, provider); - if (parsed._compactionRequest === true && (!isCanonicalOpenAiForwardProvider(provider) || parsed._portableCompaction === true)) { + if (parsed._compactionRequest === true && (!supportsNativeResponsesCompactionTrigger(provider) || parsed._portableCompaction === true)) { outBody = buildRoutedCompactionBody(outBody); } // Run after routed compaction so nested input_image parts are replaced before a malformed diff --git a/src/adapters/openai-responses/transport.ts b/src/adapters/openai-responses/transport.ts new file mode 100644 index 00000000000..f183e781529 --- /dev/null +++ b/src/adapters/openai-responses/transport.ts @@ -0,0 +1,81 @@ +import type { IncomingMeta } from "../base"; +import type { OcxProviderConfig } from "../../types"; +import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination"; +import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers"; +import { openaiResponsesUrl } from "../openai-responses-url"; + +// Caller credentials are forwarded only to the canonical Codex destination, never by capability. +export const FORWARD_HEADERS = [ + "authorization", "chatgpt-account-id", "openai-beta", "originator", "session_id", "session-id", + "thread-id", "x-client-request-id", "x-codex-beta-features", "x-codex-installation-id", + "x-codex-parent-thread-id", "x-codex-turn-metadata", "x-codex-turn-state", "x-codex-window-id", + "x-oai-attestation", "x-openai-subagent", "x-responsesapi-include-timing-metrics", CODEX_RESPONSES_LITE_HEADER, +]; + +/** Carry permitted client metadata across an internal compact handoff, never credentials. */ +export function copyResponsesClientMetadata(target: Headers, incoming: Headers, provider: OcxProviderConfig): void { + const configured = Array.isArray(provider.forwardClientHeaders) ? provider.forwardClientHeaders.slice(0, 64) : []; + for (const rawName of ["user-agent", ...configured]) { + const name = normalizeForwardedClientHeaderName(rawName); + const value = name === null ? null : incoming.get(name); + if (name !== null && value && !target.has(name)) target.set(name, value); + } +} + +/** Shared Responses URL/auth/header construction; capabilities never grant auth authority. */ +export function buildResponsesTransport(provider: OcxProviderConfig, incoming: Pick): { + url: string; headers: Record; +} { + const headers: Record = { "Content-Type": "application/json" }; + let url: string; + if (provider.authMode === "forward") { + const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider); + const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : provider.baseUrl.replace(/\/+$/, ""); + url = `${baseUrl}/responses`; + if (provider.headers) Object.assign(headers, provider.headers); + const runtimeProvider = provider as { + _codexAccountOverride?: { accessToken: string; chatgptAccountId: string }; + _codexAccountRequired?: boolean; + }; + if (mayForwardCallerCredentials && runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride) { + throw new Error("Codex pool account auth is required but unavailable"); + } + if (mayForwardCallerCredentials) { + for (const name of FORWARD_HEADERS) { + const value = incoming.headers.get(name); + if (!value) continue; + if (name === CODEX_RESPONSES_LITE_HEADER) { + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === name) delete headers[existing]; + } + } + headers[name] = value; + } + } + const override = runtimeProvider._codexAccountOverride; + if (override && mayForwardCallerCredentials) { + headers.authorization = `Bearer ${override.accessToken}`; + headers["chatgpt-account-id"] = override.chatgptAccountId; + } + } else { + url = provider.responsesPath === undefined + ? openaiResponsesUrl(provider.baseUrl) + : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`; + if (provider.apiKey) headers.Authorization = `Bearer ${provider.apiKey}`; + if (provider.headers) Object.assign(headers, provider.headers); + } + if (Array.isArray(provider.forwardClientHeaders)) { + for (const rawName of provider.forwardClientHeaders.slice(0, 64)) { + const name = normalizeForwardedClientHeaderName(rawName); + if (name === null || Object.keys(headers).some(existing => existing.toLowerCase() === name)) continue; + const value = incoming.headers.get(name); + if (value) headers[name] = value; + } + } + if (!Object.keys(headers).some(name => name.toLowerCase() === "user-agent")) { + const userAgent = incoming.headers.get("user-agent"); + if (userAgent) headers["User-Agent"] = userAgent; + } + return { url, headers }; +} diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index a0f8b861fa6..2540a0d11f7 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -335,6 +335,8 @@ export const providerConfigSchema = z.object({ dropResponsesReasoningItems: z.boolean().optional(), modelReasoningEffortsAuthoritative: z.boolean().optional(), decodesNativeCompactionBlobs: z.boolean().optional(), + supportsNativeCompactionTrigger: z.boolean().optional(), + supportsNativeCompactEndpoint: z.boolean().optional(), allowEncryptedV2AgentTasks: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), // Per-provider egress (#2894): absent inherits the global proxy decision, "direct"/null diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 5bd40b69b4b..df4b32f26b0 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -35,6 +35,8 @@ export const PROVIDER_MODEL_RENAME_ROLES = { modelReasoningEffortsAuthoritative: "none", dropResponsesReasoningItems: "none", decodesNativeCompactionBlobs: "none", + supportsNativeCompactionTrigger: "none", + supportsNativeCompactEndpoint: "none", allowEncryptedV2AgentTasks: "none", allowPrivateNetwork: "none", proxy: "none", diff --git a/src/providers/openai-tiers-destination.ts b/src/providers/openai-tiers-destination.ts index a4a8e3f7f97..13b11d38061 100644 --- a/src/providers/openai-tiers-destination.ts +++ b/src/providers/openai-tiers-destination.ts @@ -67,19 +67,26 @@ function isOfficialOpenAiResponsesDestination(provider: OcxProviderConfig): bool /** * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT * backend can, and so can the official OpenAI API — but an arbitrary gateway that - * merely speaks the Responses wire cannot, and calling it there fails compaction - * with an unhelpful error instead of falling back to a routed summary (#422). + * merely speaks the Responses wire cannot without an explicit capability opt-in (#422). + * This is a protocol claim, not OpenAI identity or permission to forward caller credentials. */ export function supportsNativeResponsesCompactEndpoint( providerName: string, provider: OcxProviderConfig, ): boolean { if (isCanonicalOpenAiForwardProvider(provider)) return true; + if (provider.adapter === "openai-responses" && provider.supportsNativeCompactEndpoint === true) return true; return providerName === OPENAI_API_PROVIDER_ID && provider.adapter === "openai-responses" && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; } +/** Native v2 is independent of the v1 endpoint and ciphertext replay capabilities. */ +export function supportsNativeResponsesCompactionTrigger(provider: OcxProviderConfig): boolean { + return isCanonicalOpenAiForwardProvider(provider) + || (provider.adapter === "openai-responses" && provider.supportsNativeCompactionTrigger === true); +} + /** * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex * surface or the official OpenAI API. diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 9b33d6e573d..ad391481901 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -3,7 +3,7 @@ import { OPENAI_PROVIDER_TIER_VERSION } from "../types"; import { openaiResponsesUrl } from "../adapters/openai-responses-url"; import { MAX_COST4_RATE } from "../usage/expected-prices"; import { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "./openai-tiers-destination"; -export { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint, isOpenAiOperatedResponsesDestination, destinationDecodesNativeCompactionBlob } from "./openai-tiers-destination"; +export { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint, supportsNativeResponsesCompactionTrigger, isOpenAiOperatedResponsesDestination, destinationDecodesNativeCompactionBlob } from "./openai-tiers-destination"; const LEGACY_OPENAI_MULTI_PREFIX = `${LEGACY_OPENAI_MULTI_PROVIDER_ID}/`; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d4eadc85a99..bade1f38e29 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1009,6 +1009,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { dropResponsesReasoningItems: "editor", modelReasoningEffortsAuthoritative: "editor", decodesNativeCompactionBlobs: "editor", + supportsNativeCompactionTrigger: "editor", + supportsNativeCompactEndpoint: "editor", allowEncryptedV2AgentTasks: "editor", allowPrivateNetwork: "editor", // A proxy URL routinely embeds `user:password@`, so it never reaches the dashboard DTO and diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 592c7583093..4baaf13f70e 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -123,6 +123,11 @@ import { import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; +import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; +import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields } from "../../adapters/openai-responses/request-strips"; +import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; +import { bindRouteReasoningReplayScope } from "./core-replay"; +import { commitReasoningReplayServingIdentity } from "../../responses/reasoning-replay-cache"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { isCodexReserveOptInMissing, @@ -147,7 +152,8 @@ import { UnsupportedContentEncodingError, } from "../request-decompress"; import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; -import { hasKeyPoolFailover, rotateProviderTransportOn429, selectProactiveApiKeyTransport } from "../../providers/key-failover"; +import { hasKeyPoolFailover, rotateProviderTransportOn429, selectProactiveApiKeyTransport, transientRetryPolicyFor } from "../../providers/key-failover"; +import { transientSendCapFor } from "./request-send-budget"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { resolveProviderTransport } from "../../providers/xai-transport"; import type { WsData } from "../ws-bridge"; @@ -197,7 +203,7 @@ import { } from "./core"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; -import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation"; +import { linkRequestSessionLane, sessionLaneIdFromRequest, reasoningReplayConversationIdFromResponsesRequest, sessionIdHeaderFromRequest } from "../request-log-conversation"; import { recallComboForLane } from "./combo-session-recall"; import { redactHostedImageDisplayPaths } from "../responses-hosted-image-display"; @@ -714,7 +720,8 @@ export async function handleResponsesCompact( // reads the account namespace as a routed provider prefix — so keying on `raw.model` sent // exactly the selector form back down the native compact endpoint this guard exists to avoid. // `route.modelId` is the same value `applyCodexAccountGatedWireNormalization` uses in core.ts. - const accountGatedCompactWireModel = codexAccountGatedCanonicalWireModel(selectedModelId); + const accountGatedCompactWireModel = isCanonicalOpenAiForwardProvider(route.provider) + ? codexAccountGatedCanonicalWireModel(selectedModelId) : undefined; logCtx.requestedModel = compactRequestedModel; logCtx.model = selectedModelId; logCtx.routeDecision = route.routeDecision; @@ -793,8 +800,8 @@ export async function handleResponsesCompact( } // Native /responses/compact exists on the canonical ChatGPT backend and on the - // official OpenAI API. Any other Responses-shaped gateway must take the routed - // summarizer path below, or compaction fails against an endpoint it never had (#422). + // official OpenAI API, or a gateway explicitly declaring the native endpoint. Other + // Responses-shaped gateways still take the routed summarizer path (#422). // Combo-resolved targets skip native compact so failover can advance through the // combo target list when the picked model returns 429/5xx — the routed path below // dispatches through handleResponses → handleComboResponses with full failover. @@ -833,7 +840,7 @@ export async function handleResponsesCompact( compactHostAdmissionLease = admission.lease; } try { - // Native ChatGPT/OpenAI model: forward the compact request verbatim to the real backend. + // Native-capable destination: forward the compact request to its configured backend. // Resolve the SAME pool/thread auth context as /v1/responses — forwarding the caller's raw // headers would run compaction on the wrong account (or 401) whenever a pool account is // active for this thread while normal turns succeed. @@ -939,7 +946,28 @@ export async function handleResponsesCompact( }); } } - const compactUrl = `${base}/responses/compact`; + // Gateway protocol capability never grants official credential authority. Reuse the normal + // Responses transport for custom paths, static auth and explicitly forwarded client metadata. + const gatewayTransport = !isCanonicalOpenAiForwardProvider(compactProvider) + && compactProvider.supportsNativeCompactEndpoint === true + ? buildResponsesTransport({ ...compactProvider, apiKey: compactProvider.apiKey ? resolveProviderApiKey(compactProvider.apiKey) : undefined }, { headers: req.headers }) + : undefined; + if (gatewayTransport) headers = new Headers(gatewayTransport.headers); + // Native v1 bypasses the adapter serializer. Bind the same replay provenance as ordinary + // turns, but leave canonical compact's established account-state handling unchanged. + const gatewayReplayParsed = gatewayTransport ? parseRequest(raw) : undefined; + if (gatewayReplayParsed) { + gatewayReplayParsed.modelId = route.modelId; + gatewayReplayParsed._clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({ + clientThreadId: gatewayReplayParsed._clientThreadId, + threadIdHeader: req.headers.get("thread-id"), + cursorConversationId: gatewayReplayParsed._cursorConversationId, + sessionIdHeader: sessionIdHeaderFromRequest(req.headers), + }); + if (clientThreadId) gatewayReplayParsed._reasoningReplayScope = { clientThreadId }; + } + const compactUrl = gatewayTransport ? `${gatewayTransport.url.replace(/\/+$/, "")}/compact` : `${base}/responses/compact`; const compactTargetKey = `${route.providerName}|${route.modelId}|compact`; const actualCompactHostKey = upstreamHostHealthKey( route.providerName, @@ -1043,12 +1071,25 @@ export async function handleResponsesCompact( recovery: "normal" | "single", sendAuthCtx: CodexAuthContext, ): Promise => { + if (gatewayReplayParsed) bindRouteReasoningReplayScope({ + parsed: gatewayReplayParsed, providerName: route.providerName, provider: sendProvider, + adapterName: sendProvider.adapter, codexAuthContext: sendAuthCtx, forwardHeaders: sendHeaders, + }); + const replayBody = gatewayReplayParsed ? sanitizeReasoningInputContent(scrubOcxCompactionItems( + compactBody, destinationDecodesNativeCompactionBlob(sendProvider), + gatewayReplayParsed._stripReasoningEncryptedContent === true, + ), { + stripEncryptedContent: gatewayReplayParsed._stripReasoningEncryptedContent === true, + dropForeignItemId: gatewayReplayParsed._dropForeignReasoningItemIds === true, + }) : compactBody; + const sendBody = isOpenAiOperatedResponsesDestination(sendProvider) + ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody); const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( compactUrl, applyUpstreamRecoveryInit({ method: "POST", headers: sendHeaders, - body: JSON.stringify({ ...compactBody, model: route.modelId }), + body: JSON.stringify({ ...sendBody as typeof compactBody, model: route.modelId }), }, upstreamRecovery), req.signal, connectMs, @@ -1073,9 +1114,11 @@ export async function handleResponsesCompact( : fetchWithTransientRetry(doFetch, { abortSignal: req.signal, label: safeHostLabel(compactUrl), - // Draws the shared remainder instead of a fresh three. Compact is a native endpoint - // of the same logical turn, so its sends belong to the same cap. - attempts: sendBudget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS), + // Key-auth policy is a logical-request total; canonical forward keeps its default + // ladder. Both intersect the same shared base remainder as ordinary Responses. + attempts: sendBudget.remainingBaseSends(transientSendCapFor( + transientRetryPolicyFor(sendProvider)?.attempts, sendBudget.used, + )), onSendsConsumed: (used: number) => { sendBudget.used += Math.max(0, used); }, }); }; @@ -1398,6 +1441,7 @@ export async function handleResponsesCompact( // request log; the routed branch gets the same through handleResponses. The // synthetic buffer errors are not upstream bodies and stay uninspected. if (buffered.ok) { + commitReasoningReplayServingIdentity(gatewayReplayParsed?._reasoningReplayScope); inspectResponseLogJson(logCtx, await buffered.clone().text()); if (!options.compactionRoutingOverride) forgetCompactHandoffRoute(req, admission); rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); @@ -1442,8 +1486,8 @@ export async function handleResponsesCompact( const inputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : []; const internalBody = { ...raw, - // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the - // native compact endpoint either, so run its synthetic compaction as SSE and collapse + // Canonical ChatGPT Responses rejects non-streaming turns. Its Daybreak route cannot use + // the native compact endpoint either, so run its synthetic compaction as SSE and collapse // the completed event back into the v1 compact JSON contract below. Combo-dispatched // turns also go out as SSE: failover can land on a canonical child that rejects a // non-streaming turn, and every combo-capable provider already serves streaming traffic. @@ -1455,6 +1499,9 @@ export async function handleResponsesCompact( const value = req.headers.get(name); if (value) internalHeaders.set(name, value); } + // Preserve the client's fingerprint across the native-404 handoff. This internal copy does + // not grant credential forwarding: the selected adapter still owns the outbound auth gate. + copyResponsesClientMetadata(internalHeaders, req.headers, route.provider); const internalReq = new Request("http://localhost/v1/responses", { method: "POST", headers: internalHeaders, diff --git a/src/server/responses/compaction-recovery.ts b/src/server/responses/compaction-recovery.ts index 5503518b833..b88a4cb33bb 100644 --- a/src/server/responses/compaction-recovery.ts +++ b/src/server/responses/compaction-recovery.ts @@ -7,7 +7,7 @@ import { readBoundedResponseBytes } from "../../lib/bounded-body"; import { isRequestExecutionBudget, type RequestExecutionBudget, type SingleUseDispatchPermit } from "../../lib/request-execution-budget"; import { isNonReplayableResponse, isNonReplayableUpstreamCode, markResponseNonReplayable, TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry"; import { isCyberPolicyCode, isTerminalRefusalCode } from "../../lib/errors"; -import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint, supportsNativeResponsesCompactionTrigger } from "../../providers/openai-tiers"; import { bridgeToResponsesSSE, formatErrorResponse } from "../../bridge"; import { buildCompactV1Output, decodeCompactionSummary, encodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { finishRequestAttempt, usageFromResponsesPayload, type RequestLogContext } from "../request-log"; @@ -60,10 +60,12 @@ function portableBody(body: Record): boolean { return selfContainedResponsesBody({ ...body, store: false, input }); } -function routed(route: RouteResult): boolean { +function routed(route: RouteResult, kind: "compaction-v1" | "compaction-v2"): boolean { return !route.combo && route.routeKind !== "policy" && route.routeReason !== "default-provider" && !isCanonicalOpenAiForwardProvider(route.provider) - && !supportsNativeResponsesCompactEndpoint(route.providerName, route.provider); + && !(kind === "compaction-v1" + ? supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) + : supportsNativeResponsesCompactionTrigger(route.provider)); } /** One reader, bounded bytes, and an exact replacement body; never clone a live stream. */ @@ -89,6 +91,7 @@ export async function runWithCompactionRecovery( req: Request, config: OcxConfig, logCtx: RequestLogContext, options: Options, dispatch: Dispatch, ): Promise { const recovery = readCompactionRecoveryConfig(config.compactionRecovery); + const kind = options.compactionRecoveryKind ?? "compaction-v2"; if (!recovery || options.compactionRecoveryAttempted || options.comboAttempt || (options.inboundWire && options.inboundWire !== "responses")) { return dispatch(req, config, logCtx, options); } @@ -132,7 +135,7 @@ export async function runWithCompactionRecovery( }, onCompactionRecoveryRoute(route) { options.onCompactionRecoveryRoute?.(route); - if (snapshot && routed(route)) sourceRoute = { ...route }; + if (snapshot && routed(route, kind)) sourceRoute = { ...route }; }, onCompactionRecoveryAdapterEvent(event) { options.onCompactionRecoveryAdapterEvent?.(event); @@ -151,7 +154,7 @@ export async function runWithCompactionRecovery( if (!snapshot || !sourceRoute || signal.aborted || req.signal.aborted || isNonReplayableResponse(response)) return keep(response); let target: RouteResult; try { target = routeConcreteModel(config, recovery.model); } catch { return keep(response); } - if (!routed(target) || identity(sourceRoute) === identity(target)) return keep(response); + if (!routed(target, kind) || identity(sourceRoute) === identity(target)) return keep(response); const originalModel = firstOptions.compactionRoutingOverride?.sourceModel ?? String(snapshot.model); // Use the established protocol commit boundary. For runTurn streams the direct event // observer additionally preserves side-effect heartbeats that the bridge does not publish. diff --git a/src/server/responses/core-auth.ts b/src/server/responses/core-auth.ts index 2f0833a9fcd..4734debe617 100644 --- a/src/server/responses/core-auth.ts +++ b/src/server/responses/core-auth.ts @@ -43,6 +43,7 @@ import { codexAuthContextLogLabel } from "../../codex/account-label"; import { forceRefreshMainAccountToken } from "../../codex/main-account"; import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy"; import { safeCallerSessionId } from "../caller-session-identity"; +import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; /** Normalize caller aliases only on native egress; preserve ingress and fallback identity. */ export function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers { @@ -335,6 +336,11 @@ export async function resolveResponsesCodexAuth( headers.delete("chatgpt-account-id"); } } + // Codex auth materialization keeps only its credential allowlist. A compact-v1 handoff + // must also retain the permitted client fingerprint for its routed summarizer send. + if (options.compactionRecoveryKind === "compaction-v1") { + copyResponsesClientMetadata(headers, authInputHeaders, route.provider); + } return { ok: true, authCtx, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index df7e5e39c6e..e4eb4ab31b6 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -641,6 +641,11 @@ export async function prepareResponsesRequest( parsed._stripReasoningEncryptedContent = true; if (parsed._compactionRequest === true) parsed._portableCompaction = true; } + // A v1 fallback needs an ocx1 summary for replacement-history decoding, even when this + // custom destination separately accepts native v2 triggers. Legacy defaults stay intact. + if (options.compactionRecoveryKind === "compaction-v1" && parsed._compactionRequest === true + && route.provider.adapter === "openai-responses" && route.provider.supportsNativeCompactionTrigger === true + && !isCanonicalOpenAiForwardProvider(route.provider)) parsed._portableCompaction = true; logCtx.routeDecision = route.routeDecision; logCtx.policyEligibility = route.policyEligibility; } catch (err) { diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index f733bb95344..63a00d9655e 100644 --- a/src/server/responses/request-sidecar-auth.ts +++ b/src/server/responses/request-sidecar-auth.ts @@ -2,7 +2,7 @@ import type { ResponsesRequestContext } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesTransport } from "./request-transport"; import type { ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactionTrigger } from "../../providers/openai-tiers"; import { omitEarlierCompactionImages } from "../../responses/compaction-images"; import { shouldResolveOpenAiVisionSidecar, @@ -51,7 +51,7 @@ export async function prepareResponsesSidecarAuth( let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined; const visionDescribeTerminal = options.visionDescribeTerminal === true; const routedCompaction = parsed._compactionRequest === true - && (!isCanonicalOpenAiForwardProvider(route.provider) || parsed._portableCompaction === true); + && (!supportsNativeResponsesCompactionTrigger(route.provider) || parsed._portableCompaction === true); if (routedCompaction) parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages); const needsOpenAiVision = !visionDescribeTerminal && shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName); diff --git a/src/types/provider.ts b/src/types/provider.ts index 5f13ce36d7f..062b2ba75b0 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -448,6 +448,10 @@ export interface OcxProviderConfig { * compaction blobs. Absent or false degrades foreign blobs to an opaque note. */ decodesNativeCompactionBlobs?: boolean; + /** Accept native Responses v2 `input` compaction_trigger items. Responses adapter only. */ + supportsNativeCompactionTrigger?: boolean; + /** Serve native Responses v1 /responses/compact. Responses adapter only; no auth authority. */ + supportsNativeCompactEndpoint?: boolean; /** * Trust this direct key-auth Responses provider to consume or relay opaque encrypted * V2 agent tasks. OpenCodex does not decrypt, translate, or recover an eligible task. diff --git a/structure/config.md b/structure/config.md index 211f6708d9f..6828f0535e0 100644 --- a/structure/config.md +++ b/structure/config.md @@ -27,7 +27,7 @@ The Codex restart command follows the [CLI restart scope contract](runtime.md#cl the [source-owned credential contract](codex-home.md#orca-source-owned-account-import) governs deduplication and credential storage separately from Codex config injection. ## Config surface - +Custom Responses providers may declare independent boolean `supportsNativeCompactionTrigger` and `supportsNativeCompactEndpoint` capabilities in `src/config/schema/leaf-validators.ts`; absence remains off. They do not imply `decodesNativeCompactionBlobs`, official identity, or credential authority; custom native v1 replay enforces the separate blob capability and known serving-identity changes. The native gateway contract and regression are in [Responses transport](transports/responses.md) and `tests/responses/native-compaction-gateway.test.ts`. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ca6d53f18da..350e6b707cc 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -198,6 +198,7 @@ rewrite rules and the routed-id settlement. | `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | +| `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | @@ -228,8 +229,7 @@ and valid padded or unpadded payloads pass unchanged without a decoding allocati Kiro metering uses the [provider credit contract](providers/kiro.md#kiro-reasoning-round-trip-signature); `src/types/request.ts` keeps reported credits separate from estimated token usage. -Adapter output must stay in internal `AdapterEvent` form until `src/bridge/sse.ts` converts it back -to Responses SSE or WebSocket frames, or `src/bridge/response-json.ts` buffers it into a JSON +Adapter output must stay in internal `AdapterEvent` form until `src/bridge/sse.ts` converts it back to Responses SSE or WebSocket frames, or `src/bridge/response-json.ts` buffers it into a JSON response. `src/bridge.ts` is the compatibility facade that re-exports both. `src/adapters/anthropic.ts` preserves finite nonnegative `output_tokens_details.thinking_tokens` within the inclusive output total as `reasoningOutputTokens`, including final cumulative SSE usage. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index f0a337e3a41..96030a72066 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -14,7 +14,7 @@ and translation budgets. Physical-send receipts and explicit retry-helper report fetch sends without double charging external reservations; one prepaid emergency permit is shared with adapter dispatch, and only additional retries draw from the remainder. The emergency target's configured initial allowance is intersected with that shared remainder plus its prepaid send; source-provider sends are not deducted from the emergency target's allowance a second time. Adapter observers retain partial-output and structured denial evidence before response projection. Native encrypted compaction, uploaded files, stored continuations, -and policy/combo routes are excluded. Emergency output must contain one readable portable +and policy/combo routes are excluded; native capability is checked independently for v1 endpoints and v2 triggers, including explicitly opted-in custom gateways. Emergency output must contain one readable portable compaction item; recent original user messages are retained verbatim, and recovery failure keeps the original failure. A source Kiro account lease is returned before the emergency child is admitted: the child shares its holder and may select the same account, so replacing that holder @@ -340,9 +340,9 @@ endpoint. A mismatch marks the credential domain as rewritten, exactly like a sh intercept, and forces the portable summarizer even for a native-capable target: `compact.ts` skips `/responses/compact`, and `request-prepare.ts` sets `parsed._portableCompaction`, which `request-sidecar-auth.ts` (`routedCompaction`) and the passthrough adapter's compaction body -build both honor for canonical ChatGPT destinations. Native ciphertext is replayable only by the +build both honor for canonical ChatGPT and explicitly native-capable custom destinations. Native ciphertext is replayable only by the backend that minted it; the conversation model would otherwise resume with an omission marker -in place of its history. +in place of its history. Native v1 key-auth compact reads `transientRetryPolicyFor` and `transientSendCapFor` like ordinary Responses: configured attempts count total physical sends including prior legs, intersected with the shared request remainder; absent/disabled policies and canonical forward retain their existing ladder. `tests/responses/native-compaction-gateway.test.ts` pins the counts. `tests/responses/responses-compaction-override.test.ts` covers source filtering, trigger selection, config validation, native and routed handlers, credential retention, portable summaries and replay, and combo failover. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index dc5434d5337..aac799f841f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -190,8 +190,7 @@ turn is routed. A gateway that validates its top-level schema rejects the reques Console Go answers with an unknown-parameter error naming the field, and every turn of that thread fails (#4853). The key is scoped by DESTINATION rather than by the canonical surface, because `src/server/responses/compact.ts` spreads the caller's raw body into the native -`/responses/compact` request without passing through this adapter, and that endpoint is offered -only to OpenAI-operated destinations; stripping on the canonical predicate would make +`/responses/compact` request without passing through this adapter and applies the same destination strip: official destinations keep it, including native v1; opted-in custom gateways do not. Stripping on the canonical predicate would make `openai-apikey` behave differently on its two endpoints. This table is not an unknown-parameter sanitizer, and the distinction is the point. It lists keys a @@ -330,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) - +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` pins these boundaries. 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0c03e737f8f..adc134237f3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -901,6 +901,7 @@ "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", diff --git a/tests/responses/native-compaction-gateway.test.ts b/tests/responses/native-compaction-gateway.test.ts new file mode 100644 index 00000000000..8550565d5cc --- /dev/null +++ b/tests/responses/native-compaction-gateway.test.ts @@ -0,0 +1,446 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses/passthrough"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import { completedPayload } from "../helpers/compaction-routing-fixtures"; +import { providerEditorConfigDTO, parseProviderEditorConfigDTO } from "../../src/server/auth-cors"; +import { getDefaultConfig } from "../../src/config"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { baseCompactionBody, compactionRequest, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { providerConfigSchema } from "../../src/config/schema/leaf-validators"; +import * as destinations from "../../src/providers/openai-tiers-destination"; +import type { OcxProviderConfig } from "../../src/types"; +import { compactionItemToText, encodeCompactionSummary } from "../../src/responses/compaction"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; + +const gateway: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "key", + apiKey: "fixture-native-gateway", +}; + +const nativeBlob = "gateway-native-compaction-fixture"; +const compactItem = { type: "compaction", encrypted_content: nativeBlob }; +const nativeProvider = { ...gateway, supportsNativeCompactionTrigger: true, decodesNativeCompactionBlobs: true }; + +async function serialized(provider: OcxProviderConfig, portable = false, changed = false) { + const input = [{ type: "message", role: "user", content: "Keep the task." }, compactItem, { type: "compaction_trigger" }]; + const raw = baseCompactionBody({ model: "some-model", input, store: false }); + const request = await createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "some-model", context: { messages: [] }, options: {}, stream: false, + _rawBody: raw, _compactionRequest: true, _portableCompaction: portable, + _stripReasoningEncryptedContent: changed, + }, { headers: new Headers(), translatorBudget: createTranslatorBudget() }); + return JSON.parse(request.body); +} + +describe("native gateway transport", () => { + test("preserves the trigger, declared functions and explicit same-provider blob replay", async () => { + const body = await serialized(nativeProvider); + expect(body.input).toContainEqual({ type: "compaction_trigger" }); + expect(body.input).toContainEqual(compactItem); + expect(body.tools).toMatchObject([{ type: "function", name: "shell" }]); + expect(body.tool_choice).toBe("auto"); + }); + + test("portable override still summarizes and serving-identity changes still strip ciphertext", async () => { + const portable = await serialized(nativeProvider, true); + expect(portable.input).not.toContainEqual({ type: "compaction_trigger" }); + expect(portable.tools).toBeUndefined(); + expect((await serialized(nativeProvider, false, true)).input).not.toContainEqual(compactItem); + expect((await serialized({ ...nativeProvider, decodesNativeCompactionBlobs: false })).input).not.toContainEqual(compactItem); + expect((await serialized({ ...gateway, supportsNativeCompactEndpoint: true })).tools).toBeUndefined(); + }); + + let home: string; + let previousHome: string | undefined; + let release: (() => void) | undefined; + const originalFetch = globalThis.fetch; + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-native-compact-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + }); + afterEach(async () => { + release?.(); + globalThis.fetch = originalFetch; + await removeCompactionFixture(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }); + + test("handler selects native passthrough instead of synthesizing ocx1 output", async () => { + let sent: Record = {}; + globalThis.fetch = (async (_url, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ id: "resp_gateway", status: "completed", output: [compactItem] }); + }) as typeof fetch; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: nativeProvider } }; + const response = await handleResponses(compactionRequest(baseCompactionBody()), config, { model: "", provider: "" }); + const payload = await response.json(); + expect({ status: response.status, payload }).toMatchObject({ status: 200, payload: { output: [compactItem] } }); + expect(sent.input).toContainEqual({ type: "compaction_trigger" }); + expect(sent.tools).toMatchObject([{ type: "function", name: "shell" }]); + expect(payload.output).toEqual([compactItem]); + }); + + test.each([ + { authMode: "key" as const, baseUrl: "https://gateway.example/v1/responses/", path: "/v1/responses/compact" }, + { authMode: "key" as const, baseUrl: "https://gateway.example", responsesPath: "/custom/responses", path: "/custom/responses/compact" }, + { authMode: "forward" as const, baseUrl: "https://gateway.example/relay", path: "/relay/responses/compact" }, + ])("v1 uses custom transport URL and static auth: $authMode $path", async row => { + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(new Request(url, init)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const provider = { ...gateway, ...row, supportsNativeCompactEndpoint: true, + headers: { Authorization: "Bearer static-fixture", "x-gateway-fixture": "native-compact" }, + forwardClientHeaders: ["originator"], + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody(), undefined, { + authorization: "Bearer caller-fixture", "chatgpt-account-id": "caller-account-fixture", + originator: "gateway-client-fixture", + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect((await response.json()).output).toEqual([compactItem]); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).pathname).toBe(row.path); + expect(calls[0]!.headers.get("authorization")).toBe("Bearer static-fixture"); + expect(calls[0]!.headers.get("x-gateway-fixture")).toBe("native-compact"); + expect(calls[0]!.headers.get("originator")).toBe("gateway-client-fixture"); + expect(calls[0]!.headers.has("chatgpt-account-id")).toBe(false); + expect((await calls[0]!.json()).model).toBe("some-model"); + }); + + test.each([ + { replay: undefined, blob: nativeBlob }, + { replay: true, blob: encodeCompactionSummary("Portable retained task fixture.") }, + { replay: true, blob: nativeBlob }, + ])("v1 normalizes replay independently of endpoint capability: $replay $blob", async row => { + let sent: Record = {}; + globalThis.fetch = (async (_url, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const provider = { ...gateway, supportsNativeCompactEndpoint: true, + ...(row.replay === undefined ? {} : { decodesNativeCompactionBlobs: row.replay }), + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const input = [{ type: "compaction", encrypted_content: row.blob }]; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ input })), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(sent.input).toEqual(row.replay && row.blob === nativeBlob ? input : [{ + type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(row.blob) }], + }]); + await response.text(); + }); + + test.each([{ change: "model" }, { change: "destination" }, { change: "credential" }])("v1 refuses native replay after a known serving $change change", async ({ change }) => { + const calls: Record[] = []; + globalThis.fetch = (async (_url, init) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json(completedPayload("Task fixture.")); + }) as typeof fetch; + const provider = { ...nativeProvider, supportsNativeCompactEndpoint: true }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const headers = { "thread-id": `v1-replay-${change}-fixture` }; + const first = await handleResponses(compactionRequest(baseCompactionBody({ input: [{ role: "user", content: "Task fixture." }] }), undefined, headers), config, { model: "", provider: "" }); + expect(first.status).toBe(200); + await first.text(); + if (change === "destination") provider.baseUrl = "https://other-gateway.example/v1"; + if (change === "credential") provider.apiKey = "different-key-fixture"; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ + model: change === "model" ? "gw/other-model" : "gw/some-model", input: [compactItem], + }), undefined, headers), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(calls).toHaveLength(2); + expect(calls[1]!.input).toEqual([{ + type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(nativeBlob) }], + }]); + await response.text(); + }); + + test.each([true, false])("v1 shares parent-thread replay provenance with v2: child header %s", async withChild => { + const calls: Record[] = []; + globalThis.fetch = (async (_url, init) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json({ id: "resp_parent_fixture", status: "completed", output: [compactItem] }); + }) as typeof fetch; + const provider = { ...nativeProvider, supportsNativeCompactEndpoint: true }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const headers = { "x-codex-parent-thread-id": ` parent-replay-${withChild}-fixture `, + ...(withChild ? { "thread-id": "child-replay-fixture" } : {}), session_id: "shared-cohort-fixture" }; + const first = await handleResponses(compactionRequest(baseCompactionBody(), undefined, headers), config, { model: "", provider: "" }); + expect((await first.json()).output).toEqual([compactItem]); + provider.apiKey = "parent-key-b-fixture"; + const compact = await handleResponsesCompact(compactionRequest(baseCompactionBody({ input: [compactItem] }), undefined, headers), config, { model: "", provider: "" }); + expect((await compact.json()).output).toEqual([compactItem]); + expect(calls[1]!.input).toEqual([{ + type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(nativeBlob) }], + }]); + // A successful v1 under B must update the same scope ordinary Responses reads. + const ordinary = await handleResponses(compactionRequest(baseCompactionBody({ input: [compactItem] }), undefined, headers), config, { model: "", provider: "" }); + expect((await ordinary.json()).output).toEqual([compactItem]); + expect(calls[2]!.input).toContainEqual(compactItem); + provider.apiKey = gateway.apiKey; + const rebound = await handleResponses(compactionRequest(baseCompactionBody({ input: [compactItem] }), undefined, headers), config, { model: "", provider: "" }); + expect((await rebound.json()).output).toEqual([compactItem]); + expect(calls).toHaveLength(4); + expect(calls[3]!.input).not.toContainEqual(compactItem); + }); + + test.each([true, false])("v1 sanitizes reasoning replay after a serving credential change: %s", async changed => { + const calls: Record[] = []; + const reasoning = { type: "reasoning", id: "rs_gateway_fixture", summary: [{ type: "summary_text", text: "Retained reasoning fixture." }], encrypted_content: "reasoning-ciphertext-fixture" }; + const idOnly = { type: "reasoning", id: "rs_id_only_fixture", summary: [] }; + globalThis.fetch = (async (_url, init) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json({ id: "resp_reasoning_fixture", status: "completed", output: [reasoning, compactItem] }); + }) as typeof fetch; + const provider = { ...nativeProvider, supportsNativeCompactEndpoint: true }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const headers = { "thread-id": `reasoning-replay-${changed}-fixture` }; + const first = await handleResponses(compactionRequest(baseCompactionBody(), undefined, headers), config, { model: "", provider: "" }); + expect((await first.json()).output).toEqual([reasoning, compactItem]); + if (changed) provider.apiKey = "reasoning-key-b-fixture"; + const compact = await handleResponsesCompact(compactionRequest(baseCompactionBody({ input: [reasoning, idOnly, compactItem] }), undefined, headers), config, { model: "", provider: "" }); + expect((await compact.json()).output).toEqual([reasoning, compactItem]); + expect(calls).toHaveLength(2); + const input = calls[1]!.input as Record[]; + expect(input[0]).toEqual(changed ? { type: "reasoning", summary: reasoning.summary } : reasoning); + expect(input[1]).toEqual(changed ? { type: "reasoning", summary: [] } : idOnly); + expect(input[2]).toEqual(changed ? { + type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(nativeBlob) }], + } : compactItem); + }); + + test.each([true, false])("custom v1 strips private top-level fields with endpoint enabled %s", async native => { + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + const call = new Request(url, init); + calls.push(call.clone()); + const body = await call.json(); + if ("access_programs" in body) return Response.json({ error: { message: "Unknown access_programs fixture." } }, { status: 400 }); + return String(url).endsWith("/compact") ? Response.json({ object: "response.compaction", output: [compactItem] }) + : Response.json(completedPayload("Strict gateway retained task fixture.")); + }) as typeof fetch; + const provider = { ...gateway, supportsNativeCompactEndpoint: native }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ + access_programs: { cyber: "standard" }, unlisted_fixture_option: "preserved", + })), config, { model: "", provider: "" }); + const payload = await response.json(); + expect(response.status).toBe(200); + expect(Array.isArray(payload.output)).toBe(true); + expect(JSON.stringify(payload)).toContain(native ? nativeBlob : "Strict gateway retained task fixture."); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).pathname).toBe(native ? "/v1/responses/compact" : "/v1/responses"); + const sent = await calls[0]!.json(); + expect(sent).not.toHaveProperty("access_programs"); + expect(sent.unlisted_fixture_option).toBe("preserved"); + }); + + test.each([ + { label: "canonical", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { label: "official API", baseUrl: "https://api.openai.com/v1", authMode: "key" as const }, + ])("native v1 preserves official private top-level fields: $label", async row => { + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(new Request(url, init)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const providerName = row.authMode === "key" ? "openai-apikey" : "gw"; + const config = { ...getDefaultConfig(), defaultProvider: providerName, providers: { [providerName]: { ...gateway, ...row } } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ + model: `${providerName}/some-model`, + access_programs: { cyber: "standard" }, + }), undefined, { authorization: "Bearer official-caller-fixture" }), config, { model: "", provider: "" }); + expect((await response.json()).output).toEqual([compactItem]); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${row.baseUrl}/responses/compact`); + expect((await calls[0]!.json()).access_programs).toEqual({ cyber: "standard" }); + }); + + test.each([true, false])("custom Daybreak model uses endpoint capability, not canonical restrictions: %s", async native => { + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(new Request(url, init)); + return native ? Response.json({ object: "response.compaction", output: [compactItem] }) + : Response.json(completedPayload("Daybreak task fixture.")); + }) as typeof fetch; + const provider = { ...gateway, supportsNativeCompactEndpoint: native }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ + model: "gw/gpt-daybreak-blue-latest", stream: false, + })), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).pathname).toBe(native ? "/v1/responses/compact" : "/v1/responses"); + expect(await calls[0]!.json()).toMatchObject({ model: "gpt-daybreak-blue-latest", stream: false }); + await response.text(); + }); + + test.each(["key", "forward"] as const)("v1 404 fallback preserves fingerprint without caller credentials: %s", async authMode => { + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(new Request(url, init)); + return String(url).endsWith("/compact") ? Response.json({ error: { message: "Missing endpoint fixture." } }, { status: 404 }) + : Response.json(completedPayload("Fallback summary fixture.")); + }) as typeof fetch; + const provider = { ...gateway, authMode, supportsNativeCompactEndpoint: true, + headers: { Authorization: "Bearer static-fixture" }, + // Runtime must reject credential opt-ins even if an unvalidated row reaches dispatch. + forwardClientHeaders: ["X-Codex-App-Version", "originator", "x-client-request-id", "authorization", "chatgpt-account-id", "x-api-key", "cookie", "x-oai-attestation"], + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const fingerprint = { "user-agent": "CodexFixture/1.0", "x-codex-app-version": "fixture-version", originator: "fixture-origin", "x-client-request-id": "fixture-request" }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody(), undefined, { + ...fingerprint, authorization: "Bearer caller-fixture", "chatgpt-account-id": "caller-account-fixture", + "x-api-key": "caller-key-fixture", cookie: "caller-cookie-fixture", "x-oai-attestation": "caller-attestation-fixture", + "x-unconfigured-client": "not-forwardable-fixture", + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(calls.map(call => new URL(call.url).pathname)).toEqual(["/v1/responses/compact", "/v1/responses"]); + for (const call of calls) { + for (const [name, value] of Object.entries(fingerprint)) expect(call.headers.get(name)).toBe(value); + expect(call.headers.get("authorization")).toBe("Bearer static-fixture"); + for (const name of ["chatgpt-account-id", "x-api-key", "cookie", "x-oai-attestation", "x-unconfigured-client"]) expect(call.headers.has(name)).toBe(false); + } + await response.text(); + }); + + test.each([ + { label: "custom configured one", policy: { enabled: true, attempts: 1 }, canonical: false, used: 0, expected: 1 }, + { label: "custom configured total", policy: { enabled: true, attempts: 2 }, canonical: false, used: 1, expected: 1 }, + { label: "custom default", policy: undefined, canonical: false, used: 0, expected: 3 }, + { label: "custom disabled", policy: { enabled: false, attempts: 1 }, canonical: false, used: 0, expected: 3 }, + { label: "canonical retains ladder", policy: { enabled: true, attempts: 1 }, canonical: true, used: 0, expected: 3 }, + ])("v1 transient retry honors key-auth policy and shared budget: $label", async row => { + let sends = 0; + globalThis.fetch = (async (_url, _init) => { + sends += 1; + return Response.json({ error: { message: "Unavailable fixture." } }, { status: 503 }); + }) as typeof fetch; + const provider: OcxProviderConfig = { ...gateway, supportsNativeCompactEndpoint: true, + ...(row.canonical ? { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } : {}), + transientRetryOn5xx: row.policy, + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const sendBudget = createRequestExecutionBudget(); + sendBudget.used = row.used; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody()), config, { model: "", provider: "" }, undefined, undefined, { sendBudget }); + expect(response.status).toBe(503); + expect(sends).toBe(row.expected); + expect(sendBudget.used).toBe(row.used + row.expected); + await response.text(); + }); + + test("v2-only opt-in keeps the v1 fallback portable", async () => { + const paths: string[] = []; + let sent: Record = {}; + globalThis.fetch = (async (url, init) => { + paths.push(new URL(String(url)).pathname); + sent = JSON.parse(String(init?.body)); + return Response.json(completedPayload("Keep the task state.")); + }) as typeof fetch; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: nativeProvider } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody()), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(paths).toEqual(["/v1/responses"]); + expect(sent.input).not.toContainEqual({ type: "compaction_trigger" }); + expect(sent.tools).toBeUndefined(); + expect(JSON.stringify(await response.json())).toContain("Keep the task state."); + }); + + test.each(["responses", "compact"])("cross-provider override forces portable summarization: %s", async endpoint => { + let sent: Record = {}; + const paths: string[] = []; + globalThis.fetch = (async (url, init) => { + paths.push(new URL(String(url)).pathname); + sent = JSON.parse(String(init?.body)); + return Response.json(completedPayload("Portable summary fixture.")); + }) as typeof fetch; + const config = { ...getDefaultConfig(), defaultProvider: "source", providers: { + source: gateway, gw: { ...nativeProvider, supportsNativeCompactEndpoint: true }, + }, compactionRouting: { model: "gw/some-model" } }; + const req = compactionRequest(baseCompactionBody({ model: "source/some-model", store: false }), undefined, { + "x-codex-turn-metadata": JSON.stringify({ request_kind: "compaction", compaction: { trigger: "manual" } }), + }); + const response = await (endpoint === "compact" ? handleResponsesCompact : handleResponses)(req, config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(paths).toEqual(["/v1/responses"]); + expect(sent.input).not.toContainEqual({ type: "compaction_trigger" }); + expect(sent.tools).toBeUndefined(); + await response.text(); + }); + + test.each(["supportsNativeCompactionTrigger", "supportsNativeCompactEndpoint"] as const)("v2 recovery eligibility follows trigger capability, not endpoint: %s", async flag => { + const paths: string[] = []; + globalThis.fetch = (async (url) => { + paths.push(String(url)); + return String(url).includes("gateway.example") + ? Response.json({ error: { code: "context_length_exceeded", message: "Fixture overflow" } }, { status: 400 }) + : Response.json(completedPayload("Recovered portable fixture.")); + }) as typeof fetch; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { + gw: { ...gateway, [flag]: true }, + emergency: { ...gateway, baseUrl: "https://emergency.example/v1" }, + }, compactionRecovery: { enabled: true, model: "emergency/rescue" } }; + const response = await handleResponses(compactionRequest(baseCompactionBody({ store: false })), config, { model: "", provider: "" }); + await response.text(); + expect(paths).toHaveLength(flag === "supportsNativeCompactionTrigger" ? 1 : 2); + }); +}); + +// Independent protocol claims must never grant official identity or credential authority. +describe("native gateway compaction capabilities", () => { + test("editor projection round-trips non-secret protocol capabilities without credentials", () => { + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { + gw: { ...nativeProvider, supportsNativeCompactEndpoint: true }, + } }; + const dto = providerEditorConfigDTO(config); + expect(dto.providers.gw).toMatchObject({ supportsNativeCompactionTrigger: true, supportsNativeCompactEndpoint: true }); + expect(dto.providers.gw).not.toHaveProperty("apiKey"); + expect(parseProviderEditorConfigDTO(dto).ok).toBe(true); + }); + test("defaults remain routed and each opt-in is independent", () => { + const trigger = (destinations as unknown as { + supportsNativeResponsesCompactionTrigger: (provider: OcxProviderConfig) => boolean; + }).supportsNativeResponsesCompactionTrigger; + expect(typeof trigger).toBe("function"); + expect(trigger(gateway)).toBe(false); + expect(destinations.supportsNativeResponsesCompactEndpoint("gw", gateway)).toBe(false); + for (const adapter of ["openai-responses", "openai-chat"] as const) { + const v2 = { ...gateway, adapter, supportsNativeCompactionTrigger: true }; + const v1 = { ...gateway, adapter, supportsNativeCompactEndpoint: true }; + expect(trigger(v2)).toBe(adapter === "openai-responses"); + expect(trigger(v1)).toBe(false); + expect(destinations.supportsNativeResponsesCompactEndpoint("gw", v1)).toBe(adapter === "openai-responses"); + expect(destinations.supportsNativeResponsesCompactEndpoint("gw", v2)).toBe(false); + for (const row of [v1, v2]) { + expect(destinations.isCanonicalOpenAiForwardProvider(row)).toBe(false); + expect(destinations.isOpenAiOperatedResponsesDestination(row)).toBe(false); + expect(destinations.destinationDecodesNativeCompactionBlob(row)).toBe(false); + } + } + expect(trigger({ ...gateway, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" })).toBe(true); + expect(trigger({ ...gateway, baseUrl: "https://api.openai.com/v1" })).toBe(false); + }); + + test("schema preserves booleans and rejects unknown values rather than coercing", () => { + for (const field of ["supportsNativeCompactionTrigger", "supportsNativeCompactEndpoint"]) { + for (const value of [true, false]) { + expect(providerConfigSchema.parse({ ...gateway, [field]: value })).toHaveProperty(field, value); + } + expect(providerConfigSchema.parse(gateway)).not.toHaveProperty(field); + for (const value of ["true", "false", 0, 1, null, {}, []]) { + expect(providerConfigSchema.safeParse({ ...gateway, [field]: value }).success).toBe(false); + } + } + }); +}); From 42582674c2e30e000cca88357508ac96bccadd7c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 05:30:57 +0900 Subject: [PATCH 2/7] fix(compaction): honor custom native v1 plaintext reasoning policy --- .../src/content/docs/reference/adapters.md | 6 ++ .../docs/reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 3 +- src/server/responses/compact.ts | 9 ++- structure/transports/responses-failover.md | 4 +- structure/transports/responses.md | 2 +- .../native-compaction-gateway.test.ts | 57 ++++++++++++++++++- 7 files changed, 71 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 72598508905..986061180b6 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -173,6 +173,12 @@ continue using portable text summaries unless the operator declares the relevant - `decodesNativeCompactionBlobs: true` preserves native blobs on subsequent turns when there is no known serving-provider mismatch. Neither generation flag implies this replay capability. +Custom native v1 also honors `preserveResponsesReasoningContent: true` for plaintext reasoning +input, just like ordinary custom Responses requests. Absent or `false` keeps content blank; +OpenAI-operated compact destinations keep blanking even if the flag is set. Internal `ocxr1:` +envelopes are always removed. Plaintext preservation is independent of the generation flags, +native blob replay capability, and serving-identity checks. + For a gateway whose **v2 generation and replay** have both been verified, merge these fields into its existing provider configuration, retaining its real URL and configured authentication: diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 68c9b4b9d47..551517bcae2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -227,7 +227,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `chatServiceTier?` | `boolean` | Provider-wide Chat-wire opt-in for forwarding caller `service_tier` values. On a classified route it governs foreign values such as `flex`, not proxy-owned canonical Fast after capability validation; on an unclassified route it governs every caller value because no Fast capability has been validated. Exact model capability does not authorize foreign forwarding. Responses routes retain their capability-based caller forwarding behavior. | | `promptCacheKey?` | `boolean` | Provider-wide `openai-chat` opt-in for forwarding a `prompt_cache_key`. The adapter forwards the key it is given and never invents one, but the key is not always the caller's: Claude Messages translation derives one from `metadata.user_id`, or from a model/system/tools cohort when no metadata is sent. Default off. Enable only when the upstream documents support, because strict gateways may reject the unknown field with HTTP 400. | -| `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek or a self-hosted Kimi on vLLM. Proxy-minted `ocxr1` envelopes are always stripped. A hand-configured `openai-responses` provider without it logs one warning per host the first time plaintext reasoning is dropped. `preserveReasoningContentModels` does not affect this wire. | +| `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it, including custom native v1 compact input (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek or a self-hosted Kimi on vLLM. Proxy-minted `ocxr1` envelopes are always stripped. A hand-configured `openai-responses` provider without it logs one warning per host the first time plaintext reasoning is dropped. `preserveReasoningContentModels` does not affect this wire. | | `preserveResponsesInputItemIds?` | `boolean` | Default off. Preserve valid `input[].id` when `store: false` for explicitly trusted launcher relays that require turn identity. Ordinary Responses providers keep stripping IDs; xAI custom-call ID repair remains active. | | `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | | `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b34722c0df0..ca3bdf8ec6b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1542,8 +1542,7 @@ "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", - "responses-compaction.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 9a9aa0f52d1..04e98257105 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -919,12 +919,15 @@ export async function handleResponsesCompact( const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here - // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend. + // so canonical reasoning stays blank and custom gateways honor their explicit plaintext policy. // #5095: the compact endpoint forwards `raw` directly, so it needs the same legacy dotted // call-name repair the adapter applies. A damaged item here refuses the compaction itself, // which is the request a long task depends on to keep going. const compactBody = repairLegacyDottedToolCallNames( - sanitizeReasoningInputContent(compactBodyRaw), + sanitizeReasoningInputContent(compactBodyRaw, { + preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(compactProvider) + && compactProvider.preserveResponsesReasoningContent === true, + }), ) as typeof compactBodyRaw; { const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers)); @@ -1093,6 +1096,8 @@ export async function handleResponsesCompact( compactBody, destinationDecodesNativeCompactionBlob(sendProvider), gatewayReplayParsed._stripReasoningEncryptedContent === true, ), { + preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(sendProvider) + && sendProvider.preserveResponsesReasoningContent === true, stripEncryptedContent: gatewayReplayParsed._stripReasoningEncryptedContent === true, dropForeignItemId: gatewayReplayParsed._dropForeignReasoningItemIds === true, }) : compactBody; diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 9928cb08f1a..9ed07da65bd 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -340,9 +340,7 @@ endpoint. A mismatch marks the credential domain as rewritten, exactly like a sh intercept, and forces the portable summarizer even for a native-capable target: `compact.ts` skips `/responses/compact`, and `request-prepare.ts` sets `parsed._portableCompaction`, which `request-sidecar-auth.ts` (`routedCompaction`) and the passthrough adapter's compaction body -build both honor for canonical ChatGPT and explicitly native-capable custom destinations. Native ciphertext is replayable only by the -backend that minted it; the conversation model would otherwise resume with an omission marker -in place of its history. Native v1 key-auth compact reads `transientRetryPolicyFor` and `transientSendCapFor` like ordinary Responses: configured attempts count total physical sends including prior legs, intersected with the shared request remainder; absent/disabled policies and canonical forward retain their existing ladder. `tests/responses/native-compaction-gateway.test.ts` pins the counts. +build both honor for canonical ChatGPT and explicitly native-capable custom destinations. Native ciphertext is replayable only by the backend that minted it; the conversation model would otherwise resume with an omission marker in place of its history. Native v1 key-auth compact reads `transientRetryPolicyFor` and `transientSendCapFor` like ordinary Responses: configured attempts count total physical sends including prior legs, intersected with the shared request remainder; absent/disabled policies and canonical forward retain their existing ladder. `tests/responses/native-compaction-gateway.test.ts` pins the counts. The portable Responses summarizer in `src/adapters/openai-responses/passthrough.ts` uses `src/adapters/openai-responses/compaction-search-history.ts` to render remaining top-level `web_search_call` items, after bridge restoration, as assistant reference notes labeled untrusted historical metadata (not instructions or fetched page content). The allowlist keeps string status and action fields (type, query/queries, URL, pattern, source URL/title/type); opaque state, IDs, unknown fields and malformed values are omitted. Strings are cut at 2048 code units, lists at 20 entries, and one request carries at most 64 KiB of notes; later hosted cells collapse into a single omission note reserved inside that budget. Tool declarations stay absent, bridge-restored call/result pairs and existing messages/citations stay intact, and only the summary request changes, never stored history, ordinary turns or native compaction. A failed summary publishes no replacement history. Lite requests retain `parallel_tool_calls=false`, which the upstream requires even without tool declarations. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b3ad135eb11..6b967289f24 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` pins these boundaries. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` pins these boundaries. 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 diff --git a/tests/responses/native-compaction-gateway.test.ts b/tests/responses/native-compaction-gateway.test.ts index 8550565d5cc..e7e3509913a 100644 --- a/tests/responses/native-compaction-gateway.test.ts +++ b/tests/responses/native-compaction-gateway.test.ts @@ -194,15 +194,66 @@ describe("native gateway transport", () => { expect(calls[3]!.input).not.toContainEqual(compactItem); }); + test.each([ + { authMode: "key" as const, preserve: true }, + { authMode: "key" as const, preserve: false }, + { authMode: "key" as const, preserve: undefined }, + { authMode: "forward" as const, preserve: true }, + { authMode: "forward" as const, preserve: false }, + { authMode: "forward" as const, preserve: undefined }, + ])("custom v1 honors plaintext reasoning policy: $authMode $preserve", async row => { + let sent: Record = {}; + globalThis.fetch = (async (_url, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const provider = { ...gateway, authMode: row.authMode, supportsNativeCompactEndpoint: true, + ...(row.preserve === undefined ? {} : { preserveResponsesReasoningContent: row.preserve }), + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const content = [{ type: "reasoning_text", text: "Retained plaintext reasoning fixture." }]; + const reasoning = { type: "reasoning", summary: [], content }; + const internal = { ...reasoning, status: "completed", encrypted_content: "ocxr1:internal-signature-fixture" }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ + input: [reasoning, internal, compactItem], + })), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const expectedReasoning = { ...reasoning, content: row.preserve === true ? content : [] }; + expect(sent.input).toEqual([expectedReasoning, expectedReasoning, { + type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(nativeBlob) }], + }]); + expect(JSON.stringify(sent)).not.toContain("ocxr1:"); + await response.text(); + }); + + test.each([true, false, undefined])("canonical v1 blanks plaintext reasoning despite preservation: %s", async preserve => { + let sent: Record = {}; + globalThis.fetch = (async (_url, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const provider = { ...gateway, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const, + ...(preserve === undefined ? {} : { preserveResponsesReasoningContent: preserve }), + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const reasoning = { type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: "Canonical reasoning fixture." }] }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ input: [reasoning, { + ...reasoning, encrypted_content: "ocxr1:canonical-signature-fixture", + }] }), undefined, { authorization: "Bearer caller-fixture" }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(sent.input).toEqual([{ ...reasoning, content: [] }, { ...reasoning, content: [] }]); + await response.text(); + }); + test.each([true, false])("v1 sanitizes reasoning replay after a serving credential change: %s", async changed => { const calls: Record[] = []; - const reasoning = { type: "reasoning", id: "rs_gateway_fixture", summary: [{ type: "summary_text", text: "Retained reasoning fixture." }], encrypted_content: "reasoning-ciphertext-fixture" }; + const reasoning = { type: "reasoning", id: "rs_gateway_fixture", content: [{ type: "reasoning_text", text: "Credential-bound reasoning fixture." }], summary: [{ type: "summary_text", text: "Retained reasoning fixture." }], encrypted_content: "reasoning-ciphertext-fixture" }; const idOnly = { type: "reasoning", id: "rs_id_only_fixture", summary: [] }; globalThis.fetch = (async (_url, init) => { calls.push(JSON.parse(String(init?.body))); return Response.json({ id: "resp_reasoning_fixture", status: "completed", output: [reasoning, compactItem] }); }) as typeof fetch; - const provider = { ...nativeProvider, supportsNativeCompactEndpoint: true }; + const provider = { ...nativeProvider, supportsNativeCompactEndpoint: true, preserveResponsesReasoningContent: true }; const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; const headers = { "thread-id": `reasoning-replay-${changed}-fixture` }; const first = await handleResponses(compactionRequest(baseCompactionBody(), undefined, headers), config, { model: "", provider: "" }); @@ -212,7 +263,7 @@ describe("native gateway transport", () => { expect((await compact.json()).output).toEqual([reasoning, compactItem]); expect(calls).toHaveLength(2); const input = calls[1]!.input as Record[]; - expect(input[0]).toEqual(changed ? { type: "reasoning", summary: reasoning.summary } : reasoning); + expect(input[0]).toEqual(changed ? { type: "reasoning", content: reasoning.content, summary: reasoning.summary } : reasoning); expect(input[1]).toEqual(changed ? { type: "reasoning", summary: [] } : idOnly); expect(input[2]).toEqual(changed ? { type: "message", role: "user", content: [{ type: "input_text", text: compactionItemToText(nativeBlob) }], From 4162b668337b7aa67f18b5ae47980997c3638b52 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 05:50:00 +0900 Subject: [PATCH 3/7] fix(compaction): align custom native v1 serializer policies --- .../docs/reference/configuration/providers.md | 4 +- scripts/test-layout/layout.json | 2 +- src/server/responses/compact.ts | 13 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../native-compaction-gateway-policy.test.ts | 148 ++++++++++++++++++ 6 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/responses/native-compaction-gateway-policy.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 551517bcae2..1f9b40d68fc 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -229,8 +229,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `promptCacheKey?` | `boolean` | Provider-wide `openai-chat` opt-in for forwarding a `prompt_cache_key`. The adapter forwards the key it is given and never invents one, but the key is not always the caller's: Claude Messages translation derives one from `metadata.user_id`, or from a model/system/tools cohort when no metadata is sent. Default off. Enable only when the upstream documents support, because strict gateways may reject the unknown field with HTTP 400. | | `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it, including custom native v1 compact input (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek or a self-hosted Kimi on vLLM. Proxy-minted `ocxr1` envelopes are always stripped. A hand-configured `openai-responses` provider without it logs one warning per host the first time plaintext reasoning is dropped. `preserveReasoningContentModels` does not affect this wire. | | `preserveResponsesInputItemIds?` | `boolean` | Default off. Preserve valid `input[].id` when `store: false` for explicitly trusted launcher relays that require turn identity. Ordinary Responses providers keep stripping IDs; xAI custom-call ID repair remains active. | -| `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | -| `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | +| `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays, including custom native v1 compact requests. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | +| `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, including custom native v1 compact requests, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, an `${ENV_VAR}` / `$ENV_VAR` reference, or a `keychain:` reference written by `ocx provider keychain store`. References resolve at request time. See [Storing keys in the OS keychain](#storing-keys-in-the-os-keychain). | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ca3bdf8ec6b..de62c746938 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1542,7 +1542,7 @@ "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "responses-compaction.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 04e98257105..ee1d5e2e113 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -128,7 +128,8 @@ import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; -import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields } from "../../adapters/openai-responses/request-strips"; +import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips"; +import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning"; import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; import { bindRouteReasoningReplayScope } from "./core-replay"; import { commitReasoningReplayServingIdentity } from "../../responses/reasoning-replay-cache"; @@ -927,6 +928,7 @@ export async function handleResponsesCompact( sanitizeReasoningInputContent(compactBodyRaw, { preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(compactProvider) && compactProvider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(compactProvider), }), ) as typeof compactBodyRaw; { @@ -1092,15 +1094,22 @@ export async function handleResponsesCompact( parsed: gatewayReplayParsed, providerName: route.providerName, provider: sendProvider, adapterName: sendProvider.adapter, codexAuthContext: sendAuthCtx, forwardHeaders: sendHeaders, }); - const replayBody = gatewayReplayParsed ? sanitizeReasoningInputContent(scrubOcxCompactionItems( + let replayBody = gatewayReplayParsed ? sanitizeReasoningInputContent(scrubOcxCompactionItems( compactBody, destinationDecodesNativeCompactionBlob(sendProvider), gatewayReplayParsed._stripReasoningEncryptedContent === true, ), { preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(sendProvider), stripEncryptedContent: gatewayReplayParsed._stripReasoningEncryptedContent === true, dropForeignItemId: gatewayReplayParsed._dropForeignReasoningItemIds === true, }) : compactBody; + if (!isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.dropResponsesReasoningItems === true) { + replayBody = dropResponsesReasoningInputItems(replayBody) as typeof replayBody; + } + if (!isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.preserveResponsesMessageMetadata !== true) { + replayBody = stripInternalChatMessageMetadataPassthrough(replayBody) as typeof replayBody; + } const sendBody = isOpenAiOperatedResponsesDestination(sendProvider) ? replayBody : stripCanonicalOnlyTopLevelFields(replayBody); const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6b967289f24..b6927128ce4 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` pins these boundaries. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` and `tests/responses/native-compaction-gateway-policy.test.ts` pin these boundaries. 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 299abfadb70..6529bf0b038 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -947,7 +947,7 @@ "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", diff --git a/tests/responses/native-compaction-gateway-policy.test.ts b/tests/responses/native-compaction-gateway-policy.test.ts new file mode 100644 index 00000000000..498baa27e8d --- /dev/null +++ b/tests/responses/native-compaction-gateway-policy.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig } from "../../src/config"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import type { OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { baseCompactionBody, compactionRequest, completedPayload, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; + +const metadataKey = "internal_chat_message_metadata_passthrough"; +const message = { type: "message", role: "user", content: [{ type: "input_text", text: "Retain the policy fixture task." }] }; +const compactItem = { type: "compaction", encrypted_content: "policy-native-blob-fixture" }; +const reasoning = { type: "reasoning", summary: [{ type: "summary_text", text: "Policy summary fixture." }], content: null }; +const rows = (["ordinary", "native", "portable"] as const).flatMap(path => + (["key", "forward"] as const).flatMap(authMode => + [undefined, false, true].map(flag => ({ path, authMode, flag })))); + +describe("native compact gateway serialization policy", () => { + let home: string; + let previousHome: string | undefined; + let release: (() => void) | undefined; + const originalFetch = globalThis.fetch; + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-compact-policy-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + }); + afterEach(async () => { + release?.(); + globalThis.fetch = originalFetch; + await removeCompactionFixture(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }); + + async function send( + row: typeof rows[number], input: unknown[], policy: Partial, + rejects: (items: Record[]) => boolean, + ) { + const calls: { url: string; body: Record }[] = []; + globalThis.fetch = (async (url, init) => { + const body = JSON.parse(String(init?.body)); + calls.push({ url: String(url), body }); + if (rejects(body.input)) return Response.json({ error: { message: "Strict gateway policy fixture rejected input." } }, { status: 400 }); + return String(url).endsWith("/compact") + ? Response.json({ object: "response.compaction", output: [compactItem] }) + : Response.json(completedPayload("Retained policy summary fixture.")); + }) as typeof fetch; + const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://gateway.example/v1", apiKey: "policy-key-fixture", + authMode: row.authMode, supportsNativeCompactEndpoint: row.path === "native", + // A v2 opt-in must not prevent the v1 portable fallback from exercising ordinary serialization. + supportsNativeCompactionTrigger: true, ...policy, + }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const raw = baseCompactionBody({ input }); + const rawBefore = JSON.stringify(raw); + const request = compactionRequest(raw, undefined, { authorization: "Bearer caller-fixture" }); + const response = await (row.path === "ordinary" ? handleResponses : handleResponsesCompact)(request, config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).pathname).toBe(row.path === "native" ? "/v1/responses/compact" : "/v1/responses"); + expect(JSON.stringify(raw)).toBe(rawBefore); + return calls[0]!.body.input as Record[]; + } + + test.each(rows)("message metadata policy: $path $authMode $flag", async row => { + const metadata = { fixture_turn: "private-policy-fixture" }; + const items = await send(row, [{ ...message, [metadataKey]: metadata }], + row.flag === undefined ? {} : { preserveResponsesMessageMetadata: row.flag }, + input => row.flag !== true && input.some(item => metadataKey in item)); + expect(items[0]).toEqual(row.flag === true ? { ...message, [metadataKey]: metadata } : message); + }); + + test.each(rows)("reasoning drop policy: $path $authMode $flag", async row => { + const inputReasoning = { ...reasoning, content: [{ type: "reasoning_text", text: "Plaintext replay fixture." }], encrypted_content: "ocxr1:policy-signature-fixture" }; + const items = await send(row, [message, inputReasoning], { + preserveResponsesReasoningContent: true, decodesNativeCompactionBlobs: true, + ...(row.flag === undefined ? {} : { dropResponsesReasoningItems: row.flag }), + }, input => row.flag === true && input.some(item => item.type === "reasoning")); + expect(items[0]).toEqual(message); + expect(items.filter(item => item.type === "reasoning")).toEqual(row.flag === true ? [] : [{ + type: "reasoning", summary: reasoning.summary, content: inputReasoning.content, + }]); + }); + + test.each(rows)("null channel independent of plaintext preservation: $path $authMode $flag", async row => { + const items = await send(row, [message, reasoning, { ...reasoning, encrypted_content: "native-reasoning-policy-fixture" }, { + ...reasoning, encrypted_content: "ocxr1:policy-signature-fixture", + }], { + decodesNativeCompactionBlobs: true, + ...(row.flag === undefined ? {} : { preserveResponsesReasoningContent: row.flag }), + }, input => input.some(item => item.type === "reasoning" && "content" in item && !Array.isArray(item.content))); + expect(items.filter(item => item.type === "reasoning")).toEqual([ + { type: "reasoning", summary: reasoning.summary }, + { type: "reasoning", summary: reasoning.summary, encrypted_content: "native-reasoning-policy-fixture" }, + { type: "reasoning", summary: reasoning.summary }, + ]); + }); + + test.each(["metadata", "null", "reasoning"])("strict gateway counterfactual rejects unsanitized %s", async defect => { + const input = defect === "metadata" ? [{ ...message, [metadataKey]: { fixture_turn: "private" } }] + : defect === "null" ? [reasoning] : [{ type: "reasoning", summary: [] }]; + globalThis.fetch = (async (_url, init) => { + const body = JSON.parse(String(init?.body)); + const rejected = body.input.some((item: Record) => defect === "metadata" ? metadataKey in item + : defect === "null" ? item.content === null : item.type === "reasoning"); + return Response.json({}, { status: rejected ? 400 : 200 }); + }) as typeof fetch; + const response = await fetch("https://gateway.example/v1/responses/compact", { + method: "POST", body: JSON.stringify(baseCompactionBody({ input })), + }); + expect(response.status).toBe(400); + await response.text(); + }); + + test.each([ + { label: "canonical", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { label: "official API", baseUrl: "https://api.openai.com/v1", authMode: "key" as const }, + ].flatMap(destination => [undefined, false, true].map(flag => ({ ...destination, flag }))))( + "OpenAI compact retains existing metadata and null channel policy: $label $flag", async row => { + let sent: Record = {}; + globalThis.fetch = (async (_url, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const provider: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: row.baseUrl, authMode: row.authMode, apiKey: "canonical-key-fixture", + supportsNativeCompactEndpoint: true, + ...(row.flag === undefined ? {} : { + preserveResponsesMessageMetadata: row.flag, preserveResponsesReasoningContent: row.flag, dropResponsesReasoningItems: row.flag, + }), + }; + const input = [{ ...message, [metadataKey]: { fixture_turn: "canonical" } }, { + ...reasoning, encrypted_content: "canonical-reasoning-policy-fixture", + }]; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ input }), undefined, { + authorization: "Bearer caller-fixture", + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(sent.input).toEqual(input); + }); +}); From 1316d59f22dfe8170abcf5f1111ec48d4092bc8d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 06:04:05 +0900 Subject: [PATCH 4/7] fix(responses): apply strict plaintext replay to native compact --- .../src/content/docs/reference/adapters.md | 5 ++ src/server/responses/compact.ts | 10 ++- structure/transports/responses.md | 2 +- .../native-compaction-gateway-policy.test.ts | 79 ++++++++++++++++++- 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 986061180b6..1c0f412d80a 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -179,6 +179,11 @@ OpenAI-operated compact destinations keep blanking even if the flag is set. Inte envelopes are always removed. Plaintext preservation is independent of the generation flags, native blob replay capability, and serving-identity checks. +If the custom provider also requires adjacent tool results (`requiresAdjacentResponsesToolResults: true`), +both ordinary Responses and native v1 remove opaque reasoning ciphertext and require plaintext replay. +Missing `reasoning_text` is filled from the item's `summary_text`, or a minimal placeholder if no +usable summary exists. Existing plaintext is preserved. OpenAI-operated compact keeps its own policy. + For a gateway whose **v2 generation and replay** have both been verified, merge these fields into its existing provider configuration, retaining its real URL and configured authentication: diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index ee1d5e2e113..0dffd825d62 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -130,6 +130,7 @@ import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativ import { buildResponsesTransport, copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; import { scrubOcxCompactionItems, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough } from "../../adapters/openai-responses/request-strips"; import { dropResponsesReasoningInputItems } from "../../adapters/openai-responses/reasoning"; +import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough"; import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; import { bindRouteReasoningReplayScope } from "./core-replay"; import { commitReasoningReplayServingIdentity } from "../../responses/reasoning-replay-cache"; @@ -918,6 +919,8 @@ export async function handleResponsesCompact( headers.set("authorization", `Bearer ${resolveProviderApiKey(compactProvider.apiKey)}`); } const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown }; + const requireCompactPlaintextReasoning = !isOpenAiOperatedResponsesDestination(compactProvider) + && requiresPlaintextReasoningReplay(compactProvider); // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here // so canonical reasoning stays blank and custom gateways honor their explicit plaintext policy. @@ -929,6 +932,8 @@ export async function handleResponsesCompact( preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(compactProvider) && compactProvider.preserveResponsesReasoningContent === true, dropNullContentChannel: !isOpenAiOperatedResponsesDestination(compactProvider), + stripEncryptedContent: requireCompactPlaintextReasoning, + requirePlaintextReasoning: requireCompactPlaintextReasoning, }), ) as typeof compactBodyRaw; { @@ -1094,6 +1099,8 @@ export async function handleResponsesCompact( parsed: gatewayReplayParsed, providerName: route.providerName, provider: sendProvider, adapterName: sendProvider.adapter, codexAuthContext: sendAuthCtx, forwardHeaders: sendHeaders, }); + const requireSendPlaintextReasoning = !isOpenAiOperatedResponsesDestination(sendProvider) + && requiresPlaintextReasoningReplay(sendProvider); let replayBody = gatewayReplayParsed ? sanitizeReasoningInputContent(scrubOcxCompactionItems( compactBody, destinationDecodesNativeCompactionBlob(sendProvider), gatewayReplayParsed._stripReasoningEncryptedContent === true, @@ -1101,8 +1108,9 @@ export async function handleResponsesCompact( preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.preserveResponsesReasoningContent === true, dropNullContentChannel: !isOpenAiOperatedResponsesDestination(sendProvider), - stripEncryptedContent: gatewayReplayParsed._stripReasoningEncryptedContent === true, + stripEncryptedContent: gatewayReplayParsed._stripReasoningEncryptedContent === true || requireSendPlaintextReasoning, dropForeignItemId: gatewayReplayParsed._dropForeignReasoningItemIds === true, + requirePlaintextReasoning: requireSendPlaintextReasoning, }) : compactBody; if (!isOpenAiOperatedResponsesDestination(sendProvider) && sendProvider.dropResponsesReasoningItems === true) { replayBody = dropResponsesReasoningInputItems(replayBody) as typeof replayBody; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b6927128ce4..9742014867d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` and `tests/responses/native-compaction-gateway-policy.test.ts` pin these boundaries. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` and `tests/responses/native-compaction-gateway-policy.test.ts` pin these boundaries. 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 diff --git a/tests/responses/native-compaction-gateway-policy.test.ts b/tests/responses/native-compaction-gateway-policy.test.ts index 498baa27e8d..56639f3697c 100644 --- a/tests/responses/native-compaction-gateway-policy.test.ts +++ b/tests/responses/native-compaction-gateway-policy.test.ts @@ -16,6 +16,13 @@ const rows = (["ordinary", "native", "portable"] as const).flatMap(path => (["key", "forward"] as const).flatMap(authMode => [undefined, false, true].map(flag => ({ path, authMode, flag })))); +function rejectsStrictPlaintextReplay(items: Record[]): boolean { + return items.some(item => item.type === "reasoning" && ( + "encrypted_content" in item || !Array.isArray(item.content) + || !item.content.some(part => part.type === "reasoning_text" && typeof part.text === "string" && part.text.length > 0) + )); +} + describe("native compact gateway serialization policy", () => { let home: string; let previousHome: string | undefined; @@ -101,13 +108,52 @@ describe("native compact gateway serialization policy", () => { ]); }); - test.each(["metadata", "null", "reasoning"])("strict gateway counterfactual rejects unsanitized %s", async defect => { + test.each(rows)("strict plaintext replay policy: $path $authMode $flag", async row => { + const plaintext = [{ type: "reasoning_text", text: "Existing plaintext fixture." }]; + const input = [message, + { ...reasoning, encrypted_content: "opaque-replay-fixture", status: "completed" }, + { type: "reasoning", summary: [], content: [], encrypted_content: "opaque-empty-fixture" }, + { ...reasoning, content: plaintext, encrypted_content: "opaque-plaintext-fixture" }, + ]; + const strict = row.flag === true; + const items = await send(row, input, { + requiresAdjacentResponsesToolResults: true, + ...(row.flag === undefined ? {} : { preserveResponsesReasoningContent: row.flag }), + }, items => strict && rejectsStrictPlaintextReplay(items)); + // Explicit wire expectations also pin ordinary and portable serialization to the native path. + expect(items[0]).toEqual(message); + expect(items.filter(item => item.type === "reasoning")).toEqual([ + { type: "reasoning", summary: reasoning.summary, ...(strict + ? { content: [{ type: "reasoning_text", text: "Policy summary fixture." }] } + : { encrypted_content: "opaque-replay-fixture" }) }, + { type: "reasoning", summary: [], content: strict ? [{ type: "reasoning_text", text: " " }] : [], + ...(!strict ? { encrypted_content: "opaque-empty-fixture" } : {}) }, + { type: "reasoning", summary: reasoning.summary, content: strict ? plaintext : [], + ...(!strict ? { encrypted_content: "opaque-plaintext-fixture" } : {}) }, + ]); + }); + + test.each((["key", "forward"] as const).flatMap(authMode => + [undefined, false].map(adjacency => ({ authMode, adjacency }))))( + "plaintext preservation alone does not require plaintext replay: $authMode $adjacency", async row => { + const input = [{ ...reasoning, encrypted_content: "preservation-only-fixture" }]; + const items = await send({ path: "native", flag: true, authMode: row.authMode }, input, { + preserveResponsesReasoningContent: true, + ...(row.adjacency === undefined ? {} : { requiresAdjacentResponsesToolResults: row.adjacency }), + }, () => false); + expect(items).toEqual([{ type: "reasoning", summary: reasoning.summary, encrypted_content: "preservation-only-fixture" }]); + }); + + test.each(["metadata", "null", "reasoning", "plaintext"])("strict gateway counterfactual rejects unsanitized %s", async defect => { const input = defect === "metadata" ? [{ ...message, [metadataKey]: { fixture_turn: "private" } }] - : defect === "null" ? [reasoning] : [{ type: "reasoning", summary: [] }]; + : defect === "null" ? [reasoning] : defect === "plaintext" + ? [{ ...reasoning, content: [{ type: "reasoning_text", text: "Valid text with ciphertext fixture." }], + encrypted_content: "opaque-replay-fixture" }] : [{ type: "reasoning", summary: [] }]; globalThis.fetch = (async (_url, init) => { const body = JSON.parse(String(init?.body)); - const rejected = body.input.some((item: Record) => defect === "metadata" ? metadataKey in item - : defect === "null" ? item.content === null : item.type === "reasoning"); + const rejected = defect === "plaintext" ? rejectsStrictPlaintextReplay(body.input) + : body.input.some((item: Record) => defect === "metadata" ? metadataKey in item + : defect === "null" ? item.content === null : item.type === "reasoning"); return Response.json({}, { status: rejected ? 400 : 200 }); }) as typeof fetch; const response = await fetch("https://gateway.example/v1/responses/compact", { @@ -117,6 +163,31 @@ describe("native compact gateway serialization policy", () => { await response.text(); }); + test.each([ + { label: "canonical", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { label: "official API", baseUrl: "https://api.openai.com/v1", authMode: "key" as const }, + ])("strict custom replay flags do not change OpenAI compact policy: $label", async row => { + let sent: Record = {}; + globalThis.fetch = (async (_url, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ object: "response.compaction", output: [compactItem] }); + }) as typeof fetch; + const provider: OcxProviderConfig = { + adapter: "openai-responses", ...row, apiKey: "canonical-key-fixture", supportsNativeCompactEndpoint: true, + preserveResponsesReasoningContent: true, requiresAdjacentResponsesToolResults: true, + }; + const input = [{ ...reasoning, content: [{ type: "reasoning_text", text: "OpenAI plaintext fixture." }], + encrypted_content: "canonical-reasoning-policy-fixture" }]; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ input }), undefined, { + authorization: "Bearer caller-fixture", + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(sent.input).toEqual([{ type: "reasoning", summary: reasoning.summary, content: [], + encrypted_content: "canonical-reasoning-policy-fixture" }]); + }); + test.each([ { label: "canonical", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, { label: "official API", baseUrl: "https://api.openai.com/v1", authMode: "key" as const }, From 26efb3b4e89bb3d1f751256631f511ac6675044b Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 06:19:41 +0900 Subject: [PATCH 5/7] fix(responses): validate native compact before provenance commit --- scripts/test-layout/layout.json | 2 +- scripts/test-layout/seeds.json | 4 +- src/responses/schema.ts | 23 +++ src/server/responses/compact.ts | 12 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../native-compaction-provenance.test.ts | 137 ++++++++++++++++++ 7 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 tests/responses/native-compaction-provenance.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index de62c746938..73b05e51ac9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1542,7 +1542,7 @@ "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "responses-compaction.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", diff --git a/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json index f39b3cb95b1..4f62df28e29 100644 --- a/scripts/test-layout/seeds.json +++ b/scripts/test-layout/seeds.json @@ -32,7 +32,7 @@ "codex-integration": { "match": [ "^context-compat\\.test\\.ts$", - "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native(?!-compaction-gateway)|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" + "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native(?!-compaction-(?:gateway|provenance))|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" ] }, "server": { @@ -62,7 +62,7 @@ }, "responses": { "match": [ - "^native-compaction-gateway\\.test\\.ts$", + "^native-compaction-(?:gateway|provenance)\\.test\\.ts$", "^openai-responses-summary-none\\.test\\.ts$", "^(?:apply|chat|citation|continuation|eventstream|legacy|namespace|passthrough|responses|sse|thought|ws)-" ] diff --git a/src/responses/schema.ts b/src/responses/schema.ts index ca125e132d0..8b58d08d253 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -1,4 +1,5 @@ import * as z from "zod/v4"; +import { isCompactionItemType } from "./compaction"; const inputTextSchema = z.object({ type: z.literal("input_text"), text: z.string() }); const plainTextSchema = z.object({ type: z.literal("text"), text: z.string() }); @@ -75,6 +76,28 @@ const reasoningItemSchema = z.object({ // Round-tripped opaque payload (native OpenAI encryption OR the proxy's ocxr1 envelope). encrypted_content: z.string().optional(), }); + +/** A native v1 replacement history may establish replay provenance only after body success. */ +export const nativeCompactResponseSchema = z.object({ + status: z.literal("completed").optional(), + error: z.null().optional(), + output: z.array(z.union([ + userMessageItemSchema.required({ content: true }), + systemMessageItemSchema.required({ content: true }), + assistantMessageItemSchema.required({ content: true }), + reasoningItemSchema, + z.object({ + type: z.string().refine(isCompactionItemType), + encrypted_content: z.string().refine(value => value.trim().length > 0), + }), + ])).min(1).refine(items => items.some(item => { + if (isCompactionItemType(item.type)) return true; + if (!("role" in item)) return false; + return typeof item.content === "string" ? item.content.trim().length > 0 + : item.content.some(part => "text" in part && part.text.trim().length > 0); + })), +}); + const functionCallItemSchema = z.object({ type: z.literal("function_call"), id: z.string().optional(), diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 0dffd825d62..8eb2a51fcc2 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -134,6 +134,7 @@ import { requiresPlaintextReasoningReplay } from "../../adapters/openai-response import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; import { bindRouteReasoningReplayScope } from "./core-replay"; import { commitReasoningReplayServingIdentity } from "../../responses/reasoning-replay-cache"; +import { nativeCompactResponseSchema } from "../../responses/schema"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { isCodexReserveOptInMissing, @@ -1481,8 +1482,15 @@ export async function handleResponsesCompact( // request log; the routed branch gets the same through handleResponses. The // synthetic buffer errors are not upstream bodies and stay uninspected. if (buffered.ok) { - commitReasoningReplayServingIdentity(gatewayReplayParsed?._reasoningReplayScope); - inspectResponseLogJson(logCtx, await buffered.clone().text()); + const compactText = await buffered.clone().text(); + if (gatewayReplayParsed) { + try { + if (nativeCompactResponseSchema.safeParse(JSON.parse(compactText)).success) { + commitReasoningReplayServingIdentity(gatewayReplayParsed._reasoningReplayScope); + } + } catch { /* A malformed body cannot establish serving provenance. */ } + } + inspectResponseLogJson(logCtx, compactText); if (!options.compactionRoutingOverride) forgetCompactHandoffRoute(req, admission); rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers)); } else if (!options.compactionRoutingOverride && quotaFailure && !storedPool401ReplayAttempted) { diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9742014867d..8ab00c01413 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; successful compact sends commit that same provenance. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts` and `tests/responses/native-compaction-gateway-policy.test.ts` pin these boundaries. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` and `tests/responses/native-compaction-provenance.test.ts` pin these boundaries. 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6529bf0b038..d22c97f3d9a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -947,7 +947,7 @@ "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", diff --git a/tests/responses/native-compaction-provenance.test.ts b/tests/responses/native-compaction-provenance.test.ts new file mode 100644 index 00000000000..ab0b4fc71bf --- /dev/null +++ b/tests/responses/native-compaction-provenance.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig } from "../../src/config"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { baseCompactionBody, compactionRequest, completedPayload, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; + +const blobA = { type: "compaction", encrypted_content: "provenance-blob-a-fixture" }; +const blobB = { type: "compaction", encrypted_content: "provenance-blob-b-fixture" }; +const message = { type: "message", role: "user", content: [{ type: "input_text", text: "Retained replacement summary fixture." }] }; +const failed = { status: "failed", output: [], error: { message: "Gateway compaction failed fixture" } }; +const rows = [ + { label: "failed 200", payload: failed, commits: false }, + { label: "failed with blob", payload: { ...failed, output: [blobB] }, commits: false }, + { label: "completed with error", payload: { ...failed, status: "completed", output: [blobB] }, commits: false }, + { label: "error without status", payload: { output: [blobB], error: { message: "Compaction error fixture" } }, commits: false }, + { label: "malformed error", payload: { status: "completed", output: [blobB], error: "failure fixture" }, commits: false }, + { label: "incomplete", payload: { status: "incomplete", output: [blobB] }, commits: false }, + { label: "unknown status", payload: { status: "success", output: [blobB] }, commits: false }, + { label: "null status", payload: { status: null, output: [blobB] }, commits: false }, + { label: "empty completed", payload: { status: "completed", output: [] }, commits: false }, + { label: "missing output", payload: { status: "completed" }, commits: false }, + { label: "nonarray output", payload: { output: blobB }, commits: false }, + { label: "null output item", payload: { output: [null] }, commits: false }, + { label: "empty output item", payload: { output: [{}] }, commits: false }, + { label: "unknown output item", payload: { output: [{ type: "unknown", encrypted_content: "fixture" }] }, commits: false }, + { label: "missing ciphertext", payload: { output: [{ type: "compaction" }] }, commits: false }, + { label: "numeric ciphertext", payload: { output: [{ type: "compaction", encrypted_content: 42 }] }, commits: false }, + { label: "blank ciphertext", payload: { output: [{ type: "compaction", encrypted_content: " " }] }, commits: false }, + { label: "missing message content", payload: { output: [{ type: "message", role: "user" }] }, commits: false }, + { label: "blank message", payload: { output: [{ ...message, content: [{ type: "input_text", text: " " }] }] }, commits: false }, + { label: "malformed message beside blob", payload: { output: [blobB, { type: "message", role: "user", content: 42 }] }, commits: false }, + { label: "reasoning without replacement", payload: { output: [{ type: "reasoning", summary: [] }] }, commits: false }, + { label: "nonobject JSON", payload: [blobB], commits: false }, + { label: "null JSON", payload: null, commits: false }, + { label: "invalid JSON", text: "{invalid compact fixture", commits: false }, + { label: "empty body", text: "", commits: false }, + { label: "HTTP 503", payload: failed, status: 503, commits: false }, + { label: "compact envelope", payload: { object: "response.compaction", output: [blobB] }, commits: true }, + { label: "completed compact", payload: { status: "completed", output: [blobB], error: null }, commits: true }, + { label: "compact alias", payload: { output: [{ ...blobB, type: "compaction_summary" }] }, commits: true }, + { label: "context compact alias", payload: { output: [{ ...blobB, type: "context_compaction" }] }, commits: true }, + { label: "plaintext replacement", payload: { output: [message] }, commits: true }, + { label: "assistant replacement", payload: { output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Replacement summary fixture." }] }] }, commits: true }, + { label: "retained history and compact", payload: { output: [message, { type: "reasoning", summary: [] }, blobB] }, commits: true }, +].flatMap(row => (["A", "B"] as const).map(replayCredential => ({ ...row, replayCredential }))); + +describe("native compact provenance publication", () => { + let home: string; + let previousHome: string | undefined; + let release: (() => void) | undefined; + const originalFetch = globalThis.fetch; + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-compact-provenance-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + }); + afterEach(async () => { + release?.(); + globalThis.fetch = originalFetch; + await removeCompactionFixture(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }); + + test.each(rows)("$label then ordinary replay under $replayCredential", async row => { + const calls: { url: string; body: Record; authorization: string | null }[] = []; + const compactText = "text" in row ? row.text : JSON.stringify(row.payload); + const compactStatus = "status" in row ? row.status : 200; + globalThis.fetch = (async (url, init) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body)), authorization: new Headers(init?.headers).get("authorization") }); + return String(url).endsWith("/compact") + ? new Response(compactText, { status: compactStatus, headers: { "content-type": "application/json" } }) + : Response.json({ ...completedPayload("Ordinary replay fixture."), output: [blobA] }); + }) as typeof fetch; + const provider = { adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "key" as const, + apiKey: "provenance-a-fixture", supportsNativeCompactEndpoint: true, decodesNativeCompactionBlobs: true, + transientRetryOn5xx: { attempts: 1 } }; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: provider } }; + const headers = { "x-codex-parent-thread-id": `provenance-${row.label}-${row.replayCredential}-fixture`, "thread-id": "child-fixture" }; + const request = (input: unknown[]) => compactionRequest(baseCompactionBody({ input }), undefined, headers); + const seeded = await handleResponses(request([message]), config, { model: "", provider: "" }); + expect(seeded.status).toBe(200); + expect((await seeded.json()).output).toEqual([blobA]); + provider.apiKey = "provenance-b-fixture"; + const log: RequestLogContext = { model: "", provider: "" }; + const compact = await handleResponsesCompact(request([blobA, { ...message, + internal_chat_message_metadata_passthrough: { private_fixture: "retained-in-client-only" }, + }]), config, log); + expect(compact.status).toBe(compactStatus); + expect(await compact.text()).toBe(compactText); + expect(calls[1]!.body.input).not.toContainEqual(blobA); + expect(JSON.stringify(calls[1]!.body)).not.toContain("internal_chat_message_metadata_passthrough"); + const payload = "payload" in row ? row.payload : undefined; + if (compactStatus === 200 && payload && !Array.isArray(payload) && "status" in payload + && ["completed", "failed", "incomplete"].includes(String(payload.status))) { + expect(log.observedTerminalStatus).toBe(payload.status); + } + provider.apiKey = row.replayCredential === "A" ? "provenance-a-fixture" : "provenance-b-fixture"; + // Replay a blob actually returned by compact, or probe the prior A blob after text replacement. + const output = payload && !Array.isArray(payload) && "output" in payload ? payload.output : undefined; + const minted = Array.isArray(output) ? output.find(item => item && typeof item === "object" + && "encrypted_content" in item && item.encrypted_content === blobB.encrypted_content) : undefined; + const replay = row.commits && minted ? minted as typeof blobB : blobA; + const ordinary = await handleResponses(request([replay]), config, { model: "", provider: "" }); + expect(ordinary.status).toBe(200); + await ordinary.text(); + expect(calls).toHaveLength(3); + expect(calls.map(call => new URL(call.url).pathname)).toEqual(["/v1/responses", "/v1/responses/compact", "/v1/responses"]); + expect(calls.map(call => call.authorization)).toEqual(["Bearer provenance-a-fixture", "Bearer provenance-b-fixture", `Bearer provenance-${row.replayCredential.toLowerCase()}-fixture`]); + const sameIssuer = row.replayCredential === (row.commits ? "B" : "A"); + if (sameIssuer) expect(calls[2]!.body.input).toContainEqual(replay); + else { + expect(calls[2]!.body.input).not.toContainEqual(replay); + expect(JSON.stringify(calls[2]!.body)).not.toContain(replay.encrypted_content); + } + }); + + test.each([failed, { status: "completed", output: [] }, { object: "response.compaction", output: [blobB] }])( + "canonical compact preserves its existing body contract: %j", async payload => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return Response.json(payload); }) as typeof fetch; + const config = { ...getDefaultConfig(), defaultProvider: "gw", providers: { gw: { + adapter: "openai-responses", authMode: "forward" as const, baseUrl: "https://chatgpt.com/backend-api/codex", + } } }; + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody(), undefined, { + authorization: "Bearer canonical-fixture", + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(payload); + expect(calls).toBe(1); + }); +}); From b93c76f936fa6a2c8c2ab928d2dca60d521b61ee Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 06:42:25 +0900 Subject: [PATCH 6/7] fix(responses): retain OAuth compact credential admission --- .../src/content/docs/reference/adapters.md | 4 +- .../docs/reference/configuration/providers.md | 2 +- .../content/docs/reference/proxy-formats.md | 2 +- .../docs/zh-cn/reference/proxy-formats.md | 2 +- .../docs/zh-tw/reference/proxy-formats.md | 2 +- scripts/test-layout/layout.json | 2 +- scripts/test-layout/seeds.json | 4 +- src/providers/openai-tiers-destination.ts | 4 +- structure/config.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../responses/native-compaction-oauth.test.ts | 147 ++++++++++++++++++ 12 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 tests/responses/native-compaction-oauth.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1c0f412d80a..f590ca618f8 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -168,8 +168,8 @@ Responses compatibility alone is not evidence of native compaction support. Cust continue using portable text summaries unless the operator declares the relevant capability: - `supportsNativeCompactionTrigger: true` preserves v2 `compaction_trigger` requests and native output. -- `supportsNativeCompactEndpoint: true` opts into the separate v1 compact endpoint. A gateway can - support v2 without implementing this endpoint; do not enable it just because v2 succeeds. +- `supportsNativeCompactEndpoint: true` opts key/local/forward routes into the separate v1 endpoint. + OAuth retains routed compaction and its existing credential lifecycle. V2 support does not imply v1 support. - `decodesNativeCompactionBlobs: true` preserves native blobs on subsequent turns when there is no known serving-provider mismatch. Neither generation flag implies this replay capability. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 1f9b40d68fc..d866438a45b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -254,7 +254,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. | | `forwardClientHeaders?` | `string[]` | `openai-responses` only: opt in to copying named inbound client metadata headers onto inference requests when the provider has not already set that header. Credential and transport-owned names such as `Authorization`, cookies, API-key headers, `Content-Type`, `Content-Length`, `Host`, and `x-oai-attestation` are rejected and also filtered at runtime. Header names are case-insensitive and may not repeat. `headers` remains authoritative; the existing caller `User-Agent` fallback still applies when neither surface sets one. Supported names: `originator`, `x-client-request-id`, `x-codex-app-version`, `user-agent`; all other names are refused. | | `supportsNativeCompactionTrigger?` | `boolean` | `openai-responses` only: declare support for native v2 `compaction_trigger` input items. Defaults off for noncanonical gateways; preserves native compaction rather than requesting a portable text summary. Independent of the v1 endpoint and blob replay capabilities. | -| `supportsNativeCompactEndpoint?` | `boolean` | `openai-responses` only: declare support for native v1 `POST /responses/compact`. Defaults off for custom gateways. Uses the provider's Responses URL plus `/compact` and its normal transport authentication. Does not enable v2 triggers or grant official OpenAI identity. | +| `supportsNativeCompactEndpoint?` | `boolean` | `openai-responses` only: declare support for native v1 `POST /responses/compact`. Defaults off for custom gateways. Key/local/forward routes use the provider's Responses URL plus `/compact` and its normal transport authentication; OAuth keeps routed compaction and its existing credential lifecycle. Does not enable v2 triggers or grant official OpenAI identity. | | `decodesNativeCompactionBlobs?` | `boolean` | Declare that this destination can replay native, non-`ocx1:` compaction blobs. Independent of either generation capability; a known serving-provider mismatch still degrades foreign ciphertext. Enable only after testing compact and a subsequent turn on the same backend. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 2d8896bbb88..1361b651de6 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -677,7 +677,7 @@ conversation. | Route type | Behavior | | --- | --- | | Canonical ChatGPT or official OpenAI route | Tries the native `/responses/compact` endpoint with the resolved account and model authentication; HTTP 404 falls back to a regular Responses compaction turn | -| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint using the provider's normal Responses URL, authentication, and client headers; HTTP 404 uses portable fallback | +| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint for key/local/forward auth using the provider's normal Responses transport; HTTP 404 uses portable fallback; OAuth retains routed compaction and credential resolution | | Other routed model | Runs an internal, non-streaming, no-tools compaction turn with a `compaction_trigger`; requires exactly one synthetic `compaction` item whose `encrypted_content` is an `ocx1:` envelope; decodes that summary into v1 replacement history | The custom v1 endpoint capability is independent of v2 `supportsNativeCompactionTrigger` diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index f49a98425dc..385f378e8fe 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -249,7 +249,7 @@ Compaction 会为需要缩短长 Responses 会话的客户端返回替换历史 | 路由类型 | 行为 | | --- | --- | | Canonical ChatGPT 或官方 OpenAI 路由 | 将请求转发到原生 `/responses/compact` 端点,并使用已解析的账号和模型认证 | -| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint with the provider's normal Responses URL, authentication, and client headers; HTTP 404 uses portable fallback | +| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint for key/local/forward auth using the provider's normal Responses transport; HTTP 404 uses portable fallback; OAuth retains routed compaction and credential resolution | | 其他路由模型 | 运行一次内部的、非流式、无工具的 compaction 轮次,并带 `compaction_trigger`;要求且只允许一个 synthetic `compaction` 项,其 `encrypted_content` 是一个 `ocx1:` 封装;把该摘要解码为 v1 替换历史 | The custom v1 capability is independent of v2 `supportsNativeCompactionTrigger` and diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index ab3fef852a2..43a329caba8 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -226,7 +226,7 @@ Compaction 為需要縮短長 Responses 對話的客戶端回傳取代歷史。 | 路由型別 | 行為 | | --- | --- | | 規範 ChatGPT 或官方 OpenAI 路由 | 以解析的帳號與模型認證將請求轉發到原生 `/responses/compact` 端點 | -| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint with the provider's normal Responses URL, authentication, and client headers; HTTP 404 uses portable fallback | +| Explicitly opted-in custom `openai-responses` gateway | `supportsNativeCompactEndpoint: true` tries the native v1 endpoint for key/local/forward auth using the provider's normal Responses transport; HTTP 404 uses portable fallback; OAuth retains routed compaction and credential resolution | | 其他路由模型 | 執行一個內部、非串流、無工具的 compaction 回合,帶有 `compaction_trigger`;需要恰好一個合成的 `compaction` 項目,其 `encrypted_content` 為 `ocx1:` 封裝;將該摘要解碼為 v1 取代歷史 | The custom v1 capability is independent of v2 `supportsNativeCompactionTrigger` and diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 73b05e51ac9..f8e40db5c11 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1542,7 +1542,7 @@ "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "responses-compaction.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", diff --git a/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json index 4f62df28e29..3ee872cbbb2 100644 --- a/scripts/test-layout/seeds.json +++ b/scripts/test-layout/seeds.json @@ -32,7 +32,7 @@ "codex-integration": { "match": [ "^context-compat\\.test\\.ts$", - "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native(?!-compaction-(?:gateway|provenance))|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" + "^(?:active|app|bearer|catalog|combos\\.test\\.ts|doctor\\.test\\.ts|effort|gather|history|injection|issue|multi|native(?!-compaction-(?:gateway|provenance|oauth))|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" ] }, "server": { @@ -62,7 +62,7 @@ }, "responses": { "match": [ - "^native-compaction-(?:gateway|provenance)\\.test\\.ts$", + "^native-compaction-(?:gateway|provenance|oauth)\\.test\\.ts$", "^openai-responses-summary-none\\.test\\.ts$", "^(?:apply|chat|citation|continuation|eventstream|legacy|namespace|passthrough|responses|sse|thought|ws)-" ] diff --git a/src/providers/openai-tiers-destination.ts b/src/providers/openai-tiers-destination.ts index 13b11d38061..93d847bf3e7 100644 --- a/src/providers/openai-tiers-destination.ts +++ b/src/providers/openai-tiers-destination.ts @@ -75,7 +75,9 @@ export function supportsNativeResponsesCompactEndpoint( provider: OcxProviderConfig, ): boolean { if (isCanonicalOpenAiForwardProvider(provider)) return true; - if (provider.adapter === "openai-responses" && provider.supportsNativeCompactEndpoint === true) return true; + // Generic OAuth selection/refresh belongs to the routed Responses path, not this direct transport. + if (provider.adapter === "openai-responses" && provider.supportsNativeCompactEndpoint === true + && provider.authMode !== "oauth") return true; return providerName === OPENAI_API_PROVIDER_ID && provider.adapter === "openai-responses" && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; diff --git a/structure/config.md b/structure/config.md index 65276b12348..710462bdec4 100644 --- a/structure/config.md +++ b/structure/config.md @@ -27,7 +27,7 @@ The Codex restart command follows the [CLI restart scope contract](runtime.md#cl the [source-owned credential contract](codex-home.md#orca-source-owned-account-import) governs deduplication and credential storage separately from Codex config injection. ## Config surface -Custom Responses providers may declare independent boolean `supportsNativeCompactionTrigger` and `supportsNativeCompactEndpoint` capabilities in `src/config/schema/leaf-validators.ts`; absence remains off. They do not imply `decodesNativeCompactionBlobs`, official identity, or credential authority; custom native v1 replay enforces the separate blob capability and known serving-identity changes. The native gateway contract and regression are in [Responses transport](transports/responses.md) and `tests/responses/native-compaction-gateway.test.ts`. +Custom Responses providers may declare independent boolean `supportsNativeCompactionTrigger` and `supportsNativeCompactEndpoint` capabilities in `src/config/schema/leaf-validators.ts`; absence remains off. They do not imply `decodesNativeCompactionBlobs`, official identity, or credential authority; custom native v1 key/local/forward replay enforces the separate blob capability and known serving-identity changes, while OAuth retains routed credential resolution. The native gateway contract and regression are in [Responses transport](transports/responses.md) and `tests/responses/native-compaction-gateway.test.ts`. Custom Responses providers can explicitly enable `preserveResponsesInputItemIds` and `preserveResponsesMessageMetadata`; both default off and accept only booleans. The [replay sanitization contract](transports/responses-wire-shapes.md) keeps ordinary destinations unchanged and preserves xAI custom-call ID repair. `src/config/schema/compaction-recovery.ts` strictly validates opt-in `compactionRecovery`; invalid disk values disable it with a warning, while candidate writes reject them. `src/config/schema/blocked-model-redirects.ts` applies the same read-degrade/write-reject boundary to malformed `blockedModelRedirects` maps. The [failure-only contract](transports/responses-failover.md) leaves provider identity, accounts and client compaction unchanged. `src/cli/config-command.ts` accepts one leading UTF-8 BOM when parsing validate/import input from a file or stdin. JSON syntax and schema validation still run before persistence; BOM characters inside string values remain data. `skills.catalog_refresh` in the proxy JSON configuration accepts `per_session` (the runtime default when absent) or `per_turn`. The former retains received skills instructions for a conversation; the latter passes through the current catalog. This is separate from Codex's `skills.include_instructions` TOML switch and does not change the live dashboard probe. See the [Responses snapshot contract](transports/responses.md#responses-httpsse). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 8ab00c01413..1819e49bad3 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` and `tests/responses/native-compaction-provenance.test.ts` pin these boundaries. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d22c97f3d9a..8d83fdf2764 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -947,7 +947,7 @@ "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", diff --git a/tests/responses/native-compaction-oauth.test.ts b/tests/responses/native-compaction-oauth.test.ts new file mode 100644 index 00000000000..9a67192aeed --- /dev/null +++ b/tests/responses/native-compaction-oauth.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig } from "../../src/config"; +import * as oauth from "../../src/oauth"; +import * as oauthStore from "../../src/oauth/store"; +import type { OAuthCredentials } from "../../src/oauth/types"; +import { supportsNativeResponsesCompactEndpoint, supportsNativeResponsesCompactionTrigger } from "../../src/providers/openai-tiers-destination"; +import { SUMMARY_PREFIX } from "../../src/responses/compaction"; +import * as thoughtReplay from "../../src/responses/thought-signature-replay"; +import { handleResponsesCompact } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { baseCompactionBody, completedPayload, installCompactionRoutingAclFixture, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; + +installCompactionRoutingAclFixture(); + +describe("OAuth compact preserves the authenticated routed path", () => { + const providerName = "kimi-responses"; + const accessToken = "resolved-oauth-compact-fixture"; + const selection = { accountId: "fixture-account", revision: "fixture-revision" }; + const generation = "fixture-generation"; + const summary = "Preserved task summary fixture."; + const retainedUser = { type: "message", role: "user", content: [{ type: "input_text", text: "Keep the task fixture." }] }; + const restores: Array<{ mockRestore(): void }> = []; + let previousHome: string | undefined; + let originalFetch: typeof fetch; + let home: string; + let release: () => void; + let loggedIn: boolean; + let oauthLookups: string[]; + let selectionCommits: string[]; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + originalFetch = globalThis.fetch; + home = mkdtempSync(join(tmpdir(), "ocx-compact-oauth-")); + process.env.OPENCODEX_HOME = home; + release = acquireOwnedSpendHome(); + loggedIn = true; + oauthLookups = []; + selectionCommits = []; + const credential: OAuthCredentials = { access: accessToken, refresh: "fixture-refresh", expires: Date.now() + 3_600_000 }; + const resolve = async (provider: string): Promise => { + oauthLookups.push(provider); + if (!loggedIn) throw new oauth.OAuthLoginRequiredError(provider); + return { provider, accountId: selection.accountId, generation, accessToken }; + }; + // Match the differential fixture's selection, generation and unexpired account row. + // Spies are restored per case; no module mock or persistent auth store is installed. + restores.push( + spyOn(oauth, "getValidAccessTokenSnapshot").mockImplementation(resolve), + spyOn(oauth, "getValidAccessSnapshotForAccount").mockImplementation(resolve), + spyOn(oauthStore, "captureOAuthAccountSelection").mockImplementation(() => loggedIn ? selection : null), + spyOn(oauthStore, "commitOAuthAccountSelection").mockImplementation(async (provider, accountId) => { + selectionCommits.push(provider); + return loggedIn && accountId === selection.accountId ? selection : null; + }), + spyOn(oauthStore, "credentialGeneration").mockReturnValue(generation), + spyOn(oauthStore, "getAccountSet").mockImplementation(() => loggedIn ? { + activeAccountId: selection.accountId, selectionRevision: selection.revision, + accounts: [{ id: selection.accountId, label: "OAuth fixture", credential }], + } : null), + spyOn(oauthStore, "getAccountCredentialWithStatus").mockImplementation(() => loggedIn ? { + credential, paused: false, needsReauth: false, + } : null), + spyOn(thoughtReplay, "thoughtSignatureReplaySalt").mockReturnValue(Buffer.alloc(32, 7)), + ); + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + try { + release(); + await removeCompactionFixture(home); + } finally { + for (const spy of restores.splice(0).reverse()) spy.mockRestore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + } + }); + + function configFor(endpoint: boolean | undefined): OcxConfig { + // Use the registered Responses ID: the "kimi" alias reconciles to Chat. + return { ...getDefaultConfig(), defaultProvider: providerName, providers: { + [providerName]: { adapter: "openai-responses", authMode: "oauth", proxy: "direct", + baseUrl: "https://api.kimi.com/coding/v1", supportsNativeCompactionTrigger: true, + ...(endpoint === undefined ? {} : { supportsNativeCompactEndpoint: endpoint }), + }, + } }; + } + + function request(): Request { + return new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-type": "application/json", authorization: "Bearer caller-compact-fixture" }, + body: JSON.stringify(baseCompactionBody({ model: `${providerName}/kimi-for-coding`, input: [retainedUser], store: false })), + }); + } + + test.each([true, false, undefined])("endpoint opt-in %s retains OAuth admission and v1 replacement history", async endpoint => { + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + const sent = new Request(url, init); + calls.push(sent); + return sent.headers.get("authorization") === `Bearer ${accessToken}` + ? Response.json(completedPayload(summary)) + : Response.json({ error: { message: "Missing OAuth fixture credential" } }, { status: 401 }); + }) as typeof fetch; + const config = configFor(endpoint); + const response = await handleResponsesCompact(request(), config, { model: "", provider: "" }); + const payload = await response.json(); + expect({ status: response.status, payload }).toMatchObject({ status: 200 }); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).pathname).toBe("/coding/v1/responses"); + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.headers.get("authorization")).toBe(`Bearer ${accessToken}`); + const sentBody = await calls[0]!.json(); + expect(sentBody.model).toBe("kimi-for-coding"); + expect(sentBody.tools).toBeUndefined(); + expect(JSON.stringify(sentBody)).not.toContain("compaction_trigger"); + expect(JSON.stringify(sentBody)).toContain("CONTEXT CHECKPOINT COMPACTION"); + expect(payload.output).toEqual([retainedUser, { + type: "message", role: "user", content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n${summary}` }], + }]); + expect(oauthLookups.length).toBeGreaterThan(0); + expect(oauthLookups.every(provider => provider === providerName)).toBe(true); + expect(selectionCommits).toEqual([providerName]); + expect(supportsNativeResponsesCompactEndpoint(providerName, config.providers[providerName]!)).toBe(false); + // Disabling the unauthenticated direct v1 path must not disable independent native v2. + expect(supportsNativeResponsesCompactionTrigger(config.providers[providerName]!)).toBe(true); + }); + + test.each([true, false, undefined])("endpoint opt-in %s without a login refuses before inference", async endpoint => { + loggedIn = false; + const calls: Request[] = []; + globalThis.fetch = (async (url, init) => { + calls.push(new Request(url, init)); + return Response.json({ error: { message: "Unexpected inference without login" } }, { status: 401 }); + }) as typeof fetch; + const response = await handleResponsesCompact(request(), configFor(endpoint), { model: "", provider: "" }); + expect(response.status).toBe(401); + await response.text(); + expect(calls).toHaveLength(0); + expect(selectionCommits).toHaveLength(0); + }); +}); From ae286e2ca60e0c66d133acb9c089eca2cc6eac50 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 15 Oct 2026 18:36:21 +0900 Subject: [PATCH 7/7] fix(compaction): keep Codex pool recovery off native gateway sends A custom native v1 compact send used whatever headers the pool recovery branches handed it. The 401 refresh replay and the quota/transient alternate-account sends materialize stored ChatGPT credentials, so a gateway row that ever carried a Codex account mode would receive them. Today the router pins the only pool-mode row to the canonical URL; this makes the gateway boundary hold locally instead of depending on that pin. Gateway sends now always use the gateway transport's headers, and pool recovery and pool outcome recording skip gateway transport. --- scripts/test-layout/layout.json | 2 +- src/server/responses/compact.ts | 12 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- ...ive-compaction-gateway-credentials.test.ts | 126 ++++++++++++++++++ 5 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 tests/responses/native-compaction-gateway-credentials.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dc85db3f791..e344f1e2d97 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1463,7 +1463,7 @@ "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-http-isolation.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "responses-compaction.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "native-compaction-gateway-credentials.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index d822b3059dc..3a3dc1b1e5e 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1038,7 +1038,8 @@ export async function handleResponsesCompact( promoteAccountId?: string; } = {}, ) => { - if (!usesCodexForwardPoolAuth(ctx, route.provider)) return; + // A gateway response is not evidence about any Codex account. + if (gatewayTransport || !usesCodexForwardPoolAuth(ctx, route.provider)) return; recordCodexUpstreamOutcome(config, ctx.accountId, outcome, { ...meta, threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined, @@ -1102,9 +1103,11 @@ export async function handleResponsesCompact( sendAuthCtx: CodexAuthContext, onPhysicalDispatch?: () => void, ): Promise => { + // A gateway send carries only its own transport headers, whatever recovery handed in. + const wireHeaders = gatewayTransport ? new Headers(gatewayTransport.headers) : sendHeaders; if (gatewayReplayParsed) bindRouteReasoningReplayScope({ parsed: gatewayReplayParsed, providerName: route.providerName, provider: sendProvider, - adapterName: sendProvider.adapter, codexAuthContext: sendAuthCtx, forwardHeaders: sendHeaders, + adapterName: sendProvider.adapter, codexAuthContext: sendAuthCtx, forwardHeaders: wireHeaders, }); const requireSendPlaintextReasoning = !isOpenAiOperatedResponsesDestination(sendProvider) && requiresPlaintextReasoningReplay(sendProvider); @@ -1131,7 +1134,7 @@ export async function handleResponsesCompact( compactUrl, applyUpstreamRecoveryInit({ method: "POST", - headers: sendHeaders, + headers: wireHeaders, body: JSON.stringify({ ...sendBody as typeof compactBody, model: route.modelId }), }, upstreamRecovery), req.signal, @@ -1221,6 +1224,7 @@ export async function handleResponsesCompact( if ( upstream.status === 401 && (authCtx.kind === "main-pool" || authCtx.kind === "pool") + && !gatewayTransport && usesCodexForwardPoolAuth(authCtx, compactProvider) && !req.signal.aborted ) { @@ -1348,6 +1352,7 @@ export async function handleResponsesCompact( // compact turn that may already have been processed. && !isNonReplayableResponse(upstream) && !storedPool401ReplayAttempted + && !gatewayTransport && usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount && route.codexAccountMode @@ -1452,6 +1457,7 @@ export async function handleResponsesCompact( if ( shouldRetryCodexPoolAccountTransient(upstream) && !storedPool401ReplayAttempted + && !gatewayTransport && usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount && route.codexAccountMode diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 48ec636d3b8..73e46887740 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -329,7 +329,7 @@ now: the TTL was previously evaluated only on read or on a generation change, so again held its entry for the life of the process. > Decision record: [ADR-0038](../decisions/ADR-0038-responses-http-sse.md) -Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. +Custom `openai-responses` gateways declare native v2 triggers with `supportsNativeCompactionTrigger` and native v1 endpoints with `supportsNativeCompactEndpoint`; neither flag implies blob replay or OpenAI identity. `src/adapters/openai-responses/transport.ts` shares normal URL/auth/header construction with custom native v1 key/local/forward routes; generic OAuth retains routed compaction so the existing credential selection, refresh and dispatch fence own authentication. Native v2 remains an independent declaration. Custom native v1 honors `preserveResponsesReasoningContent: true` in both sanitizer passes only for custom destinations; absent/false and OpenAI-operated compact destinations blank raw reasoning content. Proxy-owned `ocxr1:` envelopes are always stripped, and plaintext preservation grants neither native generation nor ciphertext replay. Both custom v1 sanitizer passes omit non-array reasoning content channels and reuse `requiresPlaintextReasoningReplay` from `src/adapters/openai-responses/passthrough.ts`: custom providers with plaintext preservation and strict tool-result adjacency strip opaque reasoning ciphertext and backfill missing `reasoning_text` from `summary_text` or the existing minimal placeholder, while preserving available plaintext. Each physical send applies `dropResponsesReasoningItems: true` and strips private input message metadata unless `preserveResponsesMessageMetadata: true`, using the ordinary adapter policy; OpenAI-operated compact metadata, reasoning items and null channels retain their established policy. Custom v1 scrubs compaction ciphertext, reasoning ciphertext and foreign reasoning ids after each serving bind, using ordinary turns' parent-thread-first replay scope; provenance commits require a successful HTTP response and a validated replacement history with replayable compact ciphertext or nonblank message text, absent/completed status and absent/null error (`nativeCompactResponseSchema` in `src/responses/schema.ts`). Invalid or empty bodies preserve the prior identity; validation never rewrites the relayed body or request-log metadata. Daybreak endpoint restrictions remain canonical-only. A native 404 handoff preserves User-Agent and allowlisted provider-opted-in metadata through `src/server/responses/core-auth.ts` without widening credential forwarding. Every custom native v1 send carries only the gateway transport's headers, and Codex pool recovery (401 refresh replay, quota and transient alternates) and pool outcome recording skip gateway sends, so recovery cannot put a stored ChatGPT credential on a gateway wire (`tests/responses/native-compaction-gateway-credentials.test.ts`). V1 fallback remains portable even for v2-capable gateways; cross-provider overrides still force portable summaries. `tests/responses/native-compaction-gateway.test.ts`, `tests/responses/native-compaction-gateway-policy.test.ts` `tests/responses/native-compaction-provenance.test.ts` and `tests/responses/native-compaction-oauth.test.ts` pin these boundaries. 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. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1b44be400b2..663f232970a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -977,7 +977,7 @@ "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", "responses-compaction-override.test.ts": "responses", "responses-compaction-policy-identity.test.ts": "responses", "responses-compaction-http-isolation.test.ts": "responses", "responses-compaction-routing.test.ts": "responses", - "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", + "native-compaction-gateway.test.ts": "responses", "native-compaction-gateway-policy.test.ts": "responses", "native-compaction-provenance.test.ts": "responses", "native-compaction-oauth.test.ts": "responses", "native-compaction-gateway-credentials.test.ts": "responses", "responses-compaction.test.ts": "responses", "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", diff --git a/tests/responses/native-compaction-gateway-credentials.test.ts b/tests/responses/native-compaction-gateway-credentials.test.ts new file mode 100644 index 00000000000..fb53f6d4ea8 --- /dev/null +++ b/tests/responses/native-compaction-gateway-credentials.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as registry from "../../src/providers/registry"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { clearAccountNeedsReauth, clearAccountQuota } from "../../src/codex/auth-api"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { handleResponsesCompact } from "../../src/server/responses"; +import { clearCompactHandoffRoutesForTests } from "../../src/server/responses/compact"; +import { clearResponseStateForTests, clearResponseStateMemoryForTests } from "../../src/responses/state"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// A native v1 gateway declaration is a protocol claim. Even when the provider row still carries a +// Codex account mode, pool recovery (401 refresh replay, 429 alternate account) must never put a +// stored ChatGPT credential on the wire to the configured gateway. +const CODEX_TOKENS = ["work-access", "refreshed-access", "other-access"]; +const originalFetch = globalThis.fetch; +let home = ""; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let releaseSpendHome: (() => void) | undefined; + +function record(accessToken: string, refreshToken: string, chatgptAccountId: string) { + return { + credential: { accessToken, refreshToken, expiresAt: Date.now() + 3_600_000, chatgptAccountId }, + generation: 3, + refreshGrantFingerprint: createHash("sha256").update(`codex-refresh-grant:${refreshToken}`).digest("hex"), + }; +} + +function gatewayConfig(): OcxConfig { + return { + defaultProvider: "gw", + activeCodexAccountId: "work", + autoSwitchThreshold: 0, + accountPoolStrategy: "round-robin", + providers: { + gw: { + adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "forward", + supportsNativeCompactEndpoint: true, headers: { authorization: "Bearer gateway-static" }, + }, + }, + codexAccounts: [{ id: "work", label: "work" }, { id: "other", label: "other" }], + } as unknown as OcxConfig; +} + +function compactRequest(): Request { + return new Request("http://localhost/v1/responses/compact", { + method: "POST", + headers: { "content-type": "application/json", "x-codex-parent-thread-id": "gateway-credential-thread" }, + body: JSON.stringify({ model: "gw/gateway-model", input: [{ type: "message", role: "user", content: "history" }] }), + }); +} + +function installGateway(status: number): string[] { + const gatewayAuth: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === "auth.openai.com") { + return Response.json({ access_token: "refreshed-access", refresh_token: "rotated-refresh", expires_in: 3600 }); + } + if (url.hostname !== "gateway.example") return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } }); + const headers = new Headers(init?.headers); + gatewayAuth.push(`${headers.get("authorization") ?? ""}|${headers.get("chatgpt-account-id") ?? ""}`); + return Response.json({ error: { message: "gateway refusal" } }, { status }); + }) as typeof fetch; + return gatewayAuth; +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-native-compact-gateway-cred-")); + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); + for (const id of ["work", "other"]) clearAccountNeedsReauth(id); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearResponseStateMemoryForTests(); + writeFileSync(join(home, "codex-accounts.json"), JSON.stringify({ + work: record("work-access", "work-refresh", "acc-work"), + other: record("other-access", "other-refresh", "acc-other"), + })); +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + clearCompactHandoffRoutesForTests(); + for (const id of ["work", "other"]) clearAccountNeedsReauth(id); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearResponseStateForTests(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +describe("native compact gateway credential boundary", () => { + test.each([401, 429])("gateway %i never receives a stored Codex credential", async status => { + // The router pins the only pool-mode row to the canonical URL today. Model a relaxation of that + // pin: the native gateway send must hold the boundary on its own. + const realMode = registry.providerCodexAccountMode; + const modeSpy = spyOn(registry, "providerCodexAccountMode").mockImplementation((id, provider) => + id === "gw" ? "pool" : realMode(id, provider)); + const gatewayAuth = installGateway(status); + const response = await handleResponsesCompact(compactRequest(), gatewayConfig(), { model: "", provider: "" } as RequestLogContext); + await response.text(); + modeSpy.mockRestore(); + expect(gatewayAuth.length).toBeGreaterThan(0); + for (const sent of gatewayAuth) { + expect(sent).toBe("Bearer gateway-static|"); + for (const token of CODEX_TOKENS) expect(sent).not.toContain(token); + } + }); +});