Skip to content
Closed
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ the same serialized request and records the recovery in Logs. Other 400 errors,
custom destinations, cancellations and repeated upload rejections remain failures.
This does not retry filtered model responses or interrupted streams.

## Codex-native ordinary Lite hosted-search history

For ordinary requests to canonical ChatGPT Codex `/responses`, only explicitly selected Responses Lite requests project unreferenced remaining `web_search_call` history into labeled assistant reference notes. The final `x-openai-internal-codex-responses-lite` header selects Lite only when its value is exactly `true`; an explicit `false` overrides frame metadata. Without a recognized header value, a valid string-map `client_metadata.ws_request_header_x_openai_internal_codex_responses_lite` value of `true` is used, matching the existing WS metadata handling. Non-Lite or unrecognized effective intent retains structural history; malformed metadata does not override an explicit HTTP `true`. Model names, subscription authentication, missing hosted declarations and `additional_tools` alone do not select this policy; no header or metadata is rewritten to make it apply. It applies to new threads/forks, cross-model continuations and cache misses/restarts, without diagnostic settings. Only a valid same-scope bridge memo proves an original function call/result that can be restored first; an ID prefix, model name or missing memo cannot determine the issuer. A hosted cell exactly referenced by a same-input `item_reference.id` is retained. `store:false` removes references earlier, and its remaining hosted history can be projected. Tool declarations/Lite `additional_tools`, choices, client call/result pairs, reasoning/native ciphertext, non-hosted order and caller raw history retain their existing contracts.

Within that Lite boundary, this is an explicit **lossy history policy**, not a claim that hosted calls are invalid schema or that all upstream errors are fixed. Notes carry untrusted status/action metadata and allowlisted URL/title/type from both action and top-level sources, never instructions or fetched page bodies: 2,048 UTF-16 units per string, 20 entries per list, 65,536 serialized bytes for all projected notes, then an omission note. IDs and unknown fields are dropped from converted cells. HTTP/SSE and WS share the final ordinary builder.

Public OpenAI API and other destinations do not gain ordinary or native-compact projection. Native `/responses/compact` remains its existing endpoint/account-scrub/official opaque-output path and retains structural hosted history. Portable/routed summaries reuse their existing projection trigger and default reference behavior; the shared renderer additionally retains allowlisted top-level sources there. No new retry, source classification or compaction summarizer is introduced.

## Empty search answers

After hosted search, a clean but empty forced-answer pass receives one additional answer
Expand Down
8 changes: 8 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 @@ -315,3 +315,11 @@ Anthropic 来源的失败会以 Anthropic 的错误封装呈现,因此该方
重放的推理项携带的 `encrypted_content` 只有生成它的 provider 和凭据才能读取。如果 opencodex 知道该对话上一次由另一个 provider 处理,它会在发送前移除这个 blob,并保留该项的摘要。如果那个 provider 还使用了不同的 endpoint 或凭据,该项的 `rs_…` id 也会被移除,因为它指向新目标无法查到的项。如果 opencodex 无从得知,例如代理重启之后,新目标会拒绝这个 blob:OpenAI 和 Azure OpenAI 返回 `400 invalid_encrypted_content`。此时 opencodex 会去掉上一个 provider 的推理状态(blob 和 `rs_…` id)后只重发一次请求;保留 id 会导致 `Item with id 'rs_…' not found`。

这项恢复适用于所有使用 Responses 协议的 adapter,因此 `openai-responses` 和 `azure-openai` 的行为相同。恢复成功后,该对话在同一目标上的后续轮次会在接下来五分钟内于首次发送前移除这些状态。重发计入请求的常规发送预算。普通的 400 和 429 不会以这种方式重发,5xx 也不会,只有一个狭窄的例外:对于携带加密工具输出的请求,响应体恰好是该解密拒绝的 502 会得到同样的一次重发。第二次拒绝会原样返回给客户端。遇到这种情况,请在目标 provider 上开始新的对话。

## Codex 原生普通 Lite 请求的搜索历史

