diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index d4e1519..9453867 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -16,6 +16,7 @@ import { getProviderConfig, } from '../../utils/provider-registry'; import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth'; +import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { @@ -106,14 +107,26 @@ export async function handleProviderRoutes( const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); try { const existing = await providerService.getAccount(accountId); + const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser' + ? 'google-gemini-cli' + : undefined; if (url.searchParams.get('apiKeyOnly') === '1') { await providerService.deleteLegacyProviderApiKey(accountId); - await syncDeletedProviderApiKeyToRuntime(existing ? providerAccountToConfig(existing) : null, accountId); + await syncDeletedProviderApiKeyToRuntime( + existing ? providerAccountToConfig(existing) : null, + accountId, + runtimeProviderKey, + ); sendJson(res, 200, { success: true }); return true; } await providerService.deleteAccount(accountId); - await syncDeletedProviderToRuntime(existing ? providerAccountToConfig(existing) : null, accountId, ctx.gatewayManager); + await syncDeletedProviderToRuntime( + existing ? providerAccountToConfig(existing) : null, + accountId, + ctx.gatewayManager, + runtimeProviderKey, + ); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -160,15 +173,22 @@ export async function handleProviderRoutes( if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') { try { const body = await parseJsonBody<{ - provider: OAuthProviderType; + provider: OAuthProviderType | BrowserOAuthProviderType; region?: 'global' | 'cn'; accountId?: string; label?: string; }>(req); - await deviceOAuthManager.startFlow(body.provider, body.region, { - accountId: body.accountId, - label: body.label, - }); + if (body.provider === 'google') { + await browserOAuthManager.startFlow(body.provider, { + accountId: body.accountId, + label: body.label, + }); + } else { + await deviceOAuthManager.startFlow(body.provider, body.region, { + accountId: body.accountId, + label: body.label, + }); + } sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -179,6 +199,7 @@ export async function handleProviderRoutes( if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') { try { await deviceOAuthManager.stopFlow(); + await browserOAuthManager.stopFlow(); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/main/index.ts b/electron/main/index.ts index ec067c8..0b9769a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -24,6 +24,7 @@ import { ensureBuiltinSkillsInstalled } from '../utils/skill-config'; import { startHostApiServer } from '../api/server'; import { HostEventBus } from '../api/event-bus'; import { deviceOAuthManager } from '../utils/device-oauth'; +import { browserOAuthManager } from '../utils/browser-oauth'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; // Disable GPU hardware acceleration globally for maximum stability across @@ -277,6 +278,18 @@ async function initialize(): Promise { hostEventBus.emit('oauth:error', error); }); + browserOAuthManager.on('oauth:start', (payload) => { + hostEventBus.emit('oauth:start', payload); + }); + + browserOAuthManager.on('oauth:success', (payload) => { + hostEventBus.emit('oauth:success', { ...payload, success: true }); + }); + + browserOAuthManager.on('oauth:error', (error) => { + hostEventBus.emit('oauth:error', error); + }); + whatsAppLoginManager.on('qr', (data) => { hostEventBus.emit('channel:whatsapp-qr', data); }); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 25b547d..c37a7ee 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -35,6 +35,7 @@ import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/ import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; +import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; import { applyProxySettings } from './proxy'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; import { getProviderService } from '../services/providers/provider-service'; @@ -892,19 +893,24 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { */ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { deviceOAuthManager.setWindow(mainWindow); + browserOAuthManager.setWindow(mainWindow); // Request Provider OAuth initialization ipcMain.handle( 'provider:requestOAuth', async ( _, - provider: OAuthProviderType, + provider: OAuthProviderType | BrowserOAuthProviderType, region?: 'global' | 'cn', options?: { accountId?: string; label?: string }, ) => { try { logger.info(`provider:requestOAuth for ${provider}`); - await deviceOAuthManager.startFlow(provider, region, options); + if (provider === 'google') { + await browserOAuthManager.startFlow(provider, options); + } else { + await deviceOAuthManager.startFlow(provider, region, options); + } return { success: true }; } catch (error) { logger.error('provider:requestOAuth failed', error); @@ -917,6 +923,7 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { ipcMain.handle('provider:cancelOAuth', async () => { try { await deviceOAuthManager.stopFlow(); + await browserOAuthManager.stopFlow(); return { success: true }; } catch (error) { logger.error('provider:cancelOAuth failed', error); @@ -940,6 +947,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); gatewayManager.debouncedRestart(8000); }); + browserOAuthManager.on('oauth:success', ({ provider, accountId }) => { + logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); + gatewayManager.debouncedRestart(8000); + }); // Get all providers with key info ipcMain.handle('provider:list', async () => { diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 967cb03..0948366 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -1,9 +1,12 @@ import type { GatewayManager } from '../../gateway/manager'; +import { getProviderAccount } from './provider-store'; +import { getProviderSecret } from '../secrets/secret-store'; import type { ProviderConfig } from '../../utils/secure-storage'; import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage'; import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry'; import { removeProviderFromOpenClaw, + saveOAuthTokenToOpenClaw, saveProviderKeyToOpenClaw, setOpenClawDefaultModel, setOpenClawDefaultModelWithOverride, @@ -12,6 +15,9 @@ import { } from '../../utils/openclaw-auth'; import { logger } from '../../utils/logger'; +const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli'; +const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`; + export function getOpenClawProviderKey(type: string, providerId: string): string { if (type === 'custom' || type === 'ollama') { const suffix = providerId.replace(/-/g, '').slice(0, 8); @@ -23,6 +29,24 @@ export function getOpenClawProviderKey(type: string, providerId: string): string return type; } +async function resolveRuntimeProviderKey(config: ProviderConfig): Promise { + const account = await getProviderAccount(config.id); + if (config.type === 'google' && account?.authMode === 'oauth_browser') { + return GOOGLE_OAUTH_RUNTIME_PROVIDER; + } + return getOpenClawProviderKey(config.type, config.id); +} + +async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise { + const account = await getProviderAccount(config.id); + if (config.type !== 'google' || account?.authMode !== 'oauth_browser') { + return false; + } + + const secret = await getProviderSecret(config.id); + return secret?.type === 'oauth'; +} + export function getProviderModelRef(config: ProviderConfig): string | undefined { const providerKey = getOpenClawProviderKey(config.type, config.id); @@ -201,12 +225,13 @@ export async function syncDeletedProviderToRuntime( provider: ProviderConfig | null, providerId: string, gatewayManager?: GatewayManager, + runtimeProviderKey?: string, ): Promise { if (!provider?.type) { return; } - const ock = getOpenClawProviderKey(provider.type, providerId); + const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); await removeProviderFromOpenClaw(ock); scheduleGatewayRestart( @@ -218,12 +243,13 @@ export async function syncDeletedProviderToRuntime( export async function syncDeletedProviderApiKeyToRuntime( provider: ProviderConfig | null, providerId: string, + runtimeProviderKey?: string, ): Promise { if (!provider?.type) { return; } - const ock = getOpenClawProviderKey(provider.type, providerId); + const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); await removeProviderFromOpenClaw(ock); } @@ -236,11 +262,12 @@ export async function syncDefaultProviderToRuntime( return; } - const ock = getOpenClawProviderKey(provider.type, providerId); + const ock = await resolveRuntimeProviderKey(provider); const providerKey = await getApiKey(providerId); const fallbackModels = await getProviderFallbackModelRefs(provider); const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - const isOAuthProvider = oauthTypes.includes(provider.type) && !providerKey; + const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider); + const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider; if (!isOAuthProvider) { const modelOverride = provider.model @@ -260,6 +287,33 @@ export async function syncDefaultProviderToRuntime( await saveProviderKeyToOpenClaw(ock, providerKey); } } else { + if (isGoogleOAuthProvider) { + const secret = await getProviderSecret(provider.id); + if (secret?.type === 'oauth') { + await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, { + access: secret.accessToken, + refresh: secret.refreshToken, + expires: secret.expiresAt, + email: secret.email, + projectId: secret.subject, + }); + } + + const modelOverride = provider.model + ? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`) + ? provider.model + : `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`) + : GOOGLE_OAUTH_DEFAULT_MODEL_REF; + + await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels); + logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`); + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`, + ); + return; + } + const defaultBaseUrl = provider.type === 'minimax-portal' ? 'https://api.minimax.io/anthropic' : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 6831e64..a9c7361 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -55,6 +55,7 @@ export class ProviderService { async createAccount(account: ProviderAccount, apiKey?: string): Promise { await ensureProviderStoreMigrated(); await saveProvider(providerAccountToConfig(account)); + await saveProviderAccount(account); if (apiKey !== undefined && apiKey.trim()) { await storeApiKey(account.id, apiKey.trim()); } @@ -80,6 +81,7 @@ export class ProviderService { }; await saveProvider(providerAccountToConfig(nextAccount)); + await saveProviderAccount(nextAccount); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts new file mode 100644 index 0000000..822037f --- /dev/null +++ b/electron/utils/browser-oauth.ts @@ -0,0 +1,161 @@ +import { EventEmitter } from 'events'; +import { BrowserWindow, shell } from 'electron'; +import { logger } from './logger'; +import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth'; +import { getProviderService } from '../services/providers/provider-service'; +import { getSecretStore } from '../services/secrets/secret-store'; +import { saveOAuthTokenToOpenClaw } from './openclaw-auth'; + +export type BrowserOAuthProviderType = 'google'; + +const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli'; +const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview'; + +class BrowserOAuthManager extends EventEmitter { + private activeProvider: BrowserOAuthProviderType | null = null; + private activeAccountId: string | null = null; + private activeLabel: string | null = null; + private active = false; + private mainWindow: BrowserWindow | null = null; + + setWindow(window: BrowserWindow) { + this.mainWindow = window; + } + + async startFlow( + provider: BrowserOAuthProviderType, + options?: { accountId?: string; label?: string }, + ): Promise { + if (this.active) { + await this.stopFlow(); + } + + this.active = true; + this.activeProvider = provider; + this.activeAccountId = options?.accountId || provider; + this.activeLabel = options?.label || null; + this.emit('oauth:start', { provider, accountId: this.activeAccountId }); + + try { + if (provider !== 'google') { + throw new Error(`Unsupported browser OAuth provider type: ${provider}`); + } + + const token = await loginGeminiCliOAuth({ + isRemote: false, + openUrl: async (url) => { + await shell.openExternal(url); + }, + log: (message) => logger.info(`[BrowserOAuth] ${message}`), + note: async (message, title) => { + logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`); + }, + prompt: async () => { + throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.'); + }, + progress: { + update: (message) => logger.info(`[BrowserOAuth] ${message}`), + stop: (message) => { + if (message) { + logger.info(`[BrowserOAuth] ${message}`); + } + }, + }, + }); + + await this.onSuccess(provider, token); + return true; + } catch (error) { + if (!this.active) { + return false; + } + logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error); + this.emitError(error instanceof Error ? error.message : String(error)); + this.active = false; + this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; + return false; + } + } + + async stopFlow(): Promise { + this.active = false; + this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; + logger.info('[BrowserOAuth] Flow explicitly stopped'); + } + + private async onSuccess( + providerType: BrowserOAuthProviderType, + token: GeminiCliOAuthCredentials, + ) { + const accountId = this.activeAccountId || providerType; + const accountLabel = this.activeLabel; + this.active = false; + this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; + logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`); + + const providerService = getProviderService(); + const existing = await providerService.getAccount(accountId); + const nextAccount = await providerService.createAccount({ + id: accountId, + vendorId: providerType, + label: accountLabel || existing?.label || 'Google Gemini', + authMode: 'oauth_browser', + baseUrl: existing?.baseUrl, + apiProtocol: existing?.apiProtocol, + model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL, + fallbackModels: existing?.fallbackModels, + fallbackAccountIds: existing?.fallbackAccountIds, + enabled: existing?.enabled ?? true, + isDefault: existing?.isDefault ?? false, + metadata: { + ...existing?.metadata, + email: token.email, + resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID, + }, + createdAt: existing?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await getSecretStore().set({ + type: 'oauth', + accountId, + accessToken: token.access, + refreshToken: token.refresh, + expiresAt: token.expires, + email: token.email, + subject: token.projectId, + }); + + await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, { + access: token.access, + refresh: token.refresh, + expires: token.expires, + email: token.email, + projectId: token.projectId, + }); + + this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id }); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:success', { + provider: providerType, + accountId: nextAccount.id, + success: true, + }); + } + } + + private emitError(message: string) { + this.emit('oauth:error', { message }); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:error', { message }); + } + } +} + +export const browserOAuthManager = new BrowserOAuthManager(); diff --git a/electron/utils/gemini-cli-oauth.ts b/electron/utils/gemini-cli-oauth.ts new file mode 100644 index 0000000..991c0d3 --- /dev/null +++ b/electron/utils/gemini-cli-oauth.ts @@ -0,0 +1,738 @@ +import { execFile, execFileSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { delimiter, dirname, join } from 'node:path'; +import { getClawXConfigDir } from './paths'; + +const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID']; +const CLIENT_SECRET_KEYS = [ + 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', + 'GEMINI_CLI_OAUTH_CLIENT_SECRET', +]; +const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback'; +const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; +const SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; +const TIER_FREE = 'free-tier'; +const TIER_LEGACY = 'legacy-tier'; +const TIER_STANDARD = 'standard-tier'; +const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli'); + +export type GeminiCliOAuthCredentials = { + access: string; + refresh: string; + expires: number; + email?: string; + projectId?: string; +}; + +export type GeminiCliOAuthContext = { + isRemote: boolean; + openUrl: (url: string) => Promise; + log: (msg: string) => void; + note: (message: string, title?: string) => Promise; + prompt: (message: string) => Promise; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; +}; + +export class DetailedError extends Error { + detail: string; + + constructor(message: string, detail: string) { + super(message); + this.name = 'DetailedError'; + this.detail = detail; + } +} + +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +function findInPath(name: string): string | null { + const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : ['']; + for (const dir of (process.env.PATH ?? '').split(delimiter)) { + if (!dir) continue; + for (const ext of exts) { + const p = join(dir, name + ext); + if (existsSync(p)) { + return p; + } + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) { + return null; + } + + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const next = join(dir, entry.name); + if (entry.isFile() && entry.name === name) { + return next; + } + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const found = findFile(next, name, depth - 1); + if (found) { + return found; + } + } + } + } catch { + return null; + } + + return null; +} + +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) { + return cachedGeminiCliCredentials; + } + + try { + const geminiPath = findInPath('gemini'); + if (!geminiPath) { + return null; + } + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDir = dirname(dirname(resolvedPath)); + const searchPaths = [ + join( + geminiCliDir, + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js', + ), + join( + geminiCliDir, + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'code_assist', + 'oauth2.js', + ), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, 'utf8'); + break; + } + } + + if (!content) { + const found = findFile(geminiCliDir, 'oauth2.js', 10); + if (found) { + content = readFileSync(found, 'utf8'); + } + } + + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + return null; + } + + return null; +} + +function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null { + const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core'); + if (!existsSync(coreDir)) { + return null; + } + + const searchPaths = [ + join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'), + join(coreDir, 'dist', 'code_assist', 'oauth2.js'), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, 'utf8'); + break; + } + } + + if (!content) { + const found = findFile(coreDir, 'oauth2.js', 10); + if (found) { + content = readFileSync(found, 'utf8'); + } + } + + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + return { clientId: idMatch[1], clientSecret: secretMatch[1] }; + } + + return null; +} + +async function installViaNpm(onProgress?: (msg: string) => void): Promise { + const npmBin = findInPath('npm'); + if (!npmBin) { + return false; + } + + onProgress?.('Installing Gemini OAuth helper...'); + + return await new Promise((resolve) => { + const useShell = process.platform === 'win32'; + const child = execFile( + npmBin, + ['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'], + { timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } }, + (err) => { + if (err) { + onProgress?.(`Gemini helper install failed, falling back to direct download...`); + resolve(false); + } else { + cachedGeminiCliCredentials = null; + onProgress?.('Gemini OAuth helper installed'); + resolve(true); + } + }, + ); + child.stderr?.on('data', () => { + // Suppress npm noise. + }); + }); +} + +async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise { + try { + onProgress?.('Downloading Gemini OAuth helper...'); + const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest'); + if (!metaRes.ok) { + onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`); + return false; + } + + const meta = (await metaRes.json()) as { dist?: { tarball?: string } }; + const tarballUrl = meta.dist?.tarball; + if (!tarballUrl) { + onProgress?.('Gemini package tarball URL missing'); + return false; + } + + const tarRes = await fetch(tarballUrl); + if (!tarRes.ok) { + onProgress?.(`Failed to download Gemini package: ${tarRes.status}`); + return false; + } + + const buffer = Buffer.from(await tarRes.arrayBuffer()); + const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core'); + mkdirSync(targetDir, { recursive: true }); + + const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz'); + writeFileSync(tmpFile, buffer); + try { + execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], { + timeout: 30_000, + }); + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore + } + } + + cachedGeminiCliCredentials = null; + onProgress?.('Gemini OAuth helper ready'); + return true; + } catch (err) { + onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`); + return false; + } +} + +async function ensureOAuthClientConfig( + onProgress?: (msg: string) => void, +): Promise<{ clientId: string; clientSecret?: string }> { + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + const localExtracted = extractFromLocalInstall(); + if (localExtracted) { + return localExtracted; + } + + mkdirSync(LOCAL_GEMINI_DIR, { recursive: true }); + const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress); + if (installed) { + const installedExtracted = extractFromLocalInstall(); + if (installedExtracted) { + return installedExtracted; + } + } + + throw new Error( + 'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.', + ); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString('hex'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +function buildAuthUrl(clientId: string, challenge: string, verifier: string): string { + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(' '), + code_challenge: challenge, + code_challenge_method: 'S256', + state: verifier, + access_type: 'offline', + prompt: 'consent', + }); + return `${AUTH_URL}?${params.toString()}`; +} + +async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8085; + const hostname = '127.0.0.1'; + const expectedPath = '/oauth2callback'; + + return new Promise((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain'); + res.end('Not found'); + return; + } + + const error = requestUrl.searchParams.get('error'); + const code = requestUrl.searchParams.get('code')?.trim(); + const state = requestUrl.searchParams.get('state')?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain'); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain'); + res.end('Missing code or state'); + finish(new Error('Missing OAuth code or state')); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end( + "

Session expired

This authorization link is from a previous attempt. Please go back to ClawX and try again.

", + ); + return; + } + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end( + "

Gemini CLI OAuth complete

You can close this window and return to ClawX.

", + ); + + finish(undefined, { code, state }); + } catch (err) { + finish(err instanceof Error ? err : new Error('OAuth callback failed')); + } + }); + + const finish = (err?: Error, result?: { code: string; state: string }) => { + if (timeout) { + clearTimeout(timeout); + } + try { + server.close(); + } catch { + // ignore + } + if (err) { + reject(err); + } else if (result) { + resolve(result); + } + }; + + server.once('error', (err) => { + finish(err instanceof Error ? err : new Error('OAuth callback server error')); + }); + + server.listen(port, hostname, () => { + params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`); + }); + + timeout = setTimeout(() => { + finish(new DetailedError( + 'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.', + `Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`, + )); + }, params.timeoutMs); + }); +} + +async function getUserEmail(accessToken: string): Promise { + try { + const response = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // ignore + } + + return undefined; +} + +function getDefaultTier( + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, +): { id?: string } | undefined { + if (!allowedTiers?.length) { + return { id: TIER_LEGACY }; + } + return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; +} + +function isVpcScAffected(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') { + return false; + } + const error = (payload as { error?: unknown }).error; + if (!error || typeof error !== 'object') { + return false; + } + const details = (error as { details?: unknown[] }).details; + if (!Array.isArray(details)) { + return false; + } + return details.some( + (item) => + typeof item === 'object' + && item + && (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED', + ); +} + +async function pollOperation( + operationName: string, + headers: Record, +): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { + for (let attempt = 0; attempt < 24; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers }); + if (!response.ok) { + continue; + } + const data = (await response.json()) as { + done?: boolean; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + if (data.done) { + return data; + } + } + + throw new Error('Operation polling timeout'); +} + +async function discoverProject(accessToken: string): Promise { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const headers = { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'gl-node/clawx', + }; + + const loadBody = { + cloudaicompanionProject: envProject, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: envProject, + }, + }; + + let data: { + currentTier?: { id?: string }; + cloudaicompanionProject?: string | { id?: string }; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + } = {}; + + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { + method: 'POST', + headers, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + } else { + throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + } + } else { + data = (await response.json()) as typeof data; + } + + if (data.currentTier) { + const project = data.cloudaicompanionProject; + if (typeof project === 'string' && project) { + return project; + } + if (typeof project === 'object' && project?.id) { + return project.id; + } + if (envProject) { + return envProject; + } + } + + const hasExistingTierButNoProject = !!data.currentTier; + const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers); + const tierId = tier?.id || TIER_FREE; + if (tierId !== TIER_FREE && !envProject) { + throw new DetailedError( + 'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.', + `tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`, + ); + } + + const onboardBody: Record = { + tierId, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }, + }; + if (tierId !== TIER_FREE && envProject) { + onboardBody.cloudaicompanionProject = envProject; + (onboardBody.metadata as Record).duetProject = envProject; + } + + const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + method: 'POST', + headers, + body: JSON.stringify(onboardBody), + }); + + if (!onboardResponse.ok) { + const respText = await onboardResponse.text().catch(() => ''); + throw new DetailedError( + 'Google project provisioning failed. Please try again later.', + `onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`, + ); + } + + let lro = (await onboardResponse.json()) as { + done?: boolean; + name?: string; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + + if (!lro.done && lro.name) { + lro = await pollOperation(lro.name, headers); + } + + const projectId = lro.response?.cloudaicompanionProject?.id; + if (projectId) { + return projectId; + } + if (envProject) { + return envProject; + } + + throw new DetailedError( + 'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.', + `tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`, + ); +} + +async function exchangeCodeForTokens( + code: string, + verifier: string, + clientConfig: { clientId: string; clientSecret?: string }, +): Promise { + const { clientId, clientSecret } = clientConfig; + const body = new URLSearchParams({ + client_id: clientId, + code, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + if (clientSecret) { + body.set('client_secret', clientSecret); + } + + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + if (!data.refresh_token) { + throw new Error('No refresh token received. Please try again.'); + } + + const email = await getUserEmail(data.access_token); + const projectId = await discoverProject(data.access_token); + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + projectId, + email, + }; +} + +export async function loginGeminiCliOAuth( + ctx: GeminiCliOAuthContext, +): Promise { + if (ctx.isRemote) { + throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.'); + } + + await ctx.note( + [ + 'Browser will open for Google authentication.', + 'Sign in with your Google account for Gemini CLI access.', + 'The callback will be captured automatically on 127.0.0.1:8085.', + ].join('\n'), + 'Gemini CLI OAuth', + ); + + ctx.progress.update('Preparing Google OAuth...'); + const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg)); + const { verifier, challenge } = generatePkce(); + const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier); + ctx.progress.update('Complete sign-in in browser...'); + + try { + await ctx.openUrl(authUrl); + } catch { + ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`); + } + + try { + const { code } = await waitForLocalCallback({ + expectedState: verifier, + timeoutMs: 5 * 60 * 1000, + onProgress: (msg) => ctx.progress.update(msg), + }); + ctx.progress.update('Exchanging authorization code for tokens...'); + return await exchangeCodeForTokens(code, verifier, clientConfig); + } catch (err) { + if ( + err instanceof Error + && (err.message.includes('EADDRINUSE') + || err.message.includes('port') + || err.message.includes('listen')) + ) { + throw new Error( + 'Port 8085 is in use by another process. Close the other application using port 8085 and try again.', + { cause: err }, + ); + } + throw err; + } +} + +// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale. +export function detectGeminiCliVersion(): string | null { + try { + const geminiPath = findInPath('gemini'); + if (!geminiPath) { + return null; + } + return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 3beabb5..30ed720 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -20,6 +20,15 @@ import { const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; +const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn', 'google-gemini-cli']; + +function shouldEnableOAuthPlugin(provider: string): boolean { + return provider === 'minimax-portal' || provider === 'qwen-portal' || provider === 'google-gemini-cli'; +} + +function getOAuthPluginId(provider: string): string { + return `${provider}-auth`; +} // ── Helpers ────────────────────────────────────────────────────── @@ -71,6 +80,8 @@ interface OAuthProfileEntry { access: string; refresh: string; expires: number; + email?: string; + projectId?: string; } interface AuthProfilesStore { @@ -141,7 +152,7 @@ async function writeOpenClawJson(config: Record): Promise */ export async function saveOAuthTokenToOpenClaw( provider: string, - token: { access: string; refresh: string; expires: number }, + token: { access: string; refresh: string; expires: number; email?: string; projectId?: string }, agentId?: string ): Promise { const agentIds = agentId ? [agentId] : await discoverAgentIds(); @@ -157,6 +168,8 @@ export async function saveOAuthTokenToOpenClaw( access: token.access, refresh: token.refresh, expires: token.expires, + email: token.email, + projectId: token.projectId, }; if (!store.order) store.order = {}; @@ -207,7 +220,6 @@ export async function saveProviderKeyToOpenClaw( apiKey: string, agentId?: string ): Promise { - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; if (OAUTH_PROVIDERS.includes(provider) && !apiKey) { console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`); return; @@ -242,7 +254,6 @@ export async function removeProviderKeyFromOpenClaw( provider: string, agentId?: string ): Promise { - const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; if (OAUTH_PROVIDERS.includes(provider)) { console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`); return; @@ -495,10 +506,16 @@ export async function syncProviderConfigToOpenClaw( } // Ensure extension is enabled for oauth providers to prevent gateway wiping config - if (provider === 'minimax-portal' || provider === 'qwen-portal') { + if (shouldEnableOAuthPlugin(provider)) { const plugins = (config.plugins || {}) as Record; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; const pEntries = (plugins.entries || {}) as Record; - pEntries[`${provider}-auth`] = { enabled: true }; + const pluginId = getOAuthPluginId(provider); + if (!allow.includes(pluginId)) { + allow.push(pluginId); + } + pEntries[pluginId] = { enabled: true }; + plugins.allow = allow; plugins.entries = pEntries; config.plugins = plugins; } @@ -573,10 +590,16 @@ export async function setOpenClawDefaultModelWithOverride( config.gateway = gateway; // Ensure the extension plugin is marked as enabled in openclaw.json - if (provider === 'minimax-portal' || provider === 'qwen-portal') { + if (shouldEnableOAuthPlugin(provider)) { const plugins = (config.plugins || {}) as Record; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; const pEntries = (plugins.entries || {}) as Record; - pEntries[`${provider}-auth`] = { enabled: true }; + const pluginId = getOAuthPluginId(provider); + if (!allow.includes(pluginId)) { + allow.push(pluginId); + } + pEntries[pluginId] = { enabled: true }; + plugins.allow = allow; plugins.entries = pEntries; config.plugins = plugins; } diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 102bf5c..bc50177 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -90,17 +90,17 @@ export function ProvidersSettings() { const { t } = useTranslation('settings'); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const { - providers, + statuses, accounts, vendors, defaultAccountId, loading, - fetchProviders, - addAccount, - deleteAccount, + refreshProviderSnapshot, + createAccount, + removeAccount, updateAccount, setDefaultAccount, - validateApiKey, + validateAccountApiKey, } = useProviderStore(); const [showAddDialog, setShowAddDialog] = useState(false); @@ -108,14 +108,14 @@ export function ProvidersSettings() { const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); const existingVendorIds = new Set(accounts.map((account) => account.vendorId)); const displayProviders = useMemo( - () => buildProviderListItems(accounts, providers, vendors, defaultAccountId), - [accounts, providers, vendors, defaultAccountId], + () => buildProviderListItems(accounts, statuses, vendors, defaultAccountId), + [accounts, statuses, vendors, defaultAccountId], ); // Fetch providers on mount useEffect(() => { - fetchProviders(); - }, [fetchProviders]); + refreshProviderSnapshot(); + }, [refreshProviderSnapshot]); const handleAddProvider = async ( type: ProviderType, @@ -127,7 +127,7 @@ export function ProvidersSettings() { const id = buildProviderAccountId(type, null, vendors); const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey); try { - await addAccount({ + await createAccount({ id, vendorId: type, label: name, @@ -155,7 +155,7 @@ export function ProvidersSettings() { const handleDeleteProvider = async (providerId: string) => { try { - await deleteAccount(providerId); + await removeAccount(providerId); toast.success(t('aiProviders.toast.deleted')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`); @@ -177,7 +177,7 @@ export function ProvidersSettings() { )} @@ -236,7 +236,7 @@ export function ProvidersSettings() { ); setEditingProvider(null); }} - onValidateKey={(key, options) => validateApiKey(item.account.id, key, options)} + onValidateKey={(key, options) => validateAccountApiKey(item.account.id, key, options)} devModeUnlocked={devModeUnlocked} /> ))} @@ -250,7 +250,7 @@ export function ProvidersSettings() { vendors={vendors} onClose={() => setShowAddDialog(false)} onAdd={handleAddProvider} - onValidateKey={(type, key, options) => validateApiKey(type, key, options)} + onValidateKey={(type, key, options) => validateAccountApiKey(type, key, options)} devModeUnlocked={devModeUnlocked} /> )} @@ -261,11 +261,11 @@ export function ProvidersSettings() { function ProviderAccountsOverview({ accounts, vendors, - defaultProviderId, + defaultAccountId, }: { accounts: ProviderAccount[]; vendors: ProviderVendorInfo[]; - defaultProviderId: string | null; + defaultAccountId: string | null; }) { const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); @@ -291,7 +291,7 @@ function ProviderAccountsOverview({ {account.label} {vendor?.name || account.vendorId} {getAuthModeLabel(account.authMode)} - {account.id === defaultProviderId || account.isDefault ? ( + {account.id === defaultAccountId || account.isDefault ? ( Default ) : null} @@ -739,6 +739,12 @@ function AddProviderDialog({ const isOAuth = typeInfo?.isOAuth ?? false; const supportsApiKey = typeInfo?.supportsApiKey ?? false; const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); + const selectedVendor = selectedType ? vendorMap.get(selectedType) : undefined; + const preferredOAuthMode = selectedVendor?.supportedAuthModes.includes('oauth_browser') + ? 'oauth_browser' + : (selectedVendor?.supportedAuthModes.includes('oauth_device') + ? 'oauth_device' + : (selectedType === 'google' ? 'oauth_browser' : null)); // Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth'); @@ -771,7 +777,7 @@ function AddProviderDialog({ // So we just fetch the latest list from the backend to update the UI. try { const store = useProviderStore.getState(); - await store.fetchProviders(); + await store.refreshProviderSnapshot(); // Auto-set as default if no default is currently configured if (!store.defaultAccountId && accountId) { @@ -902,7 +908,7 @@ function AddProviderDialog({ { baseUrl: baseUrl.trim() || undefined, model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked), - authMode: useOAuthFlow ? 'oauth_device' : selectedType === 'ollama' + authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama' ? 'local' : (isOAuth && supportsApiKey && authMode === 'apikey') ? 'api_key' diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index db45292..ed865a0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -85,6 +85,7 @@ "cancel": "Cancel", "codeCopied": "Code copied to clipboard", "authFailed": "Authentication Failed", + "browserFlowUnavailable": "Browser OAuth is not wired for this provider yet.", "tryAgain": "Try Again", "approveLogin": "Approve Login", "step1": "Copy the authorization code below.", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 5e6f25b..d8a7bfa 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -84,6 +84,7 @@ "cancel": "キャンセル", "codeCopied": "コードをクリップボードにコピーしました", "authFailed": "認証に失敗しました", + "browserFlowUnavailable": "このプロバイダーのブラウザ OAuth はまだ接続されていません。", "tryAgain": "再試行", "approveLogin": "ログインを承認", "step1": "以下の認証コードをコピーしてください。", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 7e96c4e..b2f6655 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -85,6 +85,7 @@ "cancel": "取消", "codeCopied": "代码已复制到剪贴板", "authFailed": "认证失败", + "browserFlowUnavailable": "该提供商的浏览器 OAuth 登录链路暂未接通。", "tryAgain": "重试", "approveLogin": "确认登录", "step1": "复制下方的授权码。", diff --git a/src/lib/providers.ts b/src/lib/providers.ts index d2a4f53..7d4bf5d 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -122,7 +122,18 @@ import { providerIcons } from '@/assets/providers'; export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ { id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true }, { id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true }, - { id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true }, + { + id: 'google', + name: 'Google', + icon: '🔷', + placeholder: 'AIza...', + model: 'Gemini', + requiresApiKey: true, + isOAuth: true, + supportsApiKey: true, + defaultModelId: 'gemini-3.1-pro-preview', + apiKeyUrl: 'https://aistudio.google.com/app/apikey', + }, { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' }, { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' }, diff --git a/src/stores/providers.ts b/src/stores/providers.ts index c21ce13..7d495a6 100644 --- a/src/stores/providers.ts +++ b/src/stores/providers.ts @@ -11,10 +11,7 @@ import type { } from '@/lib/providers'; import { hostApiFetch } from '@/lib/host-api'; import { - buildProviderListItems, fetchProviderSnapshot, - type ProviderListItem, - type ProviderSnapshot, } from '@/lib/provider-accounts'; // Re-export types for consumers that imported from here @@ -24,18 +21,28 @@ export type { ProviderVendorInfo, ProviderWithKeyInfo, } from '@/lib/providers'; -export type { ProviderListItem, ProviderSnapshot } from '@/lib/provider-accounts'; +export type { ProviderSnapshot } from '@/lib/provider-accounts'; interface ProviderState { - providers: ProviderWithKeyInfo[]; + statuses: ProviderWithKeyInfo[]; accounts: ProviderAccount[]; vendors: ProviderVendorInfo[]; - defaultProviderId: string | null; defaultAccountId: string | null; loading: boolean; error: string | null; // Actions + refreshProviderSnapshot: () => Promise; + createAccount: (account: ProviderAccount, apiKey?: string) => Promise; + removeAccount: (accountId: string) => Promise; + validateAccountApiKey: ( + accountId: string, + apiKey: string, + options?: { baseUrl?: string } + ) => Promise<{ valid: boolean; error?: string }>; + getAccountApiKey: (accountId: string) => Promise; + + // Legacy compatibility aliases fetchProviders: () => Promise; addProvider: (config: Omit, apiKey?: string) => Promise; addAccount: (account: ProviderAccount, apiKey?: string) => Promise; @@ -60,48 +67,24 @@ interface ProviderState { getApiKey: (providerId: string) => Promise; } -export function selectProviderSnapshot(state: ProviderState): ProviderSnapshot { - return { - accounts: state.accounts, - statuses: state.providers, - vendors: state.vendors, - defaultAccountId: state.defaultAccountId, - }; -} - -export function selectProviderListItems(state: ProviderState): ProviderListItem[] { - return buildProviderListItems( - state.accounts, - state.providers, - state.vendors, - state.defaultAccountId, - ); -} - -export function selectStatusByAccountId(state: ProviderState): Map { - return new Map(state.providers.map((provider) => [provider.id, provider])); -} - export const useProviderStore = create((set, get) => ({ - providers: [], + statuses: [], accounts: [], vendors: [], - defaultProviderId: null, defaultAccountId: null, loading: false, error: null, - fetchProviders: async () => { + refreshProviderSnapshot: async () => { set({ loading: true, error: null }); try { const snapshot = await fetchProviderSnapshot(); set({ - providers: snapshot.statuses, + statuses: snapshot.statuses, accounts: snapshot.accounts, vendors: snapshot.vendors, - defaultProviderId: snapshot.defaultAccountId, defaultAccountId: snapshot.defaultAccountId, loading: false }); @@ -109,6 +92,8 @@ export const useProviderStore = create((set, get) => ({ set({ error: String(error), loading: false }); } }, + + fetchProviders: async () => get().refreshProviderSnapshot(), addProvider: async (config, apiKey) => { try { @@ -128,14 +113,14 @@ export const useProviderStore = create((set, get) => ({ } // Refresh the list - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to add provider:', error); throw error; } }, - addAccount: async (account, apiKey) => { + createAccount: async (account, apiKey) => { try { const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', { method: 'POST', @@ -146,16 +131,18 @@ export const useProviderStore = create((set, get) => ({ throw new Error(result.error || 'Failed to create provider account'); } - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to add account:', error); throw error; } }, + + addAccount: async (account, apiKey) => get().createAccount(account, apiKey), updateProvider: async (providerId, updates, apiKey) => { try { - const existing = get().providers.find((p) => p.id === providerId); + const existing = get().statuses.find((p) => p.id === providerId); if (!existing) { throw new Error('Provider not found'); } @@ -178,7 +165,7 @@ export const useProviderStore = create((set, get) => ({ } // Refresh the list - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to update provider:', error); throw error; @@ -196,7 +183,7 @@ export const useProviderStore = create((set, get) => ({ throw new Error(result.error || 'Failed to update provider account'); } - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to update account:', error); throw error; @@ -214,14 +201,14 @@ export const useProviderStore = create((set, get) => ({ } // Refresh the list - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to delete provider:', error); throw error; } }, - deleteAccount: async (accountId) => { + removeAccount: async (accountId) => { try { const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE', @@ -231,12 +218,14 @@ export const useProviderStore = create((set, get) => ({ throw new Error(result.error || 'Failed to delete provider account'); } - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to delete account:', error); throw error; } }, + + deleteAccount: async (accountId) => get().removeAccount(accountId), setApiKey: async (providerId, apiKey) => { try { @@ -250,7 +239,7 @@ export const useProviderStore = create((set, get) => ({ } // Refresh the list - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to set API key:', error); throw error; @@ -268,7 +257,7 @@ export const useProviderStore = create((set, get) => ({ throw new Error(result.error || 'Failed to update provider'); } - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to update provider with key:', error); throw error; @@ -287,7 +276,7 @@ export const useProviderStore = create((set, get) => ({ } // Refresh the list - await get().fetchProviders(); + await get().refreshProviderSnapshot(); } catch (error) { console.error('Failed to delete API key:', error); throw error; @@ -305,7 +294,7 @@ export const useProviderStore = create((set, get) => ({ throw new Error(result.error || 'Failed to set default provider'); } - set({ defaultProviderId: providerId, defaultAccountId: providerId }); + set({ defaultAccountId: providerId }); } catch (error) { console.error('Failed to set default provider:', error); throw error; @@ -323,14 +312,14 @@ export const useProviderStore = create((set, get) => ({ throw new Error(result.error || 'Failed to set default provider account'); } - set({ defaultProviderId: accountId, defaultAccountId: accountId }); + set({ defaultAccountId: accountId }); } catch (error) { console.error('Failed to set default account:', error); throw error; } }, - validateApiKey: async (providerId, apiKey, options) => { + validateAccountApiKey: async (providerId, apiKey, options) => { try { const result = await hostApiFetch<{ valid: boolean; error?: string }>('/api/providers/validate', { method: 'POST', @@ -341,8 +330,10 @@ export const useProviderStore = create((set, get) => ({ return { valid: false, error: String(error) }; } }, + + validateApiKey: async (providerId, apiKey, options) => get().validateAccountApiKey(providerId, apiKey, options), - getApiKey: async (providerId) => { + getAccountApiKey: async (providerId) => { try { const result = await hostApiFetch<{ apiKey: string | null }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`); return result.apiKey; @@ -350,4 +341,6 @@ export const useProviderStore = create((set, get) => ({ return null; } }, + + getApiKey: async (providerId) => get().getAccountApiKey(providerId), }));