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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ runs helper features around provider requests.
| `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. |
| `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. |
| `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. |
| `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event and a Vertex AI `MALFORMED_FUNCTION_CALL` failure that arrives before any output. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. |
| `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. |
| `stallTimeoutSec?` | `number` | `300` (public) / disabled (local) | Seconds without meaningful upstream progress (Responses and native Chat) before the stream is cut. Unset, a **local** upstream (loopback, private, or a `.local`/`.lan` name) defaults to disabled and a public upstream to 300 s; a positive value applies to both (minimum 1 s); `0` disables the silence watchdog everywhere. Disabled leaves a silent-but-healthy local model connected (keep-alives still flow). Canonical ChatGPT Responses folded from SSE into non-streaming JSON retain a separate 15-minute whole-turn ceiling even when the silence watchdog is disabled. Pending `/v1/responses/compact` body reads share this budget but default to 300 s even for a local upstream — the route buffers the complete body while holding an active-turn lease — and an explicit value, including `0`, still wins. |
| `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Both routes report the outcome as `browserLaunch`: `started`, `failed` (nothing could be opened; the dashboard says so), or `skipped`. Device-code flows never open a browser either way. |
Expand Down
22 changes: 22 additions & 0 deletions src/adapters/google-truncation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redactSecretString } from "../lib/redact";
import type { AdapterEvent, OcxUsage } from "../types";

/** Gemini/Vertex finishReason values that mean the turn was cut off, not cleanly stopped. */
const TRUNCATION_REASONS = new Set(["MAX_TOKENS", "MALFORMED_FUNCTION_CALL"]);
Expand All @@ -22,3 +23,24 @@ export function isVertexTruncatedTurn(finishReason: string | undefined, toolCall
if (!isVertexTruncationReason(finishReason)) return false;
return toolCallsStarted > 0 || finishReason === "MALFORMED_FUNCTION_CALL";
}

/**
* The fail-closed terminal for a truncated Vertex turn. MALFORMED_FUNCTION_CALL with zero started
* calls means the model's only tool call was dropped upstream and nothing actionable was emitted,
* so the turn is marked replay-safe for the opt-in empty-completion guard (#6876). A truncation
* after a started call is never replay-safe: the client may already hold part of it.
*
* The reported usage rides on the error so a replayed turn meters both billable attempts.
*/
export function vertexTruncationErrorEvent(
finishReason: string | undefined,
toolCallsStarted: number,
usage?: OcxUsage,
): Extract<AdapterEvent, { type: "error" }> {
return {
type: "error",
message: vertexTruncationErrorMessage(finishReason),
...(usage ? { usage } : {}),
...(finishReason === "MALFORMED_FUNCTION_CALL" && toolCallsStarted === 0 ? { replaySafeBeforeOutput: true } : {}),
};
}
10 changes: 7 additions & 3 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { contentPartsToText, parseDataUrl } from "./image";
import { getVertexAccessToken } from "../lib/gcp-adc";
import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation";
import { isVertexTruncatedTurn, vertexTruncationErrorEvent } from "./google-truncation";
import { ANTIGRAVITY_REQUEST_UA, antigravitySessionAnchor, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
import { summarizeGoogleWireShape } from "./google-wire-shape";
import { compileGoogleWireBody } from "./google-wire-compiler";
Expand Down Expand Up @@ -1472,7 +1472,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
// an error instead of a silently-incomplete done. Mirrors kiro-truncation.
if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
&& isVertexTruncatedTurn(lastFinishReason, toolCallsStarted)) {
yield { type: "error", message: vertexTruncationErrorMessage(lastFinishReason) };
yield vertexTruncationErrorEvent(lastFinishReason, toolCallsStarted, pendingUsage);
return;
}
if (!sawAnyFrame || !sawTerminalSignal) {
Expand Down Expand Up @@ -1687,7 +1687,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
// (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done.
if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
&& isVertexTruncatedTurn(candidate.finishReason, toolCallsStarted)) {
return finish([{ type: "error", message: vertexTruncationErrorMessage(candidate.finishReason) }]);
return finish([vertexTruncationErrorEvent(
candidate.finishReason,
toolCallsStarted,
usageFromGemini(json.usageMetadata as Record<string, number> | undefined),
)]);
}

