Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3587,6 +3587,7 @@ async function handleResponsesInner(
forwardProvider: wsPlan.forwardSidecar?.provider,
anthropicSidecar: wsPlan.anthropicSidecar,
xaiSidecar: wsPlan.xaiSidecar,
geminiSidecar: wsPlan.geminiSidecar,
xaiSearchOptions: wsPlan.xaiSearchOptions,
hostedTool: wsPlan.hostedTool,
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
Expand Down
13 changes: 13 additions & 0 deletions src/web-search/backends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ export const WEB_SEARCH_BACKENDS: readonly WebSearchBackendDescriptor[] = [
},
eligibleModel: candidate => candidate.provider === "xai",
},
{
backend: "gemini",
// Probe = usable Antigravity OAuth + discovered projectId (findGeminiSidecarProvider's predicate).
isActive: (_auth, config) => {
const provider = config.providers["google-antigravity"];
if (!provider || provider.disabled === true || provider.authMode !== "oauth") return false;
const set = getAccountSet("google-antigravity");
const active = set?.accounts.find(account => account.id === set.activeAccountId);
if (!active || active.needsReauth === true) return false;
return !!(active.credential as { projectId?: string } | undefined)?.projectId;
},
eligibleModel: candidate => candidate.provider === "google-antigravity",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Map Gemini picker rows to the Gemini backend

When Antigravity OAuth is active, this descriptor adds its models to the Dashboard picker, but gui/src/pages/dashboard-shared.ts:350-352 maps every non-Anthropic model to openai, and the picker saves that inferred backend in dashboard-overview-sections.tsx:515-519. Selecting a Gemini row therefore persists backend: "openai" and sends the Gemini model ID to the ChatGPT sidecar instead of invoking this executor; return backend metadata with each option and teach the picker to preserve gemini.

Useful? React with 👍 / 👎.

},
];

/**
Expand Down
141 changes: 141 additions & 0 deletions src/web-search/gemini-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Execute ONE web search via Gemini google_search grounding on the Antigravity
* Cloud Code Assist transport (#2188 L8). Live-probed 2026-08-20/21 (devlog 002):
* the CCA envelope with tools [{google_search:{}}] returns a grounded answer
* plus groundingMetadata; a non-IDE User-Agent gets 404, so the request reuses
* the adapter's fingerprint constants. The OAuth bearer only ever travels to
* the REGISTRY-pinned endpoint — a config-level baseUrl override is never
* trusted for token transmission (same rule as src/server/images.ts).
* Never throws — returns {error} so the caller injects a graceful tool result.
*/
import type { OcxProviderConfig } from "../types";
import { getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage } from "../oauth";
import { fetchWithResetRetry } from "../lib/upstream-retry";
import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
import { readBoundedResponseBytes } from "../lib/bounded-body";
import { sidecarEnter } from "../lib/sidecar-tracker";
import { redactSecretString } from "../lib/redact";
import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
import { getProviderRegistryEntry } from "../providers/registry";
import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse";
import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor";

const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com";

function isRec(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}

