Skip to content
Merged
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
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,15 @@ The cache remains bounded; this does not extend retention or recover history the
longer has. HTTP clients must handle the error explicitly and resend their full context without
`previous_response_id`. Retrying only the same ID cannot recover missing state.

The same recovery signal applies to routed Responses providers configured with
`statelessResponses: true`, and to routed requests where a custom tool was lowered to a function
but a delta result has no local call to establish its original type. Full replay preserves the
call, result, and reasoning together; opencodex does not guess the result type or drop it.
Stateful providers still resolve native function and native-only custom continuations themselves.
These checks follow the selected wire protocol and tool declarations, not the model name. For a
gateway that cannot resolve stored response IDs, enable `statelessResponses` on that provider;
other providers keep their defaults.

### Client-side compaction (opt-in)

Authenticated loopback routing normally keeps Codex on its built-in `openai` provider identity.
Expand Down
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex W
`previous_response_id` 없이 전체 컨텍스트를 다시 보내야 합니다. 같은 ID만 재시도해서는
누락된 상태를 복구할 수 없습니다.

`statelessResponses: true`로 설정한 routed Responses provider에도 같은 복구 신호가 적용됩니다.
routed 경로에서 custom 도구를 function으로 낮췄는데 증분 결과에 대응하는 로컬 호출 기록이
없을 때도 전체 기록을 다시 요청합니다. 호출과 결과, reasoning을 함께 재생하며 결과 유형을
추측하거나 버리지 않습니다. 상태를 저장하는 provider의 네이티브 function 및 네이티브 custom
전용 continuation은 그대로 전달됩니다. 이 검사는 모델명이 아니라 선택된 wire protocol과 도구
선언을 따릅니다. 저장된 response ID를 복원하지 못하는 gateway에는 해당 provider의
`statelessResponses`를 명시적으로 켜세요. 다른 provider의 기본값은 바뀌지 않습니다.

## 스레드 식별자와 대화 기록

기본 loopback 형식은 새 thread에 네이티브 `openai` provider 태그를 유지하므로 일반적인 resume history는 다시 매핑할 필요가 없습니다. sync와 restore는 일치하는 백업 manifest만 적용하여 각 thread의 원래 provider, source, event marker를 정확히 복원합니다. manifest가 없는 `opencodex` row는 변경하지 않으며, legacy 재태깅을 명시적으로 강제하려는 경우에만 `ocx recover-history --legacy-openai --yes`를 사용합니다. 이 명령은 의도적으로 범위가 넓습니다. 사용자 메시지가 있고 현재 `opencodex`로 표시된 모든 thread를 `openai`로 바꾸고, `exec`를 `cli`로 정규화하며 event marker를 설정합니다. 정상적인 dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 범위를 의도한 경우에만 사용하세요. non-loopback 전용 provider 모드는 활성 상태일 때만 history를 `opencodex` provider 아래로 미러링하고, 종료할 때는 백업된 메타데이터를 복원합니다. history를 건드리지 않으려면 `syncResumeHistory: false`로 설정하세요.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -958,7 +958,8 @@ representation returned to the client, including the visible content-to-summary
echoing full history with `previous_response_id` does not duplicate that history. Hidden-summary
requests and opaque reasoning blobs retain their existing representation. Cache hits can also
supply earlier history for delta continuations;
after a cache miss, resend the complete conversation without `previous_response_id`. Stateless
after a cache miss, the proxy returns `previous_response_not_found` before upstream dispatch so the
client can resend the complete conversation without `previous_response_id`. Stateless
repair labels orphan results and missing tool results; it cannot reconstruct lost history or
prove whether a missing tool execution succeeded.

Expand Down
23 changes: 23 additions & 0 deletions src/responses/custom-tool-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,29 @@ export function rewriteRoutedCustomToolsForUpstream(
return { body: rewriteForUpstream(body, conversionNames, callIds), names, repairNames };
}

