diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 1d26623b94..6e25e6b8e0 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -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. | diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index fb2e47545d..eda52c345e 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { baseten: basetenProvider, gemini: geminiProvider, "gemini-web": gemini_webProvider, - "gemini-cli": gemini_cliProvider, cline: clineProvider, heroku: herokuProvider, bluesminds: bluesmindsProvider, diff --git a/open-sse/config/providers/registry/gemini-cli/index.ts b/open-sse/config/providers/registry/gemini-cli/index.ts deleted file mode 100644 index 2693e5e92f..0000000000 --- a/open-sse/config/providers/registry/gemini-cli/index.ts +++ /dev/null @@ -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, -}; diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 7b2401e4af..c978828592 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -81,7 +81,7 @@ export const REFRESH_LEAD_MS: Record = { // 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", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 48d94a6982..f39169d287 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -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", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index a15831bafe..df81732060 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -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 ", - "Content-Type": "application/json", - "User-Agent": "antigravity/ide/2.1.1 /" - }, - "nonStream": { - "Authorization": "Bearer ", - "Content-Type": "application/json", - "User-Agent": "antigravity/ide/2.1.1 /" - }, - "oauth": { - "Accept": "text/event-stream", - "Authorization": "Bearer ", - "Content-Type": "application/json", - "User-Agent": "antigravity/ide/2.1.1 /" - } - }, - "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": { diff --git a/tests/unit/gemini-cli-legacy-refresh.test.ts b/tests/unit/gemini-cli-legacy-refresh.test.ts new file mode 100644 index 0000000000..a793618a5a --- /dev/null +++ b/tests/unit/gemini-cli-legacy-refresh.test.ts @@ -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)["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; + 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; + } +}); diff --git a/tests/unit/gemini-cli-provider.test.ts b/tests/unit/gemini-cli-provider.test.ts deleted file mode 100644 index 4974c4cd96..0000000000 --- a/tests/unit/gemini-cli-provider.test.ts +++ /dev/null @@ -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(fetchImpl: TestFetch, fn: () => Promise) { - 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" }); -});