const usage = json.usageMetadata as Record<string, number> | undefined;
Expand Down
20 changes: 18 additions & 2 deletions src/server/responses/empty-completion-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,27 @@ export async function* guardEmptyCompletionEventStream(
return;
}
if (event.type === "error") {
if (retries > 0 && event.status !== 499) {
if (event.replaySafeBeforeOutput === true && retries < maxRetries) {
// The upstream failed the turn before any text or tool call (#6876: Vertex dropped a
// malformed function call). Nothing actionable reached the client, so this is the same
// replay-safe shape as an empty completion: retry the identical turn once.
usage = mergeUsage(usage, event.usage);
Comment on lines +296 to +300

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the owned structure docs for the retry contract

This adds a shared adapter event flag and changes the Responses retry policy, but the commit updates no file under structure/. The repository requires every structure document mapped to changed shared adapter, transport, server, and type areas in structure/INDEX.md to be updated in the same change; document the new replay-safe terminal and usage/retry semantics in those owners rather than only in the public configuration table.

AGENTS.md reference: src/AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

retries += 1;
try {
source = await options.continuation();
} catch {
yield emptyCompletionRetryFailedEvent(usage, true);
return;
}
terminalSeen = true;
break;
}
if (retries > 0 && event.status !== 499 && event.replaySafeBeforeOutput !== true) {
// The retry failed upstream. Its body cannot reach the client (the
// 200 head went out with the first attempt), so state the failure in
// the stream's own error framing — same move as the router's
// empty_completion_retry_failed. Client cancels (499) pass through.
// empty_completion_retry_failed. Client cancels (499) pass through, and a
// replay-safe failure that repeats keeps its own, more specific message.
yield emptyCompletionRetryFailedEvent(mergeUsage(usage, event.usage), true);
return;
}
Expand Down
5 changes: 5 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,11 @@ export type AdapterEvent =
errorType?: string;
code?: string;
retryable?: boolean;
/**
* Proxy-internal, never serialized: the upstream failed the turn before producing text or a
* tool call, so the opt-in empty-completion guard may replay the identical request once.
*/
replaySafeBeforeOutput?: boolean;
};

/**
Expand Down
28 changes: 28 additions & 0 deletions tests/adapters/google/google-vertex-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { createGoogleAdapter as createGoogleAdapterProduction } from "../../../src/adapters/google";
import { isVertexTruncatedTurn, isVertexTruncationReason, vertexTruncationErrorMessage } from "../../../src/adapters/google-truncation";
import { bridgeToResponsesSSE } from "../../../src/bridge";
import { guardEmptyCompletionEventStream } from "../../../src/server/responses/empty-completion-guard";
import type { AdapterEvent, OcxProviderConfig } from "../../../src/types";
import { withTestTranslatorBudget } from "../../helpers/translator-budget";

Expand Down Expand Up @@ -50,6 +51,17 @@ describe("vertex parseStream fail-closed truncation", () => {
const last = events[events.length - 1];
expect(last.type).toBe("error");
expect(events.some(e => e.type === "done")).toBe(false);
// A started call may already be in the client's hands, so this is never replayed.
expect(last).not.toHaveProperty("replaySafeBeforeOutput");
});

test("a truncation after a started tool call is never marked replay-safe", async () => {
const events = await collect(vertexProvider, [
{ candidates: [{ content: { parts: [{ functionCall: { name: "get_x", args: {} } }] }, finishReason: "MALFORMED_FUNCTION_CALL" }] },
]);
const last = events[events.length - 1];
expect(last.type).toBe("error");
expect(last).not.toHaveProperty("replaySafeBeforeOutput");
});

test("clean STOP stream yields done with reported usage", async () => {
Expand Down Expand Up @@ -94,6 +106,21 @@ describe("vertex parseStream fail-closed truncation", () => {
const last = events[events.length - 1];
expect(last.type).toBe("error");
expect(events.some(e => e.type === "done")).toBe(false);
// #6876: nothing actionable was emitted, so the opt-in empty-completion guard may replay it.
expect(last).toMatchObject({ type: "error", replaySafeBeforeOutput: true, usage: { inputTokens: 5, outputTokens: 0 } });
});

test("the guard replays a real pre-output malformed turn and meters both attempts", async () => {
const first = createGoogleAdapter(vertexProvider).parseStream(sseResponse([
{ candidates: [{ finishReason: "MALFORMED_FUNCTION_CALL" }], usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 0 } },
]));
const retry = createGoogleAdapter(vertexProvider).parseStream(sseResponse([
{ candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }], usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 } },
]));
const events: AdapterEvent[] = [];
for await (const event of guardEmptyCompletionEventStream({ firstEvents: first, continuation: () => retry })) events.push(event);
expect(events.some(e => e.type === "error")).toBe(false);
expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 10, outputTokens: 1 } });
});

test("usage-only final chunk (no candidates) is not dropped", async () => {
Expand Down Expand Up @@ -123,6 +150,7 @@ describe("vertex parseResponse fail-closed truncation (non-streaming)", () => {
const events = await adapter.parseResponse!(new Response(body, { status: 200 }));
expect(events[events.length - 1].type).toBe("error");
expect(events.some(e => e.type === "done")).toBe(false);
expect(events[events.length - 1]).toMatchObject({ replaySafeBeforeOutput: true, usage: { inputTokens: 5, outputTokens: 0 } });
});

test("clean STOP non-stream response yields done", async () => {
Expand Down
65 changes: 65 additions & 0 deletions tests/responses/empty-completion-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,71 @@ function eventsOf(...items: AdapterEvent[]): AsyncIterable<AdapterEvent> {
}

describe("empty-completion guard hardening", () => {
// #6876: Vertex fails a turn with MALFORMED_FUNCTION_CALL after dropping the model's only call.
const malformed = (usage?: { inputTokens: number; outputTokens: number }): AdapterEvent => ({
type: "error",
message: "Vertex AI response truncated upstream before the turn completed (MALFORMED_FUNCTION_CALL)",
replaySafeBeforeOutput: true,
...(usage ? { usage } : {}),
});

test("a replay-safe error before any output retries the identical turn once", async () => {
let continuations = 0;
const events = await collect(guardEmptyCompletionEventStream({
firstEvents: eventsOf({ type: "thinking_delta", thinking: "plan" }, malformed({ inputTokens: 10, outputTokens: 0 })),
continuation: () => {
continuations += 1;
return eventsOf({ type: "text_delta", text: "answer" }, { type: "done", usage: { inputTokens: 10, outputTokens: 3 } });
},
}));
expect(continuations).toBe(1);
expect(events.some(event => event.type === "error")).toBe(false);
expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 20, outputTokens: 3 } });
});

test("a replay-safe error that repeats surfaces its own message after one retry", async () => {
let continuations = 0;
const events = await collect(guardEmptyCompletionEventStream({
firstEvents: eventsOf(malformed({ inputTokens: 10, outputTokens: 0 })),
continuation: () => {
continuations += 1;
return eventsOf(malformed({ inputTokens: 10, outputTokens: 0 }));
},
}));
expect(continuations).toBe(1);
expect(events.at(-1)).toMatchObject({
type: "error",
message: expect.stringContaining("MALFORMED_FUNCTION_CALL"),
usage: { inputTokens: 20, outputTokens: 0 },
});
});

test("a replay-safe error after output passes through without a retry", async () => {
let continuations = 0;
const events = await collect(guardEmptyCompletionEventStream({
firstEvents: eventsOf({ type: "text_delta", text: "partial" }, malformed()),
continuation: () => {
continuations += 1;
return eventsOf({ type: "done" });
},
}));
expect(continuations).toBe(0);
expect(events.map(event => event.type)).toEqual(["text_delta", "error"]);
});

test("an ordinary pre-output error is still not retried", async () => {
let continuations = 0;
const events = await collect(guardEmptyCompletionEventStream({
firstEvents: eventsOf({ type: "error", message: "upstream 500", status: 500 }),
continuation: () => {
continuations += 1;
return eventsOf({ type: "done" });
},
}));
expect(continuations).toBe(0);
expect(events.at(-1)).toMatchObject({ type: "error", message: "upstream 500" });
});

test("usage keeps the retry's absolute context checkpoint without summing it", async () => {
const events = await collect(guardEmptyCompletionEventStream({
firstEvents: eventsOf(
Expand Down
Loading