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
91 changes: 83 additions & 8 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -885,8 +885,10 @@ 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 and a client that sends `response.steer`, enable both
options in `~/.opencodex/config.json` and restart OpenCodex before starting a fresh turn:
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
that sends `response.steer`, enable both options in `~/.opencodex/config.json` and restart
OpenCodex before starting a fresh turn:

```json
{
Expand All @@ -896,7 +898,7 @@ options in `~/.opencodex/config.json` and restart OpenCodex before starting a fr
```

Merge these keys into the existing configuration; do not replace your provider/account settings.
This option is off by default. It forwards steering to the same native ChatGPT WebSocket
This option is off by default. It forwards steering to the same explicitly configured native WebSocket
connection and selected account, preserving automatic successor responses and pending
saved-tool-result continuations. Acceptance means queued, not yet applied.

Expand All @@ -905,15 +907,15 @@ Results can arrive before `response.steer.pending`: the relay also matches the c
parent's advertised calls and approvals. A `name` on a pending function-output stub is
optional on the result, as in the native schema. Additional user messages may accompany
these results; system/developer messages, duplicate results and unrelated call IDs are refused.
Do not rerun tools or resend accepted steering text. This first implementation requires
unchanged model and request settings. A changed model/settings requires an explicitly stopped or finished turn
and normal new dispatch. Multiple independent conversations use independent connections.
Do not rerun tools or resend accepted steering text. Model, account, tool declarations and routing stay unchanged. Validated generation settings
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, other providers, translated models, sidecars, Combo attempts and plaintext V2
HTTP fallback, noncanonical gateways, 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
resubmit tools or steering text. Pending controls have fixed 90-second acknowledgement or successor deadlines;
resubmit tools or steering text. Pending controls have per-submission absolute 90-second confirmation deadlines;
saved-tool-result waits have a 30-minute cap.

The implementation has synthetic protocol and regression coverage, not live Astra/client
Expand Down Expand Up @@ -1066,3 +1068,76 @@ result remains available for a later explicit continuation. There is no automati
conversion, retry, tool rerun or account/API switch. A single-agent steering turn
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

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
instead of silently ignoring it. Normal provider pins, subagent caps, effort
mapping and summary/verbosity capability exclusions still apply.

Omitted settings retain the current effective values; explicit null resets that
setting where the upstream accepts null. Overrides replace the supplied setting
object, not individual nested fields. Changed values carry into later explicit
continuations. A rejected override does not reserve the saved result, so a
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.

### Executable direct-versus-proxy wire probe

From a source checkout, run the offline positive control:

```sh
bun scripts/steering-smoke.ts --self-test
```

Plan a comparison without reading tokens or opening any connection:

```sh
bun scripts/steering-smoke.ts --direct wss://api.openai.com/v1/responses \
--proxy ws://127.0.0.1:1455/v1/responses --model <supported-model> \
--proxy-model <provider-prefix/same-model>
```

For a subscription comparison the direct URL is
`wss://chatgpt.com/backend-api/codex/responses`. Select the same actual model and
account on both routes; the script cannot prove that a proxy configuration selected
the same account. The proxy URL must be a loopback Responses endpoint and must not
contain credentials, query parameters or a fragment.

Only after reviewing the plan, supply `STEERING_DIRECT_TOKEN` and
`STEERING_PROXY_TOKEN` through your shell environment and add **both** `--live`
and `--allow-model-requests`. A direct ChatGPT connection may additionally need
`STEERING_DIRECT_ACCOUNT_ID`; that header is never copied to the public API or the
proxy. Do not put credentials in command arguments, logs, screenshots or PRs.
The script does not read your saved Codex login, refresh tokens or change settings.

Live execution sends four synthetic initial requests (two scenarios per route),
plus any resulting successors or required-result continuations, and **can consume
model usage**. One scenario checks an automatic successor; the other returns a
fixed synthetic result only for the script's own advertised function and changes
reasoning/verbosity on its explicit continuation. No external tool is executed and
no approval is inferred. There are no retries or automatic recovery requests.
Each scenario is limited to 120 seconds, 5,000 events and 2 MiB received data.

