fix(cli): add Gemini CLI fingerprint (#1866)

Integrated into release/v3.7.8
This commit is contained in:
Raxxoor
2026-05-02 04:48:54 +01:00
committed by GitHub
parent 01092fa349
commit c12b7546a8
19 changed files with 471 additions and 158 deletions

View File

@@ -186,6 +186,19 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
],
userAgent: getAntigravityUserAgent,
},
"gemini-cli": {
headerOrder: [
"Host",
"Content-Type",
"User-Agent",
"X-Goog-Api-Client",
"Accept",
"Accept-Encoding",
"Connection",
"Authorization",
],
bodyFieldOrder: ["model", "project", "user_prompt_id", "request"],
},
qwen: {
headerOrder: [
"Host",

View File

@@ -60,6 +60,15 @@ type AntigravityCollectedStream = {
remainingCredits: Array<{ creditType: string; creditAmount: string }> | null;
};
type AntigravityRequestEnvelope = Record<string, unknown> & {
project: string;
model: string;
userAgent: "antigravity";
requestType: "agent";
requestId: string;
request: Record<string, unknown>;
};
/**
* Per-account GOOGLE_ONE_AI credits-exhausted tracker.
* Key: accountId (OAuth subject / email). Value: expiry timestamp.
@@ -249,7 +258,7 @@ export class AntigravityExecutor extends BaseExecutor {
return scrubProxyAndFingerprintHeaders(raw);
}
transformRequest(model, body, stream, credentials) {
transformRequest(model, body, stream, credentials): AntigravityRequestEnvelope | Response {
// TODO: Consider removing project override like gemini-cli.ts — stored projectId
// can become stale for Cloud Code accounts, causing 403 "has not been used in project X".
// Antigravity accounts may have more stable project IDs, but the risk exists.
@@ -352,14 +361,24 @@ export class AntigravityExecutor extends BaseExecutor {
}
}
const {
project: _project,
model: _model,
userAgent: _userAgent,
requestType: _requestType,
requestId: _requestId,
request: _request,
...passthroughFields
} = normalizedBody;
return {
...normalizedBody,
project: projectId,
model: upstreamModel,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,
request: transformedRequest,
...passthroughFields,
};
}
@@ -572,15 +591,17 @@ export class AntigravityExecutor extends BaseExecutor {
const url = this.buildUrl(model, upstreamStream, urlIndex);
const headers = this.buildHeaders(credentials, upstreamStream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
let transformedBody = await this.transformRequest(model, body, upstreamStream, credentials);
const transformed = await this.transformRequest(model, body, upstreamStream, credentials);
let requestToolNameMap: Map<string, string> | null = null;
if (transformedBody instanceof Response) {
return { response: transformedBody, url, headers, transformedBody: body };
if (transformed instanceof Response) {
return { response: transformed, url, headers, transformedBody: body };
}
let transformedBody: Record<string, unknown> = transformed;
if (transformedBody && typeof transformedBody === "object") {
const cloaked = cloakAntigravityToolPayload(transformedBody as Record<string, unknown>);
const cloaked = cloakAntigravityToolPayload(transformedBody);
transformedBody = cloaked.body;
requestToolNameMap = cloaked.toolNameMap;
}

View File

@@ -220,9 +220,11 @@ export class BaseExecutor {
buildHeaders(
credentials: ProviderCredentials,
stream = true,
clientHeaders?: Record<string, string> | null
clientHeaders?: Record<string, string> | null,
model?: string
): Record<string, string> {
void clientHeaders;
void model;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.config.headers,
@@ -442,7 +444,7 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, activeCredentials);
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders);
const headers = this.buildHeaders(activeCredentials, stream, clientHeaders, model);
applyConfiguredUserAgent(headers, activeCredentials?.providerSpecificData);
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)

View File

@@ -1,6 +1,7 @@
import { BaseExecutor } from "./base.ts";
import { randomUUID } from "crypto";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { geminiCLIUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts";
import { getGeminiCliHeaders } from "../services/geminiCliHeaders.ts";
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
import {
@@ -19,15 +20,13 @@ const ONBOARD_DELAY_MS = 5_000;
const DEFAULT_PROJECT_ID = "default-project";
const DEFAULT_ONBOARD_TIER = "free-tier";
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
ideType: "ANTIGRAVITY",
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: DEFAULT_PROJECT_ID,
});
const ONBOARD_METADATA = Object.freeze({
ideType: "ANTIGRAVITY",
ideType: "IDE_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: DEFAULT_PROJECT_ID,
});
// Per-account cache: accessToken -> { projectId, expiresAt }
@@ -51,6 +50,22 @@ function normalizeGeminiModel(model: string): string {
: "unknown";
}
function generateGeminiCliRequestId(): string {
return `agent-${randomUUID()}`;
}
function generateGeminiCliSessionId(): string {
return `-${Date.now()}`;
}
function cloneGeminiCliRecord(value: Record<string, any>): Record<string, any> {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
function extractProjectId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "";
const data = payload as LoadCodeAssistResponse;
@@ -105,31 +120,27 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
buildUrl(model, stream, urlIndex = 0) {
this._currentModel = normalizeGeminiModel(model);
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `${this.config.baseUrl}:${action}`;
}
buildHeaders(credentials, stream = true) {
const raw = {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
// Dynamic headers matching native GeminiCLI client
"User-Agent": geminiCLIUserAgent(this._currentModel || "unknown"),
"X-Goog-Api-Client": googApiClientHeader(),
...(stream && { Accept: "text/event-stream" }),
};
buildHeaders(credentials, stream = true, clientHeaders?: Record<string, string> | null, model?: string) {
void clientHeaders;
const raw = getGeminiCliHeaders(
normalizeGeminiModel(model || "unknown"),
credentials.accessToken,
stream ? "*/*" : "application/json"
);
return scrubProxyAndFingerprintHeaders(raw);
}
// Track current model for dynamic UA. BaseExecutor calls buildUrl before buildHeaders.
private _currentModel = "unknown";
async onboardManagedProject(
accessToken: string,
tierId = DEFAULT_ONBOARD_TIER,
options: OnboardOptions = {}
options: OnboardOptions = {},
model = "unknown"
): Promise<string | null> {
const currentModel = normalizeGeminiModel(model);
const attempts =
Number.isInteger(options.attempts) && options.attempts! > 0
? Number(options.attempts)
@@ -155,11 +166,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
try {
response = await fetch(ONBOARD_USER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": "GeminiCLI/1.0.0",
},
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
body: JSON.stringify(requestBody),
signal: controller.signal,
});
@@ -201,7 +208,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
* Native Gemini CLI refreshes this every 30 seconds — OmniRoute stores it once
* at OAuth connection time, so it goes stale. This method keeps it fresh.
*/
async refreshProject(accessToken: string): Promise<string | null> {
async refreshProject(accessToken: string, model = "unknown"): Promise<string | null> {
// Check cache
const cached = projectCache.get(accessToken);
if (cached && cached.expiresAt > Date.now()) {
@@ -212,7 +219,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
const inflight = inflightRefresh.get(accessToken);
if (inflight) return inflight;
const promise = this._doRefresh(accessToken);
const promise = this._doRefresh(accessToken, model);
inflightRefresh.set(accessToken, promise);
try {
return await promise;
@@ -221,7 +228,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
}
async _doRefresh(accessToken: string): Promise<string | null> {
async _doRefresh(accessToken: string, model = "unknown"): Promise<string | null> {
const currentModel = normalizeGeminiModel(model);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), LOAD_CODE_ASSIST_TIMEOUT_MS);
@@ -230,12 +238,8 @@ export class GeminiCLIExecutor extends BaseExecutor {
try {
response = await fetch(LOAD_CODE_ASSIST_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
body: JSON.stringify({
cloudaicompanionProject: DEFAULT_PROJECT_ID,
metadata: { ...LOAD_CODE_ASSIST_METADATA },
}),
signal: controller.signal,
@@ -258,7 +262,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
console.warn(
"[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding"
);
projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data));
projectId = await this.onboardManagedProject(accessToken, extractDefaultTierId(data), {}, currentModel);
}
if (!projectId) {
@@ -279,25 +283,50 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
async transformRequest(model, body, stream, credentials) {
this._currentModel = normalizeGeminiModel(model);
const currentModel = normalizeGeminiModel(model);
const normalizedBody =
shouldStripCloudCodeThinking(this.provider, this._currentModel) &&
shouldStripCloudCodeThinking(this.provider, currentModel) &&
body &&
typeof body === "object"
? stripCloudCodeThinkingConfig(body)
: body;
// Refresh the project ID via loadCodeAssist (cached for 30s).
if (normalizedBody && typeof normalizedBody === "object" && normalizedBody.request) {
if (credentials.accessToken) {
const freshProject = await this.refreshProject(credentials.accessToken);
if (freshProject) {
normalizedBody.project = freshProject;
}
}
const bodyRecord =
normalizedBody && typeof normalizedBody === "object"
? (normalizedBody as Record<string, any>)
: { request: {} };
const requestRecord =
bodyRecord.request && typeof bodyRecord.request === "object"
? cloneGeminiCliRecord(bodyRecord.request as Record<string, any>)
: {};
const envelope: Record<string, any> = {
model: currentModel,
project: bodyRecord.project || credentials.projectId || "",
user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(),
request: {
...requestRecord,
session_id: requestRecord.session_id || generateGeminiCliSessionId(),
},
};
for (const [key, value] of Object.entries(bodyRecord)) {
if (!(key in envelope) && key !== "request") {
envelope[key] = value;
}
}
// Refresh the project ID via loadCodeAssist (cached for 30s).
if (credentials.accessToken) {
const freshProject = await this.refreshProject(credentials.accessToken, currentModel);
if (freshProject) {
envelope.project = freshProject;
}
}
if (envelope.request) {
// Obfuscate sensitive client names in user content
const contents = normalizedBody.request?.contents;
const contents = envelope.request?.contents;
if (Array.isArray(contents)) {
for (const msg of contents) {
if (Array.isArray(msg.parts)) {
@@ -310,7 +339,7 @@ export class GeminiCLIExecutor extends BaseExecutor {
}
}
}
return normalizedBody;
return envelope;
}
async refreshCredentials(credentials, log) {

View File

@@ -5,10 +5,10 @@ import {
} from "./antigravityVersion.ts";
/**
* Antigravity and Gemini CLI header utilities.
* Antigravity header utilities.
*
* Generates User-Agent strings and API client headers that match
* the real Antigravity and Gemini CLI binaries.
* the real Antigravity client flows.
*
* Based on CLIProxyAPI's misc/header_utils.go.
*/
@@ -16,12 +16,10 @@ import {
type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models";
const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION;
export const GEMINI_CLI_VERSION = "0.39.1";
export const GEMINI_SDK_VERSION = "1.30.0";
export const NODE_VERSION = "v22.21.1";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/10.3.0";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT =
"google-cloud-sdk vscode_cloudshelleditor/0.1";
export const ANTIGRAVITY_CREDIT_PROBE_API_CLIENT = "google-genai-sdk/1.30.0 gl-node/v22.21.1";
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
@@ -38,32 +36,6 @@ function withOptionalBearerAuth(
return headers;
}
function getPlatform(): string {
const p = typeof process !== "undefined" ? process.platform : "unknown";
switch (p) {
case "win32":
return "windows";
case "darwin":
return "macos";
default:
return p; // "linux", etc.
}
}
function getArch(): string {
const a = typeof process !== "undefined" ? process.arch : "unknown";
switch (a) {
case "x64":
return "x64";
case "ia32":
return "x86";
case "arm64":
return "arm64";
default:
return a;
}
}
/**
* Antigravity User-Agent: "antigravity/VERSION darwin/arm64"
*
@@ -118,20 +90,9 @@ export function getAntigravityHeaders(
}
}
/**
* Gemini CLI User-Agent: "GeminiCLI/VERSION/MODEL (OS; ARCH)"
* Example: "GeminiCLI/1.0.0/gemini-3-flash (macos; arm64)"
*/
export function geminiCLIUserAgent(model: string): string {
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${getPlatform()}; ${getArch()})`;
}
/**
* X-Goog-Api-Client header value matching the real Gemini SDK.
* Example: "google-genai-sdk/1.30.0 gl-node/v22.21.1"
*/
export function googApiClientHeader(): string {
return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`;
/** X-Goog-Api-Client used by Antigravity's credit probe path. */
export function getAntigravityCreditProbeApiClientHeader(): string {
return ANTIGRAVITY_CREDIT_PROBE_API_CLIENT;
}
export { ANTIGRAVITY_VERSION };

View File

@@ -0,0 +1,41 @@
export function getRuntimePlatform(): string {
return typeof process !== "undefined" && typeof process.platform === "string"
? process.platform
: "unknown";
}
export function getRuntimeArch(): string {
return typeof process !== "undefined" && typeof process.arch === "string"
? process.arch
: "unknown";
}
export function getRuntimeNodeVersion(): string {
return typeof process !== "undefined" && process.versions?.node
? process.versions.node
: "unknown";
}
export function normalizeCloudCodePlatform(platform = getRuntimePlatform()): string {
switch (platform) {
case "win32":
return "windows";
case "darwin":
return "macos";
default:
return platform || "unknown";
}
}
export function normalizeCloudCodeArch(arch = getRuntimeArch()): string {
switch (arch) {
case "ia32":
return "x86";
default:
return arch || "unknown";
}
}
export function getCloudCodeNodeApiClientHeader(nodeVersion = getRuntimeNodeVersion()): string {
return `gl-node/${nodeVersion.replace(/^v/, "")}`;
}

View File

@@ -0,0 +1,31 @@
import {
getCloudCodeNodeApiClientHeader,
normalizeCloudCodeArch,
normalizeCloudCodePlatform,
} from "./cloudCodeHeaders.ts";
export const GEMINI_CLI_VERSION = "0.40.1";
export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1";
export function geminiCliUserAgent(model: string): string {
const normalizedModel = model || "unknown";
return `GeminiCLI/${GEMINI_CLI_VERSION}/${normalizedModel} (${normalizeCloudCodePlatform()}; ${normalizeCloudCodeArch()}; terminal) google-api-nodejs-client/${GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION}`;
}
export function geminiCliApiClientHeader(): string {
return getCloudCodeNodeApiClientHeader();
}
export function getGeminiCliHeaders(
model: string,
accessToken: string,
accept: "application/json" | "*/*"
): Record<string, string> {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": geminiCliUserAgent(model),
"X-Goog-Api-Client": geminiCliApiClientHeader(),
Accept: accept,
};
}

View File

@@ -19,7 +19,7 @@ import { safePercentage } from "@/shared/utils/formatting";
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
import {
antigravityUserAgent,
googApiClientHeader,
getAntigravityCreditProbeApiClientHeader,
getAntigravityHeaders,
getAntigravityLoadCodeAssistMetadata,
} from "./antigravityHeaders.ts";
@@ -1513,7 +1513,7 @@ async function probeAntigravityCreditBalanceUncached(
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": antigravityUserAgent(),
"X-Goog-Api-Client": googApiClientHeader(),
"X-Goog-Api-Client": getAntigravityCreditProbeApiClientHeader(),
Accept: "text/event-stream",
};

View File

@@ -73,11 +73,13 @@ type GeminiRequest = {
type CloudCodeEnvelope = {
project: string;
model: string;
userAgent: string;
requestId: string;
user_prompt_id?: string;
userAgent?: string;
requestId?: string;
requestType?: string;
request: {
sessionId: string;
session_id?: string;
sessionId?: string;
contents: GeminiContent[];
systemInstruction?: GeminiContent;
generationConfig: GeminiGenerationConfig;
@@ -393,27 +395,38 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
const envelope: CloudCodeEnvelope = {
project: projectId,
model: cleanModel,
userAgent: isAntigravity ? "antigravity" : "gemini-cli",
requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(),
request: {
sessionId: generateSessionId(),
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
};
const envelope: CloudCodeEnvelope = isAntigravity
? {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${generateUUID()}`,
request: {
sessionId: generateSessionId(),
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
}
: {
model: cleanModel,
project: projectId,
user_prompt_id: generateRequestId(),
request: {
contents: geminiCLI.contents,
systemInstruction: geminiCLI.systemInstruction,
generationConfig: geminiCLI.generationConfig,
tools: geminiCLI.tools,
},
};
if (geminiCLI._toolNameMap instanceof Map && geminiCLI._toolNameMap.size > 0) {
envelope._toolNameMap = geminiCLI._toolNameMap;
}
// Antigravity specific fields
if (isAntigravity) {
envelope.requestType = "agent";
// Inject required default system prompt for Antigravity
const defaultPart: GeminiPart = { text: ANTIGRAVITY_DEFAULT_SYSTEM };
if (envelope.request.systemInstruction?.parts) {
@@ -429,7 +442,8 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
};
}
} else {
// Keep safetySettings for Gemini CLI
// Gemini CLI's native Cloud Code envelope uses snake_case identifiers.
envelope.request.session_id = generateSessionId();
envelope.request.safetySettings = geminiCLI.safetySettings;
}

View File

@@ -14,6 +14,7 @@ export default function RoutingTab() {
const [settings, setSettings] = useState<any>({
alwaysPreserveClientCache: "auto",
antigravitySignatureCacheMode: "enabled",
cliCompatProviders: [],
});
const [loading, setLoading] = useState(true);
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);

View File

@@ -10,7 +10,7 @@ import {
import {
getAntigravityHeaders,
antigravityUserAgent,
googApiClientHeader,
getAntigravityCreditProbeApiClientHeader,
} from "@omniroute/open-sse/services/antigravityHeaders.ts";
import {
getAntigravityFetchAvailableModelsUrls,
@@ -202,7 +202,7 @@ async function probeAntigravityCreditBalance(
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": antigravityUserAgent(),
"X-Goog-Api-Client": googApiClientHeader(),
"X-Goog-Api-Client": getAntigravityCreditProbeApiClientHeader(),
Accept: "text/event-stream",
};

View File

@@ -7,6 +7,7 @@ export const IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS = [
"codex",
"github",
"antigravity",
"gemini-cli",
"qwen",
] as const;
@@ -15,6 +16,7 @@ export const CLI_COMPAT_DISPLAY_PROVIDER_IDS = [
"codex",
"copilot",
"antigravity",
"gemini-cli",
"qwen",
] as const;
@@ -81,6 +83,10 @@ export const CLI_COMPAT_PROVIDER_DISPLAY: Record<
name: "Antigravity",
description: "Google Antigravity IDE compatibility",
},
"gemini-cli": {
name: "Gemini CLI",
description: "Google Gemini CLI compatibility",
},
qwen: {
name: "Qwen Code / Qoder",
description: "Qwen Code and Qoder CLI compatibility",

View File

@@ -528,23 +528,6 @@ amp --model "{{model}}"
docsUrl: "/docs?section=cli-tools",
configType: "custom-builder",
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",
// name: "Gemini CLI",
// icon: "terminal",
// color: "#4285F4",
// description: "Google Gemini CLI",
// configType: "env",
// envVars: {
// baseUrl: "GEMINI_API_BASE_URL",
// model: "GEMINI_MODEL",
// },
// defaultModels: [
// { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", alias: "pro" },
// { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", alias: "flash" },
// ],
// },
};
// Get all provider models for mapping dropdown

View File

@@ -1,3 +1,5 @@
export function normalizeCliCompatProviderId(providerId: string): string {
return providerId.toLowerCase() === "copilot" ? "github" : providerId.toLowerCase();
const normalized = providerId.toLowerCase();
if (normalized === "copilot") return "github";
return normalized;
}

View File

@@ -75,6 +75,7 @@ test("CLI fingerprint toggles only expose implemented fingerprints and functiona
}
assert.equal(CLI_COMPAT_TOGGLE_IDS.includes("copilot"), true);
assert.equal(CLI_COMPAT_TOGGLE_IDS.includes("gemini-cli"), true);
assert.equal((CLI_COMPAT_TOGGLE_IDS as readonly string[]).includes("github"), false);
});
@@ -98,6 +99,37 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
);
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.45.1");
const geminiCli = applyFingerprint(
"gemini-cli",
{
Authorization: "Bearer token",
"Content-Type": "application/json",
"User-Agent": "GeminiCLI/0.40.1/gemini-2.5-flash (linux; arm64; terminal) google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/22.22.2",
Accept: "*/*",
},
{
request: {},
user_prompt_id: "prompt-id",
project: "project-id",
model: "gemini-2.5-flash",
}
);
assert.deepEqual(Object.keys(JSON.parse(geminiCli.bodyString)), [
"model",
"project",
"user_prompt_id",
"request",
]);
assert.deepEqual(Object.keys(geminiCli.headers), [
"Content-Type",
"User-Agent",
"X-Goog-Api-Client",
"Accept",
"Authorization",
]);
});
test("CLI fingerprint keeps legacy Copilot settings functional without exposing duplicate UI toggles", () => {
@@ -108,6 +140,9 @@ test("CLI fingerprint keeps legacy Copilot settings functional without exposing
setCliCompatProviders(["copilot"]);
assert.equal(isCliCompatEnabled("github"), true);
assert.equal(isCliCompatEnabled("copilot"), true);
setCliCompatProviders(["gemini-cli"]);
assert.equal(isCliCompatEnabled("gemini-cli"), true);
assert.equal(isCliCompatEnabled("gemini"), false);
} finally {
setCliCompatProviders([]);
}

View File

@@ -96,6 +96,14 @@ test("AntigravityExecutor.transformRequest normalizes model, project and content
assert.equal(result.project, "project-1");
assert.equal(result.model, "gemini-3.1-pro-low");
assert.deepEqual(Object.keys(result), [
"project",
"model",
"userAgent",
"requestType",
"requestId",
"request",
]);
assert.equal(result.userAgent, "antigravity");
assert.ok(result.request.sessionId);
assert.deepEqual(result.request.toolConfig, {

View File

@@ -2,7 +2,16 @@ import test from "node:test";
import assert from "node:assert/strict";
import { GeminiCLIExecutor } from "../../open-sse/executors/gemini-cli.ts";
import { GEMINI_CLI_VERSION } from "../../open-sse/services/antigravityHeaders.ts";
import { setCliCompatProviders } from "../../open-sse/config/cliFingerprints.ts";
import { GEMINI_CLI_VERSION } from "../../open-sse/services/geminiCliHeaders.ts";
type CapturedFetchCall = { url: string; body: Record<string, unknown> };
function getMetadata(body: Record<string, unknown>): Record<string, unknown> {
return body.metadata && typeof body.metadata === "object"
? (body.metadata as Record<string, unknown>)
: {};
}
test("GeminiCLIExecutor.buildUrl and buildHeaders match the native Gemini CLI fingerprint", () => {
const executor = new GeminiCLIExecutor();
@@ -16,16 +25,33 @@ test("GeminiCLIExecutor.buildUrl and buildHeaders match the native Gemini CLI fi
"https://cloudcode-pa.googleapis.com/v1internal:generateContent"
);
const headers = executor.buildHeaders({ accessToken: "gcli-token" }, true);
const headers = executor.buildHeaders(
{ accessToken: "gcli-token" },
true,
undefined,
"models/gemini-2.5-flash"
);
assert.equal(headers.Authorization, "Bearer gcli-token");
assert.equal(headers.Accept, "text/event-stream");
assert.equal(headers.Accept, "*/*");
assert.match(
headers["User-Agent"],
new RegExp(
`^GeminiCLI/${GEMINI_CLI_VERSION.replaceAll(".", "\\.")}/gemini-2\\.5-flash \\((linux|macos|windows); (x64|arm64|x86)\\)$`
`^GeminiCLI/${GEMINI_CLI_VERSION.replaceAll(".", "\\.")}/gemini-2\\.5-flash \\((linux|macos|windows); (x64|arm64|x86); terminal\\) google-api-nodejs-client/9\\.15\\.1$`
)
);
assert.match(headers["X-Goog-Api-Client"], /google-genai-sdk/);
assert.equal(headers["X-Goog-Api-Client"], `gl-node/${process.versions.node}`);
});
test("GeminiCLIExecutor.buildHeaders uses JSON accept for non-streaming requests", () => {
const executor = new GeminiCLIExecutor();
const headers = executor.buildHeaders(
{ accessToken: "gcli-token" },
false,
undefined,
"models/gemini-2.5-flash"
);
assert.equal(headers.Accept, "application/json");
});
test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest updates body.project", async () => {
@@ -95,6 +121,44 @@ test("GeminiCLIExecutor.transformRequest preserves thinking config for supported
}
});
test("GeminiCLIExecutor.transformRequest does not mutate the caller request body", async () => {
const executor = new GeminiCLIExecutor();
const originalFetch = globalThis.fetch;
const body = {
project: "stale-project",
request: {
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
generationConfig: { temperature: 0.2 },
},
};
globalThis.fetch = async () =>
new Response(JSON.stringify({ cloudaicompanionProject: "fresh-project-id" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
try {
const transformed = await executor.transformRequest("gemini-2.5-flash", body, true, {
accessToken: "access-token-clone",
});
assert.notEqual(transformed.request, body.request);
assert.deepEqual(body, {
project: "stale-project",
request: {
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
generationConfig: { temperature: 0.2 },
},
});
transformed.request.contents[0].parts[0].text = "changed";
assert.equal(body.request.contents[0].parts[0].text, "Hello");
} finally {
globalThis.fetch = originalFetch;
}
});
test("GeminiCLIExecutor.refreshProject returns null on failed loadCodeAssist responses", async () => {
const executor = new GeminiCLIExecutor();
const originalFetch = globalThis.fetch;
@@ -110,10 +174,10 @@ test("GeminiCLIExecutor.refreshProject returns null on failed loadCodeAssist res
test("GeminiCLIExecutor.refreshProject onboards a managed project when loadCodeAssist has no project", async () => {
const executor = new GeminiCLIExecutor();
const originalFetch = globalThis.fetch;
const calls = [];
const calls: CapturedFetchCall[] = [];
globalThis.fetch = async (url, init = {}) => {
const body = init.body ? JSON.parse(String(init.body)) : null;
globalThis.fetch = async (url, init: RequestInit = {}) => {
const body = init.body ? JSON.parse(String(init.body)) : {};
calls.push({ url: String(url), body });
if (String(url).endsWith("loadCodeAssist")) {
@@ -148,12 +212,12 @@ test("GeminiCLIExecutor.refreshProject onboards a managed project when loadCodeA
calls.map((call) => call.url.split(":").at(-1)),
["loadCodeAssist", "onboardUser"]
);
assert.equal(calls[0].body.cloudaicompanionProject, "default-project");
assert.equal(calls[0].body.metadata.ideType, "ANTIGRAVITY");
assert.equal(calls[0].body.metadata.duetProject, "default-project");
assert.equal(calls[0].body.cloudaicompanionProject, undefined);
assert.equal(getMetadata(calls[0].body).ideType, "IDE_UNSPECIFIED");
assert.equal(getMetadata(calls[0].body).duetProject, undefined);
assert.equal(calls[1].body.tierId, "free-tier");
assert.equal(calls[1].body.metadata.ideType, "ANTIGRAVITY");
assert.equal(calls[1].body.metadata.duetProject, "default-project");
assert.equal(getMetadata(calls[1].body).ideType, "IDE_UNSPECIFIED");
assert.equal(getMetadata(calls[1].body).duetProject, undefined);
} finally {
globalThis.fetch = originalFetch;
}
@@ -224,3 +288,68 @@ test("GeminiCLIExecutor.refreshCredentials exchanges refresh tokens via Google O
globalThis.fetch = originalFetch;
}
});
test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code request", async () => {
const executor = new GeminiCLIExecutor();
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string>; body: string }> = [];
globalThis.fetch = async (url, init: RequestInit = {}) => {
const requestUrl = String(url);
calls.push({
url: requestUrl,
headers: init.headers as Record<string, string>,
body: init.body ? String(init.body) : "",
});
if (requestUrl.endsWith("loadCodeAssist")) {
return new Response(JSON.stringify({ cloudaicompanionProject: "project-live" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(
'data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n',
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
};
try {
setCliCompatProviders(["gemini-cli"]);
await executor.execute({
model: "gemini-3.1-pro-preview",
body: { request: { contents: [{ role: "user", parts: [{ text: "hello" }] }] } },
stream: true,
credentials: { accessToken: "token", projectId: "old-project" } as any,
signal: undefined,
log: undefined,
});
const finalCall = calls.find((call) => call.url.includes("streamGenerateContent"));
assert.ok(finalCall);
const finalBody = JSON.parse(finalCall.body);
assert.deepEqual(Object.keys(finalBody), ["model", "project", "user_prompt_id", "request"]);
assert.deepEqual(Object.keys(finalCall.headers), [
"Content-Type",
"User-Agent",
"X-Goog-Api-Client",
"Accept",
"Accept-Encoding",
"Authorization",
]);
assert.equal(finalBody.model, "gemini-3.1-pro-preview");
assert.equal(finalBody.project, "project-live");
assert.match(finalBody.user_prompt_id, /^agent-/);
assert.match(finalBody.request.session_id, /^-\d+$/);
assert.match(
finalCall.headers["User-Agent"],
/^GeminiCLI\/0\.40\.1\/gemini-3\.1-pro-preview /
);
assert.equal(finalCall.headers.Accept, "*/*");
} finally {
setCliCompatProviders([]);
globalThis.fetch = originalFetch;
}
});

View File

@@ -2,8 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict";
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const { antigravityUserAgent, geminiCLIUserAgent, GEMINI_CLI_VERSION } =
await import("../../open-sse/services/antigravityHeaders.ts");
const { antigravityUserAgent } = await import("../../open-sse/services/antigravityHeaders.ts");
const { geminiCliUserAgent, GEMINI_CLI_VERSION } =
await import("../../open-sse/services/geminiCliHeaders.ts");
test("T20: antigravity config has updated User-Agent and sandbox fallback URL", () => {
const antigravity = REGISTRY.antigravity;
@@ -14,15 +15,15 @@ test("T20: antigravity config has updated User-Agent and sandbox fallback URL",
assert.equal(antigravity.headers["User-Agent"], antigravityUserAgent());
});
test("T20: gemini CLI fingerprint uses 0.39.1 and normalizes darwin to macos", () => {
assert.equal(GEMINI_CLI_VERSION, "0.39.1");
test("T20: gemini CLI fingerprint uses 0.40.1 and normalizes darwin to macos", () => {
assert.equal(GEMINI_CLI_VERSION, "0.40.1");
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin" });
try {
assert.match(
geminiCLIUserAgent("gemini-3-flash"),
/^GeminiCLI\/0\.39\.1\/gemini-3-flash \(macos; /
geminiCliUserAgent("gemini-3-flash"),
/^GeminiCLI\/0\.40\.1\/gemini-3-flash \(macos; .+; terminal\) google-api-nodejs-client\/9\.15\.1$/
);
} finally {
if (descriptor) {

View File

@@ -351,6 +351,34 @@ test("OpenAI -> Gemini CLI adds thinking config and normalizes namespaced tool n
assert.equal(getFunctionResponse(responseTurn.parts[0]).name, "weather");
});
test("OpenAI -> Gemini CLI wraps Cloud Code envelope with native top-level and request keys", async () => {
const { getRequestTranslator } = await import("../../open-sse/translator/registry.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
await import("../../open-sse/translator/request/openai-to-gemini.ts");
const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI_CLI);
assert.ok(translate, "expected Gemini CLI translator to be registered");
const result = translate(
"models/gemini-2.5-flash",
{ messages: [{ role: "user", content: "Hello" }] },
true,
{ projectId: "projects/demo" }
) as UnknownRecord;
const request = result.request as UnknownRecord;
assert.deepEqual(Object.keys(result), ["model", "project", "user_prompt_id", "request"]);
assert.equal(result.model, "gemini-2.5-flash");
assert.equal(result.project, "projects/demo");
assert.equal(typeof result.user_prompt_id, "string");
assert.equal(result.userAgent, undefined);
assert.equal(result.requestId, undefined);
assert.equal(result.requestType, undefined);
assert.equal(typeof request.session_id, "string");
assert.equal(request.sessionId, undefined);
assert.ok(Array.isArray(request.contents));
});
test("OpenAI -> Gemini request sanitizes long MCP tool names and strips unsupported schema fields", () => {
const longToolName =
"mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2";
@@ -502,6 +530,14 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", ()
);
assert.equal(result.project, "proj-1");
assert.deepEqual(Object.keys(result), [
"project",
"model",
"userAgent",
"requestType",
"requestId",
"request",
]);
assert.equal(result.userAgent, "antigravity");
assert.equal(result.requestType, "agent");
assert.match(result.requestId, /^agent-/);