diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 499819c5812..3b90fa2390d 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -241,6 +241,8 @@ Connections > API keys に音声入力とリアルタイム音声の項目があ ## `POST /v1/responses/compact` +compact リクエストに不正な OpenCodex 管理の retained-compaction 入力(`ocx2:`)が含まれる場合、HTTP 400(`error.type: "invalid_request_error"`)を返します。検証のタイミングとアカウントへの影響は[英語の正規リファレンス](/reference/proxy-formats/#post-v1responsescompact)を参照してください。 + 圧縮により、長い応答会話を短縮する必要があるクライアントの置換履歴が返されます。 |ルートの種類 |行動 | diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 3b473e0dd72..77ed94107ad 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -320,6 +320,8 @@ API 키 프로바이더는 자체 bearer를 씁니다. Codex가 join을 프록 ## `POST /v1/responses/compact` +compact 요청에 잘못된 OpenCodex 소유 retained-compaction 입력(`ocx2:`)이 있으면 HTTP 400(`error.type: "invalid_request_error"`)을 반환합니다. 검증 시점과 계정에 미치는 영향은 [영문 기준 문서](/reference/proxy-formats/#post-v1responsescompact)를 참조하세요. + Compaction은 긴 Responses 대화를 줄여야 하는 클라이언트를 위해 대체 히스토리를 반환합니다. | 경로 유형 | 동작 | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index bda869155d4..9a99c237f6c 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -912,6 +912,12 @@ and of `decodesNativeCompactionBlobs` for replay. None grants canonical OpenAI i caller credential forwarding. Cross-provider overrides remain portable; v2 success alone does not establish support for `/responses/compact`. See [provider configuration](/reference/configuration/providers/). +Malformed OpenCodex-owned retained-compaction input (`ocx2:`) on a compact +request returns HTTP 400 with `error.type: "invalid_request_error"` before +routing, account selection, credential refresh, or upstream dispatch. This +caller validation error does not trigger upstream retries or record a failure +against a pooled Codex account. + 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/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 59019908f43..ba56f2852bb 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -318,6 +318,8 @@ Proxy нормализует upstream-join URL и затем прозрачно ## `POST /v1/responses/compact` +Если compact-запрос содержит некорректные данные retained-compaction в формате OpenCodex (`ocx2:`), возвращается HTTP 400 с `error.type: "invalid_request_error"`. Порядок проверки и влияние на состояние аккаунта описаны в [каноническом справочнике на английском языке](/reference/proxy-formats/#post-v1responsescompact). + Compaction возвращает replacement history для клиентов, которым нужно сократить длинный Responses conversation. 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 395837a2b29..840481180d4 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 @@ -259,6 +259,8 @@ Connections > API keys 包含独立的听写和实时语音区域。数据密钥 ## `POST /v1/responses/compact` +如果 compact 请求包含格式错误的 OpenCodex 自有 retained-compaction 输入(`ocx2:`),则返回 HTTP 400,且 `error.type` 为 `"invalid_request_error"`。验证时机及对账号状态的影响见[英文规范参考](/reference/proxy-formats/#post-v1responsescompact)。 + Compaction 会为需要缩短长 Responses 会话的客户端返回替换历史。 | 路由类型 | 行为 | diff --git a/src/responses/retained-compaction.ts b/src/responses/retained-compaction.ts index 4552503a207..28c13a27b37 100644 --- a/src/responses/retained-compaction.ts +++ b/src/responses/retained-compaction.ts @@ -8,6 +8,14 @@ export interface RetainedCompaction { reasoning: string[]; } +/** Raised when a proxy-owned retained compaction envelope fails to decode. */ +export class InvalidRetainedCompactionEnvelopeError extends Error { + constructor() { + super("Invalid OpenCodex retained compaction envelope"); + this.name = "InvalidRetainedCompactionEnvelopeError"; + } +} + export function encodeRetainedCompaction(summary: string, reasoning: string[]): string { return RETAINED_COMPACTION_PREFIX + Buffer.from(JSON.stringify({ version: 2, summary, reasoning }), "utf-8").toString("base64"); } @@ -28,16 +36,34 @@ export function decodeRetainedCompaction(value: string): RetainedCompaction | nu } catch { return null; } } +/** True when `item` carries a proxy-owned retained envelope marker, valid payload or not. */ +function isRetainedCompactionEnvelopeItem(item: unknown): item is { type: string; encrypted_content: string } { + if (!item || typeof item !== "object") return false; + const candidate = item as { type?: unknown; encrypted_content?: unknown }; + return typeof candidate.type === "string" + && ["compaction", "compaction_summary", "context_compaction"].includes(candidate.type) + && typeof candidate.encrypted_content === "string" + && candidate.encrypted_content.startsWith(RETAINED_COMPACTION_PREFIX); +} + +/** First malformed proxy-owned retained envelope in `input`, if any. */ +export function findInvalidRetainedCompactionEnvelope(input: unknown): InvalidRetainedCompactionEnvelopeError | null { + if (!Array.isArray(input)) return null; + for (const item of input) { + if (!isRetainedCompactionEnvelopeItem(item)) continue; + if (!decodeRetainedCompaction(item.encrypted_content)) return new InvalidRetainedCompactionEnvelopeError(); + } + return null; +} + /** Expand only our structured envelopes; callers retain their existing native/ocx1 policy. */ export function expandRetainedCompactionInput(input: unknown): unknown { if (!Array.isArray(input)) return input; - if (!input.some(item => item && typeof item === "object" && ["compaction", "compaction_summary", "context_compaction"].includes(item.type) - && typeof item.encrypted_content === "string" && item.encrypted_content.startsWith(RETAINED_COMPACTION_PREFIX))) return input; + if (!input.some(isRetainedCompactionEnvelopeItem)) return input; return input.flatMap(item => { - if (!item || typeof item !== "object" || !["compaction", "compaction_summary", "context_compaction"].includes(item.type) - || typeof item.encrypted_content !== "string" || !item.encrypted_content.startsWith(RETAINED_COMPACTION_PREFIX)) return [item]; + if (!isRetainedCompactionEnvelopeItem(item)) return [item]; const data = decodeRetainedCompaction(item.encrypted_content); - if (!data) throw new Error("Invalid OpenCodex retained compaction envelope"); + if (!data) throw new InvalidRetainedCompactionEnvelopeError(); return [ { ...item, encrypted_content: "ocx1:" + Buffer.from(data.summary, "utf-8").toString("base64") }, ...data.reasoning.map(text => ({ type: "message", role: "user", content: [{ type: "input_text", text }] })), diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index e9bb55f4acd..8867cdc3a78 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -152,6 +152,7 @@ import { requiresPlaintextReasoningReplay } from "../../adapters/openai-response import { destinationDecodesNativeCompactionBlob, isOpenAiOperatedResponsesDestination } from "../../providers/openai-tiers-destination"; import { bindRouteReasoningReplayScope } from "./core-replay"; import { commitReasoningReplayServingIdentity } from "../../responses/reasoning-replay-cache"; +import { findInvalidRetainedCompactionEnvelope, InvalidRetainedCompactionEnvelopeError } from "../../responses/retained-compaction"; import { nativeCompactResponseSchema } from "../../responses/schema"; import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; import { @@ -670,6 +671,11 @@ export async function bufferCompactResponse( +/** + * Route one `/v1/responses/compact` request: validate the body (rejecting malformed + * proxy-owned retained envelopes before routing or credential resolution), select the + * compact target, and dispatch through the adapter pipeline. + */ export async function handleResponsesCompact( req: Request, config: OcxConfig, @@ -699,6 +705,10 @@ export async function handleResponsesCompact( if (typeof raw.model !== "string" || raw.model.length === 0) { return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model"); } + // A malformed proxy-owned envelope is a caller error, so reject it before routing or + // credential resolution can spend a token refresh on a request that cannot succeed. + const invalidEnvelope = findInvalidRetainedCompactionEnvelope(raw.input); + if (invalidEnvelope) return formatErrorResponse(400, "invalid_request_error", invalidEnvelope.message); options.onRequestBodyRead?.(); // Correct the IDENTITY before routing, or the synthetic id does not route at all. Held in // a local rather than written back to `raw.model`: assigning to the property widens it out @@ -1305,7 +1315,9 @@ export async function handleResponsesCompact( let outcomeCtx = authCtx; const localDispatchRefusal = (error: unknown): Response | undefined => { const cause = unwrapUpstreamRetryEvidenceError(error); - const response = requestPacingOverloadResponse(cause) ?? spendLedgerStorageErrorResponse(error, logCtx) ?? (cause instanceof SendBudgetExhaustedError + const response = cause instanceof InvalidRetainedCompactionEnvelopeError + ? formatErrorResponse(400, "invalid_request_error", cause.message) + : requestPacingOverloadResponse(cause) ?? spendLedgerStorageErrorResponse(error, logCtx) ?? (cause instanceof SendBudgetExhaustedError ? unboundPoolSpendRefusalResponse(logCtx) ?? formatErrorResponse(429, "request_send_budget_exhausted", cause.message) : mapCodexAuthContextErrorToResponse(cause, { now: Date.now(), accountSelector: route.codexAccountNamespace, diff --git a/tests/responses/compact-transient-alternate.test.ts b/tests/responses/compact-transient-alternate.test.ts index 92112f605ed..50c223b47c1 100644 --- a/tests/responses/compact-transient-alternate.test.ts +++ b/tests/responses/compact-transient-alternate.test.ts @@ -152,6 +152,62 @@ function failures(accountId: string): number { return getCodexUpstreamHealth(accountId)?.consecutiveFailures ?? 0; } +test("malformed retained compact input does not poison pooled account health", async () => { + const config = poolConfig(); + const healthBefore = [WORK, OTHER].map(id => getCodexUpstreamHealth(id)); + upstream(compacted); + for (let attempt = 0; attempt < 3; attempt++) { + const request = new Request("http://localhost/v1/responses/compact", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.5", input: [{ type: "compaction", encrypted_content: "ocx2:invalid" }] }), + }); + const response = await handleResponsesCompact(request, config, { model: "", provider: "" }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { type: "invalid_request_error" } }); + expect(sends).toHaveLength(0); + expect([WORK, OTHER].map(id => getCodexUpstreamHealth(id))).toEqual(healthBefore); + } + const { response } = await compact({ config }); + expect(response.status).toBe(200); + expect(sends).toHaveLength(1); + expect(failures(WORK)).toBe(0); + expect(failures(OTHER)).toBe(0); +}); + +test("malformed retained compact input is rejected before a pooled credential refresh", async () => { + // expiresAt inside the 60s refresh skew makes getValidCodexToken refresh, so an + // unvalidated envelope would spend a token-refresh fetch before the 400. + writeFileSync(join(home, "codex-accounts.json"), JSON.stringify(Object.fromEntries( + [WORK, OTHER].map(id => [id, { + credential: { + accessToken: `${id}-access`, + refreshToken: `${id}-grant`, + expiresAt: Date.now() + 30_000, + chatgptAccountId: `acc-${id}`, + }, + generation: 1, + refreshGrantFingerprint: createHash("sha256").update(`codex-refresh-grant:${id}-grant`).digest("hex"), + }]), + ))); + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls++; + return Response.json({ access_token: "refreshed-access", refresh_token: "refreshed-grant", expires_in: 3600 }); + }) as typeof fetch; + const request = new Request("http://localhost/v1/responses/compact", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.5", input: [{ type: "compaction", encrypted_content: "ocx2:invalid" }] }), + }); + + const response = await handleResponsesCompact(request, poolConfig(), { model: "", provider: "" }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { type: "invalid_request_error" } }); + expect(fetchCalls).toBe(0); +}); + describe("native compact moves a surviving transient 5xx to one alternate account (#6969)", () => { test("A's ladder, then B from the final recovery reserve, serves the compact", async () => { upstream(overloaded(), overloaded(), overloaded(), compacted); diff --git a/tests/responses/responses-native-compact-ocx-envelope.test.ts b/tests/responses/responses-native-compact-ocx-envelope.test.ts index e7697daf743..c0c4af69f2c 100644 --- a/tests/responses/responses-native-compact-ocx-envelope.test.ts +++ b/tests/responses/responses-native-compact-ocx-envelope.test.ts @@ -15,6 +15,10 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; // envelopes verbatim, and the upstream refused them as invalid_encrypted_content. Only the custom // gateway send lowered them; ordinary turns always do. const fetchBefore = globalThis.fetch; const homeBefore = process.env.OPENCODEX_HOME; +const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; +let proxyBefore: Array = []; +beforeEach(() => { proxyBefore = proxyKeys.map(key => process.env[key]); for (const key of proxyKeys) delete process.env[key]; }); +afterEach(() => { for (const [index, key] of proxyKeys.entries()) { if (proxyBefore[index] === undefined) delete process.env[key]; else process.env[key] = proxyBefore[index]; } }); let home: string; let release: (() => void) | undefined; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-native-compact-ocx-envelope-")); process.env.OPENCODEX_HOME = home; release = acquireOwnedSpendHome(); clearCompactHandoffRoutesForTests(); }); afterEach(() => { globalThis.fetch = fetchBefore; release?.(); clearCompactHandoffRoutesForTests(); if (homeBefore === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = homeBefore; removeTreeWithRetry(home); }); @@ -66,3 +70,22 @@ test.each([ nativeItem(), ]); }); + +test("malformed ocx2 compact input is rejected locally as a caller error", async () => { + const settings = getDefaultConfig(); delete settings.compactionRouting; settings.defaultProvider = "openai"; settings.providers = { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex" }, + }; + let fetchCalls = 0; + globalThis.fetch = (async () => { fetchCalls++; return Response.json({}); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses/compact", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}` }, + body: JSON.stringify({ model: "gpt-6-luna", input: [{ type: "compaction", encrypted_content: "ocx2:invalid" }] }), + }); + + const response = await handleResponsesCompact(req, settings, { model: "", provider: "" }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { type: "invalid_request_error" } }); + expect(fetchCalls).toBe(0); +});