diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index 7d7921abf6..b3ff57d164 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -186,6 +186,19 @@ export const CLI_FINGERPRINTS: Record = { ], 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", diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9a6f23b479..6abe29c8bb 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -60,6 +60,15 @@ type AntigravityCollectedStream = { remainingCredits: Array<{ creditType: string; creditAmount: string }> | null; }; +type AntigravityRequestEnvelope = Record & { + project: string; + model: string; + userAgent: "antigravity"; + requestType: "agent"; + requestId: string; + request: Record; +}; + /** * 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 | 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 = transformed; + if (transformedBody && typeof transformedBody === "object") { - const cloaked = cloakAntigravityToolPayload(transformedBody as Record); + const cloaked = cloakAntigravityToolPayload(transformedBody); transformedBody = cloaked.body; requestToolNameMap = cloaked.toolNameMap; } diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index f4e036f341..309e12aacd 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -220,9 +220,11 @@ export class BaseExecutor { buildHeaders( credentials: ProviderCredentials, stream = true, - clientHeaders?: Record | null + clientHeaders?: Record | null, + model?: string ): Record { void clientHeaders; + void model; const headers: Record = { "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) diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index e2560a1e07..2ede21531c 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -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): Record { + 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 | 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 { + 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 { + async refreshProject(accessToken: string, model = "unknown"): Promise { // 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 { + async _doRefresh(accessToken: string, model = "unknown"): Promise { + 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) + : { request: {} }; + const requestRecord = + bodyRecord.request && typeof bodyRecord.request === "object" + ? cloneGeminiCliRecord(bodyRecord.request as Record) + : {}; + const envelope: Record = { + 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) { diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 4b38e90cf2..758803ddf9 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -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 }; diff --git a/open-sse/services/cloudCodeHeaders.ts b/open-sse/services/cloudCodeHeaders.ts new file mode 100644 index 0000000000..c1e059b5be --- /dev/null +++ b/open-sse/services/cloudCodeHeaders.ts @@ -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/, "")}`; +} diff --git a/open-sse/services/geminiCliHeaders.ts b/open-sse/services/geminiCliHeaders.ts new file mode 100644 index 0000000000..9db02e9d6c --- /dev/null +++ b/open-sse/services/geminiCliHeaders.ts @@ -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 { + return { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": geminiCliUserAgent(model), + "X-Goog-Api-Client": geminiCliApiClientHeader(), + Accept: accept, + }; +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 5728e99eea..2bf2edec5f 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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", }; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index e5f1bed467..a72892fe33 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -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; } diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index ca886cddf0..f65e0cb132 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -14,6 +14,7 @@ export default function RoutingTab() { const [settings, setSettings] = useState({ alwaysPreserveClientCache: "auto", antigravitySignatureCacheMode: "enabled", + cliCompatProviders: [], }); const [loading, setLoading] = useState(true); const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false); diff --git a/src/lib/usage/fetcher.ts b/src/lib/usage/fetcher.ts index 288cbb7647..aa90bacd49 100644 --- a/src/lib/usage/fetcher.ts +++ b/src/lib/usage/fetcher.ts @@ -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", }; diff --git a/src/shared/constants/cliCompatProviders.ts b/src/shared/constants/cliCompatProviders.ts index 8dc7c25195..7ce794ca3a 100644 --- a/src/shared/constants/cliCompatProviders.ts +++ b/src/shared/constants/cliCompatProviders.ts @@ -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", diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index d5ba3f60c8..e50c38e199 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -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 diff --git a/src/shared/utils/cliCompat.ts b/src/shared/utils/cliCompat.ts index 0532e3d160..ef2ff0842b 100644 --- a/src/shared/utils/cliCompat.ts +++ b/src/shared/utils/cliCompat.ts @@ -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; } diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index fadd60e8a0..815914914a 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -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([]); } diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index 01d3d8341c..b5bbcd0587 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -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, { diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index 128dbab183..c4b014eb1b 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -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 }; + +function getMetadata(body: Record): Record { + return body.metadata && typeof body.metadata === "object" + ? (body.metadata as Record) + : {}; +} 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; body: string }> = []; + + globalThis.fetch = async (url, init: RequestInit = {}) => { + const requestUrl = String(url); + calls.push({ + url: requestUrl, + headers: init.headers as Record, + 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; + } +}); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index e9f6c7aeb9..e38dc0f740 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -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) { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 1ac3376c0a..0fb11a9764 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -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-/);