/**
* A delta result has no tool name. Without its call, lowering cannot tell whether it belongs
* to a converted function or a native custom tool. Request full replay instead of guessing.
*/
export function hasUnmappedRoutedCustomToolOutput(
body: unknown,
supportsResponsesCustomTools?: boolean,
): boolean {
if (!isPlainObject(body) || !Array.isArray(body.input)) return false;
if (collectRoutedCustomToolNames(body, supportsResponsesCustomTools).size === 0) return false;
const callIds = new Set<string>();
for (const item of body.input) {
if (isPlainObject(item)
&& (item.type === "custom_tool_call" || item.type === "function_call")
&& typeof item.call_id === "string") callIds.add(item.call_id);
}
return body.input.some(item => isPlainObject(item)
&& item.type === "custom_tool_call_output"
&& typeof item.call_id === "string"
&& item.call_id.length > 0
&& !callIds.has(item.call_id));
}

export function restoreRoutedCustomCalls(
value: unknown,
names: ReadonlySet<string>,
Expand Down
18 changes: 17 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ import {
payloadRewriteAsBlockRewrite,
relaySseWithBlockRewrite,
} from "../sse-payload-rewrite";
import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
import { hasUnmappedRoutedCustomToolOutput, restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
import { collectFunctionCallRepairSchemas, repairFunctionCallsInJson } from "../../responses/function-call-compat";
import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair";
Expand Down Expand Up @@ -3902,6 +3902,22 @@ async function handleResponsesInner(
);
}

if (hasUnexpandedPreviousResponse) {
const continuationProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
// Stateless destinations cannot resolve the omitted prefix. Stateful destinations may,
// but a lowered custom result still needs its call to recover the original wire type.
// Native function/custom continuations without lowering keep their upstream-owned state.
if (continuationProvider.adapter === "openai-responses"
&& (continuationProvider.statelessResponses === true
|| hasUnmappedRoutedCustomToolOutput(parsed._rawBody, continuationProvider.supportsResponsesCustomTools))) {
return formatErrorResponse(
400,
"previous_response_not_found",
"Routed continuation requires unavailable local history; resend the full conversation without previous_response_id.",
);
}
}

// Captured before normalization: whether the CLIENT asked for SSE. The
// transport-neutral upstream-streaming policy below may force a bounded JSON
// upstream for reliability (#875); the answer must then be reframed to SSE
Expand Down
4 changes: 2 additions & 2 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ export interface OcxProviderConfig {
/**
* Responses upstream that stores nothing server-side (DeepSeek documents "the API
* is stateless"). Stateful request parameters are dropped, `store` is pinned false,
* and orphaned tool results left by a replay miss are repaired rather than
* forwarded to an upstream that cannot resolve their pair.
* and missing local continuation history returns previous_response_not_found so
* clients can resend full input. Explicit input still receives orphan-item repair.
*/
statelessResponses?: boolean;
/**
Expand Down
2 changes: 2 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ an unowned client remains disconnected. OpenCodex omits Raycast API-key fields
and exports only to eligible local targets. Pro detection is an advisory hint,
not an authentication or entitlement decision.

Routed Responses continuations whose local replay state is missing resolve their recovery decision from the selected wire protocol, not the model name; the contract lives in [Responses transport](transports/responses.md).

## Remote Hub hardening ownership

`src/remote/protocol.ts` owns pure interval/feature negotiation. `src/remote/hub-state.ts` owns the `GET|HEAD /v1/hub-state` contract, its caps, and the parser both sides share. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption, hub-state reads, and key-id probes; `src/client/hub-state.ts` owns the resolution and the owner-stamped 0600 cache, and a failed read reports "unavailable" rather than degrading to the client's own local provider and login state. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes.
Expand Down
8 changes: 7 additions & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,13 @@ logic fills absent values and preserves explicit false; renamed custom configura
no new destination-based migration. The existing stateless pass sets `store: false`, removes
stored continuation parameters, and repairs orphan calls/results without claiming execution
success. A local replay-cache hit supplies history; a miss cannot reconstruct it, so callers
must resend complete history without `previous_response_id`. This flag also enables the existing
receive `previous_response_not_found` before upstream dispatch and must resend complete history
without `previous_response_id`. Routed custom-tool lowering requires the same recovery when a delta
custom result has no local call, because its original wire type cannot be established and guessing it
would send an unmatched result upstream. The check resolves the selected wire protocol and the
request's own tool declarations after final route selection, so stateful destinations keep their
upstream-owned native function and native-only custom continuations. Explicit input still receives
orphan repair; this path asks the client to replay rather than reconstructing history. This flag also enables the existing
visible content-to-summary rewrite for SSE and JSON; summary-channel items and opaque reasoning
blobs keep their existing response handling. The shared recording callback applies the same
reasoning rewrite under the exact client-visible predicate before caching output, after tool
Expand Down
132 changes: 131 additions & 1 deletion tests/codex-integration/issue-702-expired-replay-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,126 @@ afterEach(() => {
else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken;
});

describe("routed replay recovery", () => {
test.each([
{ stateless: true, custom: false, expired: false },
{ stateless: true, custom: false, expired: true },
{ stateless: false, custom: true, expired: false },
{ stateless: false, custom: true, expired: true },
{ stateless: false, custom: true, expired: false, authMode: "forward" },
{ stateless: true, custom: false, expired: false, adapter: "openai-chat" },
] as const)("recovers missing history without orphaning tool results: %j", async scenario => {
const { stateless, custom, expired } = scenario;
const upstreamRequests: Record<string, unknown>[] = [];
const realNow = Date.now;
const toolCall = custom
? { type: "custom_tool_call", call_id: "call_replay", name: "exec", input: "text(1)", status: "completed" }
: { type: "function_call", call_id: "call_replay", name: "lookup", arguments: "{}", status: "completed" };
const toolResult = {
type: custom ? "custom_tool_call_output" : "function_call_output",
call_id: "call_replay", output: "1",
};
const reasoning = { type: "reasoning", content: [{ type: "reasoning_text", text: "Use the lookup result." }] };
const history = [inputMessage(HISTORICAL_USER_SENTINEL), reasoning, toolCall];
const tools = custom
? [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "text" } }]
: [{ type: "function", name: "lookup", parameters: { type: "object", properties: {} } }];
const upstream = Bun.serve({
port: 0,
async fetch(request) {
const body = await request.json() as Record<string, unknown>;
upstreamRequests.push(body);
const items = body.input as Array<Record<string, unknown>>;
// A strict upstream cannot resolve a tool result without its matching function call.
if (!items.some(item => item.type === "function_call" && item.call_id === "call_replay")
|| !items.some(item => item.type === "function_call_output" && item.call_id === "call_replay")) {
return Response.json({ error: { type: "invalid_request_error", message: "No matching tool call" } }, { status: 400 });
}
return new Response(completedSse("resp_routed_recovered", "recovered"), {
headers: { "content-type": "text/event-stream" },
});
},
});
let server: ReturnType<typeof startServer> | null = null;
let socket: WebSocket | null = null;
try {
if (expired) {
Date.now = () => realNow() - EXPIRED_AGE_MS;
rememberResponseState(
{ input: [history[0]], tools, store: false },
{ id: FIRST_RESPONSE_ID, status: "completed", output: [reasoning, toolCall] },
undefined, { force: true },
);
Date.now = realNow;
}
saveConfig({
port: 0, hostname: "127.0.0.1", websockets: true, defaultProvider: "routed-test",
providers: {
"routed-test": {
adapter: "adapter" in scenario ? scenario.adapter : "openai-responses",
modelAdapters: { "test-model": "openai-responses" },
baseUrl: upstream.url.toString(), allowPrivateNetwork: true,
authMode: "authMode" in scenario ? scenario.authMode : "key", apiKey: "synthetic-key", defaultModel: "test-model",
statelessResponses: stateless, preserveResponsesReasoningContent: true,
},
},
} as OcxConfig);
server = startServer(0);
const deltaRequest = {
model: "routed-test/test-model", previous_response_id: FIRST_RESPONSE_ID,
input: [toolResult], tools, store: false,
};
const httpRejected = await originalFetch(new URL("/v1/responses", server.url), {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(deltaRequest),
});
expect(httpRejected.status).toBe(400);
expect(await httpRejected.json()).toMatchObject({ error: { code: "previous_response_not_found" } });
expect(upstreamRequests).toHaveLength(0);
socket = await openResponseSocket(server.url, {});
const rejected = await sendSocketTurn(socket, deltaRequest);
expect(rejected).toMatchObject({
type: "error", status: 400,
error: { type: "invalid_request_error", code: "previous_response_not_found" },
});
expect(upstreamRequests).toHaveLength(0);

socket.close();
socket = await openResponseSocket(server.url, {});
const recovered = await sendSocketTurn(socket, {
model: "routed-test/test-model", input: [...history, toolResult], tools, store: false,
});
expect(recovered.type).toBe("response.completed");
expect(upstreamRequests).toHaveLength(1);
expect(upstreamRequests[0]!.previous_response_id).toBeUndefined();
expect(upstreamRequests[0]!.input).toEqual([
history[0], reasoning,
{ type: "function_call", call_id: "call_replay", name: custom ? "exec" : "lookup",
arguments: custom ? JSON.stringify({ input: "text(1)" }) : "{}", status: "completed" },
{ ...toolResult, type: "function_call_output" },
]);
await waitForRecordedResponseState();

// Recovery must seed complete local history so the next delta does not repeat the miss.
const continued = await sendSocketTurn(socket, {
model: "routed-test/test-model", previous_response_id: "resp_routed_recovered",
input: [inputMessage(CURRENT_USER_SENTINEL)], tools, store: false,
});
expect(continued.type).toBe("response.completed");
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[1]!.previous_response_id).toBeUndefined();
const items = upstreamRequests[1]!.input as Array<Record<string, unknown>>;
expect(items.filter(item => item.call_id === "call_replay")).toHaveLength(2);
expect(items.at(-1)).toEqual(inputMessage(CURRENT_USER_SENTINEL));
} finally {
Date.now = realNow;
socket?.close();
await server?.stop(true);
await upstream.stop(true);
}
}, SERVER_BUDGET_MS);
});

describe("Issue #702 expired forward replay state", () => {
test("known continuation spill failure returns terminal structured previous_response_not_found before upstream I/O", async () => {
const responseId = "resp_issue_702_missing_spill";
Expand Down Expand Up @@ -512,7 +632,7 @@ describe("Issue #702 expired forward replay state", () => {
expect(JSON.stringify(scenario.upstreamRequests[0]!.body)).toContain(HISTORICAL_USER_SENTINEL);
});

test("API-key Responses providers can still forward native previous_response_id state", async () => {
test.each(["message", "function", "custom"] as const)("API-key Responses providers can still forward native %s continuation state", async kind => {
const upstreamRequests: Record<string, unknown>[] = [];
const realNow = Date.now;
let upstream: ReturnType<typeof Bun.serve> | null = null;
Expand All @@ -539,6 +659,7 @@ describe("Issue #702 expired forward replay state", () => {
allowPrivateNetwork: true,
apiKey: "provider-key",
defaultModel: "gpt-5.5",
statelessResponses: false,
},
},
} as OcxConfig);
Expand All @@ -551,13 +672,22 @@ describe("Issue #702 expired forward replay state", () => {
model: "test-openai/gpt-5.5",
previous_response_id: "resp_upstream_native_state",
input: [inputMessage(CURRENT_USER_SENTINEL)],
...(kind === "message" ? {} : {
tools: [kind === "custom"
? { type: "custom", name: "apply_patch", description: "Apply patch", format: { type: "text" } }
: { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }],
input: [{ type: kind === "custom" ? "custom_tool_call_output" : "function_call_output", call_id: "call_native", output: "done" }],
}),
stream: true,
}),
});
expect(response.status).toBe(200);
await response.text();
expect(upstreamRequests).toHaveLength(1);
expect(upstreamRequests[0]!.previous_response_id).toBe("resp_upstream_native_state");
if (kind !== "message") expect(upstreamRequests[0]!.input).toEqual([
{ type: kind === "custom" ? "custom_tool_call_output" : "function_call_output", call_id: "call_native", output: "done" },
]);
} finally {
Date.now = realNow;
globalThis.fetch = originalFetch;
Expand Down
Loading
Loading