From 939e86ad2f48bbde813c56ef999d7b357bbbe768 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 18:09:42 +0900 Subject: [PATCH 1/2] fix(google): let the empty-completion retry replay a pre-output Vertex malformed call (#6876) --- .../docs/reference/configuration/server.md | 2 +- src/adapters/google-truncation.ts | 18 +++++ src/adapters/google.ts | 6 +- .../responses/empty-completion-guard.ts | 20 +++++- src/types/request.ts | 5 ++ .../google/google-vertex-stream.test.ts | 14 ++++ .../empty-completion-hardening.test.ts | 65 +++++++++++++++++++ 7 files changed, 124 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ad97ba0422c..e97b4eb3861 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -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 `*.` glob to Bun's `.` bypass. That glob also bypasses the bare apex ``. 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. | diff --git a/src/adapters/google-truncation.ts b/src/adapters/google-truncation.ts index 5ac6de1cd9e..e641ada5c87 100644 --- a/src/adapters/google-truncation.ts +++ b/src/adapters/google-truncation.ts @@ -1,4 +1,5 @@ import { redactSecretString } from "../lib/redact"; +import type { AdapterEvent } 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"]); @@ -22,3 +23,20 @@ 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. + */ +export function vertexTruncationErrorEvent( + finishReason: string | undefined, + toolCallsStarted: number, +): Extract { + return { + type: "error", + message: vertexTruncationErrorMessage(finishReason), + ...(finishReason === "MALFORMED_FUNCTION_CALL" && toolCallsStarted === 0 ? { replaySafeBeforeOutput: true } : {}), + }; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index af0bd6a4504..81241999e23 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -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"; @@ -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); return; } if (!sawAnyFrame || !sawTerminalSignal) { @@ -1687,7 +1687,7 @@ 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)]); } const usage = json.usageMetadata as Record | undefined; diff --git a/src/server/responses/empty-completion-guard.ts b/src/server/responses/empty-completion-guard.ts index 10a1b563b72..6f94d3fa627 100644 --- a/src/server/responses/empty-completion-guard.ts +++ b/src/server/responses/empty-completion-guard.ts @@ -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); + 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; } diff --git a/src/types/request.ts b/src/types/request.ts index eca27f0d7db..d433b463294 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -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; }; /** diff --git a/tests/adapters/google/google-vertex-stream.test.ts b/tests/adapters/google/google-vertex-stream.test.ts index f965cb61324..fcc49cc51a1 100644 --- a/tests/adapters/google/google-vertex-stream.test.ts +++ b/tests/adapters/google/google-vertex-stream.test.ts @@ -50,6 +50,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 () => { @@ -94,6 +105,8 @@ 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 }); }); test("usage-only final chunk (no candidates) is not dropped", async () => { @@ -123,6 +136,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 }); }); test("clean STOP non-stream response yields done", async () => { diff --git a/tests/responses/empty-completion-hardening.test.ts b/tests/responses/empty-completion-hardening.test.ts index d41dd6a299d..41f4cf4f874 100644 --- a/tests/responses/empty-completion-hardening.test.ts +++ b/tests/responses/empty-completion-hardening.test.ts @@ -17,6 +17,71 @@ function eventsOf(...items: AdapterEvent[]): AsyncIterable { } 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( From e955b94904dcab8111c5216a784c5731413dbd82 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 10 Oct 2026 18:15:53 +0900 Subject: [PATCH 2/2] fix(google): carry Vertex usage on truncation errors so a replay meters both attempts --- src/adapters/google-truncation.ts | 6 +++++- src/adapters/google.ts | 8 ++++++-- .../google/google-vertex-stream.test.ts | 18 ++++++++++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/adapters/google-truncation.ts b/src/adapters/google-truncation.ts index e641ada5c87..0e8739e5e1c 100644 --- a/src/adapters/google-truncation.ts +++ b/src/adapters/google-truncation.ts @@ -1,5 +1,5 @@ import { redactSecretString } from "../lib/redact"; -import type { AdapterEvent } from "../types"; +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"]); @@ -29,14 +29,18 @@ export function isVertexTruncatedTurn(finishReason: string | undefined, toolCall * 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 { return { type: "error", message: vertexTruncationErrorMessage(finishReason), + ...(usage ? { usage } : {}), ...(finishReason === "MALFORMED_FUNCTION_CALL" && toolCallsStarted === 0 ? { replaySafeBeforeOutput: true } : {}), }; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 81241999e23..df34d14a493 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -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 vertexTruncationErrorEvent(lastFinishReason, toolCallsStarted); + yield vertexTruncationErrorEvent(lastFinishReason, toolCallsStarted, pendingUsage); return; } if (!sawAnyFrame || !sawTerminalSignal) { @@ -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([vertexTruncationErrorEvent(candidate.finishReason, toolCallsStarted)]); + return finish([vertexTruncationErrorEvent( + candidate.finishReason, + toolCallsStarted, + usageFromGemini(json.usageMetadata as Record | undefined), + )]); } const usage = json.usageMetadata as Record | undefined; diff --git a/tests/adapters/google/google-vertex-stream.test.ts b/tests/adapters/google/google-vertex-stream.test.ts index fcc49cc51a1..361a46c228b 100644 --- a/tests/adapters/google/google-vertex-stream.test.ts +++ b/tests/adapters/google/google-vertex-stream.test.ts @@ -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"; @@ -106,7 +107,20 @@ describe("vertex parseStream fail-closed truncation", () => { 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 }); + 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 () => { @@ -136,7 +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 }); + expect(events[events.length - 1]).toMatchObject({ replaySafeBeforeOutput: true, usage: { inputTokens: 5, outputTokens: 0 } }); }); test("clean STOP non-stream response yields done", async () => {