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
78 changes: 78 additions & 0 deletions src/providers/opencode-zen-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,81 @@ export function enrichOpenCodeZenUpstreamMessage(
): string {
return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
}

/**
* Console Go transient upload rejection.
*
* Both Go and Zen hand the request to OpenCode's Console gateway, which intermittently answers
* a body it accepts moments later with a 400 invalid_request_error / "Invalid upload request." —
* a rejection naming an upload step the caller never invoked (the request is plain JSON).
*
* Live evidence (2026-09-12, usage ledger, conversation a016a2a76ee921d9300342e56bf5824d):
* request ocx-ce696fa4a554e4f3e375cf120904b0da failed 400, while ocx-ed78f47b96f7d1bb7c —
* sent 22s later with the same model, the same inline-image history and the same tool output —
* answered 200. The rejection is therefore an upstream flap, not a verdict on the payload, so
* callers replay the byte-identical request once instead of failing the turn.
* Upstream tracker: anomalyco/opencode#47237.
*/
/** Canonical Console destinations: the Zen/Go provider rows and any opencode.ai /zen base URL. */
const CONSOLE_GO_PROVIDER_IDS = new Set(["opencode-go", "opencode-zen", "opencode-free"]);

export function isConsoleGoDestination(opts: {
providerName?: string;
baseUrl?: string;
}): boolean {
const name = opts.providerName?.trim();
if (name && CONSOLE_GO_PROVIDER_IDS.has(name)) return true;
const baseUrl = opts.baseUrl?.trim();
if (!baseUrl) return false;
try {
const url = new URL(baseUrl);
if (url.hostname !== "opencode.ai") return false;
const path = url.pathname.replace(/\/+$/, "");
return path === "/zen" || path.startsWith("/zen/");
} catch {
return false;
}
}

/**
* The canonical refusal envelope, as served on both Console routes:
* {"error":{"param":null,"type":"invalid_request_error","message":"Error from provider
* (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request."}}
*
* The gateway names itself Console on the Zen key route and Console Go on the Go route, so the
* anchor is the shared product name plus the refusal sentence. The message is matched whole: a
* bare string, a suffix, or any other envelope is a different refusal and must not be replayed.
* Being stricter than necessary is the safe direction: a missed match leaves the turn failing
* exactly as it does today, while a loose match spends an extra request on unrelated 400s.
*/
const CONSOLE_UPLOAD_REFUSAL_RE =
/^Error from provider \(Console(?: Go)?\): Upstream request failed: \[invalid_request_error\] Invalid upload request\.$/;

