From 66073db20241449f5afed5bb0ef42f8a4ea443a6 Mon Sep 17 00:00:00 2001 From: Kevin On <40454531+kevin-on@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:15:22 +0900 Subject: [PATCH 1/5] feat: Add Gemini plan provider with OAuth support - Introduced Gemini plan as a new provider type with OAuth integration. - Implemented authentication flow including token exchange and callback server. - Updated settings schema to include Gemini provider and associated OAuth fields. - Enhanced chat model settings to support Gemini-specific configurations. - Added migration logic to transition existing settings to support the new provider. - Comprehensive tests added for migration and Gemini functionality. --- .../modals/ConnectGeminiPlanModal.tsx | 280 ++++++++++++++++ .../sections/PlanConnectionsSection.tsx | 49 ++- .../sections/models/ChatModelSettings.tsx | 7 +- src/constants.ts | 43 +++ src/core/llm/gemini.ts | 6 +- src/core/llm/gemini.types.ts | 24 ++ src/core/llm/geminiAuth.ts | 282 ++++++++++++++++ src/core/llm/geminiCodeAssistAdapter.ts | 198 +++++++++++ src/core/llm/geminiPlanProvider.ts | 156 +++++++++ src/core/llm/geminiProject.ts | 311 ++++++++++++++++++ src/core/llm/manager.ts | 4 + src/settings/schema/migrations/14_to_15.ts | 2 +- .../schema/migrations/15_to_16.test.ts | 71 ++++ src/settings/schema/migrations/15_to_16.ts | 47 +++ src/settings/schema/migrations/index.ts | 8 +- src/types/chat-model.types.ts | 17 + src/types/embedding-model.types.ts | 4 + src/types/provider.types.ts | 14 + 18 files changed, 1513 insertions(+), 10 deletions(-) create mode 100644 src/components/settings/modals/ConnectGeminiPlanModal.tsx create mode 100644 src/core/llm/gemini.types.ts create mode 100644 src/core/llm/geminiAuth.ts create mode 100644 src/core/llm/geminiCodeAssistAdapter.ts create mode 100644 src/core/llm/geminiPlanProvider.ts create mode 100644 src/core/llm/geminiProject.ts create mode 100644 src/settings/schema/migrations/15_to_16.test.ts create mode 100644 src/settings/schema/migrations/15_to_16.ts diff --git a/src/components/settings/modals/ConnectGeminiPlanModal.tsx b/src/components/settings/modals/ConnectGeminiPlanModal.tsx new file mode 100644 index 00000000..eea44aec --- /dev/null +++ b/src/components/settings/modals/ConnectGeminiPlanModal.tsx @@ -0,0 +1,280 @@ +import { App, Notice } from 'obsidian' +import { useEffect, useState } from 'react' + +import { PROVIDER_TYPES_INFO } from '../../../constants' +import { + buildGeminiAuthorizeUrl, + exchangeGeminiCodeForTokens, + generateGeminiPkce, + generateGeminiState, + startGeminiCallbackServer, + stopGeminiCallbackServer, +} from '../../../core/llm/geminiAuth' +import SmartComposerPlugin from '../../../main' +import { ObsidianButton } from '../../common/ObsidianButton' +import { ObsidianSetting } from '../../common/ObsidianSetting' +import { ObsidianTextInput } from '../../common/ObsidianTextInput' +import { ReactModal } from '../../common/ReactModal' + +type ConnectGeminiPlanModalProps = { + plugin: SmartComposerPlugin + onClose: () => void +} + +const GEMINI_PLAN_PROVIDER_ID = PROVIDER_TYPES_INFO['gemini-plan'] + .defaultProviderId as string + +export class ConnectGeminiPlanModal extends ReactModal { + constructor(app: App, plugin: SmartComposerPlugin) { + super({ + app: app, + Component: ConnectGeminiPlanModalComponent, + props: { plugin }, + options: { + title: 'Connect Gemini plan', + }, + }) + } +} + +function ConnectGeminiPlanModalComponent({ + plugin, + onClose, +}: ConnectGeminiPlanModalProps) { + const extractParamFromRedirectUrl = (input: string, key: string) => { + const trimmed = input.trim() + if (!trimmed) return '' + try { + const parsed = new URL(trimmed) + return parsed.searchParams.get(key) ?? '' + } catch { + const match = trimmed.match(new RegExp(`[?&]${key}=([^&]+)`)) + if (match?.[1]) return decodeURIComponent(match[1]) + return '' + } + } + const extractCodeFromRedirectUrl = (input: string) => + extractParamFromRedirectUrl(input, 'code') + const extractStateFromRedirectUrl = (input: string) => + extractParamFromRedirectUrl(input, 'state') + + const [authorizeUrl, setAuthorizeUrl] = useState('') + const [redirectUrl, setRedirectUrl] = useState('') + const [pkceVerifier, setPkceVerifier] = useState('') + const [state, setState] = useState('') + const [isWaitingForCallback, setIsWaitingForCallback] = useState(false) + const [isManualConnecting, setIsManualConnecting] = useState(false) + const [autoError, setAutoError] = useState('') + const [manualError, setManualError] = useState('') + + const redirectCode = extractCodeFromRedirectUrl(redirectUrl) + const redirectState = extractStateFromRedirectUrl(redirectUrl) + const isBusy = isWaitingForCallback || isManualConnecting + + useEffect(() => { + return () => { + void stopGeminiCallbackServer() + } + }, []) + + const applyTokens = async ( + tokens: Awaited>, + ) => { + if ( + !plugin.settings.providers.find( + (p) => p.type === 'gemini-plan' && p.id === GEMINI_PLAN_PROVIDER_ID, + ) + ) { + throw new Error('Gemini Plan provider not found.') + } + await plugin.setSettings({ + ...plugin.settings, + providers: plugin.settings.providers.map((p) => { + if (p.type === 'gemini-plan' && p.id === GEMINI_PLAN_PROVIDER_ID) { + return { + ...p, + oauth: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, + email: tokens.email, + }, + } + } + return p + }), + }) + } + + const ensureAuthContext = async () => { + if (authorizeUrl && pkceVerifier && state) return + const pkce = await generateGeminiPkce() + const newState = generateGeminiState() + const url = buildGeminiAuthorizeUrl({ pkce, state: newState }) + setPkceVerifier(pkce.verifier) + setState(newState) + setAuthorizeUrl(url) + return { pkceVerifier: pkce.verifier, state: newState, authorizeUrl: url } + } + + const openLogin = async () => { + if (isBusy) return + setAutoError('') + setManualError('') + + const ensured = await ensureAuthContext() + const effectiveAuthorizeUrl = ensured?.authorizeUrl ?? authorizeUrl + const effectivePkceVerifier = ensured?.pkceVerifier ?? pkceVerifier + const effectiveState = ensured?.state ?? state + + if (!effectiveAuthorizeUrl || !effectivePkceVerifier || !effectiveState) { + new Notice('Failed to initialize OAuth flow') + return + } + + window.open(effectiveAuthorizeUrl, '_blank') + setIsWaitingForCallback(true) + + try { + const callbackCode = await startGeminiCallbackServer({ + state: effectiveState, + }) + const tokens = await exchangeGeminiCodeForTokens({ + code: callbackCode, + pkceVerifier: effectivePkceVerifier, + }) + await applyTokens(tokens) + new Notice('Gemini Plan connected') + onClose() + } catch { + setAutoError( + 'Automatic connect failed. Paste the full redirect URL below and click "Connect with URL".', + ) + } finally { + setIsWaitingForCallback(false) + } + } + + const connectWithRedirectUrl = async () => { + if (isBusy) return + setAutoError('') + + if (!redirectUrl.trim()) { + setManualError( + 'Paste the full redirect URL from your browser address bar.', + ) + return + } + + if (!redirectCode) { + setManualError( + 'No authorization code found. Paste the full redirect URL from your browser address bar.', + ) + return + } + + if (!redirectState) { + setManualError( + 'No OAuth state found. Paste the full redirect URL from your browser address bar.', + ) + return + } + + setManualError('') + setIsManualConnecting(true) + + try { + const ensured = await ensureAuthContext() + const effectivePkceVerifier = ensured?.pkceVerifier ?? pkceVerifier + const effectiveState = ensured?.state ?? state + if (!effectivePkceVerifier || !effectiveState) { + new Notice('Failed to initialize OAuth flow') + return + } + if (redirectState !== effectiveState) { + setManualError( + 'OAuth state mismatch. Start login again and paste the newest redirect URL.', + ) + return + } + const tokens = await exchangeGeminiCodeForTokens({ + code: redirectCode, + pkceVerifier: effectivePkceVerifier, + }) + await applyTokens(tokens) + new Notice('Gemini Plan connected') + onClose() + } catch { + setManualError( + 'Manual connect failed. Start login again and paste the newest redirect URL.', + ) + } finally { + setIsManualConnecting(false) + } + } + + return ( +
+
+
How it works
+
    +
  1. Login to Google in your browser
  2. +
  3. Smart Composer connects automatically when you return
  4. +
  5. + If automatic connect fails, paste the full redirect URL below and + click "Connect with URL" +
  6. +
+
+ + + void openLogin()} + cta + /> + {isWaitingForCallback && ( +
+ Waiting for authorization... +
+ )} +
+ + +
+ {autoError && ( +
{autoError}
+ )} + { + setRedirectUrl(value) + if (manualError) setManualError('') + }} + /> + void connectWithRedirectUrl()} + /> + {manualError && ( +
{manualError}
+ )} +
+
+ + + + +
+ ) +} diff --git a/src/components/settings/sections/PlanConnectionsSection.tsx b/src/components/settings/sections/PlanConnectionsSection.tsx index b775ed5f..bbe561cb 100644 --- a/src/components/settings/sections/PlanConnectionsSection.tsx +++ b/src/components/settings/sections/PlanConnectionsSection.tsx @@ -7,6 +7,7 @@ import SmartComposerPlugin from '../../../main' import { LLMProvider } from '../../../types/provider.types' import { ConfirmModal } from '../../modals/ConfirmModal' import { ConnectClaudePlanModal } from '../modals/ConnectClaudePlanModal' +import { ConnectGeminiPlanModal } from '../modals/ConnectGeminiPlanModal' import { ConnectOpenAIPlanModal } from '../modals/ConnectOpenAIPlanModal' type PlanConnectionsSectionProps = { @@ -18,6 +19,8 @@ const CLAUDE_PLAN_PROVIDER_ID = PROVIDER_TYPES_INFO['anthropic-plan'] .defaultProviderId as string const OPENAI_PLAN_PROVIDER_ID = PROVIDER_TYPES_INFO['openai-plan'] .defaultProviderId as string +const GEMINI_PLAN_PROVIDER_ID = PROVIDER_TYPES_INFO['gemini-plan'] + .defaultProviderId as string export function PlanConnectionsSection({ app, @@ -33,22 +36,33 @@ export function PlanConnectionsSection({ (p): p is Extract => p.id === OPENAI_PLAN_PROVIDER_ID && p.type === 'openai-plan', ) + const geminiPlanProvider = settings.providers.find( + (p): p is Extract => + p.id === GEMINI_PLAN_PROVIDER_ID && p.type === 'gemini-plan', + ) const isClaudeConnected = !!claudePlanProvider?.oauth?.accessToken const isOpenAIConnected = !!openAIPlanProvider?.oauth?.accessToken + const isGeminiConnected = !!geminiPlanProvider?.oauth?.accessToken - const disconnect = (providerType: 'anthropic-plan' | 'openai-plan') => { + const disconnect = ( + providerType: 'anthropic-plan' | 'openai-plan' | 'gemini-plan', + ) => { const providerId = providerType === 'anthropic-plan' ? CLAUDE_PLAN_PROVIDER_ID - : OPENAI_PLAN_PROVIDER_ID + : providerType === 'openai-plan' + ? OPENAI_PLAN_PROVIDER_ID + : GEMINI_PLAN_PROVIDER_ID new ConfirmModal(app, { title: 'Disconnect subscription', message: providerType === 'anthropic-plan' ? 'Disconnect Claude from Smart Composer?' - : 'Disconnect OpenAI from Smart Composer?', + : providerType === 'openai-plan' + ? 'Disconnect OpenAI from Smart Composer?' + : 'Disconnect Gemini from Smart Composer?', ctaText: 'Disconnect', onConfirm: async () => { await setSettings({ @@ -143,6 +157,35 @@ export function PlanConnectionsSection({ )} + +
+
+
Gemini
+ +
+ +
+ Uses your Gemini plan usage via Google account OAuth. +
+ Manage usage in the Google Cloud Console. +
+ +
+ {!isGeminiConnected && ( + + )} + {isGeminiConnected && ( + + )} +
+
) diff --git a/src/components/settings/sections/models/ChatModelSettings.tsx b/src/components/settings/sections/models/ChatModelSettings.tsx index 9b81b5fd..e8229f29 100644 --- a/src/components/settings/sections/models/ChatModelSettings.tsx +++ b/src/components/settings/sections/models/ChatModelSettings.tsx @@ -326,10 +326,13 @@ const MODEL_SETTINGS_REGISTRY: ModelSettingsRegistry[] = [ * @see https://ai.google.dev/gemini-api/docs/thinking */ { - check: (model) => model.providerType === 'gemini', + check: (model) => + model.providerType === 'gemini' || model.providerType === 'gemini-plan', SettingsComponent: (props: SettingsComponentProps) => { const { model, plugin, onClose } = props - const typedModel = model as ChatModel & { providerType: 'gemini' } + const typedModel = model as ChatModel & { + providerType: 'gemini' | 'gemini-plan' + } const [thinkingEnabled, setThinkingEnabled] = useState( typedModel.thinking?.enabled ?? false, ) diff --git a/src/constants.ts b/src/constants.ts index ce8e14e1..c40d4b10 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -33,6 +33,24 @@ export const CLAUDE_CODE_SYSTEM_MESSAGE = "You are Claude Code, Anthropic's official CLI for Claude." export const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.2 (external, cli)' +// Keep in sync with opencode-gemini-auth constants. +export const GEMINI_OAUTH_CLIENT_ID = + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com' +export const GEMINI_OAUTH_CLIENT_SECRET = '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' +export const GEMINI_OAUTH_REDIRECT_URI = 'http://localhost:8085/oauth2callback' +export const GEMINI_OAUTH_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +] as const +export const GEMINI_CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' +export const GEMINI_CODE_ASSIST_HEADERS = { + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'gl-node/22.17.0', + 'Client-Metadata': + 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI', +} as const + // Default model ids export const DEFAULT_CHAT_MODEL_ID = 'claude-sonnet-4.5' // gpt-4.1-mini is preferred over gpt-5-mini because gpt-5 models do not support @@ -49,6 +67,7 @@ export const RECOMMENDED_MODELS_FOR_EMBEDDING = [ export const PLAN_PROVIDER_TYPES: readonly LLMProviderType[] = [ 'anthropic-plan', 'openai-plan', + 'gemini-plan', ] as const export const PROVIDER_TYPES_INFO = { 'anthropic-plan': { @@ -67,6 +86,14 @@ export const PROVIDER_TYPES_INFO = { supportEmbedding: false, additionalSettings: [], }, + 'gemini-plan': { + label: 'Gemini Plan', + defaultProviderId: 'gemini-plan', + requireApiKey: false, + requireBaseUrl: false, + supportEmbedding: false, + additionalSettings: [], + }, anthropic: { label: 'Anthropic', defaultProviderId: 'anthropic', @@ -220,6 +247,10 @@ export const DEFAULT_PROVIDERS: readonly LLMProvider[] = [ type: 'openai-plan', id: PROVIDER_TYPES_INFO['openai-plan'].defaultProviderId, }, + { + type: 'gemini-plan', + id: PROVIDER_TYPES_INFO['gemini-plan'].defaultProviderId, + }, { type: 'anthropic', id: PROVIDER_TYPES_INFO.anthropic.defaultProviderId, @@ -294,6 +325,18 @@ export const DEFAULT_CHAT_MODELS: readonly ChatModel[] = [ id: 'gpt-5.2 (plan)', model: 'gpt-5.2', }, + { + providerType: 'gemini-plan', + providerId: PROVIDER_TYPES_INFO['gemini-plan'].defaultProviderId, + id: 'gemini-3-pro-preview (plan)', + model: 'gemini-3-pro-preview', + }, + { + providerType: 'gemini-plan', + providerId: PROVIDER_TYPES_INFO['gemini-plan'].defaultProviderId, + id: 'gemini-3-flash-preview (plan)', + model: 'gemini-3-flash-preview', + }, { providerType: 'anthropic', providerId: PROVIDER_TYPES_INFO.anthropic.defaultProviderId, diff --git a/src/core/llm/gemini.ts b/src/core/llm/gemini.ts index 570708e3..26c54335 100644 --- a/src/core/llm/gemini.ts +++ b/src/core/llm/gemini.ts @@ -465,7 +465,7 @@ export class GeminiProvider extends BaseLLMProvider< ) } - private static parseRequestTool(tool: RequestTool): Tool { + static parseRequestTool(tool: RequestTool): Tool { // Gemini does not support additionalProperties field in JSON schema, so we need to clean it const cleanedParameters = this.removeAdditionalProperties( tool.function.parameters, @@ -514,8 +514,8 @@ export class GeminiProvider extends BaseLLMProvider< * - Gemini 3 models use thinkingLevel * - Gemini 2.5 models use thinkingBudget */ - private static buildThinkingConfig( - model: ChatModel & { providerType: 'gemini' }, + static buildThinkingConfig( + model: ChatModel & { providerType: 'gemini' | 'gemini-plan' }, ): ThinkingConfig | undefined { if (!model.thinking?.enabled) { return undefined diff --git a/src/core/llm/gemini.types.ts b/src/core/llm/gemini.types.ts new file mode 100644 index 00000000..df02b4a1 --- /dev/null +++ b/src/core/llm/gemini.types.ts @@ -0,0 +1,24 @@ +import type { + Content, + GenerateContentConfig, + GenerateContentResponse, + ToolListUnion, +} from '@google/genai' + +export type CodeAssistRequestPayload = { + contents: Content[] + systemInstruction?: Content + tools?: ToolListUnion + generationConfig?: GenerateContentConfig +} + +export type CodeAssistGenerateContentRequest = { + project: string + model: string + request: CodeAssistRequestPayload +} + +export type CodeAssistGenerateContentResponse = { + response: GenerateContentResponse + traceId?: string +} diff --git a/src/core/llm/geminiAuth.ts b/src/core/llm/geminiAuth.ts new file mode 100644 index 00000000..fab6f114 --- /dev/null +++ b/src/core/llm/geminiAuth.ts @@ -0,0 +1,282 @@ +import type { Server } from 'http' + +import { Platform } from 'obsidian' + +import { + GEMINI_OAUTH_CLIENT_ID, + GEMINI_OAUTH_CLIENT_SECRET, + GEMINI_OAUTH_REDIRECT_URI, + GEMINI_OAUTH_SCOPES, +} from '../../constants' +import { postFormUrlEncoded } from '../../utils/llm/httpTransport' + +type GeminiPkceCodes = { + verifier: string + challenge: string +} + +type GeminiTokenResponse = { + access_token: string + refresh_token: string + expires_in?: number +} + +type GeminiUserInfo = { + email?: string +} + +type GeminiCallbackConfig = { + hostname: string + port: number + path: string + origin: string +} + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 + +let geminiCallbackServer: Server | undefined +let isGeminiCallbackStopping = false + +export function buildGeminiAuthorizeUrl(params: { + pkce: GeminiPkceCodes + state: string + redirectUri?: string +}): string { + const redirectUri = params.redirectUri ?? GEMINI_OAUTH_REDIRECT_URI + const query = new URLSearchParams({ + response_type: 'code', + client_id: GEMINI_OAUTH_CLIENT_ID, + redirect_uri: redirectUri, + scope: GEMINI_OAUTH_SCOPES.join(' '), + code_challenge: params.pkce.challenge, + code_challenge_method: 'S256', + access_type: 'offline', + prompt: 'consent', + state: params.state, + }) + const url = new URL('https://accounts.google.com/o/oauth2/v2/auth') + url.search = query.toString() + url.hash = 'smart-composer' + return url.toString() +} + +export function generateGeminiState(): string { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +export async function generateGeminiPkce(): Promise { + const verifier = generateRandomString(43) + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const hash = await crypto.subtle.digest('SHA-256', data) + const challenge = base64UrlEncode(hash) + return { verifier, challenge } +} + +export async function exchangeGeminiCodeForTokens(params: { + code: string + redirectUri?: string + pkceVerifier: string +}): Promise { + const tokens = await postFormUrlEncoded( + 'https://oauth2.googleapis.com/token', + { + grant_type: 'authorization_code', + code: params.code, + redirect_uri: params.redirectUri ?? GEMINI_OAUTH_REDIRECT_URI, + client_id: GEMINI_OAUTH_CLIENT_ID, + client_secret: GEMINI_OAUTH_CLIENT_SECRET, + code_verifier: params.pkceVerifier, + }, + ) + + const email = await fetchGeminiUserEmail(tokens.access_token) + return { + ...tokens, + email: email ?? undefined, + } +} + +export async function refreshGeminiAccessToken( + refreshToken: string, +): Promise { + return postFormUrlEncoded( + 'https://oauth2.googleapis.com/token', + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: GEMINI_OAUTH_CLIENT_ID, + client_secret: GEMINI_OAUTH_CLIENT_SECRET, + }, + ) +} + +export async function startGeminiCallbackServer(params: { + state: string + redirectUri?: string + timeoutMs?: number +}): Promise { + if (!Platform.isDesktop) { + throw new Error('Gemini OAuth callback server is not supported on mobile') + } + + const { state, redirectUri, timeoutMs } = params + const { hostname, port, path, origin } = parseGeminiRedirectUri(redirectUri) + + await stopGeminiCallbackServer() + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const http = require('http') as typeof import('http') + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + finalize( + new Error('OAuth callback timeout - authorization took too long'), + ) + }, timeoutMs ?? CALLBACK_TIMEOUT_MS) + + const server = http.createServer((req, res) => { + const requestUrl = new URL(req.url ?? '/', origin) + + if (requestUrl.pathname !== path) { + res.statusCode = 404 + res.end('Not found') + return + } + + const code = requestUrl.searchParams.get('code') + const incomingState = requestUrl.searchParams.get('state') + const error = requestUrl.searchParams.get('error') + const errorDescription = requestUrl.searchParams.get('error_description') + + if (!incomingState) { + res.statusCode = 400 + res.end('Missing state parameter') + finalize(new Error('Missing state parameter')) + return + } + + if (incomingState !== state) { + res.statusCode = 400 + res.end('Invalid state parameter') + finalize(new Error('Invalid state parameter')) + return + } + + if (error) { + const errorMsg = errorDescription ?? error + res.statusCode = 400 + res.end(`OAuth error: ${errorMsg}`) + finalize(new Error(errorMsg)) + return + } + + if (!code) { + res.statusCode = 400 + res.end('Missing authorization code') + finalize(new Error('Missing authorization code')) + return + } + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.end( + 'Authorization Successful

You can close this window.

', + ) + finalize(undefined, code) + }) + + const finalize = (error?: Error, code?: string) => { + if (isGeminiCallbackStopping) return + isGeminiCallbackStopping = true + clearTimeout(timeout) + server.close(() => { + geminiCallbackServer = undefined + isGeminiCallbackStopping = false + if (error) { + reject(error) + return + } + if (code) { + resolve(code) + return + } + reject(new Error('OAuth callback failed')) + }) + } + + server.on('error', (error) => { + finalize( + error instanceof Error + ? error + : new Error('OAuth callback server error'), + ) + }) + + server.listen(port, hostname, () => { + geminiCallbackServer = server + }) + }) +} + +export async function stopGeminiCallbackServer(): Promise { + if (!geminiCallbackServer) return + await new Promise((resolve) => { + geminiCallbackServer?.close(() => resolve()) + }) + geminiCallbackServer = undefined +} + +async function fetchGeminiUserEmail( + accessToken: string, +): Promise { + try { + const response = await fetch( + 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + if (!response.ok) { + return null + } + const payload = (await response.json()) as GeminiUserInfo + return payload.email ?? null + } catch { + return null + } +} + +function parseGeminiRedirectUri(redirectUri?: string): GeminiCallbackConfig { + const url = new URL(redirectUri ?? GEMINI_OAUTH_REDIRECT_URI) + if (!url.port) { + throw new Error('Gemini redirect URI must include an explicit port') + } + const port = Number.parseInt(url.port, 10) + return { + hostname: url.hostname, + port, + path: url.pathname ?? '/', + origin: `${url.protocol}//${url.host}`, + } +} + +function generateRandomString(length: number): string { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' + const bytes = crypto.getRandomValues(new Uint8Array(length)) + return Array.from(bytes) + .map((b) => chars[b % chars.length]) + .join('') +} + +function base64UrlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/src/core/llm/geminiCodeAssistAdapter.ts b/src/core/llm/geminiCodeAssistAdapter.ts new file mode 100644 index 00000000..f8bd9b27 --- /dev/null +++ b/src/core/llm/geminiCodeAssistAdapter.ts @@ -0,0 +1,198 @@ +import { GenerateContentResponse, Part } from '@google/genai' + +import { + GEMINI_CODE_ASSIST_ENDPOINT, + GEMINI_CODE_ASSIST_HEADERS, +} from '../../constants' +import { ChatModel } from '../../types/chat-model.types' +import { + LLMRequest, + LLMRequestNonStreaming, + LLMRequestStreaming, + RequestMessage, +} from '../../types/llm/request' +import { + LLMResponseNonStreaming, + LLMResponseStreaming, +} from '../../types/llm/response' +import { + StreamSource, + postJson, + postStream, +} from '../../utils/llm/httpTransport' +import { parseJsonSseStream } from '../../utils/llm/sse' + +import { GeminiProvider } from './gemini' +import type { + CodeAssistGenerateContentRequest, + CodeAssistGenerateContentResponse, + CodeAssistRequestPayload, +} from './gemini.types' + +type GeminiAdapterConfig = { + endpoint?: string + fetchFn?: typeof fetch +} + +export class GeminiCodeAssistAdapter { + private endpoint: string + private fetchFn?: typeof fetch + + constructor(config: GeminiAdapterConfig = {}) { + this.endpoint = config.endpoint ?? GEMINI_CODE_ASSIST_ENDPOINT + this.fetchFn = config.fetchFn + } + + async generateResponse( + model: ChatModel & { providerType: 'gemini-plan' }, + request: LLMRequestNonStreaming, + headers: Record, + projectId: string, + options?: { signal?: AbortSignal }, + ): Promise { + const requestPayload = this.buildRequestBody(model, request, projectId) + const url = `${this.endpoint}/v1internal:generateContent` + const codeAssistResponse = + await postJson(url, requestPayload, { + headers: { + ...headers, + ...GEMINI_CODE_ASSIST_HEADERS, + }, + signal: options?.signal, + fetchFn: this.fetchFn, + }) + const response = codeAssistResponse.response + // Attach SDK prototype so getter helpers like response.text work on plain JSON. + Object.setPrototypeOf(response, GenerateContentResponse.prototype) + const messageId = crypto.randomUUID() + return GeminiProvider.parseNonStreamingResponse( + response, + request.model, + messageId, + ) + } + + async streamResponse( + model: ChatModel & { providerType: 'gemini-plan' }, + request: LLMRequestStreaming, + headers: Record, + projectId: string, + options?: { signal?: AbortSignal }, + ): Promise> { + const payload = this.buildRequestBody(model, request, projectId) + const url = `${this.endpoint}/v1internal:streamGenerateContent?alt=sse` + const stream = await postStream(url, payload, { + headers: { + ...headers, + ...GEMINI_CODE_ASSIST_HEADERS, + Accept: 'text/event-stream', + }, + signal: options?.signal, + fetchFn: this.fetchFn, + }) + const messageId = crypto.randomUUID() + return this.streamResponseGenerator(stream, request.model, messageId) + } + + private async *streamResponseGenerator( + body: StreamSource, + model: string, + messageId: string, + ): AsyncIterable { + for await (const chunk of parseJsonSseStream( + body, + )) { + const response = chunk.response + // Attach SDK prototype so getter helpers like response.text work on plain JSON. + Object.setPrototypeOf(response, GenerateContentResponse.prototype) + yield GeminiProvider.parseStreamingResponseChunk( + response, + model, + messageId, + ) + } + } + + private buildRequestBody( + model: ChatModel & { providerType: 'gemini-plan' }, + request: LLMRequest, + projectId: string, + ): CodeAssistGenerateContentRequest { + const systemMessages = request.messages.filter((m) => m.role === 'system') + const systemInstruction = + systemMessages.length > 0 + ? systemMessages.map((m) => m.content).join('\n') + : undefined + + const contentEntries: { + content: NonNullable< + ReturnType + > + message: RequestMessage + }[] = [] + for (const message of request.messages) { + const content = GeminiProvider.parseRequestMessage(message) + if (content) { + contentEntries.push({ content, message }) + } + } + + const contents = contentEntries.map((entry) => entry.content) + addThoughtSignatures(contentEntries) + + const generationConfig = { + maxOutputTokens: request.max_tokens, + temperature: request.temperature, + topP: request.top_p, + presencePenalty: request.presence_penalty, + frequencyPenalty: request.frequency_penalty, + thinkingConfig: GeminiProvider.buildThinkingConfig(model), + } + + const requestPayload: CodeAssistRequestPayload = { + contents, + } + + if (systemInstruction) { + requestPayload.systemInstruction = { + role: 'system', + parts: [{ text: systemInstruction }], + } + } + if (request.tools?.length) { + requestPayload.tools = request.tools.map((tool) => + GeminiProvider.parseRequestTool(tool), + ) + } + if (Object.values(generationConfig).some((value) => value !== undefined)) { + requestPayload.generationConfig = generationConfig + } + + return { + project: projectId, + model: request.model, + request: requestPayload, + } + } +} + +function addThoughtSignatures( + entries: { + content: { parts?: Part[] } + message: RequestMessage + }[], +) { + entries.forEach((entry) => { + if (entry.message.role !== 'assistant') { + return + } + if (!entry.message.tool_calls?.length) { + return + } + entry.content.parts?.forEach((part) => { + if (part.functionCall && !part.thoughtSignature) { + part.thoughtSignature = 'skip_thought_signature_validator' + } + }) + }) +} diff --git a/src/core/llm/geminiPlanProvider.ts b/src/core/llm/geminiPlanProvider.ts new file mode 100644 index 00000000..c92cf91c --- /dev/null +++ b/src/core/llm/geminiPlanProvider.ts @@ -0,0 +1,156 @@ +import { ChatModel } from '../../types/chat-model.types' +import { + LLMOptions, + LLMRequestNonStreaming, + LLMRequestStreaming, +} from '../../types/llm/request' +import { + LLMResponseNonStreaming, + LLMResponseStreaming, +} from '../../types/llm/response' +import { LLMProvider } from '../../types/provider.types' + +import { BaseLLMProvider } from './base' +import { + LLMAPIKeyInvalidException, + LLMAPIKeyNotSetException, +} from './exception' +import { refreshGeminiAccessToken } from './geminiAuth' +import { GeminiCodeAssistAdapter } from './geminiCodeAssistAdapter' +import { + ensureProjectContext, + invalidateProjectContextCache, +} from './geminiProject' + +export class GeminiPlanProvider extends BaseLLMProvider< + Extract +> { + private adapter: GeminiCodeAssistAdapter + private onProviderUpdate?: ( + providerId: string, + update: Partial, + ) => void | Promise + + constructor( + provider: Extract, + onProviderUpdate?: ( + providerId: string, + update: Partial, + ) => void | Promise, + ) { + super(provider) + this.adapter = new GeminiCodeAssistAdapter() + this.onProviderUpdate = onProviderUpdate + } + + async generateResponse( + model: ChatModel, + request: LLMRequestNonStreaming, + options?: LLMOptions, + ): Promise { + if (model.providerType !== 'gemini-plan') { + throw new Error('Model is not a Gemini Plan model') + } + const { headers, projectId } = await this.getAuthContext() + return this.adapter.generateResponse( + model, + request, + headers, + projectId, + options, + ) + } + + async streamResponse( + model: ChatModel, + request: LLMRequestStreaming, + options?: LLMOptions, + ): Promise> { + if (model.providerType !== 'gemini-plan') { + throw new Error('Model is not a Gemini Plan model') + } + const { headers, projectId } = await this.getAuthContext() + return this.adapter.streamResponse( + model, + request, + headers, + projectId, + options, + ) + } + + async getEmbedding(_model: string, _text: string): Promise { + throw new Error( + `Provider ${this.provider.id} does not support embeddings. Please use a different provider.`, + ) + } + + private async getAuthContext(): Promise<{ + headers: Record + projectId: string + }> { + if (!this.provider.oauth?.refreshToken) { + throw new LLMAPIKeyNotSetException( + `Provider ${this.provider.id} OAuth credentials are missing. Please log in.`, + ) + } + + if ( + !this.provider.oauth.accessToken || + this.provider.oauth.expiresAt <= Date.now() + ) { + try { + const tokens = await refreshGeminiAccessToken( + this.provider.oauth.refreshToken, + ) + const updatedOauth = { + ...this.provider.oauth, + accessToken: tokens.access_token, + refreshToken: + tokens.refresh_token ?? this.provider.oauth.refreshToken, + expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + this.provider.oauth = updatedOauth + invalidateProjectContextCache(updatedOauth.refreshToken) + await this.onProviderUpdate?.(this.provider.id, { + oauth: updatedOauth, + }) + } catch (error) { + throw new LLMAPIKeyInvalidException( + 'Gemini OAuth token refresh failed. Please log in again.', + error instanceof Error ? error : undefined, + ) + } + } + + const authState = this.provider.oauth + const projectContext = await ensureProjectContext({ + auth: { + refreshToken: authState.refreshToken, + accessToken: authState.accessToken, + projectId: authState.projectId, + managedProjectId: authState.managedProjectId, + }, + onUpdate: async (nextAuth) => { + const updatedOauth = { + ...authState, + projectId: nextAuth.projectId, + managedProjectId: nextAuth.managedProjectId, + } + this.provider.oauth = updatedOauth + await this.onProviderUpdate?.(this.provider.id, { + oauth: updatedOauth, + }) + }, + }) + + const headers: Record = { + authorization: `Bearer ${authState.accessToken}`, + } + + return { + headers, + projectId: projectContext.effectiveProjectId, + } + } +} diff --git a/src/core/llm/geminiProject.ts b/src/core/llm/geminiProject.ts new file mode 100644 index 00000000..73855196 --- /dev/null +++ b/src/core/llm/geminiProject.ts @@ -0,0 +1,311 @@ +import { requestUrl } from 'obsidian' + +import { + GEMINI_CODE_ASSIST_ENDPOINT, + GEMINI_CODE_ASSIST_HEADERS, +} from '../../constants' + +type GeminiOAuthState = { + refreshToken: string + accessToken?: string + projectId?: string + managedProjectId?: string +} + +type ProjectContextResult = { + auth: GeminiOAuthState + effectiveProjectId: string +} + +const projectContextResultCache = new Map() +const projectContextPendingCache = new Map< + string, + Promise +>() + +const CODE_ASSIST_METADATA = { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', +} as const + +type GeminiUserTier = { + id?: string + isDefault?: boolean + userDefinedCloudaicompanionProject?: boolean +} + +type LoadCodeAssistPayload = { + cloudaicompanionProject?: string + currentTier?: { + id?: string + } + allowedTiers?: GeminiUserTier[] +} + +type OnboardUserPayload = { + done?: boolean + response?: { + cloudaicompanionProject?: { + id?: string + } + } +} + +class ProjectIdRequiredError extends Error { + constructor() { + super( + 'Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control.', + ) + } +} + +function buildMetadata(projectId?: string): Record { + const metadata: Record = { + ideType: CODE_ASSIST_METADATA.ideType, + platform: CODE_ASSIST_METADATA.platform, + pluginType: CODE_ASSIST_METADATA.pluginType, + } + if (projectId) { + metadata.duetProject = projectId + } + return metadata +} + +function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined { + if (!allowedTiers || allowedTiers.length === 0) { + return undefined + } + for (const tier of allowedTiers) { + if (tier?.isDefault) { + return tier.id + } + } + return allowedTiers[0]?.id +} + +function isFreeTier(tierId?: string): boolean { + if (!tierId) { + return false + } + const normalized = tierId.trim().toUpperCase() + return normalized === 'FREE' || normalized === 'FREE-TIER' +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function getCacheKey(auth: GeminiOAuthState): string | undefined { + const refresh = auth.refreshToken?.trim() + return refresh ? refresh : undefined +} + +export function invalidateProjectContextCache(refreshToken?: string): void { + if (!refreshToken) { + projectContextPendingCache.clear() + projectContextResultCache.clear() + return + } + projectContextPendingCache.delete(refreshToken) + projectContextResultCache.delete(refreshToken) +} + +export async function loadManagedProject( + accessToken: string, + projectId?: string, +): Promise { + try { + const metadata = buildMetadata(projectId) + const requestBody: Record = { metadata } + if (projectId) { + requestBody.cloudaicompanionProject = projectId + } + + const response = await requestUrl({ + url: `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + ...GEMINI_CODE_ASSIST_HEADERS, + }, + body: JSON.stringify(requestBody), + }) + + if (response.status < 200 || response.status >= 300) { + return null + } + + return response.json as LoadCodeAssistPayload + } catch (error) { + console.error('Failed to load Gemini managed project:', error) + return null + } +} + +export async function onboardManagedProject( + accessToken: string, + tierId: string, + projectId?: string, + attempts = 10, + delayMs = 5000, +): Promise { + const metadata = buildMetadata(projectId) + const requestBody: Record = { + tierId, + metadata, + } + + if (!isFreeTier(tierId)) { + if (!projectId) { + throw new ProjectIdRequiredError() + } + requestBody.cloudaicompanionProject = projectId + } + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await requestUrl({ + url: `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + ...GEMINI_CODE_ASSIST_HEADERS, + }, + body: JSON.stringify(requestBody), + }) + + if (response.status < 200 || response.status >= 300) { + return undefined + } + + const payload = response.json as OnboardUserPayload + const managedProjectId = payload.response?.cloudaicompanionProject?.id + if (payload.done && managedProjectId) { + return managedProjectId + } + if (payload.done && projectId) { + return projectId + } + } catch (error) { + console.error('Failed to onboard Gemini managed project:', error) + return undefined + } + + await wait(delayMs) + } + + return undefined +} + +export async function ensureProjectContext(params: { + auth: GeminiOAuthState + onUpdate?: (nextAuth: GeminiOAuthState) => Promise +}): Promise { + const { auth, onUpdate } = params + const accessToken = auth.accessToken + if (!accessToken) { + return { auth, effectiveProjectId: '' } + } + + const cacheKey = getCacheKey(auth) + if (cacheKey) { + const cached = projectContextResultCache.get(cacheKey) + if (cached) { + return cached + } + const pending = projectContextPendingCache.get(cacheKey) + if (pending) { + return pending + } + } + + const resolveContext = async (): Promise => { + const rawProjectId = auth.projectId?.trim() + const rawManagedProjectId = auth.managedProjectId?.trim() + const projectId = rawProjectId === '' ? undefined : rawProjectId + const managedProjectId = + rawManagedProjectId === '' ? undefined : rawManagedProjectId + + if (projectId || managedProjectId) { + return { + auth, + effectiveProjectId: projectId ?? managedProjectId ?? '', + } + } + + const loadPayload = await loadManagedProject(accessToken, projectId) + if (loadPayload?.cloudaicompanionProject) { + const updatedAuth: GeminiOAuthState = { + ...auth, + managedProjectId: loadPayload.cloudaicompanionProject, + } + await onUpdate?.(updatedAuth) + return { + auth: updatedAuth, + effectiveProjectId: loadPayload.cloudaicompanionProject, + } + } + + if (!loadPayload) { + throw new ProjectIdRequiredError() + } + + const currentTierId = loadPayload.currentTier?.id ?? undefined + if (currentTierId && !isFreeTier(currentTierId)) { + throw new ProjectIdRequiredError() + } + + const defaultTierId = getDefaultTierId(loadPayload.allowedTiers) + const tierId = defaultTierId ?? 'FREE' + + if (!isFreeTier(tierId)) { + throw new ProjectIdRequiredError() + } + + const newManagedProjectId = await onboardManagedProject( + accessToken, + tierId, + projectId, + ) + if (newManagedProjectId) { + const updatedAuth: GeminiOAuthState = { + ...auth, + managedProjectId: newManagedProjectId, + } + await onUpdate?.(updatedAuth) + return { + auth: updatedAuth, + effectiveProjectId: newManagedProjectId, + } + } + + throw new ProjectIdRequiredError() + } + + if (!cacheKey) { + return resolveContext() + } + + const promise = resolveContext() + .then((result) => { + const nextKey = getCacheKey(result.auth) ?? cacheKey + projectContextPendingCache.delete(cacheKey) + projectContextResultCache.set(nextKey, result) + if (nextKey !== cacheKey) { + projectContextResultCache.delete(cacheKey) + } + return result + }) + .catch((error) => { + projectContextPendingCache.delete(cacheKey) + throw error + }) + + projectContextPendingCache.set(cacheKey, promise) + return promise +} diff --git a/src/core/llm/manager.ts b/src/core/llm/manager.ts index 3c581aca..0bacc8d1 100644 --- a/src/core/llm/manager.ts +++ b/src/core/llm/manager.ts @@ -9,6 +9,7 @@ import { BaseLLMProvider } from './base' import { DeepSeekStudioProvider } from './deepseekStudioProvider' import { LLMModelNotFoundException } from './exception' import { GeminiProvider } from './gemini' +import { GeminiPlanProvider } from './geminiPlanProvider' import { LmStudioProvider } from './lmStudioProvider' import { MistralProvider } from './mistralProvider' import { OllamaProvider } from './ollama' @@ -60,6 +61,9 @@ export function getProviderClient({ case 'openai-plan': { return new OpenAICodexProvider(provider, onProviderUpdate) } + case 'gemini-plan': { + return new GeminiPlanProvider(provider, onProviderUpdate) + } case 'anthropic': { return new AnthropicProvider(provider) } diff --git a/src/settings/schema/migrations/14_to_15.ts b/src/settings/schema/migrations/14_to_15.ts index bd0ca954..130d581d 100644 --- a/src/settings/schema/migrations/14_to_15.ts +++ b/src/settings/schema/migrations/14_to_15.ts @@ -18,7 +18,7 @@ const DEFAULT_PROVIDERS_V15 = [ { type: 'lm-studio', id: 'lm-studio' }, ] as const -const DEFAULT_CHAT_MODELS_V15 = [ +export const DEFAULT_CHAT_MODELS_V15 = [ { providerType: 'anthropic-plan', providerId: 'anthropic-plan', diff --git a/src/settings/schema/migrations/15_to_16.test.ts b/src/settings/schema/migrations/15_to_16.test.ts new file mode 100644 index 00000000..d6a57c5f --- /dev/null +++ b/src/settings/schema/migrations/15_to_16.test.ts @@ -0,0 +1,71 @@ +import { migrateFrom15To16 } from './15_to_16' + +describe('Migration from v15 to v16', () => { + it('should increment version to 16', () => { + const oldSettings = { + version: 15, + } + const result = migrateFrom15To16(oldSettings) + expect(result.version).toBe(16) + }) + + it('should add gemini plan provider and keep custom providers', () => { + const oldSettings = { + version: 15, + providers: [ + { type: 'anthropic', id: 'anthropic', apiKey: 'anthropic-key' }, + { type: 'custom', id: 'custom-provider', apiKey: 'custom-key' }, + ], + chatModels: [], + } + + const result = migrateFrom15To16(oldSettings) + const providers = result.providers as { type: string; id: string }[] + expect(providers.find((p) => p.type === 'gemini-plan')).toBeDefined() + expect( + providers.find((p) => p.id === 'custom-provider' && p.type === 'custom'), + ).toBeDefined() + }) + + it('should add gemini plan chat models', () => { + const oldSettings = { + version: 15, + providers: [], + chatModels: [ + { + id: 'custom-model', + providerType: 'custom', + providerId: 'custom', + model: 'custom-model', + }, + ], + } + + const result = migrateFrom15To16(oldSettings) + const chatModels = result.chatModels as { + id: string + providerType: string + providerId: string + model: string + }[] + + const proPlan = chatModels.find( + (m) => m.id === 'gemini-3-pro-preview (plan)', + ) + const flashPlan = chatModels.find( + (m) => m.id === 'gemini-3-flash-preview (plan)', + ) + + expect(proPlan).toMatchObject({ + providerType: 'gemini-plan', + providerId: 'gemini-plan', + model: 'gemini-3-pro-preview', + }) + expect(flashPlan).toMatchObject({ + providerType: 'gemini-plan', + providerId: 'gemini-plan', + model: 'gemini-3-flash-preview', + }) + expect(chatModels.find((m) => m.id === 'custom-model')).toBeDefined() + }) +}) diff --git a/src/settings/schema/migrations/15_to_16.ts b/src/settings/schema/migrations/15_to_16.ts new file mode 100644 index 00000000..33cfaea9 --- /dev/null +++ b/src/settings/schema/migrations/15_to_16.ts @@ -0,0 +1,47 @@ +import { SettingMigration } from '../setting.types' + +import { DEFAULT_CHAT_MODELS_V15 } from './14_to_15' +import { getMigratedChatModels, getMigratedProviders } from './migrationUtils' + +const DEFAULT_PROVIDERS_V16 = [ + { type: 'anthropic-plan', id: 'anthropic-plan' }, + { type: 'openai-plan', id: 'openai-plan' }, + { type: 'gemini-plan', id: 'gemini-plan' }, + { type: 'anthropic', id: 'anthropic' }, + { type: 'openai', id: 'openai' }, + { type: 'gemini', id: 'gemini' }, + { type: 'xai', id: 'xai' }, + { type: 'deepseek', id: 'deepseek' }, + { type: 'mistral', id: 'mistral' }, + { type: 'perplexity', id: 'perplexity' }, + { type: 'openrouter', id: 'openrouter' }, + { type: 'ollama', id: 'ollama' }, + { type: 'lm-studio', id: 'lm-studio' }, +] as const + +const DEFAULT_CHAT_MODELS_V16 = [ + ...DEFAULT_CHAT_MODELS_V15.slice(0, 3), + { + providerType: 'gemini-plan', + providerId: 'gemini-plan', + id: 'gemini-3-pro-preview (plan)', + model: 'gemini-3-pro-preview', + }, + { + providerType: 'gemini-plan', + providerId: 'gemini-plan', + id: 'gemini-3-flash-preview (plan)', + model: 'gemini-3-flash-preview', + }, + ...DEFAULT_CHAT_MODELS_V15.slice(3), +] + +export const migrateFrom15To16: SettingMigration['migrate'] = (data) => { + const newData = { ...data } + newData.version = 16 + + newData.providers = getMigratedProviders(newData, DEFAULT_PROVIDERS_V16) + newData.chatModels = getMigratedChatModels(newData, DEFAULT_CHAT_MODELS_V16) + + return newData +} diff --git a/src/settings/schema/migrations/index.ts b/src/settings/schema/migrations/index.ts index ece845e9..54dcc5e5 100644 --- a/src/settings/schema/migrations/index.ts +++ b/src/settings/schema/migrations/index.ts @@ -6,6 +6,7 @@ import { migrateFrom11To12 } from './11_to_12' import { migrateFrom12To13 } from './12_to_13' import { migrateFrom13To14 } from './13_to_14' import { migrateFrom14To15 } from './14_to_15' +import { migrateFrom15To16 } from './15_to_16' import { migrateFrom1To2 } from './1_to_2' import { migrateFrom2To3 } from './2_to_3' import { migrateFrom3To4 } from './3_to_4' @@ -16,7 +17,7 @@ import { migrateFrom7To8 } from './7_to_8' import { migrateFrom8To9 } from './8_to_9' import { migrateFrom9To10 } from './9_to_10' -export const SETTINGS_SCHEMA_VERSION = 15 +export const SETTINGS_SCHEMA_VERSION = 16 export const SETTING_MIGRATIONS: SettingMigration[] = [ { @@ -94,4 +95,9 @@ export const SETTING_MIGRATIONS: SettingMigration[] = [ toVersion: 15, migrate: migrateFrom14To15, }, + { + fromVersion: 15, + toVersion: 16, + migrate: migrateFrom15To16, + }, ] diff --git a/src/types/chat-model.types.ts b/src/types/chat-model.types.ts index a725f843..e2b32486 100644 --- a/src/types/chat-model.types.ts +++ b/src/types/chat-model.types.ts @@ -46,6 +46,23 @@ export const chatModelSchema = z.discriminatedUnion('providerType', [ }) .optional(), }), + z.object({ + providerType: z.literal('gemini-plan'), + ...baseChatModelSchema.shape, + thinking: z + .object({ + enabled: z.boolean(), + // 'level' for Gemini 3 models, 'budget' for Gemini 2.5 models + control_mode: z.enum(['level', 'budget']).optional(), + // For Gemini 3 models + thinking_level: z.enum(['minimal', 'low', 'medium', 'high']).optional(), + // For Gemini 2.5 models: -1 for dynamic, 0 to disable, or specific token count + thinking_budget: z.number().optional(), + // Return thought summaries in response + include_thoughts: z.boolean().optional(), + }) + .optional(), + }), z.object({ providerType: z.literal('anthropic'), ...baseChatModelSchema.shape, diff --git a/src/types/embedding-model.types.ts b/src/types/embedding-model.types.ts index d7ed8991..d93a0ceb 100644 --- a/src/types/embedding-model.types.ts +++ b/src/types/embedding-model.types.ts @@ -29,6 +29,10 @@ export const embeddingModelSchema = z.discriminatedUnion('providerType', [ providerType: z.literal('openai-plan'), ...baseEmbeddingModelSchema.shape, }), + z.object({ + providerType: z.literal('gemini-plan'), + ...baseEmbeddingModelSchema.shape, + }), z.object({ providerType: z.literal('anthropic'), ...baseEmbeddingModelSchema.shape, diff --git a/src/types/provider.types.ts b/src/types/provider.types.ts index 0bc0847d..2f2929c9 100644 --- a/src/types/provider.types.ts +++ b/src/types/provider.types.ts @@ -39,6 +39,20 @@ export const llmProviderSchema = z.discriminatedUnion('type', [ }) .optional(), }), + z.object({ + type: z.literal('gemini-plan'), + ...baseLlmProviderSchema.shape, + oauth: z + .object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number(), + projectId: z.string().optional(), + managedProjectId: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + }), z.object({ type: z.literal('anthropic'), ...baseLlmProviderSchema.shape, From c21bb9846884bea5732c0d77b51e2851c1c8cf48 Mon Sep 17 00:00:00 2001 From: Kevin On <40454531+kevin-on@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:36:08 +0900 Subject: [PATCH 2/5] chore: Update Gemini plan modal and connection descriptions --- src/components/settings/modals/ConnectGeminiPlanModal.tsx | 2 +- src/components/settings/sections/PlanConnectionsSection.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/settings/modals/ConnectGeminiPlanModal.tsx b/src/components/settings/modals/ConnectGeminiPlanModal.tsx index eea44aec..18da23e1 100644 --- a/src/components/settings/modals/ConnectGeminiPlanModal.tsx +++ b/src/components/settings/modals/ConnectGeminiPlanModal.tsx @@ -31,7 +31,7 @@ export class ConnectGeminiPlanModal extends ReactModal Use a subscription instead of API-key billing. Connected subscriptions consume your plan's included usage (Codex for OpenAI, Claude Code - for Anthropic). + for Anthropic, Gemini Code Assist for Gemini).
Important: Subscriptions aren't supported on mobile environments. Models that use these subscription providers are @@ -165,9 +165,9 @@ export function PlanConnectionsSection({
- Uses your Gemini plan usage via Google account OAuth. + Uses your Gemini Code Assist usage from your Google AI Plan.
- Manage usage in the Google Cloud Console. + Check your limit in Gemini CLI with /stats.
From 0cec18507604dbb58a88841f988b1b29e024754d Mon Sep 17 00:00:00 2001 From: Kevin On <40454531+kevin-on@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:06:11 +0900 Subject: [PATCH 3/5] docs: Update README and settings to warn about risks of third-party OAuth access for Claude subscriptions - Added a warning in the README about the risks associated with connecting a Claude subscription, including potential account bans. - Introduced a warning message in the PlanConnectionsSection to inform users about the restrictions on third-party OAuth access and advised caution in usage. - Enhanced styles for warning messages to improve visibility and user awareness. --- README.md | 8 +++++++ .../sections/PlanConnectionsSection.tsx | 23 ++++++++++--------- styles.css | 14 +++++++++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 74d3fc42..b834d85c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ > A list of community-maintained forks is available in the [Community Fork Collection](https://github.com/glowingjade/obsidian-smart-composer/discussions/496). > If you're maintaining a fork, feel free to add it there. And if you're simply interested in exploring alternative versions, you're welcome to check it out as well. +> ### Risks of connecting a Claude subscription +> +> As of January 2026, Anthropic has restricted third-party OAuth access, citing Terms of Service violations. +> +> Smart Composer's subscription connect uses the same OAuth-style flow that tools like OpenCode have used. There are reports of **Claude accounts being banned or restricted** when subscription OAuth is used via third-party clients (example: [https://github.com/anomalyco/opencode/issues/6930](https://github.com/anomalyco/opencode/issues/6930)). For **OpenAI (ChatGPT)** and **Google (Gemini)**, I have not seen comparable ban reports so far, but this is still not the same as official API access, and enforcement can change at any time. +> +> **Use at your own risk.** Keep usage limited to personal, interactive sessions and avoid any automation. + ![SC1_Title.gif](https://github.com/user-attachments/assets/a50a1f80-39ff-4eba-8090-e3d75e7be98c) Everytime we ask ChatGPT, we need to put so much context information for each query. Why spend time putting background infos that are already in your vault? diff --git a/src/components/settings/sections/PlanConnectionsSection.tsx b/src/components/settings/sections/PlanConnectionsSection.tsx index 9264f472..0642e4db 100644 --- a/src/components/settings/sections/PlanConnectionsSection.tsx +++ b/src/components/settings/sections/PlanConnectionsSection.tsx @@ -84,13 +84,14 @@ export function PlanConnectionsSection({
Connect your subscription
+
+ Warning: Anthropic has restricted third-party OAuth access, and there are reports of account bans when using subscription OAuth via third-party clients. See the README for full details and use at your own risk. +
Use a subscription instead of API-key billing. Connected subscriptions consume your plan's included usage (Codex for OpenAI, Claude Code for Anthropic, Gemini Code Assist for Gemini). + Subscriptions aren't supported on mobile environments.
- Important: Subscriptions aren't supported on - mobile environments. Models that use these subscription providers are - not available on mobile.
@@ -194,15 +195,15 @@ export function PlanConnectionsSection({ function PlanConnectionStatusBadge({ connected }: { connected: boolean }) { const statusConfig = connected ? { - icon: , - label: 'Connected', - statusClass: 'smtcmp-mcp-server-status-badge--connected', - } + icon: , + label: 'Connected', + statusClass: 'smtcmp-mcp-server-status-badge--connected', + } : { - icon: , - label: 'Disconnected', - statusClass: 'smtcmp-mcp-server-status-badge--disconnected', - } + icon: , + label: 'Disconnected', + statusClass: 'smtcmp-mcp-server-status-badge--disconnected', + } return (
Date: Tue, 20 Jan 2026 20:14:14 +0900 Subject: [PATCH 4/5] fix: Fix lint --- .../sections/PlanConnectionsSection.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/components/settings/sections/PlanConnectionsSection.tsx b/src/components/settings/sections/PlanConnectionsSection.tsx index 0642e4db..5a948b4a 100644 --- a/src/components/settings/sections/PlanConnectionsSection.tsx +++ b/src/components/settings/sections/PlanConnectionsSection.tsx @@ -85,12 +85,21 @@ export function PlanConnectionsSection({
- Warning: Anthropic has restricted third-party OAuth access, and there are reports of account bans when using subscription OAuth via third-party clients. See the README for full details and use at your own risk. + + Warning: + {' '} + Anthropic has restricted third-party OAuth access, and there are + reports of account bans when using subscription OAuth via third-party + clients. See the{' '} + + README + {' '} + for full details and use at your own risk.
Use a subscription instead of API-key billing. Connected subscriptions consume your plan's included usage (Codex for OpenAI, Claude Code - for Anthropic, Gemini Code Assist for Gemini). - Subscriptions aren't supported on mobile environments. + for Anthropic, Gemini Code Assist for Gemini). Subscriptions aren't + supported on mobile environments.
@@ -195,15 +204,15 @@ export function PlanConnectionsSection({ function PlanConnectionStatusBadge({ connected }: { connected: boolean }) { const statusConfig = connected ? { - icon: , - label: 'Connected', - statusClass: 'smtcmp-mcp-server-status-badge--connected', - } + icon: , + label: 'Connected', + statusClass: 'smtcmp-mcp-server-status-badge--connected', + } : { - icon: , - label: 'Disconnected', - statusClass: 'smtcmp-mcp-server-status-badge--disconnected', - } + icon: , + label: 'Disconnected', + statusClass: 'smtcmp-mcp-server-status-badge--disconnected', + } return (
Date: Tue, 20 Jan 2026 21:18:00 +0900 Subject: [PATCH 5/5] fix: Resolve coderabbitai review --- .../settings/modals/ConnectGeminiPlanModal.tsx | 15 +++++++++++---- src/core/llm/geminiPlanProvider.ts | 16 ++++++++++------ src/core/llm/geminiProject.ts | 4 +++- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/components/settings/modals/ConnectGeminiPlanModal.tsx b/src/components/settings/modals/ConnectGeminiPlanModal.tsx index 18da23e1..309300f8 100644 --- a/src/components/settings/modals/ConnectGeminiPlanModal.tsx +++ b/src/components/settings/modals/ConnectGeminiPlanModal.tsx @@ -184,14 +184,21 @@ function ConnectGeminiPlanModalComponent({ setIsManualConnecting(true) try { - const ensured = await ensureAuthContext() + const hasRedirectState = Boolean(redirectState) + const ensured = hasRedirectState ? undefined : await ensureAuthContext() const effectivePkceVerifier = ensured?.pkceVerifier ?? pkceVerifier - const effectiveState = ensured?.state ?? state - if (!effectivePkceVerifier || !effectiveState) { + const effectiveState = redirectState ?? ensured?.state ?? state + if (!effectivePkceVerifier) { + setManualError( + 'Click "Login to Google" first, then paste the redirect URL.', + ) + return + } + if (!effectiveState) { new Notice('Failed to initialize OAuth flow') return } - if (redirectState !== effectiveState) { + if (redirectState && state && redirectState !== state) { setManualError( 'OAuth state mismatch. Start login again and paste the newest redirect URL.', ) diff --git a/src/core/llm/geminiPlanProvider.ts b/src/core/llm/geminiPlanProvider.ts index c92cf91c..2eddf41a 100644 --- a/src/core/llm/geminiPlanProvider.ts +++ b/src/core/llm/geminiPlanProvider.ts @@ -100,18 +100,22 @@ export class GeminiPlanProvider extends BaseLLMProvider< this.provider.oauth.expiresAt <= Date.now() ) { try { - const tokens = await refreshGeminiAccessToken( - this.provider.oauth.refreshToken, - ) + const previousRefreshToken = this.provider.oauth.refreshToken + invalidateProjectContextCache(previousRefreshToken) + const tokens = await refreshGeminiAccessToken(previousRefreshToken) const updatedOauth = { ...this.provider.oauth, accessToken: tokens.access_token, - refreshToken: - tokens.refresh_token ?? this.provider.oauth.refreshToken, + refreshToken: tokens.refresh_token ?? previousRefreshToken, expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, } this.provider.oauth = updatedOauth - invalidateProjectContextCache(updatedOauth.refreshToken) + if ( + tokens.refresh_token && + tokens.refresh_token !== previousRefreshToken + ) { + invalidateProjectContextCache(tokens.refresh_token) + } await this.onProviderUpdate?.(this.provider.id, { oauth: updatedOauth, }) diff --git a/src/core/llm/geminiProject.ts b/src/core/llm/geminiProject.ts index 73855196..b1ea641e 100644 --- a/src/core/llm/geminiProject.ts +++ b/src/core/llm/geminiProject.ts @@ -196,7 +196,9 @@ export async function onboardManagedProject( return undefined } - await wait(delayMs) + if (attempt < attempts - 1) { + await wait(delayMs) + } } return undefined