feat(ai,coding-agent): Google fingerprint hardening
providers/google-gemini-cli:
- parseGeminiCliCredentials() handles legacy, alias (project_id/refresh/expires),
and enriched credential JSON formats
- shouldRefreshGeminiCliCredentials() + refreshGeminiCliCredentialsIfNeeded()
proactively refresh OAuth tokens 60s before expiry for both providers
- normalizeAntigravityTools() converts parametersJsonSchema -> parameters
in function declarations for Antigravity compatibility
- VALIDATED tool calling config applied for Antigravity + Claude model combos
- maxOutputTokens removed from generation config for Antigravity non-Claude models
- Antigravity system instruction injection scoped to Claude + gemini-3-pro-high models
- Antigravity session ID: signed decimal int63 derived from SHA-256 of first user
message (or random bounded int63), replacing truncated hex hash
- Antigravity requestId uses agent-{uuid}; non-Antigravity requests omit
requestId/userAgent/requestType from payload
- ANTIGRAVITY_DAILY_ENDPOINT corrected to daily-cloudcode-pa.googleapis.com;
sandbox kept as fallback
- ANTIGRAVITY_SYSTEM_INSTRUCTION exported
oauth/google-antigravity:
- PKCE removed from OAuth flow (no code_challenge)
- loadCodeAssist metadata ideType changed to ANTIGRAVITY
- discoverProject uses single production endpoint; falls back to onboardUser LRO
(up to 5 retries, 2s interval) instead of hardcoded default project ID
- ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA exported
oauth/google-gemini-cli:
- PKCE removed from OAuth flow
oauth/index:
- getOAuthApiKey includes refreshToken, expiresAt, email, accountId in
Gemini/Antigravity JSON payload for proactive refresh support
discovery/antigravity:
- Tries production daily endpoint first, sandbox as fallback
- Removed recommended/agentModelSorts filter; applies denylist instead
- ANTIGRAVITY_DISCOVERY_DENYLIST filters low-quality/internal models
- Request body no longer includes project field
coding-agent:
- gemini_image: corrected responseModalities to uppercase IMAGE/TEXT
- Gemini web search: endpoint fallback (daily->sandbox) with retry on 429/5xx,
aligned Antigravity request metadata, ANTIGRAVITY_SYSTEM_INSTRUCTION injection
- buildGeminiRequestTools() helper for composable googleSearch/codeExecution/urlContext
- Web search schema: expose max_tokens, temperature, num_search_results params
- Web search: explicit provider falls back to auto chain when unavailable
Tests: google-antigravity-auth, google-gemini-cli-alignment, web-search-gemini
This commit is contained in:
@@ -14,6 +14,15 @@
|
||||
- `resolveAnthropicMetadataUserId()` auto-generates a cloaking user ID for OAuth requests when `metadata.user_id` is absent or invalid
|
||||
- `AnthropicOAuthFlow` is now exported for direct use
|
||||
- OAuth callback server timeout extended from 2 min to 5 min
|
||||
- `parseGeminiCliCredentials()` parses Google Cloud credential JSON with support for legacy (`{token,projectId}`), alias (`project_id`/`refresh`/`expires`), and enriched formats
|
||||
- `shouldRefreshGeminiCliCredentials()` and proactive token refresh before requests for both Gemini CLI and Antigravity providers (60s pre-expiry buffer)
|
||||
- `normalizeAntigravityTools()` converts `parametersJsonSchema` → `parameters` in function declarations for Antigravity compatibility
|
||||
- `ANTIGRAVITY_SYSTEM_INSTRUCTION` is now exported for use by search and other consumers
|
||||
- `ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA` constant exported from OAuth module with `ANTIGRAVITY` ideType
|
||||
- Antigravity project onboarding: `onboardProjectWithRetries()` provisions a new project via `onboardUser` LRO when `loadCodeAssist` returns no existing project (up to 5 attempts, 2s interval)
|
||||
- `getOAuthApiKey` now includes `refreshToken`, `expiresAt`, `email`, and `accountId` in the Gemini/Antigravity JSON credential payload to enable proactive refresh
|
||||
- Antigravity model discovery now tries the production daily endpoint first, with sandbox as fallback
|
||||
- `ANTIGRAVITY_DISCOVERY_DENYLIST` filters low-quality/internal models from discovery results
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -29,6 +38,17 @@
|
||||
- Anthropic OAuth scopes reduced to `org:create_api_key user:profile user:inference`
|
||||
- OAuth code exchange now strips URL fragment from callback code, using the fragment as state override when present
|
||||
- Claude usage headers aligned: user-agent updated to `claude-cli/2.1.63 (external, cli)`, anthropic-beta extended with full beta set
|
||||
- Antigravity session ID format changed to signed decimal (negative int63 derived from SHA-256 of first user message, or random bounded int63)
|
||||
- Antigravity `requestId` now uses `agent-{uuid}` format; non-Antigravity requests no longer include requestId/userAgent/requestType in the payload
|
||||
- `ANTIGRAVITY_DAILY_ENDPOINT` corrected to `daily-cloudcode-pa.googleapis.com`; sandbox endpoint kept as fallback only
|
||||
- Antigravity discovery: removed `recommended`/`agentModelSorts` filter; now includes all non-internal, non-denylisted models
|
||||
- Antigravity discovery no longer sends `project` in the request body
|
||||
- Gemini/Antigravity OAuth flows no longer use PKCE (code_challenge removed)
|
||||
- Antigravity `loadCodeAssist` metadata ideType changed from `IDE_UNSPECIFIED` to `ANTIGRAVITY`
|
||||
- Antigravity `discoverProject` now uses a single canonical production endpoint; falls back to project onboarding instead of a hardcoded default project ID
|
||||
- `VALIDATED` tool calling config applied to Antigravity requests with Claude models
|
||||
- `maxOutputTokens` removed from Antigravity generation config for non-Claude models
|
||||
- System instruction injection for Antigravity scoped to Claude and `gemini-3-pro-high` models only
|
||||
|
||||
## [13.3.14] - 2026-02-28
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Shared implementation for both google-gemini-cli and google-antigravity providers.
|
||||
* Uses the Cloud Code Assist API endpoint to access Gemini and Claude models.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "@google/genai";
|
||||
import { abortableSleep, readSseJson } from "@oh-my-pi/pi-utils";
|
||||
import { calculateCost } from "../models";
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
} from "../types";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
|
||||
import { refreshAntigravityToken } from "../utils/oauth/google-antigravity";
|
||||
import { refreshGoogleCloudToken } from "../utils/oauth/google-gemini-cli";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
|
||||
import {
|
||||
convertMessages,
|
||||
@@ -56,8 +58,9 @@ export interface GoogleGeminiCliOptions extends StreamOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, DEFAULT_ENDPOINT] as const;
|
||||
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
|
||||
|
||||
const GEMINI_CLI_USER_AGENT = process.env.PI_AI_GEMINI_CLI_USER_AGENT || "google-api-nodejs-client/9.15.1";
|
||||
|
||||
@@ -110,7 +113,7 @@ export function getAntigravityUserAgent() {
|
||||
}
|
||||
|
||||
// Antigravity system instruction (compact version from CLIProxyAPI).
|
||||
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
||||
export const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
||||
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
|
||||
"You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question." +
|
||||
"**Absolute paths only**" +
|
||||
@@ -126,6 +129,8 @@ const MAX_EMPTY_STREAM_RETRIES = 2;
|
||||
const EMPTY_STREAM_BASE_DELAY_MS = 500;
|
||||
const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
||||
const CLAUDE_THINKING_BETA_HEADER = "interleaved-thinking-2025-05-14";
|
||||
const GOOGLE_GEMINI_REFRESH_SKEW_MS = 60_000;
|
||||
const ANTIGRAVITY_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Extract retry delay from Gemini error response (in milliseconds).
|
||||
@@ -227,11 +232,20 @@ export function extractRetryDelay(errorText: string, response?: Response | Heade
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isClaudeModel(modelId: string): boolean {
|
||||
return modelId.toLowerCase().includes("claude");
|
||||
}
|
||||
|
||||
function isClaudeThinkingModel(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
return normalized.includes("claude") && normalized.includes("thinking");
|
||||
}
|
||||
|
||||
function shouldInjectAntigravitySystemInstruction(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
return normalized.includes("claude") || normalized.includes("gemini-3-pro-high");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is retryable (rate limit, server error, network error, etc.)
|
||||
*/
|
||||
@@ -258,6 +272,107 @@ function extractErrorMessage(errorText: string): string {
|
||||
return errorText;
|
||||
}
|
||||
|
||||
interface GeminiCliApiKeyPayload {
|
||||
token?: unknown;
|
||||
projectId?: unknown;
|
||||
project_id?: unknown;
|
||||
refreshToken?: unknown;
|
||||
expiresAt?: unknown;
|
||||
refresh?: unknown;
|
||||
expires?: unknown;
|
||||
}
|
||||
interface ParsedGeminiCliCredentials {
|
||||
accessToken: string;
|
||||
projectId: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
function normalizeExpiryMs(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value < 10_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
export function parseGeminiCliCredentials(apiKeyRaw: string): ParsedGeminiCliCredentials {
|
||||
const invalidCredentialsMessage = "Invalid Google Cloud Code Assist credentials. Use /login to re-authenticate.";
|
||||
const missingCredentialsMessage =
|
||||
"Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate.";
|
||||
|
||||
let parsed: GeminiCliApiKeyPayload;
|
||||
try {
|
||||
parsed = JSON.parse(apiKeyRaw) as GeminiCliApiKeyPayload;
|
||||
} catch {
|
||||
throw new Error(invalidCredentialsMessage);
|
||||
}
|
||||
|
||||
const projectId =
|
||||
typeof parsed.projectId === "string"
|
||||
? parsed.projectId
|
||||
: typeof parsed.project_id === "string"
|
||||
? parsed.project_id
|
||||
: undefined;
|
||||
|
||||
if (typeof parsed.token !== "string" || typeof projectId !== "string") {
|
||||
throw new Error(missingCredentialsMessage);
|
||||
}
|
||||
|
||||
const refreshToken =
|
||||
typeof parsed.refreshToken === "string"
|
||||
? parsed.refreshToken
|
||||
: typeof parsed.refresh === "string"
|
||||
? parsed.refresh
|
||||
: undefined;
|
||||
const expiresAt = normalizeExpiryMs(parsed.expiresAt ?? parsed.expires);
|
||||
|
||||
return {
|
||||
accessToken: parsed.token,
|
||||
projectId,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRefreshGeminiCliCredentials(
|
||||
expiresAt: number | undefined,
|
||||
isAntigravity: boolean,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
if (expiresAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const skewMs = isAntigravity ? ANTIGRAVITY_REFRESH_SKEW_MS : GOOGLE_GEMINI_REFRESH_SKEW_MS;
|
||||
return nowMs + skewMs >= expiresAt;
|
||||
}
|
||||
|
||||
async function refreshGeminiCliCredentialsIfNeeded(
|
||||
credentials: ParsedGeminiCliCredentials,
|
||||
isAntigravity: boolean,
|
||||
): Promise<ParsedGeminiCliCredentials> {
|
||||
if (!credentials.refreshToken || !shouldRefreshGeminiCliCredentials(credentials.expiresAt, isAntigravity)) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = isAntigravity
|
||||
? await refreshAntigravityToken(credentials.refreshToken, credentials.projectId)
|
||||
: await refreshGoogleCloudToken(credentials.refreshToken, credentials.projectId);
|
||||
return {
|
||||
accessToken: refreshed.access,
|
||||
projectId: credentials.projectId,
|
||||
refreshToken: refreshed.refresh,
|
||||
expiresAt: refreshed.expires,
|
||||
};
|
||||
} catch (error) {
|
||||
if (credentials.expiresAt !== undefined && Date.now() >= credentials.expiresAt) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`OAuth token refresh failed before request: ${reason}`);
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
}
|
||||
interface CloudCodeAssistRequest {
|
||||
project: string;
|
||||
model: string;
|
||||
@@ -349,28 +464,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
let rawRequestDump: RawHttpRequestDump | undefined;
|
||||
|
||||
try {
|
||||
// apiKey is JSON-encoded: { token, projectId }
|
||||
const apiKeyRaw = options?.apiKey;
|
||||
if (!apiKeyRaw) {
|
||||
throw new Error("Google Cloud Code Assist requires OAuth authentication. Use /login to authenticate.");
|
||||
}
|
||||
|
||||
let accessToken: string;
|
||||
let projectId: string;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(apiKeyRaw) as { token: string; projectId: string };
|
||||
accessToken = parsed.token;
|
||||
projectId = parsed.projectId;
|
||||
} catch {
|
||||
throw new Error("Invalid Google Cloud Code Assist credentials. Use /login to re-authenticate.");
|
||||
}
|
||||
|
||||
if (!accessToken || !projectId) {
|
||||
throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate.");
|
||||
}
|
||||
|
||||
const isAntigravity = model.provider === "google-antigravity";
|
||||
const parsedCredentials = parseGeminiCliCredentials(apiKeyRaw);
|
||||
const activeCredentials = await refreshGeminiCliCredentialsIfNeeded(parsedCredentials, isAntigravity);
|
||||
const { accessToken, projectId } = activeCredentials;
|
||||
|
||||
const baseUrl = model.baseUrl?.trim();
|
||||
const endpoints = baseUrl ? [baseUrl] : isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
|
||||
|
||||
@@ -383,7 +486,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
...headers,
|
||||
...(isClaudeThinkingModel(model.id) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}),
|
||||
...(!isAntigravity && isClaudeThinkingModel(model.id)
|
||||
? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER }
|
||||
: {}),
|
||||
...(options?.headers ?? {}),
|
||||
};
|
||||
const requestBodyJson = JSON.stringify(requestBody);
|
||||
@@ -791,33 +896,89 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
return stream;
|
||||
};
|
||||
|
||||
function deriveSessionId(context: Context): string | undefined {
|
||||
const INT63_MASK = (1n << 63n) - 1n;
|
||||
const ANTIGRAVITY_RANDOM_BOUND = 9_000_000_000_000_000_000n;
|
||||
|
||||
function formatSignedDecimalSessionId(value: bigint): string {
|
||||
return `-${value.toString()}`;
|
||||
}
|
||||
|
||||
function deriveSignedDecimalFromHash(text: string): string {
|
||||
const digest = createHash("sha256").update(text).digest();
|
||||
let value = 0n;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
value = (value << 8n) | BigInt(digest[index] ?? 0);
|
||||
}
|
||||
return formatSignedDecimalSessionId(value & INT63_MASK);
|
||||
}
|
||||
|
||||
function randomBoundedInt63(maxExclusive: bigint): bigint {
|
||||
while (true) {
|
||||
const bytes = randomBytes(8);
|
||||
let value = 0n;
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8n) | BigInt(byte);
|
||||
}
|
||||
value &= INT63_MASK;
|
||||
if (value < maxExclusive) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function randomSignedDecimalSessionId(): string {
|
||||
return formatSignedDecimalSessionId(randomBoundedInt63(ANTIGRAVITY_RANDOM_BOUND));
|
||||
}
|
||||
|
||||
function getFirstUserTextForAntigravitySession(context: Context): string | undefined {
|
||||
for (const message of context.messages) {
|
||||
if (message.role !== "user" && message.role !== "developer") {
|
||||
if (message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = "";
|
||||
if (typeof message.content === "string") {
|
||||
text = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
text = message.content
|
||||
.filter((item): item is TextContent => item.type === "text")
|
||||
.map(item => item.text)
|
||||
.join("\n");
|
||||
return message.content;
|
||||
}
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return undefined;
|
||||
if (Array.isArray(message.content)) {
|
||||
const firstTextPart = message.content.find((item): item is TextContent => item.type === "text");
|
||||
return firstTextPart?.text;
|
||||
}
|
||||
|
||||
const hash = createHash("sha256").update(text).digest("hex");
|
||||
return hash.slice(0, 32);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function deriveAntigravitySessionId(context: Context): string {
|
||||
const text = getFirstUserTextForAntigravitySession(context);
|
||||
if (text && text.trim().length > 0) {
|
||||
return deriveSignedDecimalFromHash(text);
|
||||
}
|
||||
|
||||
return randomSignedDecimalSessionId();
|
||||
}
|
||||
|
||||
function normalizeAntigravityTools(
|
||||
tools: CloudCodeAssistRequest["request"]["tools"],
|
||||
): CloudCodeAssistRequest["request"]["tools"] {
|
||||
return tools?.map(tool => ({
|
||||
...tool,
|
||||
functionDeclarations: tool.functionDeclarations.map(declaration => {
|
||||
if (!("parametersJsonSchema" in declaration)) {
|
||||
return declaration;
|
||||
}
|
||||
|
||||
const { parametersJsonSchema, ...rest } = declaration;
|
||||
return {
|
||||
...rest,
|
||||
parameters: parametersJsonSchema,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildRequest(
|
||||
model: Model<"google-gemini-cli">,
|
||||
context: Context,
|
||||
@@ -868,9 +1029,8 @@ export function buildRequest(
|
||||
contents,
|
||||
};
|
||||
|
||||
const sessionId = deriveSessionId(context);
|
||||
if (sessionId) {
|
||||
request.sessionId = sessionId;
|
||||
if (isAntigravity) {
|
||||
request.sessionId = deriveAntigravitySessionId(context);
|
||||
}
|
||||
|
||||
// System instruction must be object with parts, not plain string
|
||||
@@ -885,7 +1045,8 @@ export function buildRequest(
|
||||
}
|
||||
|
||||
if (context.tools && context.tools.length > 0) {
|
||||
request.tools = convertTools(context.tools, model);
|
||||
const convertedTools = convertTools(context.tools, model);
|
||||
request.tools = isAntigravity ? normalizeAntigravityTools(convertedTools) : convertedTools;
|
||||
if (options.toolChoice) {
|
||||
request.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
@@ -895,7 +1056,22 @@ export function buildRequest(
|
||||
}
|
||||
}
|
||||
|
||||
if (isAntigravity) {
|
||||
if (isAntigravity && !isClaudeModel(model.id) && request.generationConfig?.maxOutputTokens !== undefined) {
|
||||
delete request.generationConfig.maxOutputTokens;
|
||||
if (Object.keys(request.generationConfig).length === 0) {
|
||||
delete request.generationConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAntigravity && isClaudeModel(model.id)) {
|
||||
request.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: "VALIDATED" as FunctionCallingConfigMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isAntigravity && shouldInjectAntigravitySystemInstruction(model.id)) {
|
||||
const existingParts = request.systemInstruction?.parts ?? [];
|
||||
request.systemInstruction = {
|
||||
role: "user",
|
||||
@@ -911,8 +1087,12 @@ export function buildRequest(
|
||||
project: projectId,
|
||||
model: model.id,
|
||||
request,
|
||||
...(isAntigravity ? { requestType: "agent" } : {}),
|
||||
userAgent: isAntigravity ? "antigravity" : "pi-coding-agent",
|
||||
requestId: `${isAntigravity ? "agent" : "pi"}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
...(isAntigravity
|
||||
? {
|
||||
requestType: "agent",
|
||||
userAgent: "antigravity",
|
||||
requestId: `agent-${randomUUID()}`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,11 +3,21 @@ import { getAntigravityUserAgent } from "../../providers/google-gemini-cli";
|
||||
import type { Model } from "../../types";
|
||||
import { toPositiveNumber } from "../../utils";
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS = [
|
||||
"https://daily-cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
] as const;
|
||||
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
const DEFAULT_MAX_TOKENS = 64_000;
|
||||
const ANTIGRAVITY_DISCOVERY_DENYLIST = new Set([
|
||||
"chat_20706",
|
||||
"chat_23310",
|
||||
"gemini-2.5-flash-thinking",
|
||||
"gemini-3-pro-low",
|
||||
"gemini-2.5-pro",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Raw model metadata returned by Antigravity's `fetchAvailableModels` endpoint.
|
||||
@@ -142,9 +152,9 @@ const AntigravityDiscoveryApiResponseSchema: z.ZodType<AntigravityDiscoveryApiRe
|
||||
export interface FetchAntigravityDiscoveryModelsOptions {
|
||||
/** OAuth access token used as `Authorization: Bearer <token>`. */
|
||||
token: string;
|
||||
/** Optional endpoint override. Defaults to Antigravity daily endpoint. */
|
||||
/** Optional endpoint override. Defaults to Antigravity fallback endpoints. */
|
||||
endpoint?: string;
|
||||
/** Optional project id. Defaults to an empty string for discovery. */
|
||||
/** Deprecated and ignored for antigravity discovery parity. */
|
||||
project?: string;
|
||||
/** Optional user agent override. */
|
||||
userAgent?: string;
|
||||
@@ -164,87 +174,78 @@ export async function fetchAntigravityDiscoveryModels(
|
||||
options: FetchAntigravityDiscoveryModelsOptions,
|
||||
): Promise<Model<"google-gemini-cli">[] | null> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const endpoint = trimTrailingSlashes(options.endpoint ?? DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINT);
|
||||
const endpoints = options.endpoint
|
||||
? [trimTrailingSlashes(options.endpoint)]
|
||||
: DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS.map(trimTrailingSlashes);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": options.userAgent ?? getAntigravityUserAgent(),
|
||||
},
|
||||
body: JSON.stringify({ project: options.project ?? "" }),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseAntigravityDiscoveryResponse(payload);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recommendedIds = collectRecommendedModelIds(parsed.agentModelSorts ?? []);
|
||||
const models: Model<"google-gemini-cli">[] = [];
|
||||
|
||||
for (const [modelId, model] of Object.entries(parsed.models ?? {})) {
|
||||
if (model.isInternal === true) {
|
||||
continue;
|
||||
}
|
||||
if (model.recommended !== true && !recommendedIds.has(modelId)) {
|
||||
for (const endpoint of endpoints) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": options.userAgent ?? getAntigravityUserAgent(),
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const supportsImages = model.supportsImages === true;
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: model.displayName ? `${model.displayName} (Antigravity)` : modelId,
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-antigravity",
|
||||
baseUrl: endpoint,
|
||||
reasoning: model.supportsThinking === true,
|
||||
input: supportsImages ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: toPositiveNumber(model.maxTokens, DEFAULT_CONTEXT_WINDOW),
|
||||
maxTokens: toPositiveNumber(model.maxOutputTokens, DEFAULT_MAX_TOKENS),
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
models.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
return models;
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
function collectRecommendedModelIds(sorts: AntigravityDiscoveryAgentModelSort[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const sort of sorts) {
|
||||
for (const group of sort.groups ?? []) {
|
||||
for (const modelId of group.modelIds ?? []) {
|
||||
if (typeof modelId === "string" && modelId.length > 0) {
|
||||
ids.add(modelId);
|
||||
}
|
||||
const parsed = parseAntigravityDiscoveryResponse(payload);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const models: Model<"google-gemini-cli">[] = [];
|
||||
|
||||
for (const [modelId, model] of Object.entries(parsed.models ?? {})) {
|
||||
if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId)) {
|
||||
continue;
|
||||
}
|
||||
if (model.isInternal === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const supportsImages = model.supportsImages === true;
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: model.displayName ? `${model.displayName} (Antigravity)` : modelId,
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-antigravity",
|
||||
baseUrl: endpoint,
|
||||
reasoning: model.supportsThinking === true,
|
||||
input: supportsImages ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: toPositiveNumber(model.maxTokens, DEFAULT_CONTEXT_WINDOW),
|
||||
maxTokens: toPositiveNumber(model.maxOutputTokens, DEFAULT_MAX_TOKENS),
|
||||
});
|
||||
}
|
||||
|
||||
models.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
return models;
|
||||
}
|
||||
return ids;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAntigravityDiscoveryResponse(value: unknown): AntigravityDiscoveryApiResponse | null {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { getAntigravityAuthHeaders } from "../../providers/google-gemini-cli";
|
||||
import { OAuthCallbackFlow } from "./callback-server";
|
||||
import { generatePKCE } from "./pkce";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
|
||||
const decode = (s: string) => atob(s);
|
||||
@@ -25,7 +24,10 @@ const SCOPES = [
|
||||
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
const CLOUD_CODE_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const TIER_LEGACY = "legacy-tier";
|
||||
const PROJECT_ONBOARD_MAX_ATTEMPTS = 5;
|
||||
const PROJECT_ONBOARD_INTERVAL_MS = 2000;
|
||||
|
||||
interface LoadCodeAssistPayload {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
@@ -33,6 +35,79 @@ interface LoadCodeAssistPayload {
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
||||
}
|
||||
|
||||
interface LongRunningOperationResponse {
|
||||
done?: boolean;
|
||||
response?: {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export const ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA = Object.freeze({
|
||||
ideType: "ANTIGRAVITY",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
});
|
||||
|
||||
function readProjectId(value: string | { id?: string } | undefined): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object" && typeof value.id === "string" && value.id.length > 0) {
|
||||
return value.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDefaultTierId(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): string {
|
||||
if (!allowedTiers || allowedTiers.length === 0) {
|
||||
return TIER_LEGACY;
|
||||
}
|
||||
const defaultTier = allowedTiers.find(tier => tier.isDefault && typeof tier.id === "string" && tier.id.length > 0);
|
||||
if (defaultTier?.id) {
|
||||
return defaultTier.id;
|
||||
}
|
||||
return TIER_LEGACY;
|
||||
}
|
||||
|
||||
async function onboardProjectWithRetries(
|
||||
endpoint: string,
|
||||
headers: Record<string, string>,
|
||||
onboardBody: { tierId: string; metadata: typeof ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA },
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<string> {
|
||||
for (let attempt = 1; attempt <= PROJECT_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (attempt > 1) {
|
||||
onProgress?.(`Waiting for project provisioning (attempt ${attempt}/${PROJECT_ONBOARD_MAX_ATTEMPTS})...`);
|
||||
await Bun.sleep(PROJECT_ONBOARD_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const onboardResponse = await fetch(`${endpoint}/v1internal:onboardUser`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(onboardBody),
|
||||
});
|
||||
|
||||
if (!onboardResponse.ok) {
|
||||
const errorText = await onboardResponse.text();
|
||||
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`);
|
||||
}
|
||||
|
||||
const operation = (await onboardResponse.json()) as LongRunningOperationResponse;
|
||||
if (!operation.done) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectId = readProjectId(operation.response?.cloudaicompanionProject);
|
||||
if (projectId) {
|
||||
return projectId;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`onboardUser did not return a provisioned project id after ${PROJECT_ONBOARD_MAX_ATTEMPTS} attempts`,
|
||||
);
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -40,45 +115,41 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin
|
||||
...getAntigravityAuthHeaders(),
|
||||
};
|
||||
|
||||
const endpoints = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"];
|
||||
|
||||
onProgress?.("Checking for existing project...");
|
||||
const endpoint = CLOUD_CODE_ENDPOINT;
|
||||
try {
|
||||
const loadResponse = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA,
|
||||
}),
|
||||
});
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const loadResponse = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (loadResponse.ok) {
|
||||
const data = (await loadResponse.json()) as LoadCodeAssistPayload;
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try next endpoint
|
||||
if (!loadResponse.ok) {
|
||||
const errorText = await loadResponse.text();
|
||||
throw new Error(`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.("Using default project...");
|
||||
return DEFAULT_PROJECT_ID;
|
||||
const loadPayload = (await loadResponse.json()) as LoadCodeAssistPayload;
|
||||
const existingProject = readProjectId(loadPayload.cloudaicompanionProject);
|
||||
if (existingProject) {
|
||||
return existingProject;
|
||||
}
|
||||
|
||||
const tierId = getDefaultTierId(loadPayload.allowedTiers);
|
||||
onProgress?.("Provisioning project...");
|
||||
const onboardBody = {
|
||||
tierId,
|
||||
metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA,
|
||||
};
|
||||
const provisionedProject = await onboardProjectWithRetries(endpoint, headers, onboardBody, onProgress);
|
||||
return provisionedProject;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not discover or provision an Antigravity project. ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
@@ -98,25 +169,16 @@ async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
class AntigravityOAuthFlow extends OAuthCallbackFlow {
|
||||
#verifier: string = "";
|
||||
#challenge: string = "";
|
||||
|
||||
constructor(ctrl: OAuthController) {
|
||||
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
||||
}
|
||||
|
||||
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
||||
const pkce = await generatePKCE();
|
||||
this.#verifier = pkce.verifier;
|
||||
this.#challenge = pkce.challenge;
|
||||
|
||||
const authParams = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: this.#challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
@@ -138,7 +200,6 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow {
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: this.#verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import { getGeminiCliHeaders } from "../../providers/google-gemini-cli";
|
||||
import { OAuthCallbackFlow } from "./callback-server";
|
||||
import { generatePKCE } from "./pkce";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
|
||||
const decode = (s: string) => atob(s);
|
||||
@@ -228,25 +227,16 @@ async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
class GeminiCliOAuthFlow extends OAuthCallbackFlow {
|
||||
#verifier: string = "";
|
||||
#challenge: string = "";
|
||||
|
||||
constructor(ctrl: OAuthController) {
|
||||
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
||||
}
|
||||
|
||||
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
||||
const pkce = await generatePKCE();
|
||||
this.#verifier = pkce.verifier;
|
||||
this.#challenge = pkce.challenge;
|
||||
|
||||
const authParams = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: this.#challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
@@ -268,7 +258,6 @@ class GeminiCliOAuthFlow extends OAuthCallbackFlow {
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: this.#verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -375,8 +375,8 @@ function getPerplexityJwtExpiryMs(token: string): number | undefined {
|
||||
* Get API key for a provider from OAuth credentials.
|
||||
* Automatically refreshes expired tokens.
|
||||
*
|
||||
* For google-gemini-cli and antigravity, returns JSON-encoded { token, projectId }
|
||||
*
|
||||
* For google-gemini-cli and antigravity, returns JSON-encoded credentials including token/projectId
|
||||
* plus refresh/expiry metadata for proactive refresh support.
|
||||
* @returns API key string, or null if no credentials
|
||||
* @throws Error if refresh fails
|
||||
*/
|
||||
@@ -416,7 +416,16 @@ export async function getOAuthApiKey(
|
||||
}
|
||||
// For providers that need projectId, return JSON
|
||||
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
|
||||
const apiKey = needsProjectId ? JSON.stringify({ token: creds.access, projectId: creds.projectId }) : creds.access;
|
||||
const apiKey = needsProjectId
|
||||
? JSON.stringify({
|
||||
token: creds.access,
|
||||
projectId: creds.projectId,
|
||||
refreshToken: creds.refresh,
|
||||
expiresAt: creds.expires,
|
||||
email: creds.email,
|
||||
accountId: creds.accountId,
|
||||
})
|
||||
: creds.access;
|
||||
return { newCredentials: creds, apiKey };
|
||||
}
|
||||
|
||||
|
||||
@@ -284,8 +284,8 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
});
|
||||
|
||||
it("treats tool prefix helpers as no-ops when prefix is empty", () => {
|
||||
expect(applyClaudeToolPrefix("Read")).toBe("Read");
|
||||
expect(stripClaudeToolPrefix("proxy_Read")).toBe("proxy_Read");
|
||||
expect(applyClaudeToolPrefix("Read", "")).toBe("Read");
|
||||
expect(stripClaudeToolPrefix("proxy_Read", "")).toBe("proxy_Read");
|
||||
});
|
||||
|
||||
it("does not prefix built-in Anthropic tool names when prefix is configured", () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { getAntigravityAuthHeaders } from "../src/providers/google-gemini-cli";
|
||||
import { ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA } from "../src/utils/oauth/google-antigravity";
|
||||
|
||||
describe("Google Antigravity auth alignment", () => {
|
||||
it("uses ANTIGRAVITY ideType in loadCodeAssist metadata payload", () => {
|
||||
expect(ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA).toEqual({
|
||||
ideType: "ANTIGRAVITY",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auth header client-metadata aligned with CLI defaults", () => {
|
||||
const headers = getAntigravityAuthHeaders();
|
||||
const rawMetadata = headers["Client-Metadata"];
|
||||
expect(rawMetadata).toBeDefined();
|
||||
const metadata = JSON.parse(rawMetadata) as {
|
||||
ideType?: string;
|
||||
platform?: string;
|
||||
pluginType?: string;
|
||||
};
|
||||
expect(metadata).toEqual({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildRequest,
|
||||
parseGeminiCliCredentials,
|
||||
shouldRefreshGeminiCliCredentials,
|
||||
} from "../src/providers/google-gemini-cli";
|
||||
import type { Context, Model } from "../src/types";
|
||||
import { getOAuthApiKey } from "../src/utils/oauth";
|
||||
|
||||
function createModel(provider: "google-gemini-cli" | "google-antigravity"): Model<"google-gemini-cli"> {
|
||||
return {
|
||||
id: provider === "google-antigravity" ? "gemini-3-flash" : "gemini-2.5-flash",
|
||||
name: provider,
|
||||
api: "google-gemini-cli",
|
||||
provider,
|
||||
baseUrl: "https://example.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(): Context {
|
||||
return {
|
||||
messages: [{ role: "user", content: "implement token refresh", timestamp: Date.now() }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Google Gemini CLI alignment", () => {
|
||||
it("encodes enriched OAuth JSON while preserving token + projectId", async () => {
|
||||
const expiresAt = Date.now() + 60 * 60 * 1000;
|
||||
const result = await getOAuthApiKey("google-gemini-cli", {
|
||||
"google-gemini-cli": {
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: expiresAt,
|
||||
projectId: "proj-123",
|
||||
email: "dev@example.com",
|
||||
accountId: "acct-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const payload = JSON.parse(result!.apiKey) as {
|
||||
token?: string;
|
||||
projectId?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
email?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
expect(payload.token).toBe("access-token");
|
||||
expect(payload.projectId).toBe("proj-123");
|
||||
expect(payload.refreshToken).toBe("refresh-token");
|
||||
expect(payload.expiresAt).toBe(expiresAt);
|
||||
expect(payload.email).toBe("dev@example.com");
|
||||
expect(payload.accountId).toBe("acct-1");
|
||||
});
|
||||
|
||||
it("accepts legacy, alias, and enriched OAuth JSON payloads", () => {
|
||||
const legacy = parseGeminiCliCredentials(JSON.stringify({ token: "legacy-token", projectId: "proj-legacy" }));
|
||||
expect(legacy).toEqual({
|
||||
accessToken: "legacy-token",
|
||||
projectId: "proj-legacy",
|
||||
refreshToken: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
|
||||
const aliasPayload = parseGeminiCliCredentials(
|
||||
JSON.stringify({
|
||||
token: "alias-token",
|
||||
project_id: "proj-alias",
|
||||
refresh: "refresh-alias",
|
||||
expires: 1_737_000_000,
|
||||
}),
|
||||
);
|
||||
expect(aliasPayload).toEqual({
|
||||
accessToken: "alias-token",
|
||||
projectId: "proj-alias",
|
||||
refreshToken: "refresh-alias",
|
||||
expiresAt: 1_737_000_000_000,
|
||||
});
|
||||
|
||||
const enriched = parseGeminiCliCredentials(
|
||||
JSON.stringify({
|
||||
token: "enriched-token",
|
||||
projectId: "proj-enriched",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1_737_000_000_000,
|
||||
}),
|
||||
);
|
||||
expect(enriched).toEqual({
|
||||
accessToken: "enriched-token",
|
||||
projectId: "proj-enriched",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1_737_000_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("avoids excessive antigravity refresh churn with pre-buffered OAuth expiry", () => {
|
||||
const issuedAt = 1_700_000_000_000;
|
||||
const preBufferedExpiry = issuedAt + 55 * 60 * 1000;
|
||||
|
||||
expect(shouldRefreshGeminiCliCredentials(preBufferedExpiry, true, issuedAt + 10 * 60 * 1000)).toBe(false);
|
||||
expect(shouldRefreshGeminiCliCredentials(preBufferedExpiry, true, issuedAt + 54 * 60 * 1000)).toBe(true);
|
||||
expect(shouldRefreshGeminiCliCredentials(preBufferedExpiry, false, issuedAt + 54 * 60 * 1000)).toBe(true);
|
||||
});
|
||||
it("omits antigravity-only metadata in non-antigravity request payloads", () => {
|
||||
const model = createModel("google-gemini-cli");
|
||||
const payload = buildRequest(model, createContext(), "proj-123", {}, false) as {
|
||||
request: { sessionId?: string };
|
||||
requestType?: string;
|
||||
userAgent?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
expect(payload.request.sessionId).toBeUndefined();
|
||||
expect(payload.requestType).toBeUndefined();
|
||||
expect(payload.userAgent).toBeUndefined();
|
||||
expect(payload.requestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps antigravity metadata in antigravity request payloads", () => {
|
||||
const model = createModel("google-antigravity");
|
||||
const payload = buildRequest(model, createContext(), "proj-123", {}, true) as {
|
||||
request: { sessionId?: string };
|
||||
requestType?: string;
|
||||
userAgent?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
expect(payload.request.sessionId).toMatch(/^-[0-9]+$/);
|
||||
expect(payload.requestType).toBe("agent");
|
||||
expect(payload.userAgent).toBe("antigravity");
|
||||
expect(payload.requestId).toMatch(/^agent-/);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,15 @@
|
||||
- Both AST tools now report `scopePath`, `files`, and per-file match/replacement counts in tool details
|
||||
- Task item `id` max length raised from 32 to 48 characters
|
||||
- Anthropic web search provider now uses `buildAnthropicSearchHeaders` (dedicated search header builder separate from inference headers)
|
||||
- Gemini web search provider: endpoint fallback (daily → sandbox) with retry on 429/5xx
|
||||
- Gemini web search now injects Antigravity system instruction and aligned request metadata (`requestType`, `userAgent`, `requestId`) for Antigravity credentials
|
||||
- `buildGeminiRequestTools()` helper for composable Gemini tool configuration (googleSearch, codeExecution, urlContext)
|
||||
- Web search schema exposes `max_tokens`, `temperature`, and `num_search_results` as tool parameters
|
||||
- Web search provider fallback: when an explicit provider is unavailable, resolves the auto chain instead of returning empty results
|
||||
|
||||
### Fixed
|
||||
|
||||
- `gemini_image` tool: corrected `responseModalities` values from `'Image'`/`'Text'` to uppercase `'IMAGE'`/`'TEXT'` matching the API enum
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ interface ImageApiKey {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
const responseModalitySchema = StringEnum(["Image", "Text"]);
|
||||
const responseModalitySchema = StringEnum(["IMAGE", "TEXT"]);
|
||||
const aspectRatioSchema = StringEnum(["1:1", "3:4", "4:3", "9:16", "16:9"], {
|
||||
description: "Aspect ratio (1:1, 3:4, 4:3, 9:16, 16:9).",
|
||||
});
|
||||
@@ -536,7 +536,7 @@ function buildAntigravityRequest(
|
||||
contents: [{ role: "user", parts }],
|
||||
systemInstruction: { parts: [{ text: IMAGE_SYSTEM_INSTRUCTION }] },
|
||||
generationConfig: {
|
||||
responseModalities: ["Image"],
|
||||
responseModalities: ["IMAGE"],
|
||||
imageConfig,
|
||||
candidateCount: 1,
|
||||
},
|
||||
@@ -788,7 +788,7 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
|
||||
responseModalities: GeminiResponseModality[];
|
||||
imageConfig?: { aspectRatio?: string; imageSize?: string };
|
||||
} = {
|
||||
responseModalities: ["Image"],
|
||||
responseModalities: ["IMAGE"],
|
||||
};
|
||||
|
||||
if (params.aspect_ratio || params.image_size) {
|
||||
|
||||
@@ -46,6 +46,9 @@ export const webSearchSchema = Type.Object({
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "Max results to return" })),
|
||||
max_tokens: Type.Optional(Type.Number({ description: "Maximum output tokens" })),
|
||||
temperature: Type.Optional(Type.Number({ description: "Sampling temperature" })),
|
||||
num_search_results: Type.Optional(Type.Number({ description: "Number of search results to retrieve" })),
|
||||
});
|
||||
|
||||
export type SearchParams = {
|
||||
@@ -70,7 +73,7 @@ export type SearchParams = {
|
||||
temperature?: number;
|
||||
/** Number of search results to retrieve. Defaults to 10. */
|
||||
num_search_results?: number;
|
||||
/** Disable provider fallback when explicit provider is selected (CLI/debug use). */
|
||||
/** Deprecated CLI flag; explicit provider fallback now happens only when provider is unavailable. */
|
||||
no_fallback?: boolean;
|
||||
};
|
||||
|
||||
@@ -185,12 +188,11 @@ async function executeSearch(
|
||||
params: SearchParams,
|
||||
): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
|
||||
const providers =
|
||||
params.provider && params.provider !== "auto" && params.no_fallback
|
||||
params.provider && params.provider !== "auto"
|
||||
? (await getSearchProvider(params.provider).isAvailable())
|
||||
? [getSearchProvider(params.provider)]
|
||||
: []
|
||||
: await resolveProviderChain("auto")
|
||||
: await resolveProviderChain(params.provider);
|
||||
|
||||
if (providers.length === 0) {
|
||||
const message = "No web search provider configured.";
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface SearchParams {
|
||||
maxOutputTokens?: number;
|
||||
numSearchResults?: number;
|
||||
temperature?: number;
|
||||
googleSearch?: Record<string, unknown>;
|
||||
codeExecution?: Record<string, unknown>;
|
||||
urlContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Base class for web search providers. */
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
* Requires OAuth credentials stored in agent.db for provider "google-gemini-cli" or "google-antigravity".
|
||||
* Returns synthesized answers with citations and source metadata from grounding chunks.
|
||||
*/
|
||||
import { getAntigravityHeaders, getGeminiCliHeaders, refreshGoogleCloudToken } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
||||
getAntigravityHeaders,
|
||||
getGeminiCliHeaders,
|
||||
refreshGoogleCloudToken,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { getAgentDbPath } from "@oh-my-pi/pi-utils";
|
||||
import { AgentStorage } from "../../../session/agent-storage";
|
||||
import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
@@ -14,10 +19,18 @@ import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
|
||||
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
|
||||
const DEFAULT_MODEL = "gemini-2.5-flash";
|
||||
|
||||
export interface GeminiSearchParams {
|
||||
interface GeminiToolParams {
|
||||
google_search?: Record<string, unknown>;
|
||||
code_execution?: Record<string, unknown>;
|
||||
url_context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GeminiSearchParams extends GeminiToolParams {
|
||||
query: string;
|
||||
system_prompt?: string;
|
||||
num_results?: number;
|
||||
@@ -27,6 +40,17 @@ export interface GeminiSearchParams {
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
|
||||
const tools: Array<Record<string, Record<string, unknown>>> = [{ googleSearch: params.google_search ?? {} }];
|
||||
if (params.code_execution !== undefined) {
|
||||
tools.push({ codeExecution: params.code_execution });
|
||||
}
|
||||
if (params.url_context !== undefined) {
|
||||
tools.push({ urlContext: params.url_context });
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/** OAuth credential stored in agent.db */
|
||||
interface GeminiOAuthCredential {
|
||||
type: "oauth";
|
||||
@@ -48,15 +72,15 @@ interface GeminiAuth {
|
||||
|
||||
/**
|
||||
* Finds valid Gemini OAuth credentials from agent.db.
|
||||
* Checks google-antigravity first (daily sandbox, more quota), then google-gemini-cli (prod).
|
||||
* Checks google-gemini-cli first (stable prod), then google-antigravity (daily sandbox).
|
||||
* @returns OAuth credential with access token and project ID, or null if none found
|
||||
*/
|
||||
export async function findGeminiAuth(): Promise<GeminiAuth | null> {
|
||||
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
|
||||
const now = Date.now();
|
||||
|
||||
// Try providers in order: antigravity first (more quota), then gemini-cli
|
||||
const providers = ["google-antigravity", "google-gemini-cli"] as const;
|
||||
// Try providers in deterministic order: gemini-cli first, then antigravity
|
||||
const providers = ["google-gemini-cli", "google-antigravity"] as const;
|
||||
|
||||
try {
|
||||
const storage = await AgentStorage.open(getAgentDbPath());
|
||||
@@ -180,6 +204,7 @@ async function callGeminiSearch(
|
||||
systemPrompt?: string,
|
||||
maxOutputTokens?: number,
|
||||
temperature?: number,
|
||||
toolParams: GeminiToolParams = {},
|
||||
): Promise<{
|
||||
answer: string;
|
||||
sources: SearchSource[];
|
||||
@@ -188,10 +213,31 @@ async function callGeminiSearch(
|
||||
model: string;
|
||||
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
|
||||
}> {
|
||||
const endpoint = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT : DEFAULT_ENDPOINT;
|
||||
const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`;
|
||||
const endpoints = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
|
||||
const headers = auth.isAntigravity ? getAntigravityHeaders() : getGeminiCliHeaders();
|
||||
|
||||
const requestMetadata = auth.isAntigravity
|
||||
? {
|
||||
requestType: "agent",
|
||||
userAgent: "antigravity",
|
||||
requestId: `agent-${crypto.randomUUID()}`,
|
||||
}
|
||||
: {
|
||||
userAgent: "pi-coding-agent",
|
||||
requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
};
|
||||
|
||||
const normalizedSystemPrompt = systemPrompt?.toWellFormed();
|
||||
const systemInstructionParts: Array<{ text: string }> = [
|
||||
...(auth.isAntigravity
|
||||
? [
|
||||
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
|
||||
{ text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` },
|
||||
]
|
||||
: []),
|
||||
...(normalizedSystemPrompt ? [{ text: normalizedSystemPrompt }] : []),
|
||||
];
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
project: auth.projectId,
|
||||
model: DEFAULT_MODEL,
|
||||
@@ -202,16 +248,15 @@ async function callGeminiSearch(
|
||||
parts: [{ text: query }],
|
||||
},
|
||||
],
|
||||
// Add googleSearch tool for grounding
|
||||
tools: [{ googleSearch: {} }],
|
||||
...(systemPrompt && {
|
||||
tools: buildGeminiRequestTools(toolParams),
|
||||
...(systemInstructionParts.length > 0 && {
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemPrompt }],
|
||||
...(auth.isAntigravity ? { role: "user" } : {}),
|
||||
parts: systemInstructionParts,
|
||||
},
|
||||
}),
|
||||
},
|
||||
userAgent: "pi-web-search",
|
||||
requestId: `search-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
...requestMetadata,
|
||||
};
|
||||
|
||||
if (maxOutputTokens !== undefined || temperature !== undefined) {
|
||||
@@ -224,17 +269,52 @@ async function callGeminiSearch(
|
||||
}
|
||||
(requestBody.request as Record<string, unknown>).generationConfig = generationConfig;
|
||||
}
|
||||
let response: Response | undefined;
|
||||
for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
|
||||
const url = `${endpoints[endpointIndex]}/v1internal:streamGenerateContent?alt=sse`;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
} catch (error) {
|
||||
if (auth.isAntigravity && endpointIndex < endpoints.length - 1) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (response.ok) {
|
||||
break;
|
||||
}
|
||||
|
||||
const errorText = await response.text();
|
||||
const isRetryableStatus =
|
||||
response.status === 429 ||
|
||||
response.status === 500 ||
|
||||
response.status === 502 ||
|
||||
response.status === 503 ||
|
||||
response.status === 504;
|
||||
if (auth.isAntigravity && isRetryableStatus && endpointIndex < endpoints.length - 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new SearchProviderError(
|
||||
"gemini",
|
||||
`Gemini Cloud Code API error (${response.status}): ${errorText}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new SearchProviderError("gemini", "Gemini API request failed", 500);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@@ -396,6 +476,11 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
|
||||
params.system_prompt,
|
||||
params.max_output_tokens,
|
||||
params.temperature,
|
||||
{
|
||||
google_search: params.google_search,
|
||||
code_execution: params.code_execution,
|
||||
url_context: params.url_context,
|
||||
},
|
||||
);
|
||||
|
||||
let sources = result.sources;
|
||||
@@ -432,6 +517,9 @@ export class GeminiProvider extends SearchProvider {
|
||||
num_results: params.numSearchResults ?? params.limit,
|
||||
max_output_tokens: params.maxOutputTokens,
|
||||
temperature: params.temperature,
|
||||
google_search: params.googleSearch,
|
||||
code_execution: params.codeExecution,
|
||||
url_context: params.urlContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, vi } from "bun:test";
|
||||
import { AgentStorage } from "../../src/session/agent-storage";
|
||||
import { searchGemini } from "../../src/web/search/providers/gemini";
|
||||
|
||||
type CapturedRequest = {
|
||||
body: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const SSE_RESPONSE =
|
||||
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Gemini answer"}]}}],"modelVersion":"gemini-2.5-flash"}}\n\n';
|
||||
|
||||
describe("searchGemini tools serialization", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedRequest: CapturedRequest | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedRequest = null;
|
||||
vi.spyOn(AgentStorage, "open").mockResolvedValue({
|
||||
listAuthCredentials: () => [
|
||||
{
|
||||
id: 1,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "test-access-token",
|
||||
expires: Date.now() + 600_000,
|
||||
projectId: "test-project",
|
||||
},
|
||||
},
|
||||
],
|
||||
updateAuthCredential: () => undefined,
|
||||
} as unknown as AgentStorage);
|
||||
|
||||
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
capturedRequest = {
|
||||
body: init?.body ? (JSON.parse(init.body as string) as Record<string, unknown>) : null,
|
||||
};
|
||||
return new Response(SSE_RESPONSE, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
globalThis.fetch = originalFetch;
|
||||
capturedRequest = null;
|
||||
});
|
||||
|
||||
it("sends default googleSearch tool when no passthrough payloads are provided", async () => {
|
||||
await searchGemini({ query: "default tools" });
|
||||
|
||||
expect(capturedRequest).not.toBeNull();
|
||||
expect(capturedRequest?.body?.request).toMatchObject({
|
||||
tools: [{ googleSearch: {} }],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through google_search payload into googleSearch tool", async () => {
|
||||
await searchGemini({
|
||||
query: "google payload",
|
||||
google_search: { dynamicRetrievalConfig: { mode: "MODE_DYNAMIC" } },
|
||||
});
|
||||
|
||||
expect(capturedRequest).not.toBeNull();
|
||||
expect(capturedRequest?.body?.request).toMatchObject({
|
||||
tools: [{ googleSearch: { dynamicRetrievalConfig: { mode: "MODE_DYNAMIC" } } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes codeExecution and urlContext tools when provided", async () => {
|
||||
await searchGemini({
|
||||
query: "extended tools",
|
||||
code_execution: {},
|
||||
url_context: { allowedDomains: ["example.com"] },
|
||||
});
|
||||
|
||||
expect(capturedRequest).not.toBeNull();
|
||||
expect(capturedRequest?.body?.request).toMatchObject({
|
||||
tools: [{ googleSearch: {} }, { codeExecution: {} }, { urlContext: { allowedDomains: ["example.com"] } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user