/**
* True only for the canonical Console upload refusal on a canonical Console destination.
* Route-gated on purpose: the message alone would let any other upstream that happens to answer
* with this English sentence trigger a second send from an unrelated provider.
*/
export function isTransientConsoleGoUploadRejection(opts: {
status: number;
errorBody: string | undefined;
providerName?: string;
baseUrl?: string;
}): boolean {
if (opts.status !== 400 || !opts.errorBody) return false;
if (!isConsoleGoDestination({ providerName: opts.providerName, baseUrl: opts.baseUrl })) return false;
let payload: unknown;
try {
payload = JSON.parse(opts.errorBody);
} catch {
return false;
}
const error = (payload as { error?: unknown } | null)?.error;
if (!error || typeof error !== "object" || Array.isArray(error)) return false;
// The whole envelope, not just the sentence: Console always answers this refusal as
// invalid_request_error with a null param, so a partial envelope is a different error.
const envelope = error as { param?: unknown; type?: unknown; message?: unknown };
if (envelope.type !== "invalid_request_error" || envelope.param !== null) return false;
// Matched without trimming: padding means the gateway wrapped or appended something.
return typeof envelope.message === "string" && CONSOLE_UPLOAD_REFUSAL_RE.test(envelope.message);
}
102 changes: 101 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ import {
} from "../../lib/errors";
import { injectionDebugLog } from "../../lib/injection-debug-log";
import { resolveClientRetryAfter } from "../../lib/retry-after";
import { enrichOpenCodeZenUpstreamMessage } from "../../providers/opencode-zen-rate-limit";
import {
enrichOpenCodeZenUpstreamMessage,
isTransientConsoleGoUploadRejection,
} from "../../providers/opencode-zen-rate-limit";
import { CODE_MODE_EXEC_TOOL_NAME, modelInList, namespacedToolName } from "../../types";
import type {
AdapterEvent,
Expand Down Expand Up @@ -213,6 +216,7 @@ import {
fetchWithTransientRetry,
isNonReplayableResponse,
prepareSameTarget429Wait,
sleepWithAbort,
} from "../../lib/upstream-retry";
import {
ForwardAdmissionCredentialError,
Expand Down Expand Up @@ -848,6 +852,33 @@ async function opaqueBlobRejectionBodyForRecovery(
}
}

/**
* Console Go transient-400 retry delay. A short pause lets a momentary gateway state clear
* before the single byte-identical replay; it is imperceptible inside a turn that already
* waits seconds for the model.
*/
const CONSOLE_GO_UPLOAD_RETRY_DELAY_MS = 800;

/**
* Peek the upstream error body for the Console Go transient-400 recovery. Only a complete,
* display-safe body may drive a retry decision (same contract as
* opaqueBlobRejectionBodyForRecovery), and reading a clone leaves the original response intact
* for the caller's own error surface when no retry is taken.
*/
async function consoleGoUploadRejectionBody(
response: Response,
alreadyAttempted: boolean,
signal: AbortSignal,
): Promise<string | undefined> {
if (response.status !== 400 || alreadyAttempted) return undefined;
try {
const body = await readBoundedResponseBody(response.clone(), { signal });
return body.displaySafe && !body.truncated ? body.text : undefined;
} catch {
return undefined;
}
}

/**
* Materialize an upstream error body only when the bounded reader observed a complete,
* display-safe payload. Partial timeout and over-limit prefixes are attacker-controlled,
Expand Down Expand Up @@ -5231,6 +5262,9 @@ async function handleResponsesInner(
const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false };
let oauth401ReplayAttempted = false;
let codex401ReplayKind: "main" | "stored" | null = null;
// Console Go answers a transient 400 "Invalid upload request." for bodies it accepts
// moments later; at most one byte-identical replay is allowed per request.
const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false };
const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
let rateLimitRetries = 0;
const rebuildAndRefetch = async (
Expand Down Expand Up @@ -5778,6 +5812,38 @@ async function handleResponsesInner(
logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason;
}
}
// Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds
// later with 400 invalid_request_error / "Invalid upload request." Replay the byte-identical
// request once: the same conversation re-sent 22s later answered 200, so surfacing this flap
// would fail a turn the upstream would have served. Single-shot guard.
// No same-target cache invalidation here: the passthrough rebuild calls buildRequest on every
// attempt, and the cache guarded by invalidateSameTargetRequest() belongs to the generic loop.
if (!consoleGoUploadRetryGuard.attempted) {
const uploadRejectionBody = await consoleGoUploadRejectionBody(
upstreamResponse,
consoleGoUploadRetryGuard.attempted,
upstream.signal,
);
if (uploadRejectionBody !== undefined
&& isTransientConsoleGoUploadRejection({
status: upstreamResponse.status,
errorBody: uploadRejectionBody,
providerName: route.providerName,
baseUrl: route.provider.baseUrl,
})) {
consoleGoUploadRetryGuard.attempted = true;
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
if (!upstream.signal.aborted) {
try {
await sleepWithAbort(CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, upstream.signal);
} catch { /* client aborted: the replay below settles through the normal abort path */ }
}
const result = await rebuildAndRefetch("console-go-upload-retry");
if ("failed" in result) return result.failed;
upstreamResponse = result;
continue passthroughRecovery;
}
}
break;
}
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
Expand Down Expand Up @@ -7267,6 +7333,9 @@ async function handleResponsesInner(
// 413→429 rotation cannot silently undo the tightening.
let imageRetryAttempted = false;
const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false };
// Console Go answers a transient 400 "Invalid upload request." for bodies it accepts
// moments later; at most one byte-identical replay is allowed per request.
const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false };
let oauth401ReplayAttempted = false;
/**
* Rebuild the request from the current parsed input (and any image-tier bias) and refetch
Expand Down Expand Up @@ -7651,6 +7720,37 @@ async function handleResponsesInner(
upstreamResponse = result;
continue recovery;
}
// Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds
// later with 400 invalid_request_error / "Invalid upload request." Replay the
// byte-identical request once: the same conversation re-sent 22s later answered 200, so
// surfacing this flap would fail a turn the upstream would have served. Single-shot guard.
if (!consoleGoUploadRetryGuard.attempted) {
const uploadRejectionBody = await consoleGoUploadRejectionBody(
upstreamResponse,
consoleGoUploadRetryGuard.attempted,
upstream.signal,
);
if (uploadRejectionBody !== undefined
&& isTransientConsoleGoUploadRejection({
status: upstreamResponse.status,
errorBody: uploadRejectionBody,
providerName: route.providerName,
baseUrl: route.provider.baseUrl,
})) {
consoleGoUploadRetryGuard.attempted = true;
invalidateSameTargetRequest();
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
if (!upstream.signal.aborted) {
try {
await sleepWithAbort(CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, upstream.signal);
} catch { /* client aborted: the replay below settles through the normal abort path */ }
}
const result = await rebuildAndRefetch("console-go-upload-retry");
if ("failed" in result) return result.failed;
upstreamResponse = result;
continue recovery;
}
}
break;
}
if (!upstreamResponse.ok) {
Expand Down
2 changes: 2 additions & 0 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export type AttemptRecoveryKind =
| "anthropic-oauth-429"
| "oauth-account-429"
| "image-413"
| "console-go-upload-retry"
| "opaque-blob-rejection"
| "empty-completion";

Expand Down Expand Up @@ -309,6 +310,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
"anthropic-oauth-429",
"oauth-account-429",
"image-413",
"console-go-upload-retry",
"opaque-blob-rejection",
"empty-completion",
]);
Expand Down
62 changes: 62 additions & 0 deletions tests/providers/opencode-zen-rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
enrichOpenCodeZenFreeTierMessage,
enrichOpenCodeZenRateLimitMessage,
enrichOpenCodeZenUpstreamMessage,
isTransientConsoleGoUploadRejection,
isOpenCodeZenFreeTierLockIn,
isOpenCodeZenRateLimitProvider,
} from "../../src/providers/opencode-zen-rate-limit";
Expand Down Expand Up @@ -230,3 +231,64 @@ describe("opencode-free keyless tier lock-in (#4121)", () => {
expect(lockedOut).not.toContain(OPENCODE_ZEN_OBSERVED_RPM_HINT);
});
});

