feat(search): add Antigravity as a web search provider
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user