发往标准 ChatGPT Codex `/responses` 的普通请求中,只有明确选择 Responses Lite 的请求才会把未被同输入引用的剩余 `web_search_call` 历史转为带标签的 assistant 参考说明。最终 `x-openai-internal-codex-responses-lite` header 值精确为 `true` 时选择 Lite;明确的 `false` 优先于 frame metadata。没有可识别 header 值时,采用有效字符串 map 中 `client_metadata.ws_request_header_x_openai_internal_codex_responses_lite` 的 `true` 值,沿用既有 WS metadata 语义。最终有效意图为非 Lite 或无法识别时保留结构化历史;格式无效的 metadata 不会覆盖明确的 HTTP `true`。模型名、订阅鉴权、hosted 声明缺失或仅有 `additional_tools` 都不能选择这项策略;不会为触发转换而改写 header 或 metadata。新 thread、fork、跨模型续聊、缓存未命中或重启后都适用,不依赖诊断设置。只有有效同 scope bridge memo 能证明并先恢复原函数调用及结果;ID 前缀、模型名或 memo 缺失不能判断来源。若同输入的 `item_reference.id` 精确指向某 hosted cell,该 cell 保留;`store:false` 会先移除引用,剩余 hosted 历史可投影。工具声明、Lite `additional_tools`、选择、call/result 配对、推理/原生密文、非 hosted 顺序及调用方 raw 历史保持原契约。

这是仅在上述 Lite 边界内默认采用的**有损历史策略**,不是 hosted schema 非法或所有上游错误已修复的断言。说明标为不可信参考数据,复制 status/action 及 action 或顶层 sources 中允许的 URL/title/type,不是指令或网页正文;每字符串最多 2,048 个 UTF-16 单元、每列表 20 项、全部已投影说明共 65,536 字节,超出的 action 用省略说明替代,转换项不复制 ID 或未知字段。HTTP/SSE 与 WS 共用普通请求的最终 builder。