describe("Console Go transient upload refusal", () => {
// The observed Go-route refusal, byte-for-byte as Console serves it.
const GO_MESSAGE = "Error from provider (Console Go): Upstream request failed: [invalid_request_error] Invalid upload request.";
// The Zen key route names the same gateway without the Go suffix.
const ZEN_MESSAGE = "Error from provider (Console): Upstream request failed: [invalid_request_error] Invalid upload request.";
const envelope = (message: string) => JSON.stringify({ model: "muse-spark-1.3-contributor", error: { param: null, type: "invalid_request_error", message } });
const GO_ROUTE = { providerName: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1" };
const ZEN_ROUTE = { providerName: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1" };

test("accepts the canonical refusal on both canonical Console routes", () => {
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), ...GO_ROUTE })).toBe(true);
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(ZEN_MESSAGE), ...ZEN_ROUTE })).toBe(true);
// A custom row pointed at the same destination is still Console: the base URL decides.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), providerName: "my-go-row", baseUrl: "https://opencode.ai/zen/go/v1" })).toBe(true);
});

test("rejects the refusal text from a non-Console route", () => {
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" })).toBe(false);
// opencode.ai without the /zen segment is not the Console gateway.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), providerName: "other", baseUrl: "https://opencode.ai/v1" })).toBe(false);
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE) })).toBe(false);
});

test("rejects noncanonical envelopes, suffixes, and other statuses", () => {
// A bare string is not the structured envelope.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: JSON.stringify({ error: "Invalid upload request." }), ...GO_ROUTE })).toBe(false);
// A suffix means the gateway said something else; do not guess.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE + " Please retry."), ...GO_ROUTE })).toBe(false);
// Different status: only the gateway 400 is the flap.
expect(isTransientConsoleGoUploadRejection({ status: 500, errorBody: envelope(GO_MESSAGE), ...GO_ROUTE })).toBe(false);
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: undefined, ...GO_ROUTE })).toBe(false);
// Other 400s on the same wire are verdicts on the request, not flaps.
expect(isTransientConsoleGoUploadRejection({
status: 400,
errorBody: envelope("Error from provider (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an active Muse Code subscription for model muse-spark-1.3-contributor."),
...GO_ROUTE,
})).toBe(false);
expect(isTransientConsoleGoUploadRejection({
status: 400,
errorBody: JSON.stringify({ type: "error", error: { type: "MissingSessionID", message: "Request is missing x-opencode-session" } }),
...GO_ROUTE,
})).toBe(false);
});

test("rejects partial envelopes and padded messages", () => {
const withError = (error: unknown) => JSON.stringify({ model: "muse-spark-1.3-contributor", error });
// type carries the refusal identity; a partial envelope is a different error.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false);
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, type: "server_error", message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false);
// param must be present and null.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ type: "invalid_request_error", message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false);
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: "input", type: "invalid_request_error", message: GO_MESSAGE }), ...GO_ROUTE })).toBe(false);
// Padding means the gateway wrapped or appended something.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, type: "invalid_request_error", message: " " + GO_MESSAGE }), ...GO_ROUTE })).toBe(false);
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: withError({ param: null, type: "invalid_request_error", message: GO_MESSAGE + "\n" }), ...GO_ROUTE })).toBe(false);
// The exact canonical envelope still matches.
expect(isTransientConsoleGoUploadRejection({ status: 400, errorBody: envelope(GO_MESSAGE), ...GO_ROUTE })).toBe(true);
});
});

Loading
Loading