Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
06ec553
Merge pull request #3678 from lidge-jun/codex/promote-main-243-01a07240
lidge-jun Sep 5, 2026
116c2ac
Merge commit '44ea9576e27c6be8be7f13a86e32bb349368c54d' into codex/re…
invalid-email-address Sep 6, 2026
07b48da
Merge pull request #3785 from lidge-jun/codex/release-244-main-07c0
lidge-jun Sep 6, 2026
bcdf559
chore(release): promote validated 2.45.0 to main [skip ci]
invalid-email-address Sep 6, 2026
b0900e5
chore(release): promote 2.45.0 to main (#3813)
lidge-jun Sep 6, 2026
3970601
chore(release): prepare 2.46.0 stable promotion
invalid-email-address Sep 7, 2026
bba6322
Merge pull request #3851 from lidge-jun/codex/release-246-main
lidge-jun Sep 7, 2026
3d53e5f
release: prepare 2.47.0 from audited regression candidate
invalid-email-address Sep 7, 2026
eda8754
Merge commit '48ab3e1e66cfa6e0c873de2fafa4540ac61d6c7d' into codex/re…
invalid-email-address Sep 7, 2026
f9e3515
Merge commit '57252193b' into codex/release-247-main
invalid-email-address Sep 7, 2026
6f71931
release: promote 2.47.0 to main (#3929)
lidge-jun Sep 7, 2026
9a60256
Merge commit 'd0737cff3' into codex/release-247-main-final
invalid-email-address Sep 7, 2026
9e9b1d3
Merge commit 'f48c322c0' into codex/release-247-main-final
invalid-email-address Sep 7, 2026
947bae9
Merge commit '0d7652ad1' into codex/release-247-main-final
invalid-email-address Sep 7, 2026
f7f890f
release: apply final roster correction to main (#3933)
lidge-jun Sep 7, 2026
544ebee
release: promote 2.48.0 to main
invalid-email-address Sep 8, 2026
d24ff57
release: set main channel version 2.48.0
invalid-email-address Sep 8, 2026
9a27e86
Merge pull request #4011 from lidge-jun/codex/release-248-main
lidge-jun Sep 8, 2026
62849df
release: promote verified 2.49.0 product tree to main
lidge-jun Sep 9, 2026
2f3f736
Merge pull request #4117 from lidge-jun/codex/release-249-main-01a08498
lidge-jun Sep 9, 2026
3a3de88
release: promote verified 2.50.0 product tree to main
lidge-jun Sep 10, 2026
2d4d7a2
Merge pull request #4195 from lidge-jun/codex/release-250-main-01a08a81
lidge-jun Sep 10, 2026
cf456e8
release: promote verified 2.51.0 product tree to main
lidge-jun Sep 11, 2026
c155cc7
Merge pull request #4271 from lidge-jun/codex/release-251-main
lidge-jun Sep 11, 2026
f01fcdc
feat(antigravity): sync to 2.51.0 with quota balancing, content filte…
Sep 13, 2026
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
46 changes: 46 additions & 0 deletions src/adapters/google-content-filter.ts
Original file line number Diff line number Diff line change
@@ -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<AdapterEvent, { type: "error" }> | 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<AdapterEvent, { type: "error" }> | 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 } : {}),
};
}
55 changes: 42 additions & 13 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -996,6 +997,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
let pendingUsage: OcxUsage | undefined;
let toolCallsStarted = 0;
let lastFinishReason: string | undefined;
let contentFilterError: Extract<AdapterEvent, { type: "error" }> | 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, number> | 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
Expand All @@ -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<string, number> | undefined));
if (contentBlock) return finish([contentBlock]);
let toolCallsStarted = 0;
const imageBudget = createImageBudget();
const rawContent: unknown = candidate.content;
Expand Down Expand Up @@ -1455,16 +1488,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
}

const usage = json.usageMetadata as Record<string, number> | 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),
Expand Down
1 change: 1 addition & 0 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick<Ocx
if (slash <= 0) return slug;
const provider = slug.slice(0, slash);
let modelId = slug.slice(slash + 1);
if (provider === "google-antigravity" || provider === "xai") return modelId;
if (provider === "google-antigravity") {
if (model?.providerAlias === null) return slug;
const alias = (typeof model?.providerAlias === "string" && model.providerAlias.trim().length > 0)
Expand Down
12 changes: 8 additions & 4 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/oauth/antigravity-balance.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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;
}
}
Loading
Loading