The JSON report contains only outcomes, timing and boolean checkpoints. A pass
requires queued acceptance, a created successor and the synthetic marker in its
completed output. Missing confirmations are `unknown`; if the model never enters
the required-input path the result is `not_exercised`. Neither is counted as pass.
The process exits 0 only if all four live scenarios pass, 1 otherwise, and 2 for
invalid arguments or missing credentials. This is a **wire diagnostic**, not an
end-to-end Codex App/CLI interface test, live certification or instruction to enable
the experimental feature for production work.
18 changes: 7 additions & 11 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ runs helper features around provider requests.
| `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. |
| `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. |
| `websockets?` | `boolean` | `false` | Advertise and admit the client-facing Responses WebSocket path. False keeps clients on HTTP/SSE; it does not disable an eligible canonical ChatGPT upstream WS optimization. Complete-input requests may reuse an upstream connection within the same selected credential, account, thread and turn; changed handshake policy or missing identity keeps requests on separate connections. This does not trim HTTP input or create previous-response IDs. |
| `codexNativeSteering?` | `boolean` | `false` | Experimental, native-only mid-turn steering on the Responses WebSocket endpoint. Requires `websockets: true`, a compatible upstream/client, and unchanged model/settings for saved-tool-result continuations. Does not enable translated models or HTTP fallback. See [native steering](/guides/codex-integration/#experimental-native-mid-turn-steering). |
| `codexNativeSteering?` | `boolean` | `false` | Experimental, native-only mid-turn steering on the Responses WebSocket endpoint. Requires `websockets: true`, a compatible upstream/client, and a pinned account/model/tool surface. Validated generation settings can change in explicit saved-result continuations. Does not enable translated models or HTTP fallback. See [native steering](/guides/codex-integration/#experimental-native-mid-turn-steering). |
| `codexNativeInjection?` | `boolean` | `false` | Experimental saved function-result injection on compatible native multi-agent WebSocket turns. Requires `websockets: true`, explicit `multi_agent.enabled`, and an eligible provider. Separate from steering; no automatic tool rerun or recovery create. See [native injection](/guides/codex-integration/#experimental-native-function-result-injection). |
| `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. Authority-based browser extension origins such as `chrome-extension://<extension-id>` are supported; `*` is not a wildcard. Firefox and Safari regenerate the extension UUID (per install / per browser launch), so update the entry when the origin changes. |
| `apiKeys?` | `OcxApiKey[]` | `[]` | Generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Dashboard-managed. |
Expand Down Expand Up @@ -574,14 +574,10 @@ companion form is what makes a hub a single-port deployment, and it is refused o
wildcard `hostname`, where the public listener already holds `127.0.0.1:<port>`.


The opt-in `codexNativeInjection` owner also accepts typed saved-result
continuations on the original account/socket. It does not widen
`response.inject` beyond string-valued function results, and multi-agent requests
never acquire the single-agent steering owner merely because injection is disabled.
See [the continuation contract](/guides/codex-integration/#rich-tool-results-and-explicit-approvals-after-response-completion).
## Experimental native response controls


`codexNativeSteering` confirmation uses fixed absolute deadlines and retains
completed output for local continuation history. See
[steering confirmation deadlines and retained context](/guides/codex-integration/#steering-confirmation-deadlines-and-retained-context)
for phase timing, unknown-delivery recovery and live-comparison precautions.
`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),
[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).
123 changes: 123 additions & 0 deletions scripts/steering-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
type Frame = Record<string, any>;
export type ProbeScenario = "automatic" | "required-input";
export type ProbeReport = {
scenario: ProbeScenario; outcome: "passed" | "failed" | "unknown" | "not_exercised";
accepted: boolean; successorCreated: boolean; markerObserved: boolean; explicitContinuation: boolean;
sentControls: number; elapsedMs: number; code?: string;
};
const MARKER = "STEERING_PROBE_OK";
const safeCodes = new Set(["steering_not_supported", "response_not_active", "response_already_completed",
"invalid_input", "steering_settings_changed", "steering_settings_unsupported", "too_many_pending_steers"]);

/** Content-free, single-attempt probe state. It never executes external tools or approval decisions. */
export class SteeringProbe {
private base?: Frame;
private root?: string;
private successor?: string;
private steerId?: string;
private callId?: string;
private rootEnded = false;
private sentSteer = false;
private reportValue?: ProbeReport;
private bytes = 0;
private frames = 0;
private markerObserved = false;
private explicit = false;
private sentControls = 0;
private textTail = "";
private started = performance.now();
constructor(readonly scenario: ProbeScenario, private readonly send: (frame: Frame) => void) {}

/** Only fixed synthetic prompts and a non-executing tool are sent by this harness. */
request(model: string): Frame {
return this.base = { type: "response.create", model, store: false, reasoning: { effort: "low" },
input: this.scenario === "automatic"
? "Explain five techniques for organizing a fictional book collection. Work through each in detail."
: "Call steering_probe once, then use its saved result to answer briefly.",
...(this.scenario === "required-input" ? {
tools: [{ type: "function", name: "steering_probe", description: "Returns a fixed synthetic fixture; performs no external action.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }, strict: true }],
tool_choice: "auto",
} : {}),
};
}
private steer(): void {
if (!this.root || this.sentSteer || this.rootEnded) return;
this.sentSteer = true; this.sentControls++;
this.send({ type: "response.steer", previous_response_id: this.root, input: `Change the answer: respond only with ${MARKER}. Do not run more tools.` });
}
/** Stop with sanitized state, never returning IDs, model output, tokens or endpoint paths. */
finish(outcome: ProbeReport["outcome"], code?: string): ProbeReport {
return this.reportValue ??= { scenario: this.scenario, outcome, accepted: !!this.steerId,
successorCreated: !!this.successor, markerObserved: this.markerObserved, explicitContinuation: this.explicit,
sentControls: this.sentControls, elapsedMs: Math.max(0, Math.round(performance.now() - this.started)), ...(code ? { code } : {}) };
}
get report(): ProbeReport | undefined { return this.reportValue; }

/** Observe a bounded wire stream. Acceptance alone is never a passing probe. */
receive(raw: string): ProbeReport | undefined {
if (this.reportValue) return this.reportValue;
this.bytes += Buffer.byteLength(raw);
if (++this.frames > 5000 || this.bytes > 2 * 1024 * 1024) return this.finish("unknown", "probe_budget_exceeded");
let event: Frame;
try { event = JSON.parse(raw); } catch { return this.finish("failed", "invalid_event"); }
if (!event || typeof event !== "object" || Array.isArray(event)) return this.finish("failed", "invalid_event");
const response = event.response;
if (event.type === "response.created") {
if (!response || typeof response.id !== "string" || (!response.id.length || response.id.length > 512)) return this.finish("failed", "invalid_identity");
if (!this.root) { this.root = response.id; if (this.scenario === "automatic") this.steer(); }
else {
if (this.successor || response.id === this.root || !this.rootEnded || !this.steerId
|| (response.previous_response_id != null && response.previous_response_id !== this.root)) return this.finish("failed", "unexpected_successor");
this.successor = response.id;
}
} else if (event.type === "response.output_item.done" && !this.successor && this.scenario === "required-input") {
const item = event.item;
if (event.response_id != null && event.response_id !== this.root) return this.finish("failed", "output_identity_mismatch");
if (item?.type === "function_call" && item.name === "steering_probe" && typeof item.call_id === "string") {
if (this.callId && item.call_id !== this.callId) return this.finish("failed", "unexpected_tool");
this.callId = item.call_id; this.steer();
}
} else if (event.type === "response.steer.accepted") {
if (!this.sentSteer || this.steerId || event.steer?.previous_response_id !== this.root || typeof event.steer?.id !== "string") {
return this.finish("failed", "unexpected_acceptance");
}
this.steerId = event.steer.id;
} else if (event.type === "response.steer.pending") {
if (!this.steerId || event.steer?.id !== this.steerId || event.steer?.previous_response_id !== this.root || !this.rootEnded) {
return this.finish("failed", "unexpected_pending");
}
if (this.explicit) return this.finish("failed", "duplicate_pending");
const stubs = event.required_input;
if (event.reason !== "waiting_for_required_input" || !Array.isArray(stubs) || stubs.length !== 1
|| stubs[0]?.type !== "function_call_output" || stubs[0]?.call_id !== this.callId || !this.callId) {
return this.finish("not_exercised", "unsupported_required_input");
}
this.explicit = true; this.sentControls++;
this.send({ ...this.base, type: "response.create", previous_response_id: this.root,
...(event.stream_id !== undefined ? { stream_id: event.stream_id } : {}),
input: [{ type: "function_call_output", call_id: this.callId, output: "synthetic saved result; no action was executed" }],
reasoning: { effort: "medium" }, text: { verbosity: "low" } });
} else if (event.type === "response.steer.failed" || event.type === "error") {
const code = event.error?.code;
return this.finish("failed", safeCodes.has(code) ? code : "upstream_rejection");
} else if (["response.completed", "response.incomplete", "response.failed"].includes(event.type)) {
if (this.root && response?.id === this.root) {
this.rootEnded = true;
if (!this.sentSteer) return this.finish("not_exercised", "no_steering_window");
} else if (this.successor && response?.id === this.successor) {
for (const item of Array.isArray(response.output) ? response.output : []) {
for (const part of Array.isArray(item?.content) ? item.content : []) if (typeof part?.text === "string" && part.text.includes(MARKER)) this.markerObserved = true;
}
if (event.type !== "response.completed") return this.finish("failed", "successor_not_completed");
if (this.scenario === "required-input" && !this.explicit) return this.finish("not_exercised", "required_input_not_observed");
return this.finish(this.markerObserved ? "passed" : "failed", this.markerObserved ? undefined : "marker_missing");
} else return this.finish("failed", "terminal_identity_mismatch");
} else if (event.type === "response.output_text.delta" && this.successor && typeof event.delta === "string") {
if (event.response_id != null && event.response_id !== this.successor) return this.finish("failed", "output_identity_mismatch");
const text = this.textTail + event.delta;
this.markerObserved ||= text.includes(MARKER); this.textTail = text.slice(-MARKER.length);
}
return undefined;
}
}
Loading
Loading