Skip to content

Commit 912d57c

Browse files
committed
Add PKCE integration for Hugging Face
1 parent 7cd6520 commit 912d57c

File tree

11 files changed

+197
-22
lines changed

11 files changed

+197
-22
lines changed

pnpm-lock.yaml

Lines changed: 5 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/activate/handleUri.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export const handleUri = async (uri: vscode.Uri) => {
2828
}
2929
break
3030
}
31+
case "/huggingface": {
32+
const code = query.get("code")
33+
const state = query.get("state")
34+
if (code) {
35+
await visibleProvider.handleHuggingFaceCallback(code, state || undefined)
36+
}
37+
break
38+
}
3139
case "/requesty": {
3240
const code = query.get("code")
3341
if (code) {

src/core/webview/ClineProvider.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { formatLanguage } from "../../shared/language"
5555
import { WebviewMessage } from "../../shared/WebviewMessage"
5656
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
5757
import { ProfileValidator } from "../../shared/ProfileValidator"
58+
import { HUGGING_FACE_OAUTH_CLIENT_ID } from "../../shared/oauth-constants"
5859

5960
import { Terminal } from "../../integrations/terminal/Terminal"
6061
import { downloadTask } from "../../integrations/misc/export-markdown"
@@ -1424,6 +1425,68 @@ export class ClineProvider
14241425
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
14251426
}
14261427

1428+
// HuggingFace
1429+
1430+
async handleHuggingFaceCallback(code: string, returnedState?: string) {
1431+
let { apiConfiguration, currentApiConfigName = "default" } = await this.getState()
1432+
1433+
try {
1434+
// Retrieve stored PKCE verifier and state from extension state
1435+
const pkceSecret = await this.context.secrets.get("huggingFacePkce")
1436+
const pkceData = pkceSecret ? JSON.parse(pkceSecret) : undefined
1437+
1438+
if (!pkceData || !pkceData.verifier || !pkceData.state) {
1439+
throw new Error("PKCE verifier or state not found in extension state.")
1440+
}
1441+
1442+
// Optional state validation (if state was provided in the callback)
1443+
if (returnedState && pkceData.state && returnedState !== pkceData.state) {
1444+
// Clear stored data before throwing to avoid reuse
1445+
await this.context.secrets.delete("huggingFacePkce")
1446+
throw new Error("OAuth state mismatch.")
1447+
}
1448+
1449+
const verifier: string = pkceData.verifier
1450+
1451+
// Clear PKCE data to prevent reuse
1452+
await this.context.secrets.delete("huggingFacePkce")
1453+
1454+
const redirectUri = `${vscode.env.uriScheme}://${Package.publisher}.${Package.name}/huggingface`
1455+
1456+
const params = new URLSearchParams()
1457+
params.append("grant_type", "authorization_code")
1458+
params.append("code", code)
1459+
params.append("client_id", HUGGING_FACE_OAUTH_CLIENT_ID)
1460+
params.append("code_verifier", verifier)
1461+
params.append("redirect_uri", redirectUri)
1462+
1463+
const response = await axios.post("https://huggingface.co/oauth/token", params, {
1464+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
1465+
})
1466+
1467+
const accessToken: string | undefined = response.data?.access_token
1468+
1469+
if (!accessToken) {
1470+
throw new Error("Invalid response from Hugging Face token endpoint")
1471+
}
1472+
1473+
const newConfiguration: ProviderSettings = {
1474+
...apiConfiguration,
1475+
apiProvider: "huggingface",
1476+
huggingFaceApiKey: accessToken,
1477+
huggingFaceModelId: apiConfiguration?.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct",
1478+
huggingFaceInferenceProvider: apiConfiguration?.huggingFaceInferenceProvider || "auto",
1479+
}
1480+
1481+
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
1482+
} catch (error) {
1483+
this.log(
1484+
`Error exchanging code for Hugging Face access token: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
1485+
)
1486+
throw error
1487+
}
1488+
}
1489+
14271490
// Requesty
14281491

14291492
async handleRequestyCallback(code: string) {

src/core/webview/webviewMessageHandler.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,19 @@ export const webviewMessageHandler = async (
978978
vscode.env.openExternal(vscode.Uri.parse(message.url))
979979
}
980980
break
981+
case "storeHuggingFacePkce": {
982+
// Store PKCE verifier/state as a secret to avoid typing constraints on ContextProxy keys
983+
const verifier = message.values?.verifier
984+
const state = message.values?.state
985+
if (typeof verifier === "string" && typeof state === "string" && verifier.length > 0 && state.length > 0) {
986+
try {
987+
await provider.context.secrets.store("huggingFacePkce", JSON.stringify({ verifier, state }))
988+
} catch (error) {
989+
console.error("Failed to store Hugging Face PKCE data:", error)
990+
}
991+
}
992+
break
993+
}
981994
case "checkpointDiff":
982995
const result = checkoutDiffPayloadSchema.safeParse(message.payload)
983996

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export interface WebviewMessage {
191191
| "profileThresholds"
192192
| "setHistoryPreviewCollapsed"
193193
| "openExternal"
194+
| "storeHuggingFacePkce"
194195
| "filterMarketplaceItems"
195196
| "marketplaceButtonClicked"
196197
| "installMarketplaceItem"

src/shared/oauth-constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* OAuth Client IDs and Constants
3+
*
4+
* These are public OAuth client identifiers used in OAuth flows.
5+
* They are safe to be exposed as they identify the application to OAuth providers.
6+
* Unlike client secrets, these are designed to be public in OAuth 2.0 public clients.
7+
*/
8+
9+
// Hugging Face OAuth client ID for PKCE flow
10+
export const HUGGING_FACE_OAUTH_CLIENT_ID = "aba045f7-aceb-4e53-9247-5c85d7c2b7cb"

webview-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"lru-cache": "^11.1.0",
5151
"lucide-react": "^0.518.0",
5252
"mermaid": "^11.4.1",
53+
"pkce-challenge": "^5.0.0",
5354
"posthog-js": "^1.227.2",
5455
"pretty-bytes": "^7.0.0",
5556
"react": "^18.3.1",

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,11 @@ const ApiOptions = ({
595595
)}
596596

597597
{selectedProvider === "huggingface" && (
598-
<HuggingFace apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
598+
<HuggingFace
599+
apiConfiguration={apiConfiguration}
600+
setApiConfigurationField={setApiConfigurationField}
601+
uriScheme={uriScheme}
602+
/>
599603
)}
600604

601605
{selectedProvider === "cerebras" && (

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,49 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
212212
}
213213
}, [settingsImportedAt, extensionState])
214214

215+
// Sync OAuth-driven key updates from extension without requiring a profile name change
216+
// This should only happen when the extension updates the key (e.g., after OAuth callback)
217+
// not when the user is manually editing it
218+
const [hasHuggingFaceSynced, setHasHuggingFaceSynced] = useState(false)
219+
220+
useEffect(() => {
221+
const extApi = extensionState.apiConfiguration ?? {}
222+
const cachedApi = cachedState.apiConfiguration ?? {}
223+
224+
// Only sync if:
225+
// 1. Extension has a key
226+
// 2. It's different from what we started with
227+
// 3. We haven't synced this key yet
228+
if (
229+
extApi.huggingFaceApiKey &&
230+
extApi.huggingFaceApiKey !== cachedApi.huggingFaceApiKey &&
231+
!hasHuggingFaceSynced
232+
) {
233+
setCachedState((prev) => ({
234+
...prev,
235+
apiConfiguration: {
236+
...prev.apiConfiguration,
237+
// Keep provider in sync if extension switched it during callback
238+
apiProvider: extApi.apiProvider ?? prev.apiConfiguration?.apiProvider,
239+
huggingFaceApiKey: extApi.huggingFaceApiKey,
240+
// Preserve/merge model fields from extension if present
241+
huggingFaceModelId: extApi.huggingFaceModelId ?? prev.apiConfiguration?.huggingFaceModelId,
242+
huggingFaceInferenceProvider:
243+
extApi.huggingFaceInferenceProvider ?? prev.apiConfiguration?.huggingFaceInferenceProvider,
244+
},
245+
}))
246+
// Mark that we've synced this key
247+
setHasHuggingFaceSynced(true)
248+
// Receiving fresh state from the extension should not mark the form dirty
249+
setChangeDetected(false)
250+
}
251+
}, [extensionState.apiConfiguration, cachedState.apiConfiguration, hasHuggingFaceSynced])
252+
253+
// Reset the sync flag when the profile changes
254+
useEffect(() => {
255+
setHasHuggingFaceSynced(false)
256+
}, [currentApiConfigName])
257+
215258
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
216259
setCachedState((prevState) => {
217260
if (prevState[field] === value) {

webview-ui/src/components/settings/providers/HuggingFace.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { useCallback, useState, useEffect, useMemo } from "react"
22
import { useEvent } from "react-use"
3-
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
3+
import { VSCodeTextField, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
4+
import pkceChallenge from "pkce-challenge"
45

56
import type { ProviderSettings } from "@roo-code/types"
67

78
import { ExtensionMessage } from "@roo/ExtensionMessage"
89
import { vscode } from "@src/utils/vscode"
910
import { useAppTranslation } from "@src/i18n/TranslationContext"
10-
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
1111
import { SearchableSelect, type SearchableSelectOption } from "@src/components/ui"
1212
import { cn } from "@src/lib/utils"
1313
import { formatPrice } from "@/utils/formatPrice"
14+
import { getHuggingFaceAuthUrl } from "@src/oauth/urls"
1415

1516
import { inputEventTransform } from "../transforms"
1617

@@ -39,9 +40,10 @@ type HuggingFaceProps = {
3940
value: ProviderSettings[keyof ProviderSettings],
4041
isUserAction?: boolean,
4142
) => void
43+
uriScheme?: string
4244
}
4345

44-
export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: HuggingFaceProps) => {
46+
export const HuggingFace = ({ apiConfiguration, setApiConfigurationField, uriScheme }: HuggingFaceProps) => {
4547
const { t } = useAppTranslation()
4648
const [models, setModels] = useState<HuggingFaceModel[]>([])
4749
const [loading, setLoading] = useState(false)
@@ -109,6 +111,28 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
109111
setApiConfigurationField,
110112
])
111113

114+
// Start OAuth with PKCE
115+
const handleStartOauth = useCallback(async () => {
116+
try {
117+
// Generate PKCE challenge using the library
118+
const pkce = await pkceChallenge()
119+
const state = crypto.randomUUID() // Use built-in UUID for state
120+
121+
// Store verifier/state in extension (secrets)
122+
vscode.postMessage({
123+
type: "storeHuggingFacePkce",
124+
values: { verifier: pkce.code_verifier, state },
125+
})
126+
127+
const authUrl = getHuggingFaceAuthUrl(uriScheme, pkce.code_challenge, state)
128+
129+
// Open externally via extension
130+
vscode.postMessage({ type: "openExternal", url: authUrl })
131+
} catch (e) {
132+
console.error("Failed to start Hugging Face OAuth:", e)
133+
}
134+
}, [uriScheme])
135+
112136
const handleModelSelect = (modelId: string) => {
113137
setApiConfigurationField("huggingFaceModelId", modelId)
114138
// Reset provider selection when model changes
@@ -180,9 +204,9 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
180204
</div>
181205

182206
{!apiConfiguration?.huggingFaceApiKey && (
183-
<VSCodeButtonLink href="https://huggingface.co/settings/tokens" appearance="secondary">
207+
<VSCodeButton appearance="primary" onClick={handleStartOauth} style={{ width: "100%" }}>
184208
{t("settings:providers.getHuggingFaceApiKey")}
185-
</VSCodeButtonLink>
209+
</VSCodeButton>
186210
)}
187211

188212
<div className="flex flex-col gap-2">

0 commit comments

Comments
 (0)