export async function runGeminiWebSearch(
query: string,
providerName: string,
_provider: OcxProviderConfig,
settings: SidecarSettings,
abortSignal?: AbortSignal,
): Promise<SidecarOutcome> {
let token: string;
let project: string | undefined;
try {
const snapshot = await getValidAccessTokenSnapshot(providerName);
token = snapshot.accessToken;
project = snapshot.projectId;
} catch (e) {
return { text: "", sources: [], error: `gemini sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` };
}
if (!project) {
return { text: "", sources: [], error: "gemini sidecar missing Cloud Code Assist project id — re-run ocx login google-antigravity" };
}
// Destination pinned to the registry endpoint (see module doc).
const base = getProviderRegistryEntry("google-antigravity")?.baseUrl ?? CCA_FALLBACK_BASE;
const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(settings.model, settings.reasoning, base);
const instruction = settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION;
const envelope = {
model: wireModelId,
userAgent: "antigravity",
requestType: "agent",
project,
requestId: `agent-${crypto.randomUUID()}`,
request: {
systemInstruction: { role: "user", parts: [{ text: instruction }] },
contents: [{ role: "user", parts: [{ text: query }] }],
tools: [{ google_search: {} }],
sessionId: crypto.randomUUID(),
...(thinkingLevel ? { generationConfig: { thinkingConfig: { thinkingLevel } } } : {}),
},
};
const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
const sidecarExit = sidecarEnter("web-search");
const t0 = Date.now();
try {
const res = await fetchWithResetRetry(
() => fetch(`${base}/v1internal:generateContent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"User-Agent": ANTIGRAVITY_REQUEST_UA,
},
body: JSON.stringify(envelope),
signal: linkedSignal.signal,
redirect: "manual",
}),
{ abortSignal: linkedSignal.signal, label: "gemini-web-search-sidecar" },
);
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
try {
const bounded = await readBoundedResponseBytes(res, {
maxBytes: MAX_SIDECAR_RESPONSE_BYTES,
signal: linkedSignal.signal,
});
if (bounded.oversized) {
const prefix = res.ok ? "gemini sidecar response" : `gemini sidecar HTTP ${res.status} response`;
return { text: "", sources: [], error: `${prefix} exceeded byte bound` };
}
const text = new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes);
if (!res.ok) {
return { text: "", sources: [], error: `gemini sidecar HTTP ${res.status}: ${redactSecretString(text.slice(0, 200))}` };
}
let payload: unknown = null;
try {
payload = JSON.parse(text);
} catch {
// The mapper owns the stable malformed/empty JSON outcome.
}
return mapCcaGroundedResponse(payload);
} finally {
detachBodyGuard();
}
} catch (e) {
const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
console.warn(`[web-search] gemini sidecar ${kind} (${Date.now() - t0}ms)`);
return { text: "", sources: [], error: redactSecretString(e instanceof Error ? e.message : String(e)) };
} finally {
sidecarExit();
linkedSignal.cleanup();
}
}

/** Map a CCA generateContent payload (possibly wrapped in {response}) to text + grounding sources. */
export function mapCcaGroundedResponse(payload: unknown): SidecarOutcome {
const root = isRec(payload) && isRec(payload.response) ? payload.response : payload;
if (!isRec(root)) return { text: "", sources: [], error: "gemini sidecar returned a non-JSON or empty body" };
const candidate = Array.isArray(root.candidates) && isRec(root.candidates[0]) ? root.candidates[0] : undefined;
if (!candidate) return { text: "", sources: [], error: "gemini sidecar returned no candidates" };
const parts = isRec(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : [];
const text = parts.map(p => (isRec(p) && typeof p.text === "string" ? p.text : "")).join("");
const sources: WebSearchSource[] = [];
const seen = new Set<string>();
const gm = isRec(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined;
if (gm && Array.isArray(gm.groundingChunks)) {
for (const chunk of gm.groundingChunks) {
const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined;
const uri = web && typeof web.uri === "string" ? web.uri : undefined;
if (!uri || seen.has(uri)) continue;
seen.add(uri);
sources.push({ url: uri, ...(typeof web?.title === "string" && web.title.length > 0 ? { title: web.title } : {}) });
}
}
if (text.length === 0) return { text: "", sources, error: "gemini sidecar returned no text" };
return { text, sources };
}
45 changes: 42 additions & 3 deletions src/web-search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ export { runWithWebSearch } from "./loop";
export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME };
export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor";
export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor";
export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor";

const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
// Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset).
const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5";
// Default Grok model for the xai-backed sidecar (probe-verified with hosted tools, devlog 003).
const DEFAULT_XAI_SIDECAR_MODEL = "grok-4.6";
// Default Gemini model for the gemini-backed sidecar (CCA grounding probe, devlog 002).
const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.7-flash";
Comment on lines +24 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document that the Gemini backend is now live

This introduces a user-selectable backend and default model without updating the user documentation: docs-site/src/content/docs/guides/sidecars.md:26-28 still describes only OpenAI and Anthropic, while structure/04_transports-and-sidecars.md:898-904 explicitly says Gemini is inert. Update the English configuration/sidecar guidance, scoped translations, and the architecture record so operators can configure the required Antigravity OAuth/project flow without following contradictory instructions.

AGENTS.md reference: AGENTS.md:L279-L280

Useful? React with 👍 / 👎.

// "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected:
// "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap.
const DEFAULT_SIDECAR_REASONING = "low";
Expand Down Expand Up @@ -114,6 +117,23 @@ export function findXaiSidecarProvider(config: OcxConfig): { providerName: strin
return undefined;
}

/**
* First usable Antigravity credential holder: the "google-antigravity" provider
* (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose
* active stored account is healthy AND carries a discovered CCA projectId — the
* executor cannot form the envelope without it.
*/
export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined {
const provider = config.providers["google-antigravity"];
if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined;
const set = getAccountSet("google-antigravity");
const active = set?.accounts.find(account => account.id === set.activeAccountId);
if (!active || active.needsReauth === true) return undefined;
const projectId = (active.credential as { projectId?: string } | undefined)?.projectId;
if (!projectId) return undefined;
return { providerName: "google-antigravity", provider };
}

/** Lift the persisted xSearch config block into executor options (absent block = web_search only). */
export function xaiSearchOptionsFromConfig(cfg: Pick<OcxWebSearchSidecarConfig, "xSearch">): XaiSearchOptions {
const x = cfg.xSearch;
Expand Down Expand Up @@ -154,6 +174,8 @@ export interface SidecarPlan {
anthropicSidecar?: AnthropicSidecarProvider;
/** Present for the xai backend (stored Grok OAuth /v1/responses path). */
xaiSidecar?: { providerName: string; provider: OcxProviderConfig };
/** Present for the gemini backend (Antigravity CCA grounding path). */
geminiSidecar?: { providerName: string; provider: OcxProviderConfig };
/** Opt-in x_search options for the xai backend (validated at the management layer and again in the executor). */
xaiSearchOptions?: XaiSearchOptions;
hostedTool: Record<string, unknown>;
Expand Down Expand Up @@ -206,9 +228,9 @@ export function planWebSearch(
? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider }
: undefined;
const backend = resolveSidecarBackend(cfg.backend);
// Inert arms (roadmap 060): gemini/exa stay fail-closed until their executor
// layers land. The xai arm went live in L7 below.
if (backend === "gemini" || backend === "exa") return undefined;
// Inert arm (roadmap 060): exa stays fail-closed until its executor layer lands.
// xai went live in L7; gemini in L8 below.
if (backend === "exa") return undefined;
const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES;
const stallTimeoutSec = webSearchStallTimeoutSec(
config.stallTimeoutSec,
Expand Down Expand Up @@ -260,6 +282,23 @@ export function planWebSearch(
};
}

// Gemini backend (L8): explicit-only, authenticated by the stored Antigravity CCA
// OAuth credential; requires the discovered projectId. Fail-closed like the others.
if (backend === "gemini") {
const geminiSidecar = findGeminiSidecarProvider(config);
if (!geminiSidecar) return undefined;
return {
backend: "gemini",
geminiSidecar,
hostedTool: parsed._webSearch,
settings: { model: cfg.model ?? DEFAULT_GEMINI_SIDECAR_MODEL, reasoning, timeoutMs, describeImages },
maxSearches,
routedModelStallTimeoutMs,
stallTimeoutSec,
streamRoutedModelOutput,
};
}

// OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search.
if (!openAiSidecar) return undefined;
return {
Expand Down
8 changes: 8 additions & 0 deletions src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { bridgeToResponsesSSE } from "../bridge";
import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
import { runAnthropicWebSearch } from "./anthropic-executor";
import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor";
import { runGeminiWebSearch } from "./gemini-executor";
import type { WebSearchBackendId } from "./index";
import { clearableDeadline } from "../lib/abort";
import { redactSecretString } from "../lib/redact";
Expand Down Expand Up @@ -264,6 +265,8 @@ export interface WebSearchLoopDeps {
anthropicSidecar?: { providerName: string; provider: OcxProviderConfig };
/** Required for the xai backend: the stored Grok OAuth provider (L7). */
xaiSidecar?: { providerName: string; provider: OcxProviderConfig };
/** Required for the gemini backend: the stored Antigravity CCA provider (L8). */
geminiSidecar?: { providerName: string; provider: OcxProviderConfig };
/** Opt-in x_search options for the xai backend. */
xaiSearchOptions?: XaiSearchOptions;
hostedTool: Record<string, unknown>;
Expand Down Expand Up @@ -677,6 +680,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
outcome = deps.xaiSidecar
? await runXaiWebSearch(query, deps.xaiSidecar.providerName, deps.xaiSidecar.provider, settings, deps.xaiSearchOptions ?? {}, signal)
: { text: "", sources: [], error: "xai backend selected without a resolved Grok OAuth provider" };
} else if (backend === "gemini") {
// L8: Antigravity CCA grounding; same fail-closed invariant stance as xai.
outcome = deps.geminiSidecar
? await runGeminiWebSearch(query, deps.geminiSidecar.providerName, deps.geminiSidecar.provider, settings, signal)
: { text: "", sources: [], error: "gemini backend selected without a resolved Antigravity provider" };
} else {
outcome = await runWebSearch(query, hostedTool, forwardProvider!, selectedForwardHeaders, settings, signal, recordSidecarOutcome);
}
Expand Down
4 changes: 2 additions & 2 deletions src/web-search/xai-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export async function runXaiWebSearch(
} catch (e) {
const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
console.warn(`[web-search] xai sidecar ${kind} (${Date.now() - t0}ms)`);
return { text: "", sources: [], error: e instanceof Error ? redactSecretString(e.message) : String(e) };
return { text: "", sources: [], error: redactSecretString(e instanceof Error ? e.message : String(e)) };
} finally {
sidecarExit();
linkedSignal.cleanup();
Expand Down Expand Up @@ -208,7 +208,7 @@ export async function parseXaiResponsesSSE(response: Response): Promise<SidecarO
}
}
} catch (e) {
error = e instanceof Error ? redactSecretString(e.message) : String(e);
error = redactSecretString(e instanceof Error ? e.message : String(e));
} finally {
reader.releaseLock();
}
Expand Down
Loading
Loading