mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green.
588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
import { BaseExecutor, mergeUpstreamExtraHeaders, mergeAbortSignals } from "./base.ts";
|
|
import { randomUUID } from "crypto";
|
|
import { PROVIDERS, OAUTH_ENDPOINTS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
|
import { getGeminiCliHeaders } from "../services/geminiCliHeaders.ts";
|
|
import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts";
|
|
import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts";
|
|
import {
|
|
shouldStripCloudCodeThinking,
|
|
stripCloudCodeThinkingConfig,
|
|
} from "../services/cloudCodeThinking.ts";
|
|
|
|
const LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist";
|
|
const ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser";
|
|
const PROJECT_TTL_MS = 30_000; // 30 seconds — matches native Gemini CLI
|
|
const MAX_CACHE_SIZE = 100;
|
|
const LOAD_CODE_ASSIST_TIMEOUT_MS = 10_000; // 10 seconds timeout
|
|
const ONBOARD_TIMEOUT_MS = 30_000;
|
|
const ONBOARD_MAX_ATTEMPTS = 10;
|
|
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: "IDE_UNSPECIFIED",
|
|
platform: "PLATFORM_UNSPECIFIED",
|
|
pluginType: "GEMINI",
|
|
});
|
|
const ONBOARD_METADATA = Object.freeze({
|
|
ideType: "IDE_UNSPECIFIED",
|
|
pluginType: "GEMINI",
|
|
});
|
|
|
|
// Per-account cache: accessToken -> { projectId, expiresAt }
|
|
const projectCache = new Map<string, { projectId: string; expiresAt: number }>();
|
|
// In-flight deduplication: prevents thundering herd on cache miss
|
|
const inflightRefresh = new Map<string, Promise<string | null>>();
|
|
|
|
type LoadCodeAssistResponse = {
|
|
cloudaicompanionProject?: string | { id?: string | null } | null;
|
|
allowedTiers?: Array<{ id?: string | null; isDefault?: boolean | null }> | null;
|
|
};
|
|
|
|
type OnboardOptions = {
|
|
attempts?: number;
|
|
delayMs?: number;
|
|
};
|
|
|
|
function normalizeGeminiModel(model: string): string {
|
|
return typeof model === "string" && model.trim().length > 0
|
|
? model.replace(/^models\//, "").trim()
|
|
: "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;
|
|
if (typeof data.cloudaicompanionProject === "string") {
|
|
return data.cloudaicompanionProject.trim();
|
|
}
|
|
if (typeof data.cloudaicompanionProject?.id === "string") {
|
|
return data.cloudaicompanionProject.id.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function resolveGeminiCliProjectId(value: unknown): string {
|
|
if (typeof value !== "string") return "";
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return "";
|
|
const normalized = trimmed.toLowerCase();
|
|
if (normalized === DEFAULT_PROJECT_ID || normalized === `projects/${DEFAULT_PROJECT_ID}`) {
|
|
return "";
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function extractDefaultTierId(payload: unknown): string {
|
|
if (!payload || typeof payload !== "object") return DEFAULT_ONBOARD_TIER;
|
|
const tiers = Array.isArray((payload as LoadCodeAssistResponse).allowedTiers)
|
|
? (payload as LoadCodeAssistResponse).allowedTiers
|
|
: [];
|
|
for (const tier of tiers) {
|
|
if (tier?.isDefault && typeof tier.id === "string" && tier.id.trim()) {
|
|
return tier.id.trim();
|
|
}
|
|
}
|
|
return DEFAULT_ONBOARD_TIER;
|
|
}
|
|
|
|
function cacheProject(accessToken: string, projectId: string): void {
|
|
if (projectCache.size >= MAX_CACHE_SIZE) {
|
|
const now = Date.now();
|
|
for (const [key, val] of projectCache) {
|
|
if (val.expiresAt <= now) projectCache.delete(key);
|
|
}
|
|
if (projectCache.size >= MAX_CACHE_SIZE) {
|
|
const firstKey = projectCache.keys().next().value;
|
|
if (firstKey !== undefined) projectCache.delete(firstKey);
|
|
}
|
|
}
|
|
|
|
projectCache.set(accessToken, {
|
|
projectId,
|
|
expiresAt: Date.now() + PROJECT_TTL_MS,
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
if (!ms || ms <= 0) return Promise.resolve();
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export class GeminiCLIExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("gemini-cli", PROVIDERS["gemini-cli"]);
|
|
}
|
|
|
|
buildUrl(model, stream, urlIndex = 0) {
|
|
void model;
|
|
void urlIndex;
|
|
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
|
return `${this.config.baseUrl}:${action}`;
|
|
}
|
|
|
|
buildHeaders(
|
|
credentials,
|
|
stream = true,
|
|
clientHeaders?: Record<string, string> | null,
|
|
model?: string
|
|
) {
|
|
void clientHeaders;
|
|
|
|
const activeModel = model || "unknown";
|
|
|
|
const raw = getGeminiCliHeaders(
|
|
normalizeGeminiModel(activeModel),
|
|
credentials.accessToken,
|
|
stream ? "*/*" : "application/json"
|
|
);
|
|
|
|
if (credentials.apiKey) {
|
|
raw["x-goog-api-key"] = credentials.apiKey;
|
|
// getGeminiCliHeaders adds Authorization: Bearer undefined if accessToken is empty, so we clean it up
|
|
if (!credentials.accessToken) {
|
|
delete raw["Authorization"];
|
|
}
|
|
}
|
|
|
|
return scrubProxyAndFingerprintHeaders(raw);
|
|
}
|
|
|
|
async onboardManagedProject(
|
|
accessToken: string,
|
|
tierId = DEFAULT_ONBOARD_TIER,
|
|
options: OnboardOptions = {},
|
|
model = "unknown"
|
|
): Promise<string | null> {
|
|
const currentModel = normalizeGeminiModel(model);
|
|
const attempts =
|
|
Number.isInteger(options.attempts) && options.attempts! > 0
|
|
? Number(options.attempts)
|
|
: ONBOARD_MAX_ATTEMPTS;
|
|
const delayMs =
|
|
typeof options.delayMs === "number" &&
|
|
Number.isFinite(options.delayMs) &&
|
|
options.delayMs >= 0
|
|
? options.delayMs
|
|
: ONBOARD_DELAY_MS;
|
|
|
|
const requestBody = {
|
|
tierId: tierId || DEFAULT_ONBOARD_TIER,
|
|
metadata: { ...ONBOARD_METADATA },
|
|
};
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), ONBOARD_TIMEOUT_MS);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(ONBOARD_USER_URL, {
|
|
method: "POST",
|
|
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
|
|
body: JSON.stringify(requestBody),
|
|
signal: controller.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (response.ok) {
|
|
const payload = await response.json();
|
|
const managedProjectId = extractProjectId(payload?.response);
|
|
|
|
if (payload?.done === true && managedProjectId) {
|
|
return managedProjectId;
|
|
}
|
|
|
|
if (payload?.done === true) {
|
|
return null;
|
|
}
|
|
} else {
|
|
console.warn(
|
|
`[OmniRoute] onboardUser returned ${response.status} on attempt ${attempt + 1}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[OmniRoute] onboardUser attempt ${attempt + 1} failed (${msg})`);
|
|
}
|
|
|
|
if (attempt < attempts - 1) {
|
|
await sleep(delayMs);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fetch the current cloudaicompanionProject via loadCodeAssist API.
|
|
* 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, model = "unknown"): Promise<string | null> {
|
|
// Check cache
|
|
const cached = projectCache.get(accessToken);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
return cached.projectId;
|
|
}
|
|
|
|
// Deduplicate in-flight requests (thundering herd prevention)
|
|
const inflight = inflightRefresh.get(accessToken);
|
|
if (inflight) return inflight;
|
|
|
|
const promise = this._doRefresh(accessToken, model);
|
|
inflightRefresh.set(accessToken, promise);
|
|
try {
|
|
return await promise;
|
|
} finally {
|
|
inflightRefresh.delete(accessToken);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(LOAD_CODE_ASSIST_URL, {
|
|
method: "POST",
|
|
headers: getGeminiCliHeaders(currentModel, accessToken, "application/json"),
|
|
body: JSON.stringify({
|
|
metadata: { ...LOAD_CODE_ASSIST_METADATA },
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.warn(
|
|
`[OmniRoute] loadCodeAssist returned ${response.status} — falling back to stored projectId`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const data = (await response.json()) as LoadCodeAssistResponse;
|
|
let projectId = resolveGeminiCliProjectId(extractProjectId(data));
|
|
|
|
if (!projectId) {
|
|
console.warn(
|
|
"[OmniRoute] loadCodeAssist returned no project — attempting managed project onboarding"
|
|
);
|
|
projectId = await this.onboardManagedProject(
|
|
accessToken,
|
|
extractDefaultTierId(data),
|
|
{},
|
|
currentModel
|
|
);
|
|
}
|
|
|
|
if (!projectId) {
|
|
console.warn(
|
|
"[OmniRoute] managed project onboarding failed — falling back to stored projectId"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
cacheProject(accessToken, projectId);
|
|
|
|
return projectId;
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[OmniRoute] loadCodeAssist failed (${msg}) — falling back to stored projectId`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async transformRequest(model, body, stream, credentials) {
|
|
const currentModel = normalizeGeminiModel(model);
|
|
const normalizedBody =
|
|
shouldStripCloudCodeThinking(this.provider, currentModel) && body && typeof body === "object"
|
|
? stripCloudCodeThinkingConfig(body)
|
|
: body;
|
|
|
|
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 providerSpecificData = credentials.providerSpecificData as Record<string, unknown>;
|
|
const storedProject =
|
|
resolveGeminiCliProjectId(providerSpecificData?.projectId) ||
|
|
resolveGeminiCliProjectId(credentials.projectId) ||
|
|
resolveGeminiCliProjectId(bodyRecord.project) ||
|
|
"";
|
|
|
|
const envelope: Record<string, any> = {
|
|
model: currentModel,
|
|
user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(),
|
|
request: {
|
|
...requestRecord,
|
|
session_id: requestRecord.session_id || generateGeminiCliSessionId(),
|
|
},
|
|
};
|
|
|
|
if (typeof storedProject === "string" ? storedProject.trim() : storedProject) {
|
|
envelope.project = storedProject;
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(bodyRecord)) {
|
|
if (!(key in envelope) && key !== "request" && key !== "project") {
|
|
envelope[key] = value;
|
|
}
|
|
}
|
|
|
|
// Native Gemini CLI refreshes the Cloud Code project periodically because
|
|
// stored project IDs can go stale. Keep the stored value as a fallback.
|
|
if (credentials.accessToken) {
|
|
const freshProject = await this.refreshProject(credentials.accessToken, currentModel);
|
|
if (resolveGeminiCliProjectId(freshProject)) {
|
|
envelope.project = freshProject;
|
|
}
|
|
}
|
|
|
|
if (envelope.request) {
|
|
// Obfuscate sensitive client names in user content
|
|
const contents = envelope.request?.contents;
|
|
if (Array.isArray(contents)) {
|
|
for (const msg of contents) {
|
|
if (Array.isArray(msg.parts)) {
|
|
for (const part of msg.parts) {
|
|
if (typeof part.text === "string") {
|
|
part.text = obfuscateSensitiveWords(part.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return envelope;
|
|
}
|
|
|
|
async execute({
|
|
model,
|
|
body,
|
|
stream,
|
|
credentials,
|
|
signal,
|
|
log,
|
|
upstreamExtraHeaders,
|
|
}: ExecuteInput) {
|
|
const fallbackCount = this.getFallbackCount();
|
|
let lastError = null;
|
|
let lastStatus = 0;
|
|
const MAX_AUTO_RETRIES = 3;
|
|
const retryAttemptsByUrl: Record<number, number> = {};
|
|
|
|
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
|
const url = this.buildUrl(model, stream, urlIndex);
|
|
const headers = this.buildHeaders(credentials, stream, null, model);
|
|
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
|
|
|
|
const transformed = await this.transformRequest(model, body, stream, credentials);
|
|
if (transformed instanceof Response) {
|
|
return { response: transformed, url, headers, transformedBody: body };
|
|
}
|
|
const transformedBody = transformed;
|
|
|
|
if (!retryAttemptsByUrl[urlIndex]) {
|
|
retryAttemptsByUrl[urlIndex] = 0;
|
|
}
|
|
|
|
try {
|
|
log?.debug?.(
|
|
"TELEMETRY",
|
|
`[Gemini CLI] Execute - URL: ${url}, Model: ${model}, Retry: ${retryAttemptsByUrl[urlIndex]}`
|
|
);
|
|
|
|
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
const mergedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal;
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(transformedBody),
|
|
signal: mergedSignal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
log?.warn?.(
|
|
"TELEMETRY",
|
|
`[Gemini CLI] Error Response - URL: ${url}, Status: ${response.status}`
|
|
);
|
|
|
|
let retryMs: number | null = null;
|
|
if (response.status === 429 || response.status === 503) {
|
|
try {
|
|
const errorBody = await response.clone().text();
|
|
retryMs = this.parseRetryFromErrorMessage(errorBody);
|
|
} catch {
|
|
/* ignore parse error */
|
|
}
|
|
|
|
if ((!retryMs || retryMs <= 60000) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) {
|
|
retryAttemptsByUrl[urlIndex]++;
|
|
const backoffMs =
|
|
retryMs || Math.min(1000 * 2 ** retryAttemptsByUrl[urlIndex], 30000);
|
|
log?.debug?.(
|
|
"RETRY",
|
|
`Gemini CLI 429 retry ${retryAttemptsByUrl[urlIndex]} after ${backoffMs}ms`
|
|
);
|
|
await sleep(backoffMs);
|
|
urlIndex--;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.shouldRetry(response.status, urlIndex)) {
|
|
lastStatus = response.status;
|
|
continue;
|
|
}
|
|
|
|
return { response, url, headers, transformedBody };
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (urlIndex + 1 < fallbackCount) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
|
|
}
|
|
|
|
async refreshCredentials(credentials, log) {
|
|
if (!credentials.refreshToken) return null;
|
|
|
|
try {
|
|
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: credentials.refreshToken,
|
|
client_id: this.config.clientId,
|
|
client_secret: this.config.clientSecret,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => "");
|
|
log?.error?.("TOKEN", "Gemini CLI refresh failed", {
|
|
status: response.status,
|
|
error: errorText.slice(0, 200),
|
|
});
|
|
// Match refreshGoogleToken's pattern: invalid_grant means the refresh
|
|
// token was revoked / replaced — surface as unrecoverable so the caller
|
|
// marks the account expired instead of retrying forever with a dead token.
|
|
try {
|
|
const errorBody = JSON.parse(errorText);
|
|
if (errorBody?.error === "invalid_grant") {
|
|
return { error: "unrecoverable_refresh_error", code: "invalid_grant" } as never;
|
|
}
|
|
} catch {
|
|
// not JSON — fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
log?.info?.("TOKEN", "Gemini CLI refreshed");
|
|
|
|
const refreshed: Record<string, unknown> = {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || credentials.refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
projectId: credentials.projectId,
|
|
};
|
|
if (credentials.providerSpecificData !== undefined) {
|
|
refreshed.providerSpecificData = credentials.providerSpecificData;
|
|
}
|
|
return refreshed as never;
|
|
} catch (error) {
|
|
log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Parse retry time from Gemini error message body. Two shapes are handled:
|
|
// 1. Structured google.rpc.RetryInfo in the 429/503 JSON body:
|
|
// { error: { details: [{ "@type": ".../google.rpc.RetryInfo", retryDelay: "30s" }] } }
|
|
// 2. Human-readable prose: "Your quota will reset after 2h7m23s"
|
|
// The structured hint is authoritative (it is what the Cloud Code Assist API
|
|
// sends), so it is checked first and falls through to the prose form on miss.
|
|
parseRetryFromErrorMessage(errorMessage: unknown): number | null {
|
|
if (!errorMessage || typeof errorMessage !== "string") return null;
|
|
|
|
const structuredMs = this.parseStructuredRetryDelay(errorMessage);
|
|
if (structuredMs !== null) return structuredMs;
|
|
|
|
const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i);
|
|
if (!match) return null;
|
|
|
|
let totalMs = 0;
|
|
if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000;
|
|
if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000;
|
|
if (match[3]) totalMs += parseInt(match[3]) * 1000;
|
|
|
|
return totalMs || 2_000;
|
|
}
|
|
|
|
// Read google.rpc.RetryInfo.retryDelay from a Google API error JSON body and
|
|
// convert the protobuf Duration string ("30s", "1.5s", "0.500s") into ms.
|
|
// Returns null when the body is not JSON or carries no RetryInfo detail.
|
|
private parseStructuredRetryDelay(bodyText: string): number | null {
|
|
if (!bodyText.includes("RetryInfo")) return null;
|
|
try {
|
|
const parsed = JSON.parse(bodyText);
|
|
const details = parsed?.error?.details;
|
|
if (!Array.isArray(details)) return null;
|
|
for (const detail of details) {
|
|
if (
|
|
detail?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo" &&
|
|
typeof detail?.retryDelay === "string"
|
|
) {
|
|
const seconds = parseFloat(detail.retryDelay.replace(/s$/i, ""));
|
|
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
return Math.round(seconds * 1000) || 2_000;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* not JSON — caller falls back to prose parsing */
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default GeminiCLIExecutor;
|