公开 OpenAI API 及其他目的地不新增普通或 native compact 投影。原生 `/responses/compact` 保持既有端点、账户清理及官方 opaque 输出路径,保留结构化 hosted 历史。portable/routed 摘要仍沿用原有触发条件与默认引用行为;共享 renderer 在这些既有路径中也补充顶层 sources 白名单保留。本次不增加重试、来源分类或新的 compaction summarizer。
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
"explicit": {
"explicit": { "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses",
"messages-request-id-headers.test.ts": "claude-integration",
"messages-request-id-endpoint.test.ts": "claude-integration",
"anthropic-revoked-token.test.ts": "adapters/anthropic",
Expand Down
21 changes: 14 additions & 7 deletions src/adapters/openai-responses/compaction-search-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const MAX_SEARCH_NOTE_FIELD_CHARS = 2048;
/** Most `queries` or `sources` entries copied from one hosted call. */
export const MAX_SEARCH_NOTE_LIST_ENTRIES = 20;
/**
* Serialized bytes all notes of one summary request may use, the omission note included. Request
* Serialized bytes all projected notes of one request may use, the omission note included. Request
* body limits are off by default, so the projection carries its own bound.
*/
export const MAX_PROJECTED_SEARCH_NOTE_BYTES = 65_536;
Expand Down Expand Up @@ -49,14 +49,19 @@ function noteBytes(note: unknown): number {
}

function omissionNote(count: number): Record<string, unknown> {
return referenceNote(`${count} further hosted web search actions omitted from this summary request.`);
return referenceNote(`${count} further hosted web search actions omitted from this request.`);
}

/** Request-local projection of one hosted call for a summarizer with no hosted tool declarations. */
/** Request-local metadata projection for native Codex replay and portable summaries. */
export function renderCompactionSearchHistory(item: unknown): unknown {
if (!isPlainObject(item) || item.type !== "web_search_call") return item;
// Allowlist action metadata, not opaque state, IDs, or unknown provider fields.
const metadata: Record<string, unknown> = stringFields(item, ["status"]);
if (Array.isArray(item.sources)) {
metadata.sources = firstEntries(item.sources, source => isPlainObject(source) && typeof source.url === "string"
? stringFields(source, ["type", "url", "title"])
: undefined);
}
if (isPlainObject(item.action)) {
const action: Record<string, unknown> = stringFields(item.action, ["type", "query", "url", "pattern"]);
if (Array.isArray(item.action.queries)) {
Expand All @@ -73,21 +78,23 @@ export function renderCompactionSearchHistory(item: unknown): unknown {
}

/**
* Project every hosted call in a summary request's input, in place order, within
* Project every hosted call in the selected input, in place order, within
* MAX_PROJECTED_SEARCH_NOTE_BYTES. Once the next note would cross the budget less the reserved
* omission note, that call and every later hosted call are dropped, and one omission note stands at
* the first dropped position. Items other than hosted calls pass through untouched.
*/
export function renderCompactionSearchHistoryItems(items: readonly unknown[]): unknown[] {
const hostedCount = items.filter(item => isPlainObject(item) && item.type === "web_search_call").length;
export function renderCompactionSearchHistoryItems(items: readonly unknown[], referencedHostedIds?: ReadonlySet<string>): unknown[] {
const shouldProject = (item: unknown) => isPlainObject(item) && item.type === "web_search_call"
&& !(typeof item.id === "string" && referencedHostedIds?.has(item.id));
const hostedCount = items.filter(shouldProject).length;
if (hostedCount === 0) return [...items];
const reserve = noteBytes(omissionNote(hostedCount));
const out: unknown[] = [];
let used = 0;
let omitted = 0;
let omissionIndex = -1;
for (const item of items) {
if (!isPlainObject(item) || item.type !== "web_search_call") {
if (!shouldProject(item)) {
out.push(item);
continue;
}
Expand Down
23 changes: 22 additions & 1 deletion src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { applyGithubCopilotContextTier } from "../../providers/github-copilot-co
import { Buffer } from "node:buffer";
import type { IncomingMeta, ProviderAdapter } from "../base";
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types";
import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../../codex/forward-transport-headers";
import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_RESPONSES_LITE_METADATA_KEY, CODEX_ROUTING_HINT_HEADER } from "../../codex/forward-transport-headers";
import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType, transferCompactionCiphertextLease } from "../../responses/compaction";
import { decodeServerSentEvents } from "../../lib/sse-decoder";
import {
Expand Down Expand Up @@ -125,6 +125,16 @@ function stripInputImagesDeep(value: unknown): unknown {
return out;
}

function codexResponsesLiteSelected(headers: Record<string, string>, body: unknown): boolean {
const lite = new Headers(headers).get(CODEX_RESPONSES_LITE_HEADER);
if (lite === "true" || lite === "false") return lite === "true";
// Match existing canonical WS preparation: explicit HTTP intent wins, otherwise native
// string-map frame metadata survives. This reads intent without rewriting the transport.
if (!isPlainObject(body) || !isPlainObject(body.client_metadata)) return false;
return Object.values(body.client_metadata).every(value => typeof value === "string")
&& body.client_metadata[CODEX_RESPONSES_LITE_METADATA_KEY] === "true";
}

/**
* 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
Expand Down Expand Up @@ -571,6 +581,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
// here, on the serialized body, not on the parsed selector. One place covers both the
// HTTP and the WebSocket outbound, because the WS path transports this same request
// instead of rebuilding it.
// Codex Lite history policy: replay remaining hosted actions as untrusted metadata,
// after bridge restoration/tool validation. Tool declarations and other input stay intact.
if (url === `${CODEX_FORWARD_BASE_URL}/responses`
&& !parsed._compactionRequest && !parsed._portableCompaction
&& codexResponsesLiteSelected(headers, finalBody)
&& isPlainObject(finalBody) && Array.isArray(finalBody.input)
&& finalBody.input.some(item => isPlainObject(item) && item.type === "web_search_call")) {
const referencedIds = new Set(finalBody.input.flatMap(item => isPlainObject(item)
&& item.type === "item_reference" && typeof item.id === "string" ? [item.id] : []));
finalBody = { ...finalBody, input: renderCompactionSearchHistoryItems(finalBody.input, referencedIds) };
}
observeOutbound(parsed._rawBody, finalBody, headers);
if (!isCanonicalOpenAiForwardProvider(provider)) {
validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ADR-6930 — decision recorded under "Hosted-search continuation binding"

- Contract owner: [providers-and-adapters.md](../providers-and-adapters.md#hosted-search-continuation-binding)

## Decision record

- Purpose and intent: Let an explicitly selected Codex Responses Lite continuation carry useful metadata from old hosted-search history when there is no same-scope bridge call/result to restore.
- Prior constraints and evidence: Official Codex 0.161.0 omits hosted model tools on its Lite path and can expose client-executed `web.run` through standalone search. It still defines and replays `WebSearchCall` history. The report in [openai/codex#51962](https://github.com/openai/codex/issues/51962) records a successful default new Sol conversation, failure compacting restored hosted history, success after removing those hosted cells, and a raw hosted-history request that succeeds when the matching hosted tool is declared. This supports a Lite/history compatibility problem, not a universal hosted-schema ban or proof that every ordinary Lite request fails. Public Responses documentation continues to describe [hosted web-search output](https://developers.openai.com/api/docs/guides/tools-web-search#output-and-citations).
- Alternatives considered: Keep the original all-canonical ordinary projection; infer Lite from model names, subscription authentication or absent hosted declarations; require a separate provider opt-in; or narrow the existing policy to explicit current-request Lite intent. A narrower action/query repair would retain more structure but cannot represent an old hosted call as a client-executed Lite tool.
- Chosen approach: The contributor narrowed ordinary projection to the existing exact canonical ChatGPT `/responses` destination and explicit Lite intent after bridge restoration and normalization. The final HTTP header's literal `true`/`false` wins; otherwise literal `true` in valid string-map frame metadata is the fallback, matching existing WS preparation. Missing, non-Lite or unrecognized effective intent keeps structural hosted history. No transport signal is created or rewritten by this decision.
- Reason for this choice: The current request supplies a protocol-specific boundary without guessing where historical cells originated or assuming every model/account uses Lite. Keeping non-Lite and unknown requests structural avoids imposing a lossy policy on compatible hosted-tool continuations. Compared with a new provider switch, the existing Lite signal ties the repair to the client's selected protocol; it does not prove that loss is necessary for every Lite request.
- Consequences and limitations: Lite still defaults to lossy, bounded, untrusted reference notes for unreferenced remaining hosted cells. Same-input references and same-scope bridge restoration keep their existing exemptions/order; public API, native compaction and portable/routed summary triggers are unchanged. The original renderer enhancement preserves allowlisted top-level sources in portable summaries as well. This record explains the contributor's authorized narrower implementation, not project-owner acceptance or readiness. Fixture tests establish serialization behavior only; live ordinary HTTP/WS acceptance, all upstream errors and backend policy remain unverified.
- Fixed-source references: [Lite hosted-tool exclusion](https://github.com/openai/codex/blob/979011409de0a60b52f179721948e65531d26144/codex-rs/core/src/tools/spec_plan.rs#L622-L651), [native WS Lite metadata](https://github.com/openai/codex/blob/979011409de0a60b52f179721948e65531d26144/codex-rs/core/src/client.rs#L822-L835), [HTTP Lite header](https://github.com/openai/codex/blob/979011409de0a60b52f179721948e65531d26144/codex-rs/core/src/client.rs#L2345-L2354), and [hosted history replay](https://github.com/openai/codex/blob/979011409de0a60b52f179721948e65531d26144/codex-rs/core/src/context_manager/history.rs#L1015-L1036).
4 changes: 3 additions & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,10 @@ error rather than replayed without the effort, because the operator asked for it
of the compatibility behavior record for the same reason as above.

The shared Responses path follows the [bounded multipart recovery contract](subagents.md#multipart-encrypted-task-recovery); credential admission and retry policy remain unchanged.

## Hosted-search continuation binding
> Decision record: [ADR-6930](decisions/ADR-6930-hosted-search-continuation-binding.md)

The Codex-native ordinary Lite-history policy in `src/adapters/openai-responses/passthrough.ts` projects unreferenced remaining `web_search_call` cells through `src/adapters/openai-responses/compaction-search-history.ts` after bridge restoration, query backfill and schema normalization. Exact canonical ChatGPT `/responses` requires explicit Responses Lite intent: final `x-openai-internal-codex-responses-lite` header `true`/`false` is authoritative, otherwise an exact `true` in a valid string-map `client_metadata.ws_request_header_x_openai_internal_codex_responses_lite` selects the existing WS metadata intent. This reads the final request without changing headers, metadata or transport behavior. Non-Lite or unrecognized effective intent keeps structural hosted history; models, subscription auth, missing hosted tools and `additional_tools` alone are not a gate. The policy remains independent of diagnostic controls; only a same-scope bridge memo proves a restorable call/result, never an id prefix, model name or cache miss. Same-input `item_reference.id` matches retain their hosted cell; `store:false` removes references earlier. Notes are untrusted metadata, not instructions/page content: action and top-level `sources` copy only url/title/type, strings cap at 2,048 UTF-16 units, lists at 20, projected notes at 65,536 bytes with an omission note. Tool declarations/Lite wrappers, choices, non-hosted order, call/results, reasoning and caller raw input keep their contracts. Native `/responses/compact` stays unchanged, including account scrubbing and opaque output; portable/routed summaries keep their existing triggers/default projection (now preserving allowlisted top-level sources), not the ordinary reference exemption. `tests/adapters/openai/openai-native-hosted-search-history.test.ts` and `tests/responses/responses-native-compact-search-history.test.ts` pin these boundaries. This is an explicit lossy policy, not issuer inference, retry, or a claim that Codex rejects valid hosted schema.

The opt-in key-auth Responses hosted-search bridge in `src/server/responses/passthrough-delivery.ts` captures the
request binding that served the first leg, after any permitted initial reselection. Before every
Expand Down
Loading
Loading