Skip to content

Commit d152d4d

Browse files
authored
Add Gemini Plan (OAuth) support (#513)
* 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. * chore: Update Gemini plan modal and connection descriptions * 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. * fix: Fix lint * fix: Resolve coderabbitai review
1 parent 2e3197d commit d152d4d

20 files changed

+1562
-14
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
> A list of community-maintained forks is available in the [Community Fork Collection](https://github.com/glowingjade/obsidian-smart-composer/discussions/496).
2828
> 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.
2929
30+
> ### Risks of connecting a Claude subscription
31+
>
32+
> As of January 2026, Anthropic has restricted third-party OAuth access, citing Terms of Service violations.
33+
>
34+
> 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.
35+
>
36+
> **Use at your own risk.** Keep usage limited to personal, interactive sessions and avoid any automation.
37+
3038
![SC1_Title.gif](https://github.com/user-attachments/assets/a50a1f80-39ff-4eba-8090-e3d75e7be98c)
3139

3240
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?
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { App, Notice } from 'obsidian'
2+
import { useEffect, useState } from 'react'
3+
4+
import { PROVIDER_TYPES_INFO } from '../../../constants'
5+
import {
6+
buildGeminiAuthorizeUrl,
7+
exchangeGeminiCodeForTokens,
8+
generateGeminiPkce,
9+
generateGeminiState,
10+
startGeminiCallbackServer,
11+
stopGeminiCallbackServer,
12+
} from '../../../core/llm/geminiAuth'
13+
import SmartComposerPlugin from '../../../main'
14+
import { ObsidianButton } from '../../common/ObsidianButton'
15+
import { ObsidianSetting } from '../../common/ObsidianSetting'
16+
import { ObsidianTextInput } from '../../common/ObsidianTextInput'
17+
import { ReactModal } from '../../common/ReactModal'
18+
19+
type ConnectGeminiPlanModalProps = {
20+
plugin: SmartComposerPlugin
21+
onClose: () => void
22+
}
23+
24+
const GEMINI_PLAN_PROVIDER_ID = PROVIDER_TYPES_INFO['gemini-plan']
25+
.defaultProviderId as string
26+
27+
export class ConnectGeminiPlanModal extends ReactModal<ConnectGeminiPlanModalProps> {
28+
constructor(app: App, plugin: SmartComposerPlugin) {
29+
super({
30+
app: app,
31+
Component: ConnectGeminiPlanModalComponent,
32+
props: { plugin },
33+
options: {
34+
title: 'Connect Google AI subscription',
35+
},
36+
})
37+
}
38+
}
39+
40+
function ConnectGeminiPlanModalComponent({
41+
plugin,
42+
onClose,
43+
}: ConnectGeminiPlanModalProps) {
44+
const extractParamFromRedirectUrl = (input: string, key: string) => {
45+
const trimmed = input.trim()
46+
if (!trimmed) return ''
47+
try {
48+
const parsed = new URL(trimmed)
49+
return parsed.searchParams.get(key) ?? ''
50+
} catch {
51+
const match = trimmed.match(new RegExp(`[?&]${key}=([^&]+)`))
52+
if (match?.[1]) return decodeURIComponent(match[1])
53+
return ''
54+
}
55+
}
56+
const extractCodeFromRedirectUrl = (input: string) =>
57+
extractParamFromRedirectUrl(input, 'code')
58+
const extractStateFromRedirectUrl = (input: string) =>
59+
extractParamFromRedirectUrl(input, 'state')
60+
61+
const [authorizeUrl, setAuthorizeUrl] = useState('')
62+
const [redirectUrl, setRedirectUrl] = useState('')
63+
const [pkceVerifier, setPkceVerifier] = useState('')
64+
const [state, setState] = useState('')
65+
const [isWaitingForCallback, setIsWaitingForCallback] = useState(false)
66+
const [isManualConnecting, setIsManualConnecting] = useState(false)
67+
const [autoError, setAutoError] = useState('')
68+
const [manualError, setManualError] = useState('')
69+
70+
const redirectCode = extractCodeFromRedirectUrl(redirectUrl)
71+
const redirectState = extractStateFromRedirectUrl(redirectUrl)
72+
const isBusy = isWaitingForCallback || isManualConnecting
73+
74+
useEffect(() => {
75+
return () => {
76+
void stopGeminiCallbackServer()
77+
}
78+
}, [])
79+
80+
const applyTokens = async (
81+
tokens: Awaited<ReturnType<typeof exchangeGeminiCodeForTokens>>,
82+
) => {
83+
if (
84+
!plugin.settings.providers.find(
85+
(p) => p.type === 'gemini-plan' && p.id === GEMINI_PLAN_PROVIDER_ID,
86+
)
87+
) {
88+
throw new Error('Gemini Plan provider not found.')
89+
}
90+
await plugin.setSettings({
91+
...plugin.settings,
92+
providers: plugin.settings.providers.map((p) => {
93+
if (p.type === 'gemini-plan' && p.id === GEMINI_PLAN_PROVIDER_ID) {
94+
return {
95+
...p,
96+
oauth: {
97+
accessToken: tokens.access_token,
98+
refreshToken: tokens.refresh_token,
99+
expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
100+
email: tokens.email,
101+
},
102+
}
103+
}
104+
return p
105+
}),
106+
})
107+
}
108+
109+
const ensureAuthContext = async () => {
110+
if (authorizeUrl && pkceVerifier && state) return
111+
const pkce = await generateGeminiPkce()
112+
const newState = generateGeminiState()
113+
const url = buildGeminiAuthorizeUrl({ pkce, state: newState })
114+
setPkceVerifier(pkce.verifier)
115+
setState(newState)
116+
setAuthorizeUrl(url)
117+
return { pkceVerifier: pkce.verifier, state: newState, authorizeUrl: url }
118+
}
119+
120+
const openLogin = async () => {
121+
if (isBusy) return
122+
setAutoError('')
123+
setManualError('')
124+
125+
const ensured = await ensureAuthContext()
126+
const effectiveAuthorizeUrl = ensured?.authorizeUrl ?? authorizeUrl
127+
const effectivePkceVerifier = ensured?.pkceVerifier ?? pkceVerifier
128+
const effectiveState = ensured?.state ?? state
129+
130+
if (!effectiveAuthorizeUrl || !effectivePkceVerifier || !effectiveState) {
131+
new Notice('Failed to initialize OAuth flow')
132+
return
133+
}
134+
135+
window.open(effectiveAuthorizeUrl, '_blank')
136+
setIsWaitingForCallback(true)
137+
138+
try {
139+
const callbackCode = await startGeminiCallbackServer({
140+
state: effectiveState,
141+
})
142+
const tokens = await exchangeGeminiCodeForTokens({
143+
code: callbackCode,
144+
pkceVerifier: effectivePkceVerifier,
145+
})
146+
await applyTokens(tokens)
147+
new Notice('Gemini Plan connected')
148+
onClose()
149+
} catch {
150+
setAutoError(
151+
'Automatic connect failed. Paste the full redirect URL below and click "Connect with URL".',
152+
)
153+
} finally {
154+
setIsWaitingForCallback(false)
155+
}
156+
}
157+
158+
const connectWithRedirectUrl = async () => {
159+
if (isBusy) return
160+
setAutoError('')
161+
162+
if (!redirectUrl.trim()) {
163+
setManualError(
164+
'Paste the full redirect URL from your browser address bar.',
165+
)
166+
return
167+
}
168+
169+
if (!redirectCode) {
170+
setManualError(
171+
'No authorization code found. Paste the full redirect URL from your browser address bar.',
172+
)
173+
return
174+
}
175+
176+
if (!redirectState) {
177+
setManualError(
178+
'No OAuth state found. Paste the full redirect URL from your browser address bar.',
179+
)
180+
return
181+
}
182+
183+
setManualError('')
184+
setIsManualConnecting(true)
185+
186+
try {
187+
const hasRedirectState = Boolean(redirectState)
188+
const ensured = hasRedirectState ? undefined : await ensureAuthContext()
189+
const effectivePkceVerifier = ensured?.pkceVerifier ?? pkceVerifier
190+
const effectiveState = redirectState ?? ensured?.state ?? state
191+
if (!effectivePkceVerifier) {
192+
setManualError(
193+
'Click "Login to Google" first, then paste the redirect URL.',
194+
)
195+
return
196+
}
197+
if (!effectiveState) {
198+
new Notice('Failed to initialize OAuth flow')
199+
return
200+
}
201+
if (redirectState && state && redirectState !== state) {
202+
setManualError(
203+
'OAuth state mismatch. Start login again and paste the newest redirect URL.',
204+
)
205+
return
206+
}
207+
const tokens = await exchangeGeminiCodeForTokens({
208+
code: redirectCode,
209+
pkceVerifier: effectivePkceVerifier,
210+
})
211+
await applyTokens(tokens)
212+
new Notice('Gemini Plan connected')
213+
onClose()
214+
} catch {
215+
setManualError(
216+
'Manual connect failed. Start login again and paste the newest redirect URL.',
217+
)
218+
} finally {
219+
setIsManualConnecting(false)
220+
}
221+
}
222+
223+
return (
224+
<div>
225+
<div className="smtcmp-plan-connect-steps">
226+
<div className="smtcmp-plan-connect-steps-title">How it works</div>
227+
<ol>
228+
<li>Login to Google in your browser</li>
229+
<li>Smart Composer connects automatically when you return</li>
230+
<li>
231+
If automatic connect fails, paste the full redirect URL below and
232+
click &quot;Connect with URL&quot;
233+
</li>
234+
</ol>
235+
</div>
236+
237+
<ObsidianSetting
238+
name="Gemini login"
239+
desc="Login to Google in your browser. Smart Composer connects automatically when you return."
240+
>
241+
<ObsidianButton
242+
text="Login to Google"
243+
disabled={isBusy}
244+
onClick={() => void openLogin()}
245+
cta
246+
/>
247+
{isWaitingForCallback && (
248+
<div className="smtcmp-plan-connect-waiting">
249+
Waiting for authorization...
250+
</div>
251+
)}
252+
</ObsidianSetting>
253+
254+
<ObsidianSetting
255+
name="Redirect URL (fallback)"
256+
desc="Use this only if automatic connect fails. Paste the full redirect URL from your browser address bar."
257+
className="smtcmp-plan-connect-fallback"
258+
>
259+
<div className="smtcmp-plan-connect-fallback-controls">
260+
{autoError && (
261+
<div className="smtcmp-plan-connect-error">{autoError}</div>
262+
)}
263+
<ObsidianTextInput
264+
value={redirectUrl}
265+
placeholder="http://localhost:8085/oauth2callback?code=..."
266+
onChange={(value) => {
267+
setRedirectUrl(value)
268+
if (manualError) setManualError('')
269+
}}
270+
/>
271+
<ObsidianButton
272+
text="Connect with URL"
273+
disabled={!redirectCode || isBusy}
274+
onClick={() => void connectWithRedirectUrl()}
275+
/>
276+
{manualError && (
277+
<div className="smtcmp-plan-connect-error">{manualError}</div>
278+
)}
279+
</div>
280+
</ObsidianSetting>
281+
282+
<ObsidianSetting>
283+
<ObsidianButton text="Cancel" onClick={onClose} />
284+
</ObsidianSetting>
285+
</div>
286+
)
287+
}

0 commit comments

Comments
 (0)