Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ja/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)を参照してください。

圧縮により、長い応答会話を短縮する必要があるクライアントの置換履歴が返されます。

|ルートの種類 |行動 |
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 대화를 줄여야 하는 클라이언트를 위해 대체 히스토리를 반환합니다.

| 경로 유형 | 동작 |
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
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
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ru/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-cn/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 会话的客户端返回替换历史。

| 路由类型 | 行为 |
Expand Down
36 changes: 31 additions & 5 deletions src/responses/retained-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
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 }] })),
Expand Down
14 changes: 13 additions & 1 deletion src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: 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,
Expand Down
56 changes: 56 additions & 0 deletions tests/responses/compact-transient-alternate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions tests/responses/responses-native-compact-ocx-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> = [];
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); });
Expand Down Expand Up @@ -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);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
});
Loading