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
43 changes: 29 additions & 14 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,14 @@ Codex. Native custom calls and converted function calls use the same completion
patch previews are held while their executable form is unresolved. JavaScript that merely
contains patch text and unrelated native custom payloads stay unchanged.

A routed model can also mistakenly send a shell-argument object such as
`{"cmd":"git status --short"}` to code-mode `exec`. For a verified code-mode catalog,
opencodex converts an unambiguous shell object into `tools.exec_command(...)` JavaScript
and forwards its output through `text(...)`. Shell options are preserved, and Codex still
executes and authorizes the command. Valid JavaScript fallback fields, ambiguous objects,
and unrelated tool namespaces are not converted. This compatibility repair does not bypass
provider rate limits or change the configured retry policy.

Routed code-mode turns are also told the host's rules for the nested helpers before the first
call: `tools.apply_patch` takes one string that opens and closes with the bare patch marker lines,
the isolate has no `import`, and long-running commands are polled through `write_stdin`. When a
Expand Down Expand Up @@ -927,8 +935,7 @@ Do not rewrite an active paginated rollout or thread row to migrate those conver

## Experimental native mid-turn steering

For a compatible model on the canonical ChatGPT forward route or an explicitly configured
[OpenAI API WebSocket route](#steering-continuation-settings-and-public-api), and a client
For a compatible model on the canonical ChatGPT forward route, and a client
that sends `response.steer`, enable both options in `~/.opencodex/config.json` and restart
OpenCodex before starting a fresh turn:

Expand All @@ -953,7 +960,7 @@ Do not rerun tools or resend accepted steering text. Model, account, tool declar
may change in an explicit saved-result continuation as described below. Other changes
require an explicitly stopped or finished turn and normal new dispatch. Multiple independent conversations use independent connections.

HTTP fallback, noncanonical gateways, translated models, sidecars, Combo attempts and plaintext V2
HTTP fallback, noncanonical gateways, public API-key routes, translated models, sidecars, Combo attempts and plaintext V2
restoration do not support this option. It does not add steering capability to a model or
a client that lacks it. Unsupported routes return a protocol error rather than silently
ignoring input. Disconnected or timed-out delivery may be unknown: never automatically
Expand Down Expand Up @@ -984,6 +991,16 @@ During that time the turn holds one physical socket and one pinned credential th
because the channel deliberately never re-enters account selection. Treat an enabled steering
connection as a long-lived session resource rather than an ordinary bounded request.

Steering frames also share the proxy's configured body and memory limits. A control frame above
[`maxInboundBodyBytes`](/reference/inbound-body-admission/) is refused before
it is parsed on an established control connection (an initial frame is still parsed before
its type-based limit applies), and the reconstructed body sent upstream is refused when it exceeds
[`maxUpstreamBodyBytes`](/reference/configuration/providers/). Each
connection's replay journal is capped at 32 MiB and counted as pinned state against
[`appOwnedMemoryBudgetMb`](/reference/configuration/server/); admitting a
journal demotes evictable caches first rather than failing, and the aggregate across live journals
is capped at 128 MiB regardless of the configured budget.

A timeout means **delivery is unknown**, not that the server rejected the input.
Do not resend an accepted instruction or rerun a tool automatically. Inspect the
actual task state before deciding how to resume. No account switch or paid API
Expand Down Expand Up @@ -1064,6 +1081,8 @@ A missing acknowledgement or a disconnect means delivery can be **unknown**. Do
not automatically resend a result, restart a tool or change accounts to retry it.
The pending queue is limited to 32 frames and 8 MiB, with 1,024 advertised function
calls, a 32 MiB replay journal and at most 128 responses per owned connection.
Injection journals share the pinned memory budget and 128 MiB aggregate ceiling
with steering journals; see [steering memory limits](#steering-confirmation-deadlines-and-retained-context).
Each sent injection has a 90-second acknowledgement deadline that unrelated output
cannot extend; a saved-result wait is limited to 30 minutes. Existing frame limits
and stall timeouts still apply.
Expand Down Expand Up @@ -1112,12 +1131,11 @@ can follow a completed multi-agent turn as a new explicit request using ordinary
routing. Client support and backend entitlement still require live verification.


## Steering continuation settings and public API
## Steering continuation settings

An explicit saved-result `response.create` may override `reasoning` (effort and
summary), `text` (verbosity and supported structured-output format), and
`stream_options`. On an explicitly configured public API route it may also
change `max_output_tokens`. Subscription routes refuse that token-limit override
`stream_options`. Subscription routes refuse a `max_output_tokens` override
instead of silently ignoring it. Normal provider pins, subagent caps, effort
mapping and summary/verbosity capability exclusions still apply.

Expand All @@ -1129,14 +1147,11 @@ corrected request can be submitted without rerunning its tool. The server still
decides which settings the chosen model accepts. Changes to model, account,
provider, tools, instructions or service tier require a separate ordinary turn.

For public API steering, configure an `openai-responses` provider with exactly
`https://api.openai.com/v1`, its API key and `upstreamWebsocket: true`, then use its
normal prefixed model selector with `websockets: true` and
`codexNativeSteering: true`. This does not buy API credit or redirect a ChatGPT
subscription to separately billed usage. A supporting single-agent model/execution
mode is still required. Conversation-bound responses and API automatic compaction
are not steerable; their ordinary responses are preserved and a steering attempt
receives an explanatory error. The multi-agent injection path stays separate.
Native steering is restricted to the canonical ChatGPT subscription route. Public
API-key and gateway routes are not steerable; their ordinary responses are preserved
and a steering attempt receives an explanatory error. This prevents successor
generations on a retained socket from bypassing normal per-request admission. The
separately gated public API multi-agent injection path remains available.

### Executable direct-versus-proxy wire probe

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ gateways, routes whose final destination is another provider, and non-Responses
rewritten.

For an eligible v2 request, opencodex recognizes the catalog by a top-level `collaboration`
namespace with a direct `spawn_agent` child. It removes
namespace with a direct `spawn_agent` child. The catalog can be in top-level `tools`, or in the
first input item's developer `additional_tools` when `tools` is absent (Responses Lite).
An explicit top-level catalog takes precedence; user-role and later historical catalogs do not
activate the option. It removes
`parameters.properties.message.encrypted: true`, when present, only from `spawn_agent`,
`send_message`, and `followup_task`. ChatGPT reserves both the `collaboration` namespace and those
three tool names, so the request uses fixed private aliases for all four identities. Before making
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,6 @@ wildcard `hostname`, where the public listener already holds `127.0.0.1:<port>`.

`codexNativeSteering` and `codexNativeInjection` enable separate, default-off native
WebSocket control paths. See the canonical guide for
[supported steering routes and settings](../../guides/codex-integration.md#steering-continuation-settings-and-public-api),
[supported steering routes and settings](../../guides/codex-integration.md#steering-continuation-settings),
[typed result and approval continuations](../../guides/codex-integration.md#rich-tool-results-and-explicit-approvals-after-response-completion),
and [confirmation deadlines and retained context](../../guides/codex-integration.md#steering-confirmation-deadlines-and-retained-context).
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ Codex 显示的模型来自一个磁盘上的 catalog(默认是 `$CODEX_HOME/o
结果仍包含宿主的某条失败消息,opencodex 会追加一行提示,指出对应规则。此变更不会重写模型的
代码或补丁文本。

如果路由模型误把 `{"cmd":"git status --short"}` 这样的 shell 参数对象传给 code-mode `exec`,
opencodex 会在确认工具目录为 code mode 且内容无歧义时,将它转换为调用
`tools.exec_command(...)` 并通过 `text(...)` 返回结果的 JavaScript。shell 选项会保留,
命令执行与权限检查仍由 Codex 处理。合法的 JavaScript 后备字段、歧义对象和其他工具命名空间
不会被转换;这项兼容修复不会绕过提供方限流,也不改变配置的重试策略。

所选 provider 必须支持 function/tool calling。不支持 tool call 的 text-only provider 无法使用 `exec`、
Browser 或 Computer Use。原生 OpenAI 条目会保持其上游 tool mode 不变。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ opencodex 会跳过已禁用、不可路由、不健康、处于冷却中,或
`authMode: "forward"` 和准确的基础地址 `https://chatgpt.com/backend-api/codex`。OpenAI API key
provider、自定义 OpenAI 兼容网关、最终发往其他 provider 的请求,以及非 Responses 调用都不会被改写。

对于符合条件的 v2 请求,opencodex 只识别顶层 `collaboration` namespace,而且它必须直接包含
`spawn_agent`。原生 ChatGPT 收到请求前,opencodex 会删除 `spawn_agent`、`send_message` 和
对于符合条件的 v2 请求,opencodex 只识别工具目录顶层的 `collaboration` namespace,而且它必须直接包含
`spawn_agent`。目录可以位于顶层 `tools`;如果该字段不存在,也可以位于首个输入项的 developer
`additional_tools` 中(Responses Lite)。显式顶层目录优先,user 角色和后续历史目录不会启用转换。
原生 ChatGPT 收到请求前,opencodex 会删除 `spawn_agent`、`send_message` 和
`followup_task` 中已有的 `parameters.properties.message.encrypted: true`。ChatGPT 会按保留的
`collaboration` namespace 和三个工具名处理消息,因此请求会给这四个名称使用固定的临时别名。
修改前,opencodex 会检查顶层和 `additional_tools` 工具目录、嵌套 namespace、
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,7 @@
"devin-stated-reset-retry.test.ts": "providers",
"ci-structure-gate.test.ts": "ci-workflows",
"responses-code-mode-patch-compile.test.ts": "responses",
"responses-code-mode-shell-compile.test.ts": "responses",
"gui-codex-usage-score-parity.test.ts": "gui",
"gui-tray-vibrancy-surface.test.ts": "gui",
"web-search-sidecar-429.test.ts": "web-search",
Expand Down
2 changes: 2 additions & 0 deletions src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { progressiveFreeformInput } from "../responses/progressive-freeform-input";
import { encodeCompactionSummary } from "../responses/compaction";
import { compileCodeModeHelperInput, resolveCodeModeHelperName } from "../responses/code-mode-helper-compat";
import { mayBecomeCodeModeShellInput } from "../responses/code-mode-shell-input";
import { isTruncatedStopReason, truncationReasonFor } from "../responses/truncated-stop-reason";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "../responses/reasoning-envelope";
import { rememberReasoningForCall } from "../responses/reasoning-replay-cache";
Expand Down Expand Up @@ -1089,6 +1090,7 @@ export function bridgeToResponsesSSE(
// replaced by the normalized ones.
const mayNormalize = ownsFreeformGrammar && currentToolCall.name === "apply_patch";
if (!((mayCompile || mayNormalize) && mayBecomePatchEnvelope(full))
&& !(mayCompile && mayBecomeCodeModeShellInput(currentToolCall.args, full))
&& full.startsWith(emitted) && full.length > emitted.length) {
emit("response.custom_tool_call_input.delta", {
item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/app-owned-memory-stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { translatorObservedBufferSnapshot } from "./translator-budget";
import { imageFulfillmentTailSnapshot } from "../images/fulfill";
import { oauthMutationTailSnapshot } from "../oauth/store";
import { grokApplyFlightSnapshot } from "../server/management/agent-settings-routes";
import { nativeControlReplayRetainedStoreSnapshot } from "../server/responses/native-steering-replay";

function ringSnapshot(metrics: { entries: number; bytes: number; oldestAt: number | null }): RetainedStoreSnapshot {
return {
Expand Down Expand Up @@ -187,6 +188,12 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [
snapshot: responseContinuationRetainedStoreSnapshot,
evictOldest: evictOldestResponseContinuationForBudget,
},
{
id: "native_control_replay",
category: "continuation",
snapshot: nativeControlReplayRetainedStoreSnapshot,
evictOldest: () => 0,
},
] as const satisfies readonly RetainedStoreRegistration[];

export function registerDefaultAppOwnedMemoryStores(): void {
Expand Down
15 changes: 13 additions & 2 deletions src/lib/app-owned-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,14 @@ function warnPinnedSaturation(): void {
console.warn("[app-owned-memory] retained state remains over budget with no evictable candidate");
}

export function enforceAppOwnedMemoryBudget(): AppOwnedBytesSnapshot {
export function enforceAppOwnedMemoryBudget(reservedPinnedBytes = 0): AppOwnedBytesSnapshot {
if (isEnforcing) return appOwnedBytesSnapshot();
isEnforcing = true;
enforcementCounters.runs += 1;
try {
const ineligible = new Set<string>();
const current = retainedSnapshots();
while (current.total > budgetBytes) {
while (current.total + reservedPinnedBytes > budgetBytes) {
const candidate = nextCandidate(current.stores, ineligible);
if (!candidate) {
enforcementCounters.noEvictableCandidate += 1;
Expand Down Expand Up @@ -250,6 +250,17 @@ export function enforceAppOwnedMemoryBudget(): AppOwnedBytesSnapshot {
}
}

/**
* Admission for a pinned allocation the owner cannot demote later. The proposal is
* counted against the shared target BEFORE the normal eviction pass runs, so
* reclaimable logs, caches, blobs and continuations are demoted first and only a
* projected total still above budget — pinned state that cannot fit — is refused.
*/
export function admitAppOwnedPinnedBytes(proposedPinnedBytes: number): boolean {
const snapshot = enforceAppOwnedMemoryBudget(Math.max(0, proposedPinnedBytes));
return snapshot.retainedBytes + Math.max(0, proposedPinnedBytes) <= snapshot.budgetBytes;
}

export function resetAppOwnedMemoryForTests(): void {
retainedStores.clear();
observedBuffers.clear();
Expand Down
18 changes: 14 additions & 4 deletions src/responses/code-mode-helper-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
unwrapFreeformToolInput,
} from "./apply-patch-envelope";
import { declaresCodeModeExec } from "../types/tools";
import { parseCodeModeShellInput } from "./code-mode-shell-input";

function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
Expand Down Expand Up @@ -31,6 +32,10 @@ export function compileCodeModeHelperInput(
const helperName = toolName.startsWith("default.")
? toolName.slice("default.".length)
: toolName;
if (helperName === "exec_command" && wireToolName === "exec") {
const args = parseCodeModeShellInput(argumentsText);
if (args) return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`;
}
if (helperName === "apply_patch") {
// `resolveCodeModeHelperName` decides this IS an apply-patch call by reading
// `unwrapFreeformToolInput(argumentsText, wireToolName)`, which strips an outer Markdown
Expand All @@ -43,8 +48,12 @@ export function compileCodeModeHelperInput(
// is an apply_patch wrapper and is not an `exec` fallback field, and the recognizer already
// declines it under `exec`; reading it here would compile a body that recognition rejected,
// which is exactly the drift a second, looser unwrap introduces.
const bodyToolName = wireToolName ?? helperName;
const normalizedBodyToolName = bodyToolName.startsWith("default.")
? bodyToolName.slice("default.".length)
: bodyToolName;
const patch = normalizeApplyPatchDelimiters(
unwrapFreeformToolInput(argumentsText, wireToolName ?? helperName),
unwrapFreeformToolInput(argumentsText, normalizedBodyToolName),
);
return `const result = await tools.apply_patch(${JSON.stringify(patch)});\ntext(result);`;
}
Expand Down Expand Up @@ -96,8 +105,8 @@ export function compileCodeModeHelperInput(
* wrong.
*
* This adds that second case: the name is already `exec` so nothing was rewritten, but
* the body is a complete patch envelope and therefore cannot be the JavaScript that
* `exec` runs. Same inference the name-based path makes, drawn from the payload.
* the body is a complete patch envelope or an unambiguous structured shell call.
* Same inference the name-based path makes, drawn from the payload.
*
* Returns undefined for everything else, including JavaScript that merely mentions a
* patch envelope — that body is a real program and is forwarded byte-identical.
Expand All @@ -116,5 +125,6 @@ export function resolveCodeModeHelperName(
// `tools.apply_patch(...)` JavaScript would be the mis-route this repair exists to avoid.
if (!declaresCodeModeExec(declaredNames)) return undefined;
if (typeof argumentsText !== "string" || argumentsText === "") return undefined;
return isCompletePatchEnvelope(unwrapFreeformToolInput(argumentsText, "exec")) ? "apply_patch" : undefined;
if (isCompletePatchEnvelope(unwrapFreeformToolInput(argumentsText, "exec"))) return "apply_patch";
return parseCodeModeShellInput(argumentsText) ? "exec_command" : undefined;
}
Loading
Loading