From 2f17352cc2e4eee077190819cf47a36a38c71cf2 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 27 Aug 2026 20:48:43 +0700 Subject: [PATCH] feat(search): add Antigravity as a web search provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route POST /v1/search with provider "antigravity" through Google Search grounding on v1internal:generateContent, using the existing Antigravity OAuth account pool. Grounding chunks become citations with the grounded sentence as snippet and its surrounding answer text as content. Upstream repeats a source across chunks, so citations are keyed by URL and their snippets merged. A missing projectId is reported up front — upstream answers a fabricated or absent project with a misleading "no valid license" 403. Based on the approach in #3437 by @Nautilaceae. --- open-sse/handlers/search/chatSearch.js | 104 ++++++++++++++++++++- open-sse/providers/registry/antigravity.js | 7 +- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js index c5bfb3ad..75f5e02b 100644 --- a/open-sse/handlers/search/chatSearch.js +++ b/open-sse/handlers/search/chatSearch.js @@ -1,8 +1,10 @@ /** * Wrap chat-completions endpoints (with built-in web search) into the unified - * /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity. + * /v1/search response format. Supports gemini, antigravity, openai, xai, kimi, + * minimax, perplexity. */ import { PROVIDER_MEDIA } from "../../providers/index.js"; +import { ANTIGRAVITY_IDE_USER_AGENT } from "../../providers/shared.js"; // Default search model + endpoint derive from registry searchViaChat (single source) const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel; @@ -28,13 +30,37 @@ function toResult(c, index, provider, retrievedAt) { score: null, published_at: null, favicon_url: null, - content: null, + content: c.content || null, metadata: {}, citation: { provider, retrieved_at: retrievedAt, rank: index + 1 }, provider_raw: null }; } +// Antigravity search request envelope (mirrors the IDE client) +const AG_CLIENT_NAME = "antigravity"; +const AG_SEARCH_GENERATION_CONFIG = { temperature: 1.0, maxOutputTokens: 8192 }; +const AG_CONTEXT_BEFORE = 150; +const AG_CONTEXT_AFTER = 250; + +/** Widen a grounded segment to its surrounding sentence(s) in the answer text. */ +function expandSegment(text, segment) { + const { startIndex, endIndex } = segment || {}; + if (!text || !Number.isInteger(startIndex) || !Number.isInteger(endIndex)) return ""; + const start = Math.max(0, startIndex - AG_CONTEXT_BEFORE); + const end = Math.min(text.length, endIndex + AG_CONTEXT_AFTER); + let out = text.slice(start, end).trim(); + // Drop the partial words the window cut off at either edge + if (start > 0) out = `...${out.replace(/^\S+/, "")}`; + if (end < text.length) out = `${out.replace(/\S+$/, "")}...`; + return out.trim(); +} + +/** Join deduped grounding pieces, skipping empties. */ +function joinPieces(set, sep) { + return [...(set || [])].filter(Boolean).join(sep).trim(); +} + /** Coerce a citation that might be a raw URL string or an object. */ function normalizeCitation(c) { if (!c) return null; @@ -46,6 +72,8 @@ function normalizeCitation(c) { /** * Provider-specific configuration map. All providers must implement: * { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer } + * Optional: requireCredentials(credentials) → error string when a provider needs + * more than a token (returns null when satisfied). */ const CHAT_SEARCH_CONFIG = { gemini: { @@ -73,6 +101,71 @@ const CHAT_SEARCH_CONFIG = { } }, + antigravity: { + endpoint: () => searchEndpoint("antigravity"), + // Upstream 403s on a missing or fabricated project — surface the real cause + requireCredentials: (credentials) => + credentials?.projectId ? null : "Antigravity account has no projectId — reconnect the account", + buildBody: (query, model, credentials) => ({ + project: credentials.projectId, + model, + userAgent: AG_CLIENT_NAME, + requestType: "search", + request: { + contents: [{ role: "user", parts: [{ text: query }] }], + tools: [{ googleSearch: {} }], + generationConfig: AG_SEARCH_GENERATION_CONFIG + } + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": ANTIGRAVITY_IDE_USER_AGENT + }), + extractAnswer: (data) => { + // Antigravity wraps the Gemini payload in { response: {...} } + const response = data?.response || data; + const candidate = response?.candidates?.[0]; + const parts = candidate?.content?.parts || []; + const text = parts.map((p) => p?.text || "").filter(Boolean).join(""); + const grounding = candidate?.groundingMetadata || {}; + const chunks = grounding.groundingChunks || []; + const supports = grounding.groundingSupports || []; + + // Upstream repeats the same source across chunks — key by URL so it stays one citation. + // Map, not a plain object: both the index and the URL come from upstream. + const sources = new Map(); + const byIndex = chunks.map((ch) => { + const web = ch?.web; + const url = web?.uri || web?.url || ""; + if (!url) return null; + if (!sources.has(url)) sources.set(url, { title: web.title || "", snippets: new Set(), contexts: new Set() }); + return sources.get(url); + }); + + // Each support ties a sentence of the answer back to the chunks that grounded it + for (const s of supports) { + const segment = s?.segment; + const grounded = segment?.text || ""; + const expanded = expandSegment(text, segment) || grounded; + for (const idx of s?.groundingChunkIndices || []) { + const source = Number.isInteger(idx) ? byIndex[idx] : null; + if (!source) continue; + if (grounded) source.snippets.add(grounded); + if (expanded) source.contexts.add(expanded); + } + } + + const citations = [...sources].map(([url, src]) => { + const snippet = joinPieces(src.snippets, " | ") || src.title; + return { url, title: src.title, snippet, content: joinPieces(src.contexts, "\n\n") || snippet }; + }); + + const tokens = response?.usageMetadata?.totalTokenCount || 0; + return { text, citations, tokens }; + } + }, + openai: { endpoint: () => searchEndpoint("openai"), buildBody: (query, model) => { @@ -366,13 +459,18 @@ export async function handleChatSearch({ }; } + const credentialError = cfg.requireCredentials?.(credentials); + if (credentialError) { + return { success: false, status: 401, error: credentialError }; + } + const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : DEFAULT_MAX_RESULTS; const useModel = model || searchModel(provider); const url = cfg.endpoint(useModel); - const body = cfg.buildBody(query, useModel); + const body = cfg.buildBody(query, useModel, credentials); const headers = cfg.buildHeaders(token); const controller = new AbortController(); diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 2666552b..1f14f415 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -17,7 +17,7 @@ export default { deprecationNotice: "RISK_NOTICE", }, category: "oauth", - serviceKinds: ["llm", "image"], + serviceKinds: ["llm", "image", "webSearch"], transport: { baseUrls: [ANTIGRAVITY_IDE_BASE_URL], format: "antigravity", @@ -82,6 +82,11 @@ export default { loadCodeAssistUserAgent: ANTIGRAVITY_IDE_USER_AGENT, refreshLeadMs: 300000, }, + searchViaChat: { + defaultModel: "gemini-2.5-flash", + endpoint: `${ANTIGRAVITY_IDE_BASE_URL}/v1internal:generateContent`, + freeTier: "Free — Google Search grounding through an Antigravity OAuth account.", + }, features: { usage: true, },