diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 70cf9514f98..4f38db46b85 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -172,6 +172,46 @@ 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 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. + +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. + +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: + +```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 50c3520b0df..37e4bd4a727 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -258,10 +258,10 @@ 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` | Default off for custom providers. In **Providers → Settings**, enable **Replay plaintext reasoning** for a custom Responses endpoint only if it accepts this history. This sends prior plaintext reasoning to that endpoint and may increase token use. 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` | Default off for custom providers. In **Providers → Settings**, enable **Replay plaintext reasoning** for a custom Responses endpoint only if it accepts this history. This sends prior plaintext reasoning to that endpoint and may increase token use. 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. | @@ -286,6 +286,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. 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. | | `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 0168480b8e6..2c44cd5eced 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -705,8 +705,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 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` +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 2b8778764f2..da0def96f83 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 @@ -260,8 +260,14 @@ Compaction 会为需要缩短长 Responses 会话的客户端返回替换历史 | 路由类型 | 行为 | | --- | --- | | Canonical ChatGPT 或官方 OpenAI 路由 | 将请求转发到原生 `/responses/compact` 端点,并使用已解析的账号和模型认证 | +| 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 +`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 f45782de4b3..2a68eb1d0d6 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 @@ -237,8 +237,14 @@ Compaction 為需要縮短長 Responses 對話的客戶端回傳取代歷史。 | 路由型別 | 行為 | | --- | --- | | 規範 ChatGPT 或官方 OpenAI 路由 | 以解析的帳號與模型認證將請求轉發到原生 `/responses/compact` 端點 | +| 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 +`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 16227fe5cf1..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", - "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/scripts/test-layout/seeds.json b/scripts/test-layout/seeds.json index 0db0d3d33cd..b2efdcb7c0f 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|provenance|oauth))|parallel|project|selected|slug|ultrafast|warmup\\.test\\.ts)-" ] }, "server": { @@ -62,6 +62,7 @@ }, "responses": { "match": [ + "^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/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 19a1cefabba..42635a5b78c 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -11,10 +11,10 @@ import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_ 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 { @@ -47,7 +48,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"; import { renderCompactionSearchHistoryItems } from "./compaction-search-history"; import { stripKimiEmptyAssistantMessages } from "./empty-assistant"; @@ -65,55 +66,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); @@ -127,9 +79,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 @@ -225,61 +177,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; @@ -404,12 +302,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. @@ -466,13 +364,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, new Headers(headers).get(CODEX_RESPONSES_LITE_HEADER) === "true"); } // 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 75b39a29ba3..44556a74c9a 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -340,6 +340,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 bba6fc99e91..e949e5056ba 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..93d847bf3e7 100644 --- a/src/providers/openai-tiers-destination.ts +++ b/src/providers/openai-tiers-destination.ts @@ -67,19 +67,28 @@ 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; + // 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; } +/** 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/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/auth-cors.ts b/src/server/auth-cors.ts index 71f7d43d436..11d56d94a60 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1056,6 +1056,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 c3c74102035..3a3dc1b1e5e 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -127,6 +127,14 @@ 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, 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"; +import { nativeCompactResponseSchema } from "../../responses/schema"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { isCodexReserveOptInMissing, @@ -151,7 +159,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"; @@ -202,7 +211,7 @@ import { import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers"; import { shouldRetryCodexPoolAccountQuota, shouldRetryCodexPoolAccountTransient } from "./core-codex-account"; 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"; @@ -719,7 +728,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; @@ -799,8 +809,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. @@ -839,7 +849,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. @@ -911,14 +921,22 @@ 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 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, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(compactProvider), + stripEncryptedContent: requireCompactPlaintextReasoning, + requirePlaintextReasoning: requireCompactPlaintextReasoning, + }), ) as typeof compactBodyRaw; { const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers)); @@ -945,7 +963,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, @@ -999,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, @@ -1063,12 +1103,39 @@ 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: wireHeaders, + }); + const requireSendPlaintextReasoning = !isOpenAiOperatedResponsesDestination(sendProvider) + && requiresPlaintextReasoningReplay(sendProvider); + let replayBody = gatewayReplayParsed ? sanitizeReasoningInputContent(scrubOcxCompactionItems( + compactBody, destinationDecodesNativeCompactionBlob(sendProvider), + gatewayReplayParsed._stripReasoningEncryptedContent === true, + ), { + preserveRawReasoningContent: !isOpenAiOperatedResponsesDestination(sendProvider) + && sendProvider.preserveResponsesReasoningContent === true, + dropNullContentChannel: !isOpenAiOperatedResponsesDestination(sendProvider), + stripEncryptedContent: gatewayReplayParsed._stripReasoningEncryptedContent === true || requireSendPlaintextReasoning, + dropForeignItemId: gatewayReplayParsed._dropForeignReasoningItemIds === true, + requirePlaintextReasoning: requireSendPlaintextReasoning, + }) : 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( compactUrl, applyUpstreamRecoveryInit({ method: "POST", - headers: sendHeaders, - body: JSON.stringify({ ...compactBody, model: route.modelId }), + headers: wireHeaders, + body: JSON.stringify({ ...sendBody as typeof compactBody, model: route.modelId }), }, upstreamRecovery), req.signal, connectMs, @@ -1094,9 +1161,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: createPhysicalSendReporter(sendBudget, () => ({ poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel })), }); @@ -1155,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 ) { @@ -1282,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 @@ -1386,6 +1457,7 @@ export async function handleResponsesCompact( if ( shouldRetryCodexPoolAccountTransient(upstream) && !storedPool401ReplayAttempted + && !gatewayTransport && usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount && route.codexAccountMode @@ -1494,7 +1566,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) { - 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) { @@ -1538,8 +1618,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. @@ -1551,6 +1631,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 16a2b9e9182..fd6a5dba343 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 4cf050b1abd..78feff50e02 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -730,6 +730,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 a06e907a420..e66b93320c8 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -453,6 +453,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 f803d408c5c..95dcfbe53f7 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 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. Optional `desktopCompatibility: { startOnProxyStart: boolean }` likewise rejects invalid writes and degrades invalid disk values to off; it saves neither certificate consent nor correction mode. Its field-scoped dashboard writer adopts committed state into the live comparison baseline; see [Codex Desktop startup](clients/codex-desktop.md#proxy-startup-preference). `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 c522806f375..1510a0ec7ff 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -207,6 +207,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. | @@ -237,8 +238,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 535ffb4c842..312c244febd 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 @@ -331,7 +331,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 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. +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 0a065aec8ba..73e46887740 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 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 210221f4d50..663f232970a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -977,6 +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-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); + } + }); +}); 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..56639f3697c --- /dev/null +++ b/tests/responses/native-compaction-gateway-policy.test.ts @@ -0,0 +1,219 @@ +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 })))); + +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; + 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(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] : 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 = 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", { + 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 }, + ])("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 }, + ].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); + }); +}); diff --git a/tests/responses/native-compaction-gateway.test.ts b/tests/responses/native-compaction-gateway.test.ts new file mode 100644 index 00000000000..e7e3509913a --- /dev/null +++ b/tests/responses/native-compaction-gateway.test.ts @@ -0,0 +1,497 @@ +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([ + { 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", 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, 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: "" }); + 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", 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) }], + } : 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); + } + } + }); +}); 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); + }); +}); 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); + }); +});