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
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/reference/configuration/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ runs helper features around provider requests.

## Server fields

`completeOnMaxOutputTokens` is an optional boolean, default `false`. Set it to `true` to
report a token-limited translated Responses text answer as `completed` rather than `incomplete`.
The answer remains truncated: OpenCodex preserves its exact text, including unfinished Markdown.
Only nonempty plain-text output qualifies. Tool calls and hosted operations (even if completed),
reasoning, `text.format` with `json_schema` or `json_object`, compaction, explicitly unfinished
turns, errors and missing terminal events remain excluded. Context-window exhaustion and
`pause_turn` remain incomplete. Chat Completions, Anthropic Messages and native Responses
passthrough, including native WebSocket output, retain their existing stop behavior.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `completeOnMaxOutputTokens?` | `boolean` | `false` | Opt in to completing eligible token-limited translated Responses text output. Text stays truncated; see the eligibility and exclusions above. |
| `port` | `number` | `10100` | Proxy listen port. |
| `hostname?` | `string` | `"127.0.0.1"` | Bind address. A non-loopback bind requires a data-admission token, resolved from `OPENCODEX_API_AUTH_TOKEN`, then `OCX_API_TOKEN_FILE`, then the installed owner-only `service-api-token` — nothing has to be exported by hand. See [Remote access](#remote-access). |
| `proxy?` | `string` | — | Outbound HTTP(S) or SOCKS5 proxy URL (`socks5://host:port`), `${ENV_VAR}`, or `"auto"`. HTTP URLs apply to `HTTP_PROXY` / `HTTPS_PROXY` when those are unset. SOCKS5 URLs use OpenCodex's real SOCKS5 transport and are also exposed through `ALL_PROXY` (`ocx start --socks5`); inherited `HTTP(S)_PROXY` is cleared in this process. Loopback stays in `NO_PROXY`. `"auto"` reads Windows WinINET or macOS static HTTP/HTTPS settings once at startup. Inherited HTTP(S) proxy variables skip discovery; on macOS, inherited `ALL_PROXY`/`all_proxy` also skips it. Windows keeps separate `http=` and `https=` entries; a bare `ProxyServer` applies to both. macOS translates IP literals, `*`, and a valid `*.<domain>` glob to Bun's `.<domain>` bypass. That glob also bypasses the bare apex `<domain>`. The exact link-local ranges `169.254/16`, `169.254.0.0/16`, and `fe80::/10` are omitted with a diagnostic: link-local IP literals use the proxy. Other CIDRs, globs, and simple-host exceptions refuse discovery without changing the proxy environment. Disabled, malformed, PAC/WPAD, SOCKS-only, and live changes are not followed. Restart after changing system settings. |
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout-responses.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"graceful-max-output-tokens.test.ts": "responses",
"graceful-max-output-tokens-exclusions.test.ts": "responses",
"advisor-web-search.test.ts": "advisor",
"passthrough-media-type.test.ts": "responses",
"passthrough-override.test.ts": "responses",
Expand Down
41 changes: 41 additions & 0 deletions src/bridge/max-output-completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { AdapterEvent, OcxRequestOptions } from "../types";

export interface MaxOutputCompletionOptions {
/** Set only for an explicitly opted-in Responses inbound request. */
completeOnMaxOutputTokens?: boolean;
textFormat?: OcxRequestOptions["textFormat"];
compaction?: boolean;
}

/** Track the original event stream: bridge cleanup must not erase an unfinished operation. */
export function createMaxOutputCompletion(options?: MaxOutputCompletionOptions) {
let textOnly = options?.completeOnMaxOutputTokens === true && !options.compaction && !options.textFormat;
let hasText = false;
let completed = false;
return {
observe(event: AdapterEvent): void {
switch (event.type) {
case "text_delta":
if (event.anthropicThinkingSignature !== undefined) textOnly = false;
if (event.text.length > 0) hasText = true;
break;
case "heartbeat":
case "assistant_boundary":
break;
case "done":
case "incomplete": {
const reason = event.type === "done" ? event.stopReason : event.reason;
completed = textOnly && hasText && event.endTurn !== false
&& (event.type !== "done" || event.compactionEncryptedContent === undefined)
&& (reason === "max_tokens" || reason === "length" || reason === "max_output_tokens");
break;
}
default:
// All non-text output and unknown future operations are excluded, even if hidden or closed.
textOnly = false;
completed = false;
}
},
get completed(): boolean { return completed; },
};
}
13 changes: 9 additions & 4 deletions src/bridge/response-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { adapterFailureFromEvent, emptyChunks, joinChunks, responsesUsage, toolCallArgumentsUsable, uuid, webSearchAction, webSearchCellItemId } from "./internal";
import type { OutputItem, StringChunks } from "./internal";
import { bridgeToResponsesSSE } from "./sse";
import { createMaxOutputCompletion, type MaxOutputCompletionOptions } from "./max-output-completion";

/** Build a buffered Responses result within a caller-owned or temporary translator budget. */
export function buildResponseJSON(
Expand Down Expand Up @@ -84,7 +85,7 @@ export function buildResponseJSON(
function buildResponseJSONWithBudget(
events: AdapterEvent[],
modelId: string,
options?: {
options?: MaxOutputCompletionOptions & {
hideThinkingSummary?: boolean;
/** Internal Chat delivery override for raw reasoning only; summaries keep their policy. */
chatRawReasoningVisible?: boolean;
Expand Down Expand Up @@ -202,6 +203,7 @@ function buildResponseJSONWithBudget(
// which is only true for a `done` without a stop reason. A buffered turn whose adapter simply
// stopped emitting has no terminal at all, and must not be reported as a success.
let sawTerminal = false;
const maxOutputCompletion = createMaxOutputCompletion(options);
let batchCompaction = emptyChunks();
let compactionEncryptedContent: string | undefined;

Expand Down Expand Up @@ -371,6 +373,7 @@ function buildResponseJSONWithBudget(
};

for (const e of events) {
maxOutputCompletion.observe(e);
if (errorEvent) {
// Match streaming: once the turn fails, later parallel calls must not become executable
// completed output. Still release every retained event in order and preserve terminal usage.
Expand Down Expand Up @@ -570,6 +573,7 @@ function buildResponseJSONWithBudget(
break;
case "incomplete":
incompleteEvent = e;
usage = e.usage ?? usage;
sawTerminal = true;
endTurn = e.endTurn;
if (e.providerState) options?.onProviderState?.(e.providerState);
Expand Down Expand Up @@ -640,9 +644,10 @@ function buildResponseJSONWithBudget(
}

const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined;
const gracefulComplete = maxOutputCompletion.completed && !errorEvent;
const status = errorEvent
? "failed"
: incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter"
: !gracefulComplete && (incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter")
? "incomplete"
: sawTerminal
? "completed"
Expand All @@ -662,13 +667,13 @@ function buildResponseJSONWithBudget(
...(failure && isCyberPolicyCode(failure.error.code)
? { retryable: false }
: errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}),
...(incompleteEvent ? {
...(!gracefulComplete && incompleteEvent ? {
incomplete_details: {
reason: truncationReasonFor(incompleteEvent.reason) ?? incompleteEvent.reason,
...(incompleteEvent.message ? { message: incompleteEvent.message } : {}),
...(incompleteEvent.retryable !== undefined ? { retryable: incompleteEvent.retryable } : {}),
},
} : stopReason === "max_tokens" ? {
} : !gracefulComplete && stopReason === "max_tokens" ? {
incomplete_details: { reason: "max_output_tokens" },
} : stopReason === "content_filter" ? {
incomplete_details: { reason: "content_filter" },
Expand Down
15 changes: 13 additions & 2 deletions src/bridge/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction, webSearchCellItemId } from "./internal";
import type { OutputItem, StringChunks } from "./internal";
import { terminalRefusalFailure } from "./terminal-refusal";
import { createMaxOutputCompletion, type MaxOutputCompletionOptions } from "./max-output-completion";

function sseEvent(name: string, data: Record<string, unknown>): string {
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
Expand All @@ -73,7 +74,7 @@ export function bridgeToResponsesSSE(
toolSearchToolNames?: Set<string>,
onCancel?: () => void,
heartbeatMs = 2_000,
options?: {
options?: MaxOutputCompletionOptions & {
responseId?: string;
/** Trusted structural grouping only; never provider state or execution authority. */
outputItemIds?: { next(kind: "message" | "reasoning" | "function_call" | "custom_tool_call" | "tool_search_call"): string };
Expand Down Expand Up @@ -165,6 +166,7 @@ export function bridgeToResponsesSSE(
},
): ReadableStream<Uint8Array> {
const replayCacheScope = options?.replayCacheScope;
const maxOutputCompletion = createMaxOutputCompletion(options);
const outputItemId = (kind: "message" | "reasoning" | "function_call" | "custom_tool_call" | "tool_search_call", prefix: string): string =>
options?.outputItemIds?.next(kind) ?? `${prefix}_${uuid()}`;

Expand Down Expand Up @@ -852,6 +854,7 @@ export function bridgeToResponsesSSE(
}
if (next.done) { upstreamDone = true; break; }
const event = next.value;
maxOutputCompletion.observe(event);
let terminalEvent = false;
// Invisible adapter heartbeats (and buffered web-search progress) count as upstream
// liveness only — they must not suppress wire keepalives that re-arm Codex idle timers.
Expand Down Expand Up @@ -1293,7 +1296,7 @@ export function bridgeToResponsesSSE(
// Suppression and terminal status must agree: withholding the compaction item while
// still reporting success hands codex-rs a completed response with zero compaction
// items, which it treats as fatal.
if (truncationReasonFor(event.stopReason)) {
if (truncationReasonFor(event.stopReason) && !maxOutputCompletion.completed) {
// Upstream stopped before a normal completion. Surface as incomplete so the
// client can distinguish a truncated/filtered turn from a finished one.
// #1926 gap 2: bound the window in which a handed-out thought signature is
Expand Down Expand Up @@ -1337,6 +1340,14 @@ export function bridgeToResponsesSSE(
flushHiddenReasoningEnvelope();
options?.onUsage?.(event.usage);
await awaitThoughtSignatureDurability();
if (maxOutputCompletion.completed) {
const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) };
options?.onCompletedResponse?.(response, event.providerState);
emit("response.completed", { response });
reportTerminal("completed");
terminalEvent = true;
break;
}
const refusal = options?.terminalRefusalAsFailed ? terminalRefusalFailure(event) : undefined;
if (refusal) {
emit("response.failed", {
Expand Down
1 change: 1 addition & 0 deletions src/config/schema/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export const configSchema = z.object({
emptyCompletionRetry: z.boolean().optional().catch(false),
// Header suppression changes what Codex sees, so absence and malformed edits stay off.
dropCodexSafetyBuffering: z.boolean().optional().catch(false),
completeOnMaxOutputTokens: z.boolean().optional().catch(false),
// A malformed hand edit must not silently stop opening the browser: fall back
// to undefined, which resolves to the historical auto-open behavior.
oauthOpenBrowser: z.boolean().optional().catch(undefined),
Expand Down
4 changes: 4 additions & 0 deletions src/server/responses/adapter-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ export async function deliverAdapterResponse(
toolParameterSchemas,
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}),
completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
textFormat: parsed.options.textFormat,
// Same grok-surface split as the runTurn branch above.
...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}),
onUsage: usage => {
Expand Down Expand Up @@ -378,6 +380,8 @@ export async function deliverAdapterResponse(
freeformToolNames,
toolSearchToolNames,
...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}),
completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
textFormat: parsed.options.textFormat,
onProviderState: state => { providerState = state; },
onUsage: usage => {
transportState.bindKeyUsageFromBridge(usage);
Expand Down
4 changes: 4 additions & 0 deletions src/server/responses/run-turn-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,8 @@ export async function executeResponsesRunTurn(
toolParameterSchemas,
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}),
completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && enforceDeclaredToolNames,
textFormat: parsed.options.textFormat,
// grok-build's strict decoder dies on the typed response.heartbeat frame; its
// eventsource layer tolerates comment keep-alives. Codex needs the opposite.
...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}),
Expand Down Expand Up @@ -1072,6 +1074,8 @@ export async function executeResponsesRunTurn(
bareCustomToolNames,
toolSearchToolNames,
...(routedCompaction ? { compaction: true, compactionRetention: parsed._compactionRetention } : {}),
completeOnMaxOutputTokens: config.completeOnMaxOutputTokens === true && enforceDeclaredToolNames,
textFormat: parsed.options.textFormat,
onProviderState: state => { providerState = state; },
onUsage: usage => {
transportState.bindKeyUsageFromBridge(usage);
Expand Down
2 changes: 2 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,8 @@ export interface OcxConfig {
emptyCompletionRetry?: boolean;
/** Suppress allowlisted client-facing Codex transport hints; provider enforcement is unchanged. */
dropCodexSafetyBuffering?: boolean;
/** Opt in to completing truncated plain-text translated Responses turns; default off. */
completeOnMaxOutputTokens?: boolean;
/**
* Whether a login may open a browser on the machine running the proxy.
*
Expand Down
Loading
Loading