mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(providers): limit Gemini CLI to legacy OAuth refresh (#8275)
#8232 correctly targeted OAuth auto-refresh for legacy stored Gemini CLI connections, but exceeded that compatibility goal by recreating a complete routable and UI-visible provider with an Antigravity model catalog. Preserve legacy refresh by mapping gemini-cli rows to the existing Gemini OAuth credentials. Remove the public provider registry, OAuth preset, model routing snapshot, and canonical-provider tests while keeping Gemini API and Antigravity unchanged.
This commit is contained in:
@@ -33,7 +33,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
|
||||
---
|
||||
|
||||
## OAuth Providers (24)
|
||||
## OAuth Providers (23)
|
||||
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
|----|-------|------|------|---------|-------|
|
||||
@@ -47,7 +47,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `codex` | `cx` | OpenAI Codex | OAuth | — | — |
|
||||
| `cursor` | `cu` | Cursor IDE | OAuth | — | — |
|
||||
| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
|
||||
| `gemini-cli` | — | Gemini CLI | OAuth | — | — |
|
||||
| `ghe-copilot` | `ghe-copilot` | GitHub Enterprise Copilot | OAuth | — | Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow. |
|
||||
| `github` | `gh` | GitHub Copilot | OAuth | — | — |
|
||||
| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. |
|
||||
|
||||
@@ -190,7 +190,6 @@ import { maritalkProvider } from "./registry/maritalk/index.ts";
|
||||
import { basetenProvider } from "./registry/baseten/index.ts";
|
||||
import { geminiProvider } from "./registry/gemini/index.ts";
|
||||
import { gemini_webProvider } from "./registry/gemini/web/index.ts";
|
||||
import { gemini_cliProvider } from "./registry/gemini-cli/index.ts";
|
||||
import { clineProvider } from "./registry/cline/index.ts";
|
||||
import { herokuProvider } from "./registry/heroku/index.ts";
|
||||
import { bluesmindsProvider } from "./registry/bluesminds/index.ts";
|
||||
@@ -409,7 +408,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
baseten: basetenProvider,
|
||||
gemini: geminiProvider,
|
||||
"gemini-web": gemini_webProvider,
|
||||
"gemini-cli": gemini_cliProvider,
|
||||
cline: clineProvider,
|
||||
heroku: herokuProvider,
|
||||
bluesminds: bluesmindsProvider,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import {
|
||||
buildAntigravityUrl,
|
||||
ANTIGRAVITY_RUNTIME_BASE_URLS,
|
||||
ANTIGRAVITY_PUBLIC_MODELS,
|
||||
getAntigravityProviderHeaders,
|
||||
resolvePublicCred,
|
||||
} from "../../shared.ts";
|
||||
|
||||
// Gemini CLI (Google's official `gemini` command-line tool) — Google Cloud
|
||||
// Code / Code Assist OAuth. Shares the same Cloud Code backend family as
|
||||
// Antigravity (see errorClassifier.ts's `isCloudCodeProvider` grouping,
|
||||
// which already treats "gemini-cli" and "antigravity" identically for 403
|
||||
// project-config recovery) but authenticates with its OWN distinct public
|
||||
// OAuth client — the well-known Gemini CLI installed-app client
|
||||
// (681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com),
|
||||
// already embedded as `gemini_id` / `gemini_alt` and reused unchanged from
|
||||
// the Gemini Studio API-key provider's own (unused-for-refresh) oauth block.
|
||||
//
|
||||
// Before this entry existed, `gemini-cli` connections had NO PROVIDERS row at
|
||||
// all: `supportsTokenRefresh()`'s generic `PROVIDERS[e].tokenUrl` fallback
|
||||
// returned false (the id has a hyphen the explicit allow-set didn't cover),
|
||||
// and even the allow-set path would have dead-ended in
|
||||
// `_getAccessTokenInternal`'s dispatch switch, which had no "gemini-cli"
|
||||
// case and would fall through to the generic OAuth `refreshAccessToken()`
|
||||
// helper — which also needs `PROVIDERS["gemini-cli"]` to exist. Background
|
||||
// health-check refresh silently no-opped ("refresh unsupported") until the
|
||||
// access token expired and the account went dark, requiring a full
|
||||
// re-authentication even though the refresh token was perfectly valid.
|
||||
export const gemini_cliProvider: RegistryEntry = {
|
||||
id: "gemini-cli",
|
||||
alias: undefined,
|
||||
format: "antigravity",
|
||||
executor: "antigravity",
|
||||
baseUrls: [...ANTIGRAVITY_RUNTIME_BASE_URLS],
|
||||
urlBuilder: buildAntigravityUrl,
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
headers: getAntigravityProviderHeaders(),
|
||||
oauth: {
|
||||
// Reuses the SAME public Gemini CLI OAuth client/env-var names as the
|
||||
// "gemini" (Studio API-key) provider's oauth block above — it is
|
||||
// literally the same credential, just used here for the refresh_token
|
||||
// grant instead of the (currently dormant) apikey-exchange path.
|
||||
clientIdEnv: "GEMINI_OAUTH_CLIENT_ID",
|
||||
clientIdDefault: resolvePublicCred("gemini_id"),
|
||||
clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET",
|
||||
clientSecretDefault: resolvePublicCred("gemini_alt"),
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
refreshUrl: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
models: [...ANTIGRAVITY_PUBLIC_MODELS],
|
||||
passthroughModels: true,
|
||||
};
|
||||
@@ -81,7 +81,7 @@ export const REFRESH_LEAD_MS: Record<string, number> = {
|
||||
// is safe and reduces unnecessary upstream chatter.
|
||||
antigravity: 15 * 60 * 1000,
|
||||
agy: 15 * 60 * 1000, // same Google backend as antigravity (non-rotating refresh tokens)
|
||||
"gemini-cli": 15 * 60 * 1000, // same Google backend family (non-rotating refresh tokens)
|
||||
"gemini-cli": 15 * 60 * 1000, // legacy stored connections; provider is no longer public
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -372,8 +372,19 @@ export async function refreshAccessToken(
|
||||
*/
|
||||
async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: unknown = null) {
|
||||
switch (provider) {
|
||||
case "gemini":
|
||||
case "gemini-cli":
|
||||
// Legacy DB rows can retain this discontinued provider id. Refresh them
|
||||
// with the same public OAuth client used by Gemini CLI without restoring
|
||||
// gemini-cli to the routable provider or OAuth UI registries.
|
||||
return await refreshGoogleToken(
|
||||
credentials.refreshToken,
|
||||
PROVIDERS.gemini.clientId,
|
||||
PROVIDERS.gemini.clientSecret,
|
||||
log,
|
||||
proxyConfig
|
||||
);
|
||||
|
||||
case "gemini":
|
||||
case "antigravity":
|
||||
case "agy":
|
||||
return await refreshGoogleToken(
|
||||
@@ -449,7 +460,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
|
||||
export function supportsTokenRefresh(provider) {
|
||||
const explicitlySupported = new Set([
|
||||
"gemini",
|
||||
"gemini-cli",
|
||||
"gemini-cli", // legacy refresh compatibility only; not a routable provider
|
||||
"antigravity",
|
||||
"agy",
|
||||
"claude",
|
||||
|
||||
@@ -104,15 +104,6 @@ export const OAUTH_PROVIDERS = {
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "oauth",
|
||||
},
|
||||
"gemini-cli": {
|
||||
id: "gemini-cli",
|
||||
alias: undefined,
|
||||
name: "Gemini CLI",
|
||||
icon: "rocket_launch",
|
||||
color: "#F59E0B",
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "oauth",
|
||||
},
|
||||
codex: {
|
||||
id: "codex",
|
||||
alias: "cx",
|
||||
|
||||
@@ -1971,32 +1971,6 @@
|
||||
"stream": "https://generativelanguage.googleapis.com/v1beta/models/test-model:streamGenerateContent?alt=sse"
|
||||
}
|
||||
},
|
||||
"gemini-cli": {
|
||||
"format": "antigravity",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "antigravity/ide/2.1.1 <OS>/<ARCH>"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "antigravity/ide/2.1.1 <OS>/<ARCH>"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "antigravity/ide/2.1.1 <OS>/<ARCH>"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent",
|
||||
"stream": "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
|
||||
}
|
||||
},
|
||||
"gemini-web": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
103
tests/unit/gemini-cli-legacy-refresh.test.ts
Normal file
103
tests/unit/gemini-cli-legacy-refresh.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { OAUTH_ENDPOINTS, PROVIDERS as LEGACY_PROVIDERS } from "../../open-sse/config/constants.ts";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import {
|
||||
getAccessToken,
|
||||
REFRESH_LEAD_MS,
|
||||
supportsTokenRefresh,
|
||||
} from "../../open-sse/services/tokenRefresh.ts";
|
||||
import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts";
|
||||
|
||||
// #8232 set out to repair OAuth refresh for legacy stored connections, but
|
||||
// exceeded that compatibility goal by restoring a complete routable and
|
||||
// UI-visible Gemini CLI provider. Preserve only the legacy refresh path while
|
||||
// keeping the discontinued provider out of public registries and routing.
|
||||
|
||||
test("Gemini CLI stays out of the chat and OAuth provider registries", () => {
|
||||
assert.equal(REGISTRY["gemini-cli"], undefined);
|
||||
assert.equal(LEGACY_PROVIDERS["gemini-cli"], undefined);
|
||||
assert.equal((OAUTH_PROVIDERS as Record<string, unknown>)["gemini-cli"], undefined);
|
||||
assert.ok(REGISTRY.gemini);
|
||||
assert.ok(REGISTRY.antigravity);
|
||||
});
|
||||
|
||||
test("legacy Gemini CLI connections retain proactive token refresh", () => {
|
||||
assert.equal(REFRESH_LEAD_MS["gemini-cli"], REFRESH_LEAD_MS.antigravity);
|
||||
assert.equal(supportsTokenRefresh("gemini-cli"), true);
|
||||
});
|
||||
|
||||
test("Gemini CLI stays out of the provider translation snapshot", () => {
|
||||
const snapshotPath = new URL("../snapshots/provider/translate-path.json", import.meta.url);
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as Record<string, unknown>;
|
||||
assert.equal(snapshot["gemini-cli"], undefined);
|
||||
});
|
||||
|
||||
test("legacy Gemini CLI refresh reuses Gemini OAuth credentials without a provider entry", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; options: RequestInit }> = [];
|
||||
|
||||
globalThis.fetch = (async (url, options: RequestInit = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "legacy-gemini-cli-access-new",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await getAccessToken(
|
||||
"gemini-cli",
|
||||
{ refreshToken: "legacy-gemini-cli-refresh-old" },
|
||||
{}
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, OAUTH_ENDPOINTS.google.token);
|
||||
|
||||
const body = new URLSearchParams(String(calls[0].options.body));
|
||||
assert.equal(body.get("grant_type"), "refresh_token");
|
||||
assert.equal(body.get("refresh_token"), "legacy-gemini-cli-refresh-old");
|
||||
assert.equal(body.get("client_id"), LEGACY_PROVIDERS.gemini.clientId);
|
||||
assert.equal(body.get("client_secret"), LEGACY_PROVIDERS.gemini.clientSecret);
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "legacy-gemini-cli-access-new",
|
||||
refreshToken: "legacy-gemini-cli-refresh-old",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy Gemini CLI refresh surfaces revoked tokens as unrecoverable", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await getAccessToken(
|
||||
"gemini-cli",
|
||||
{ refreshToken: "legacy-gemini-cli-refresh-revoked" },
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
error: "unrecoverable_refresh_error",
|
||||
code: "invalid_grant",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts");
|
||||
const { PROVIDERS: LEGACY_PROVIDERS, OAUTH_ENDPOINTS } =
|
||||
await import("../../open-sse/config/constants.ts");
|
||||
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
|
||||
const { supportsTokenRefresh, REFRESH_LEAD_MS, getAccessToken } = tokenRefresh;
|
||||
|
||||
type TestFetch = typeof fetch;
|
||||
type FetchCall = { url: string; options: RequestInit };
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function bodyToString(body: BodyInit | null | undefined) {
|
||||
if (typeof body === "string") return body;
|
||||
if (body instanceof URLSearchParams) return body.toString();
|
||||
return String(body ?? "");
|
||||
}
|
||||
|
||||
async function withMockedFetch<TResult>(fetchImpl: TestFetch, fn: () => Promise<TResult>) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = fetchImpl;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function createLog() {
|
||||
return {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Regression tests for the two-gap gemini-cli refresh bug ────────────────
|
||||
//
|
||||
// Root cause (both gaps had to be fixed together):
|
||||
// 1. supportsTokenRefresh() gated on a hardcoded id allow-set that had
|
||||
// "gemini" but not "gemini-cli" (the id actually stored on gemini-cli
|
||||
// OAuth connections), and its PROVIDERS[e].tokenUrl fallback also
|
||||
// failed because...
|
||||
// 2. ...the OAuth PROVIDERS registry had NO entry at all for "gemini-cli"
|
||||
// — no clientId/clientSecret/tokenUrl/refreshUrl — so even a provider
|
||||
// that reached the generic refresh path had nothing to refresh with.
|
||||
//
|
||||
// Together this meant the proactive health-check scheduler permanently
|
||||
// skipped gemini-cli connections ("[HealthCheck] Skipping gemini-cli/...
|
||||
// (refresh unsupported)") until the access token expired and the account
|
||||
// went dark, forcing a full re-authentication even though the stored
|
||||
// refresh_token was perfectly valid.
|
||||
|
||||
test("gemini-cli is registered in the OAuth PROVIDERS registry with Google refresh credentials", () => {
|
||||
const entry = REGISTRY["gemini-cli"];
|
||||
assert.ok(entry, "REGISTRY['gemini-cli'] must exist");
|
||||
assert.equal(entry.authType, "oauth");
|
||||
|
||||
const legacy = LEGACY_PROVIDERS["gemini-cli"];
|
||||
assert.ok(legacy, "LEGACY_PROVIDERS['gemini-cli'] must exist");
|
||||
assert.ok(legacy.clientId, "gemini-cli must have a clientId");
|
||||
assert.ok(legacy.clientSecret, "gemini-cli must have a clientSecret");
|
||||
assert.equal(legacy.tokenUrl, "https://oauth2.googleapis.com/token");
|
||||
assert.equal(legacy.refreshUrl, "https://oauth2.googleapis.com/token");
|
||||
|
||||
// Same well-known public Gemini CLI OAuth client as the Gemini Studio
|
||||
// provider's own (previously unused-for-refresh) oauth block — not a new
|
||||
// embedded secret.
|
||||
assert.equal(legacy.clientId, LEGACY_PROVIDERS.gemini.clientId);
|
||||
assert.equal(legacy.clientSecret, LEGACY_PROVIDERS.gemini.clientSecret);
|
||||
assert.equal(
|
||||
legacy.clientId,
|
||||
"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("supportsTokenRefresh('gemini-cli') is true (both the explicit allow-set and the PROVIDERS fallback)", () => {
|
||||
assert.equal(supportsTokenRefresh("gemini-cli"), true);
|
||||
// Sanity: still false for a random unregistered id.
|
||||
assert.equal(supportsTokenRefresh("gemini-cli-typo"), false);
|
||||
});
|
||||
|
||||
test("gemini-cli gets the same 15-minute proactive refresh lead as antigravity/agy (non-rotating Google tokens)", () => {
|
||||
assert.equal(REFRESH_LEAD_MS["gemini-cli"], REFRESH_LEAD_MS.antigravity);
|
||||
assert.equal(REFRESH_LEAD_MS["gemini-cli"], REFRESH_LEAD_MS.agy);
|
||||
});
|
||||
|
||||
test("getAccessToken('gemini-cli', ...) performs a real grant_type=refresh_token exchange against Google's token endpoint", async () => {
|
||||
const log = createLog();
|
||||
const calls: FetchCall[] = [];
|
||||
|
||||
const result = await withMockedFetch(
|
||||
async (url, options: RequestInit = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return jsonResponse({
|
||||
access_token: "gemini-cli-access-new",
|
||||
refresh_token: "gemini-cli-refresh-new",
|
||||
expires_in: 3600,
|
||||
});
|
||||
},
|
||||
async () => getAccessToken("gemini-cli", { refreshToken: "gemini-cli-refresh-old" }, log)
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 1, "must hit the network exactly once");
|
||||
assert.equal(calls[0].url, OAUTH_ENDPOINTS.google.token);
|
||||
assert.equal(calls[0].url, "https://oauth2.googleapis.com/token");
|
||||
|
||||
const sentBody = bodyToString(calls[0].options.body);
|
||||
assert.match(sentBody, /grant_type=refresh_token/);
|
||||
assert.match(sentBody, /refresh_token=gemini-cli-refresh-old/);
|
||||
assert.ok(
|
||||
sentBody.includes(`client_id=${encodeURIComponent(LEGACY_PROVIDERS["gemini-cli"].clientId)}`),
|
||||
"must send the gemini-cli client_id"
|
||||
);
|
||||
assert.ok(sentBody.includes("client_secret="), "must send client_secret");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "gemini-cli-access-new",
|
||||
refreshToken: "gemini-cli-refresh-new",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
test("getAccessToken('gemini-cli', ...) surfaces an unrecoverable error on invalid_grant (revoked/expired refresh token) instead of looping forever", async () => {
|
||||
const log = createLog();
|
||||
|
||||
const result = await withMockedFetch(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
async () => getAccessToken("gemini-cli", { refreshToken: "revoked-refresh-token" }, log)
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { error: "unrecoverable_refresh_error", code: "invalid_grant" });
|
||||
});
|
||||
Reference in New Issue
Block a user