From d24ff57bc4dd53afbbb1d2c972266e6d89ea5c24 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 8 Sep 2026 17:44:26 +0900 Subject: [PATCH 1/2] release: set main channel version 2.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d94d23cab..6547da6552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.47.0", + "version": "2.48.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From f01fcdca0a36268b6a94ccdbe392d9d5531b8d48 Mon Sep 17 00:00:00 2001 From: vanch Date: Sun, 13 Sep 2026 19:43:20 +0800 Subject: [PATCH 2/2] feat(antigravity): sync to 2.51.0 with quota balancing, content filter handling, and priority routing --- src/adapters/google-content-filter.ts | 46 +++++++++ src/adapters/google.ts | 55 ++++++++--- src/codex/catalog/sync.ts | 1 + src/codex/routing.ts | 12 ++- src/oauth/antigravity-balance.ts | 49 ++++++++++ src/oauth/generic-account-failover.ts | 21 +++- src/server/responses/core.ts | 19 ++-- tests/antigravity-balance.test.ts | 35 +++++++ tests/codex-affinity-priority.test.ts | 48 ++++++++++ tests/generic-balance.test.ts | 38 ++++++++ tests/google-content-filter-codex-smoke.ts | 48 ++++++++++ tests/google-content-filter.test.ts | 106 +++++++++++++++++++++ 12 files changed, 453 insertions(+), 25 deletions(-) create mode 100644 src/adapters/google-content-filter.ts create mode 100644 src/oauth/antigravity-balance.ts create mode 100644 tests/antigravity-balance.test.ts create mode 100644 tests/codex-affinity-priority.test.ts create mode 100644 tests/generic-balance.test.ts create mode 100644 tests/google-content-filter-codex-smoke.ts create mode 100644 tests/google-content-filter.test.ts diff --git a/src/adapters/google-content-filter.ts b/src/adapters/google-content-filter.ts new file mode 100644 index 0000000000..d515a55a4c --- /dev/null +++ b/src/adapters/google-content-filter.ts @@ -0,0 +1,46 @@ +import type { AdapterEvent, OcxUsage } from "../types"; + +const FILTERED_FINISH_REASONS = new Set([ + "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", + "IMAGE_SAFETY", "IMAGE_PROHIBITED_CONTENT", "IMAGE_RECITATION", +]); +const BLOCKED_PROMPT_REASONS = new Set([ + "SAFETY", "BLOCKLIST", "PROHIBITED_CONTENT", "IMAGE_SAFETY", "JAILBREAK", "OTHER", +]); + +// Observed CCA refusal copy: only classify this exact standalone message on missing-terminal +// EOF. Mentions, quotations, longer answers and normally completed responses must not match. +const TEXT_POLICY_REFUSAL = "The prompt could not be submitted. The prompt contains sensitive words that violate Google's [Generative AI Prohibited Use policy](https://policies.google.com/terms/generative-ai/use-policy). Try rephrasing the prompt. If you think this was an error, [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting)."; + +export function googleTextPolicyRefusalEvent(text: string, usage?: OcxUsage): Extract | undefined { + if (text.trim().replace(/\s+/g, " ") !== TEXT_POLICY_REFUSAL) return undefined; + return { + type: "error", status: 400, errorType: "invalid_request_error", code: "invalid_prompt", + retryable: false, + message: "Google/Antigravity content_filter: upstream returned a standalone content-policy refusal and closed without a completion signal. No structured filter category was provided. Review the request against the provider's content policy before trying again.", + ...(usage ? { usage } : {}), + }; +} + +/** A provider policy stop is terminal, not a transient disconnect to retry across accounts. */ +export function googleContentFilterEvent( + reason: unknown, + source: "finishReason" | "promptFeedback.blockReason", + usage?: OcxUsage, +): Extract | undefined { + const allowed = source === "finishReason" ? FILTERED_FINISH_REASONS : BLOCKED_PROMPT_REASONS; + if (typeof reason !== "string" || !allowed.has(reason)) return undefined; + // Only known enums reach logs/UI. Never copy finishMessage, prompt text, or generated content. + return { + type: "error", + status: 400, + errorType: "invalid_request_error", + // Codex 0.153.4 treats unknown SSE error codes as retryable even when retryable=false. + // invalid_prompt is its supported terminal content-policy code; preserve Google's enum + // in the message so this normalization never hides the upstream reason. + code: "invalid_prompt", + retryable: false, + message: `Google/Antigravity content_filter: blocked this response (${source}=${reason}). The response was not completed. Review the request against the provider's content policy before trying again.`, + ...(usage ? { usage } : {}), + }; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9e1b46307e..c6fcac1f59 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -20,6 +20,7 @@ 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 { googleContentFilterEvent, googleTextPolicyRefusalEvent } from "./google-content-filter"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; @@ -48,9 +49,9 @@ import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-eff // providers are unaffected. const GOOGLE_BREVITY_INSTRUCTION = [ "Output style for this session:", - "- While you are still working (between tool calls), keep any text you emit to a single short line; do not narrate at length.", - "- Do detailed reasoning internally, not as visible intermediate output.", - "- Prefer taking the next tool action over explaining; keep calling tools until the task is complete.", + "- While you are still working, emit one short visible progress line before each tool call so the user knows what you are doing.", + "- Keep private chain-of-thought internal; the visible line should only summarize the next action and current status.", + "- Prefer taking the next tool action over long narration; keep calling tools until the task is complete.", "- This applies only to intermediate progress text. Your final answer after the work is done is exempt: write it in full and at whatever length the task requires.", "- Formatting: The client environment renders standard Markdown and does not support LaTeX math delimiters ($...$, $$...$$, \\(...\\), \\[...\\]). Do not use LaTeX math delimiters or LaTeX markup (such as \\text{}, \\times, \\le, \\ge, etc.) for variables, formulas, dimensions, or units. Use clean plain text, Markdown, and Unicode symbols (e.g. 180°, 2560 × 1920 px, ≤, ≥, Δ, ±) instead.", ].join("\n"); @@ -996,6 +997,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let pendingUsage: OcxUsage | undefined; let toolCallsStarted = 0; let lastFinishReason: string | undefined; + let contentFilterError: Extract | undefined; + // Fixed, small diagnostic prefix; overflow disables matching rather than retaining output. + let policyRefusalText: string | undefined = ""; let sawAnyFrame = false; let sawTerminalSignal = false; let pendingStreamThoughtSig: string | undefined; @@ -1058,6 +1062,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte pendingUsage = usageFromGemini(usageMeta); sawTerminalSignal = true; } + const feedback = root.promptFeedback; + if (isGoogleRecord(feedback)) { + contentFilterError ??= googleContentFilterEvent(feedback.blockReason, "promptFeedback.blockReason"); + } + // Drain trailing usage, but do not release content from a provider-blocked frame. + if (contentFilterError) return "continue"; const rawCandidates = root.candidates; // `null` is an absence encoding, not corruption, and terminating on it is the #1219 // failure mode one rung in: a `{"candidates":null}` frame arriving between a content @@ -1094,6 +1104,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte lastFinishReason = candidate.finishReason; sawTerminalSignal = true; } + contentFilterError = googleContentFilterEvent(candidate.finishReason, "finishReason"); + if (contentFilterError) return "continue"; // One rung below the candidate guard above, same rule: this is claimed content, not // padding, so it fails closed rather than being iterated or silently dropped (#1325). @@ -1141,11 +1153,16 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const textEvent = googlePartTextEvent(part); if (textEvent) { + if (textEvent.type === "text_delta" && policyRefusalText !== undefined) { + policyRefusalText = policyRefusalText.length + textEvent.text.length <= 1024 + ? policyRefusalText + textEvent.text : undefined; + } emittedContentEvent = true; yield textEvent; } const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; if (inline && typeof inline.data === "string") { + policyRefusalText = undefined; if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) { yield { type: "error", message: "inline image exceeds per-image size cap" }; } else { @@ -1232,6 +1249,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return; } else if ((yield* handleDataLine(residual)) === "terminate") return; } + if (contentFilterError) { + yield { ...contentFilterError, usage: pendingUsage }; + return; + } // Fail-closed: a turn cut off mid tool call (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces // an error instead of a silently-incomplete done. Mirrors kiro-truncation. if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") @@ -1240,14 +1261,18 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return; } if (!sawAnyFrame || !sawTerminalSignal) { + const textRefusal = provider.googleMode === "cloud-code-assist" && toolCallsStarted === 0 && policyRefusalText !== undefined + ? googleTextPolicyRefusalEvent(policyRefusalText, pendingUsage) : undefined; + if (textRefusal) { + yield textRefusal; + return; + } yield { type: "error", message: "upstream stream ended without a terminal signal — possible truncation" }; return; } const stopReason = lastFinishReason === "MAX_TOKENS" ? "max_tokens" - : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(lastFinishReason ?? "") - ? "content_filter" - : undefined; + : undefined; yield { type: "done", usage: pendingUsage, @@ -1363,6 +1388,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const events: AdapterEvent[] = []; + const feedback = json.promptFeedback; + const promptBlock = isGoogleRecord(feedback) + ? googleContentFilterEvent(feedback.blockReason, "promptFeedback.blockReason", usageFromGemini(json.usageMetadata as Record | undefined)) + : undefined; + if (promptBlock) return finish([promptBlock]); + const rawCandidates: unknown = json.candidates; // Parity with the streaming path, which has rejected a non-array `candidates` since #1332. // Buffered accepted `"abc"` outright (`"abc".length` is 3, so the emptiness check below @@ -1389,6 +1420,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte })]); } const candidate = rawCandidate as { content?: unknown; finishReason?: string }; + const contentBlock = googleContentFilterEvent(candidate.finishReason, "finishReason", usageFromGemini(json.usageMetadata as Record | undefined)); + if (contentBlock) return finish([contentBlock]); let toolCallsStarted = 0; const imageBudget = createImageBudget(); const rawContent: unknown = candidate.content; @@ -1455,16 +1488,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const usage = json.usageMetadata as Record | undefined; - // Mirror the streaming path: a buffered turn cut off by the token limit or a content filter - // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated - // turn as completed — and, on a compaction turn, installs the half-written summary as - // replacement history (#422). + // Content blocks returned an explicit failure above. Token-limit turns still carry their + // stop reason, so the bridge cannot install a half-written compaction as completed (#422). const finishReason = candidate.finishReason as string | undefined; const stopReason = finishReason === "MAX_TOKENS" ? "max_tokens" - : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "") - ? "content_filter" - : undefined; + : undefined; events.push({ type: "done", usage: usageFromGemini(usage), diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 074d9ddef0..3d500a497e 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -300,6 +300,7 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick 0) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index a1f1f4fbd9..062d4423f7 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1710,12 +1710,12 @@ function isUnknownUsage(usage: number): boolean { } /** - * Move an unbound request back up when a higher tier regains headroom — the + * Move a request back up when a higher tier regains headroom — the * weekly-reset case. Returns null when nothing should change. * * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only * fires when the tier filter has already excluded `active`, and only toward a - * tier that strictly outranks it. Threads bound by affinity never reach here. + * tier that strictly outranks it. Existing thread bindings use the same check. */ function pickPriorityPreemption( config: OcxConfig, @@ -1908,6 +1908,8 @@ function previewReusableAffinityAccount( ) { return null; } + const preferred = pickPriorityPreemption(config, entry.accountId, now, quotaScope, selectionOptions); + if (preferred) return preferred; // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { @@ -1936,8 +1938,8 @@ function previewReusableAffinityAccount( } /** - * Re-evaluate an affined account under the quota strategy. Returns a strictly - * cooler replacement, or null when the current binding should remain. + * Re-evaluate priority on every bound request, then quota under the quota strategy. + * Returns a higher-priority or cooler replacement, or null to preserve the binding. */ function reevaluateAffinityQuota( entry: ThreadAffinityEntry, @@ -1946,6 +1948,8 @@ function reevaluateAffinityQuota( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { + const preferred = pickPriorityPreemption(config, entry.accountId, now, quotaScope, selectionOptions); + if (preferred) return preferred; if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null; const threshold = config.autoSwitchThreshold ?? 80; const usage = threshold > 0 diff --git a/src/oauth/antigravity-balance.ts b/src/oauth/antigravity-balance.ts new file mode 100644 index 0000000000..da9d60007d --- /dev/null +++ b/src/oauth/antigravity-balance.ts @@ -0,0 +1,49 @@ +import type { ProviderQuota } from "../providers/quota-types"; + +const MAX_QUOTA_AGE_MS = 5 * 60_000; +const BALANCE_BAND = 5; + +export function antigravityQuotaFamily(modelId = ""): string { + return /claude/i.test(modelId) ? "Cla" : /gemini/i.test(modelId) ? "Gem" : "all"; +} + +/** Percent USED, scoped to the requested model family. Stale/reset readings are unknown. */ +export function antigravityBalanceUsage(quota: ProviderQuota | null | undefined, family: string, now: number): number | null { + if (!quota || now - quota.updatedAt > MAX_QUOTA_AGE_MS) return null; + const windows = (quota.customWindows ?? []).filter(w => family === "all" || w.label === family); + const values = windows.length > 0 + ? windows.filter(w => !w.resetAt || w.resetAt > now).map(w => w.percent) + : [quota.fiveHourResetAt && quota.fiveHourResetAt <= now ? undefined : quota.fiveHourPercent, + quota.weeklyResetAt && quota.weeklyResetAt <= now ? undefined : quota.weeklyPercent]; + const valid = values.filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + return valid.length ? Math.max(0, Math.min(100, Math.max(...valid))) : null; +} + +/** Request reservations break ties immediately, including concurrent dispatches. */ +export class AntigravityBalancer { + private sequence = 0; + private lastPicked = new Map(); + + clear(): void { this.sequence = 0; this.lastPicked.clear(); } + + pick(candidates: readonly { id: string; usage: number | null }[], family: string): string | null { + const known = candidates.flatMap(c => c.usage === null ? [] : [c.usage]); + const minimum = known.length ? Math.min(...known) : 0; + // Unknown accounts must get sampled, not be permanently starved by measured ones. + const eligible = candidates.filter(c => c.usage === null || (c.usage < 100 && c.usage <= minimum + BALANCE_BAND)); + const pool = eligible.length ? eligible : candidates; + let best: string | null = null; + let oldest = Infinity; + for (const c of pool) { + const order = this.lastPicked.get(`${family}:${c.id}`) ?? 0; + if (order < oldest) { best = c.id; oldest = order; } + } + if (best) this.lastPicked.set(`${family}:${best}`, ++this.sequence); + // Bound state to the current roster; three family slots per account. + const ids = new Set(candidates.map(c => c.id)); + for (const key of this.lastPicked.keys()) { + if (!ids.has(key.slice(key.indexOf(":") + 1))) this.lastPicked.delete(key); + } + return best; + } +} diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index 1ccfaf71df..fae5a1ffdd 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -15,6 +15,8 @@ * Both are excluded by `isGenericFailoverProvider`. */ import { getAccountSet } from "./store"; +import { getCachedProviderAccountQuota } from "../providers/quota"; +import { AntigravityBalancer, antigravityBalanceUsage, antigravityQuotaFamily } from "./antigravity-balance"; import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index"; import { exhaustedCooldownMs, hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom } from "./account-quota-rank"; import { parseRetryAfterMs } from "../combos/failover"; @@ -23,6 +25,17 @@ import type { OcxConfig, OcxProviderConfig } from "../types"; /** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */ export const GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST = 3; +export const genericOAuthFailoverLimit = (providerName: string): number => + providerName === "google-antigravity" ? 8 : GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST; +const antigravityBalancer = new AntigravityBalancer(); + +function balanceAntigravity(config: OcxConfig, providerName: string, ids: string[], now: number, modelId?: string): string | null { + if (providerName !== "google-antigravity" || config.providers?.[providerName]?.oauthAccountFailover?.strategy !== "quota") return null; + const family = antigravityQuotaFamily(modelId); + return antigravityBalancer.pick(ids.map(id => ({ + id, usage: antigravityBalanceUsage(getCachedProviderAccountQuota(providerName, id), family, now), + })), family); +} const DEFAULT_COOLDOWN_MS = 60_000; const MAX_COOLDOWN_MS = 15 * 60_000; @@ -181,6 +194,7 @@ export function rotateGenericOAuthAccountOn429( failedAccountId: string, retryAfterHeader: string | null | undefined, now = Date.now(), + modelId?: string, ): string | null { if (!isGenericOAuthFailoverEnabled(config, providerName)) return null; const set = getAccountSet(providerName); @@ -214,7 +228,8 @@ export function rotateGenericOAuthAccountOn429( if (candidates.length === 0) return null; // With no quota evidence this returns the ring untouched, so providers without // per-account quota keep exactly the traversal they have today. - return rankAccountsByHeadroom(providerName, candidates)[0] ?? null; + return balanceAntigravity(config, providerName, candidates, now, modelId) + ?? rankAccountsByHeadroom(providerName, candidates)[0] ?? null; } /** @@ -247,6 +262,7 @@ export function preferredInitialAccount( config: OcxConfig, providerName: string, now = Date.now(), + modelId?: string, ): string | null { // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it. @@ -258,6 +274,8 @@ export function preferredInitialAccount( const active = selected.activeAccountId; const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id); if (order.length < 2) return null; + const balanced = balanceAntigravity(config, providerName, order.filter(id => !isCooled(providerName, id, now)), now, modelId); + if (balanced) return balanced === active ? null : balanced; const activeRow = selected.accounts.find(account => account.id === active); if (activeRow && activeRow.needsReauth !== true @@ -311,6 +329,7 @@ export function forgetGenericFailoverRoster(providerName: string): void { /** Test seam and manual-recovery hook. */ export function clearGenericFailoverHealth(providerName?: string): void { + if (!providerName || providerName === "google-antigravity") antigravityBalancer.clear(); if (!providerName) { health.clear(); presence.clear(); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3f4cfe4345..88e7c077b7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -142,7 +142,7 @@ import { stampOAuthAccountLabel } from "../../providers/label"; import { failoverAccountSnapshot, forgetGenericFailoverRoster, - GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST, + genericOAuthFailoverLimit, isGenericFailoverProvider, isGenericOAuthFailoverEnabled, preferredInitialAccount, @@ -4337,7 +4337,7 @@ async function handleResponsesInner( // measured as spent. A null answer means "use the active account", so every provider // without quota evidence keeps the resolution it has today. const preferredAccountId = isGenericFailoverProvider(route.providerName, route.provider) - ? preferredInitialAccount(config, route.providerName) + ? preferredInitialAccount(config, route.providerName, Date.now(), route.modelId) : null; // Resolved account-scoped, NOT through failoverAccountSnapshot: that helper marks a // rotation site, and rotation sites must apply their credential through @@ -5486,12 +5486,13 @@ async function handleResponsesInner( if ( upstreamResponse.status === 429 && genericFailoverAccountId - && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && genericFailovers < genericOAuthFailoverLimit(route.providerName) && isGenericOAuthFailoverEnabled(config, route.providerName) ) { const nextAccountId = rotateGenericOAuthAccountOn429( config, route.providerName, genericFailoverAccountId, upstreamResponse.headers.get("retry-after"), + Date.now(), route.modelId, ); let snapshot: OAuthAccessSnapshot | undefined; if (nextAccountId) { @@ -6417,7 +6418,7 @@ async function handleResponsesInner( // excludes it), so its sidecar 429s died on this guard before the Anthropic arm below // could ever be considered. genericFailoverAccountId - && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && genericFailovers < genericOAuthFailoverLimit(route.providerName) && isGenericOAuthFailoverEnabled(config, route.providerName) ) { const nextAccountId = rotateGenericOAuthAccountOn429( @@ -6425,6 +6426,7 @@ async function handleResponsesInner( route.providerName, genericFailoverAccountId, retryAfter, + Date.now(), route.modelId, ); if (!nextAccountId) return null; try { @@ -6790,7 +6792,7 @@ async function handleResponsesInner( if ( status !== 429 || !genericFailoverAccountId - || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + || genericFailovers >= genericOAuthFailoverLimit(route.providerName) || !isGenericOAuthFailoverEnabled(config, route.providerName) ) return false; const nextAccountId = rotateGenericOAuthAccountOn429( @@ -6798,6 +6800,7 @@ async function handleResponsesInner( route.providerName, genericFailoverAccountId, null, + Date.now(), route.modelId, ); if (!nextAccountId) return false; try { @@ -7524,7 +7527,7 @@ async function handleResponsesInner( while ( upstreamResponse.status === 429 && genericFailoverAccountId - && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && genericFailovers < genericOAuthFailoverLimit(route.providerName) && isGenericOAuthFailoverEnabled(config, route.providerName) ) { const nextAccountId = rotateGenericOAuthAccountOn429( @@ -7532,6 +7535,7 @@ async function handleResponsesInner( route.providerName, genericFailoverAccountId, upstreamResponse.headers.get("retry-after"), + Date.now(), route.modelId, ); if (!nextAccountId) break; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } @@ -7935,7 +7939,7 @@ async function handleResponsesInner( if ( response.status === 429 && genericFailoverAccountId - && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && genericFailovers < genericOAuthFailoverLimit(route.providerName) && isGenericOAuthFailoverEnabled(config, route.providerName) ) { const nextAccountId = rotateGenericOAuthAccountOn429( @@ -7943,6 +7947,7 @@ async function handleResponsesInner( route.providerName, genericFailoverAccountId, response.headers.get("retry-after"), + Date.now(), route.modelId, ); if (nextAccountId) { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } diff --git a/tests/antigravity-balance.test.ts b/tests/antigravity-balance.test.ts new file mode 100644 index 0000000000..c65d36afe4 --- /dev/null +++ b/tests/antigravity-balance.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { AntigravityBalancer, antigravityBalanceUsage, antigravityQuotaFamily } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/oauth/antigravity-balance"; + +describe("Antigravity quota balancing", () => { + test("uses requested family instead of unrelated quota", () => { + const q = { updatedAt: 1000, customWindows: [{ label: "Gem", percent: 79 }, { label: "Cla", percent: 0 }] }; + expect(antigravityBalanceUsage(q, antigravityQuotaFamily("gemini-3.8-flash"), 1001)).toBe(79); + expect(antigravityBalanceUsage(q, "Cla", 1001)).toBe(0); + }); + test("stale, expired and invalid quota cannot pin routing", () => { + expect(antigravityBalanceUsage({ updatedAt: 1, fiveHourPercent: 0 }, "Gem", 400000)).toBeNull(); + expect(antigravityBalanceUsage({ updatedAt: 1000, customWindows: [{ label: "Gem", percent: 99, resetAt: 1000 }] }, "Gem", 1001)).toBeNull(); + expect(antigravityBalanceUsage({ updatedAt: 1000, fiveHourPercent: NaN }, "Gem", 1001)).toBeNull(); + }); + test("80 equal requests spread exactly across eight accounts", () => { + const b = new AntigravityBalancer(); + const candidates = Array.from({ length: 8 }, (_, i) => ({ id: String(i), usage: 0 })); + const counts = new Map(); + for (let i = 0; i < 80; i++) { const id = b.pick(candidates, "Gem")!; counts.set(id, (counts.get(id) ?? 0) + 1); } + expect([...counts.values()]).toEqual(Array(8).fill(10)); + }); + test("avoids heavily used account and distributes close readings", () => { + const b = new AntigravityBalancer(); + const rows = [{ id: "a", usage: 2 }, { id: "b", usage: 4 }, { id: "low", usage: 79 }]; + expect(Array.from({ length: 4 }, () => b.pick(rows, "Gem"))).toEqual(["a", "b", "a", "b"]); + }); + test("unknown accounts get sampled and family counters are independent", () => { + const b = new AntigravityBalancer(); + const rows = [{ id: "a", usage: 0 }, { id: "b", usage: null }]; + expect(b.pick(rows, "Gem")).toBe("a"); + expect(b.pick(rows, "Gem")).toBe("b"); + expect(b.pick(rows, "Cla")).toBe("a"); + expect(b.pick([], "Gem")).toBeNull(); + }); +}); diff --git a/tests/codex-affinity-priority.test.ts b/tests/codex-affinity-priority.test.ts new file mode 100644 index 0000000000..5902f6a09b --- /dev/null +++ b/tests/codex-affinity-priority.test.ts @@ -0,0 +1,48 @@ +import { test, expect } from "bun:test"; +import { selectPriorityTier } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/codex/pool-rotation"; +const source = await Bun.file("/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/codex/routing.ts").text(); +// Execute the actual routing functions in a deterministic, credential-free harness. +const extract = (name: string) => { + const start = source.indexOf(`function ${name}(`); + const end = source.indexOf("\n}\n", start) + 2; + return source.slice(start, end); +}; +function harness(eligible = ["plus", "prolite"], pin?: string) { + const priority = (id: string) => id === "plus" ? 2 : -1; + const deps = { + getEligiblePoolAccounts: () => selectPriorityTier(eligible, priority, () => true, pin), + pinnedCodexAccountId: () => pin, + hasCodexQuotaHeadroom: () => true, + codexAccountPriorityLookup: () => priority, + pickLowestUsageAmong: (_: unknown, ids: string[]) => ids[0] ?? null, + normalizeAccountPoolStrategy: (s: string) => s ?? "quota", + computeCodexUsageScore: () => 10, + getAccountQuota: () => ({}), getPoolAccountPlanForSelection: () => "plus", + isUnknownUsage: () => false, CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS: 60000, + pickLowerUsageAccount: () => null, + isThreadAffinityExpired: () => false, isThreadAffinityGenerationLive: () => true, + isCodexAccountSelectable: () => true, shouldFailover: () => false, + }; + const js = new Bun.Transpiler({ loader: "ts" }).transformSync( + ["pickPriorityPreemption", "reevaluateAffinityQuota", "previewReusableAffinityAccount"].map(extract).join("\n") + + "\nreturn {reevaluateAffinityQuota,previewReusableAffinityAccount};", + ); + return new Function(...Object.keys(deps), js)(...Object.values(deps)); +} +const entry = { accountId: "prolite", lastReevalAt: 1000 }; +test("old binding moves upward immediately in all strategies, including preview", () => { + const h = harness(); + for (const accountPoolStrategy of ["quota", "fill-first", "round-robin"]) { + expect(h.reevaluateAffinityQuota(entry, { accountPoolStrategy }, 1001)).toBe("plus"); + expect(h.previewReusableAffinityAccount(entry, { accountPoolStrategy }, 1001)).toBe("plus"); + } +}); +test("unavailable/model-ineligible higher tier cannot preempt", () => { + expect(harness(["prolite"]).reevaluateAffinityQuota(entry, {}, 1001)).toBeNull(); +}); +test("explicit manual pin retains its priority ceiling", () => { + expect(harness(undefined, "prolite").reevaluateAffinityQuota(entry, {}, 1001)).toBeNull(); +}); +test("higher tier already bound does not switch down", () => { + expect(harness().reevaluateAffinityQuota({ ...entry, accountId: "plus" }, {}, 1001)).toBeNull(); +}); diff --git a/tests/generic-balance.test.ts b/tests/generic-balance.test.ts new file mode 100644 index 0000000000..7bb7ce9e76 --- /dev/null +++ b/tests/generic-balance.test.ts @@ -0,0 +1,38 @@ +import { test, expect, mock } from "bun:test"; +const root = "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src"; +const roster = { activeAccountId: "a", accounts: ["a","b","c"].map(id => ({id})) }; +let readings: Record = {}; +mock.module(root + "/oauth/store", () => ({ getAccountSet: () => roster })); +mock.module(root + "/oauth/index", () => ({ getValidAccessSnapshotForAccount: async () => ({}) })); +mock.module(root + "/providers/quota", () => ({ + hasPassiveAccountQuota: () => false, + getCachedProviderAccountQuota: (_: string,id: string) => readings[id] ? { + updatedAt: Date.now(), customWindows: [{ label: "Gem", percent: readings[id][0] },{ label: "Cla", percent: readings[id][1] }], + } : null, +})); +const r = await import(root + "/oauth/generic-account-failover"); +const config = { providers: { "google-antigravity": { authMode: "oauth", oauthAccountFailover: { enabled: true, strategy: "quota" } } } }; +test("healthy existing account no longer pins all requests; unknown quotas rotate", () => { + r.clearGenericFailoverHealth(); readings = {}; roster.activeAccountId = "a"; + expect(Array.from({length:6},()=>r.preferredInitialAccount(config,"google-antigravity",Date.now(),"gemini-3.8-flash") ?? roster.activeAccountId)).toEqual(["a","b","c","a","b","c"]); +}); +test("Gemini and Claude quota selection remains family-specific", () => { + r.clearGenericFailoverHealth(); readings = { a:[80,5],b:[5,80],c:[50,50] }; + expect(r.preferredInitialAccount(config,"google-antigravity",Date.now(),"gemini-3.8-flash")).toBe("b"); + expect(r.preferredInitialAccount(config,"google-antigravity",Date.now(),"claude-sonnet-4-6") ?? "a").toBe("a"); +}); +test("429 cools failed account and selects a different eligible account", () => { + r.clearGenericFailoverHealth(); readings = {}; + expect(r.rotateGenericOAuthAccountOn429(config,"google-antigravity","a","60",Date.now(),"gemini-3.8-flash")).toBe("b"); + expect(r.eligibleFailoverAccounts("google-antigravity")).not.toContain("a"); + expect(r.genericOAuthFailoverLimit("google-antigravity")).toBe(8); + expect(r.genericOAuthFailoverLimit("xai")).toBe(3); +}); +test("explicit preference disable and reauth exclusion are respected", () => { + r.clearGenericFailoverHealth(); readings = {}; + expect(r.preferredInitialAccount({providers:{"google-antigravity":{authMode:"oauth",oauthAccountFailover:{enabled:false,strategy:"quota"}}}},"google-antigravity")).toBeNull(); + roster.accounts[1].needsReauth = true; + const picked = Array.from({length:4},()=>r.preferredInitialAccount(config,"google-antigravity",Date.now(),"gemini-3.8-flash") ?? "a"); + expect(picked).not.toContain("b"); + delete roster.accounts[1].needsReauth; +}); diff --git a/tests/google-content-filter-codex-smoke.ts b/tests/google-content-filter-codex-smoke.ts new file mode 100644 index 0000000000..9030f42e07 --- /dev/null +++ b/tests/google-content-filter-codex-smoke.ts @@ -0,0 +1,48 @@ +import { createGoogleAdapter } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/adapters/google"; +import { createTranslatorBudget } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/lib/translator-budget"; +import { bridgeToResponsesSSE } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/bridge"; +import { strict as assert } from "node:assert"; + +// Local-only protocol test: no real provider call, conversation replay, or persisted task. +let requests = 0; +const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(request) { + if (!new URL(request.url).pathname.endsWith("/responses")) return new Response("not found", { status: 404 }); + requests++; + const budget = createTranslatorBudget(); + const adapter = createGoogleAdapter({ name: "google-antigravity", googleMode: "cloud-code-assist", adapter: "google", baseUrl: "http://invalid.local", models: [] } as any); + const textMode = process.argv.includes("--text-refusal"); + const refusal = "The prompt could not be submitted. The prompt contains sensitive words that violate Google's [Generative AI Prohibited Use policy](https://policies.google.com/terms/generative-ai/use-policy). Try rephrasing the prompt. If you think this was an error, [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting)."; + const frame = textMode ? { candidates: [{ content: { parts: [{ text: refusal }] } }] } : { candidates: [{ finishReason: "SAFETY" }], usageMetadata: { promptTokenCount: 10 } }; + const body = new Response(`data: ${JSON.stringify({ response: frame })}\n\n`); + const events = (async function* () { + try { yield* adapter.parseStream(body, budget); } + finally { budget.dispose(); } + })(); + return new Response(bridgeToResponsesSSE(events, "gpt-5.5"), { headers: { "content-type": "text/event-stream" } }); + }, +}); +const child = Bun.spawn([ + "/Applications/ChatGPT.app/Contents/Resources/codex", "exec", "--ignore-user-config", "--ephemeral", "--skip-git-repo-check", "--json", "-C", "/tmp", "-s", "read-only", + "-c", 'model_provider="ocx_filter_test"', "-m", "gpt-5.5", + "-c", 'model_providers.ocx_filter_test.name="Local filter protocol test"', + "-c", `model_providers.ocx_filter_test.base_url="http://127.0.0.1:${server.port}/v1"`, + "-c", 'model_providers.ocx_filter_test.wire_api="responses"', + "-c", "model_providers.ocx_filter_test.requires_openai_auth=false", + "-c", "model_providers.ocx_filter_test.stream_max_retries=5", + "Local protocol check. No tool actions are needed.", +], { stdout: "pipe", stderr: "pipe" }); +const timeout = setTimeout(() => child.kill(), 30000); +try { + const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + const output = stdout + stderr; + const expectedReason = process.argv.includes("--text-refusal") ? "standalone content-policy refusal" : "finishReason=SAFETY"; + console.log(JSON.stringify({ requests, exitCode, explicitReason: output.includes(expectedReason), reconnecting: /Reconnecting|Retrying/i.test(output) })); + assert.equal(requests, 1, "Codex must not retry a provider content block"); + assert.notEqual(exitCode, 0, "a provider block must remain a failed turn"); + assert.ok(output.includes(expectedReason), "Codex must surface the provider reason"); +} finally { + clearTimeout(timeout); + server.stop(true); +} diff --git a/tests/google-content-filter.test.ts b/tests/google-content-filter.test.ts new file mode 100644 index 0000000000..58cddf5311 --- /dev/null +++ b/tests/google-content-filter.test.ts @@ -0,0 +1,106 @@ +import { test, expect } from "bun:test"; +import { createGoogleAdapter } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/adapters/google"; +import { createTranslatorBudget } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/lib/translator-budget"; +import { bridgeToResponsesSSE, buildResponseJSON } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/bridge"; +import { httpStatusFromTerminalError } from "/Users/vanch/Documents/Codex/2026-08-05/https-github-com-lidge-jun-opencodex/outputs/upgrade-2.51.0/merged/src/lib/errors"; + +const provider = { name: "google-antigravity", adapter: "google", baseUrl: "http://invalid.local", googleMode: "cloud-code-assist", models: [] } as const; +const usage = { promptTokenCount: 123, candidatesTokenCount: 2, thoughtsTokenCount: 4 }; +const refusal = "The prompt could not be submitted. The prompt contains sensitive words that violate Google's [Generative AI Prohibited Use policy](https://policies.google.com/terms/generative-ai/use-policy). Try rephrasing the prompt. If you think this was an error, [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting)."; +async function parse(frames: object[], stream = true) { + const adapter = createGoogleAdapter(provider as any); + const budget = createTranslatorBudget(); + try { + const events = []; + if (stream) { + const response = new Response(frames.map(response => `data: ${JSON.stringify({ response })}\n\n`).join("")); + for await (const event of adapter.parseStream(response, budget)) { + if (event.type !== "heartbeat") events.push(event); + } + return events; + } + return await adapter.parseResponse!(Response.json({ response: frames[0] }), budget); + } finally { + budget.dispose(); + } +} + +for (const stream of [true, false]) { + for (const reason of ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"]) { + test(`${stream ? "SSE" : "JSON"}: ${reason} is explicit, non-retryable, retains usage`, async () => { + const events = await parse([{ candidates: [{ finishReason: reason }], usageMetadata: usage }], stream); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "invalid_prompt", errorType: "invalid_request_error", retryable: false, usage: { inputTokens: 123, outputTokens: 2 } }); + expect((events[0] as any).message).toContain(`finishReason=${reason}`); + const wire = buildResponseJSON(events, "gemini-3.8-flash"); + expect(wire.status).toBe("failed"); + expect(wire.retryable).toBe(false); + expect(httpStatusFromTerminalError(wire.error as any)).toBe(400); + }); + } + test(`${stream ? "SSE" : "JSON"}: prompt block does not become empty-completion`, async () => { + const events = await parse([{ promptFeedback: { blockReason: "SAFETY" }, usageMetadata: usage }], stream); + expect(events[0]).toMatchObject({ type: "error", code: "invalid_prompt", retryable: false }); + expect((events[0] as any).message).toContain("promptFeedback.blockReason=SAFETY"); + }); + test(`${stream ? "SSE" : "JSON"}: blocked frame cannot emit a tool call or blocked text`, async () => { + const events = await parse([{ candidates: [{ finishReason: "SAFETY", finishMessage: "sensitive upstream text", content: { parts: [{ text: "blocked output" }, { functionCall: { name: "exec", args: {} } }] } }] }], stream); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + expect(JSON.stringify(events)).not.toContain("sensitive upstream text"); + expect(JSON.stringify(events)).not.toContain("blocked output"); + }); + test(`${stream ? "SSE" : "JSON"}: normal stop and token limit keep existing semantics`, async () => { + for (const reason of ["STOP", "MAX_TOKENS"]) { + const events = await parse([{ candidates: [{ finishReason: reason, content: { parts: [{ text: "OK" }] } }], usageMetadata: usage }], stream); + const wire = buildResponseJSON(events, "gemini-3.8-flash"); + expect(wire.status).toBe(reason === "STOP" ? "completed" : "incomplete"); + if (reason === "MAX_TOKENS") expect(wire.incomplete_details).toEqual({ reason: "max_output_tokens" }); + } + }); + test(`${stream ? "SSE" : "JSON"}: malformed tool generation stays distinct from content filtering`, async () => { + const events = await parse([{ candidates: [{ finishReason: "MALFORMED_FUNCTION_CALL" }] }], stream); + expect(events[0].type).toBe("error"); + expect((events[0] as any).message).toContain("MALFORMED_FUNCTION_CALL"); + expect((events[0] as any).code).not.toBe("invalid_prompt"); + }); +} + +test("SSE: trailing usage survives filtering, bridge fails once without caching a compaction", async () => { + const events = await parse([ + { candidates: [{ content: { parts: [{ text: "Checking progress" }] } }] }, + { candidates: [{ finishReason: "RECITATION" }] }, + { usageMetadata: usage }, + ]); + expect(events.at(-1)).toMatchObject({ type: "error", retryable: false, usage: { inputTokens: 123 } }); + let cached = false; + const body = bridgeToResponsesSSE((async function* () { yield* events; })(), "gemini-3.8-flash", undefined, undefined, undefined, undefined, 2000, { compaction: true, onCompletedResponse: () => { cached = true; } }); + const wire = await new Response(body).text(); + expect(wire).toContain("event: response.failed"); + expect(wire).toContain('"retryable":false'); + expect(wire).not.toContain("event: response.incomplete"); + expect(wire).not.toContain("event: response.completed"); + expect(wire).not.toContain('"type":"compaction"'); + expect(cached).toBe(false); +}); + +test("SSE: exact split text policy refusal at EOF is terminal, not a reconnect", async () => { + const events = await parse([refusal.slice(0, 55), refusal.slice(55)].map(text => ({ candidates: [{ content: { parts: [{ text }] } }] }))); + expect(events.at(-1)).toMatchObject({ type: "error", code: "invalid_prompt", retryable: false }); + expect((events.at(-1) as any).message).toContain("No structured filter category"); +}); + +test("SSE: ordinary EOF, quoted refusal and oversized output remain transport errors", async () => { + for (const text of ["A normal partial answer", `Example: ${refusal}`, "x".repeat(1100) + refusal]) { + const events = await parse([{ candidates: [{ content: { parts: [{ text }] } }] }]); + expect((events.at(-1) as any).message).toContain("without a terminal signal"); + expect((events.at(-1) as any).code).not.toBe("invalid_prompt"); + } +}); + +test("SSE: completed text and tool-containing EOF are never text-matched as policy", async () => { + const complete = await parse([{ candidates: [{ finishReason: "STOP", content: { parts: [{ text: refusal }] } }] }]); + expect(complete.at(-1)?.type).toBe("done"); + const tool = await parse([{ candidates: [{ content: { parts: [{ text: refusal }, { functionCall: { name: "exec", args: {} } }] } }] }]); + expect((tool.at(-1) as any).code).not.toBe("invalid_prompt"); +});