Add PKCE integration for Hugging Face

This commit is contained in:
Matt Rubens 2025-09-10 12:27:19 -04:00
parent 7cd6520302
commit 912d57c692
11 changed files with 197 additions and 22 deletions

21
pnpm-lock.yaml generated
View file

@ -1029,6 +1029,9 @@ importers:
mermaid:
specifier: ^11.4.1
version: 11.10.0
pkce-challenge:
specifier: ^5.0.0
version: 5.0.0
posthog-js:
specifier: ^1.227.2
version: 1.242.1
@ -4049,9 +4052,6 @@ packages:
'@types/node@20.17.57':
resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==}
'@types/node@20.19.13':
resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==}
'@types/node@24.2.1':
resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==}
@ -9450,9 +9450,6 @@ packages:
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
@ -13533,11 +13530,6 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/node@20.19.13':
dependencies:
undici-types: 6.21.0
optional: true
'@types/node@24.2.1':
dependencies:
undici-types: 7.10.0
@ -13603,7 +13595,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.13
'@types/node': 24.2.1
optional: true
'@types/yargs-parser@21.0.3': {}
@ -13776,7 +13768,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -19851,9 +19843,6 @@ snapshots:
undici-types@6.19.8: {}
undici-types@6.21.0:
optional: true
undici-types@7.10.0: {}
undici@6.21.3: {}

View file

@ -28,6 +28,14 @@ export const handleUri = async (uri: vscode.Uri) => {
}
break
}
case "/huggingface": {
const code = query.get("code")
const state = query.get("state")
if (code) {
await visibleProvider.handleHuggingFaceCallback(code, state || undefined)
}
break
}
case "/requesty": {
const code = query.get("code")
if (code) {

View file

@ -55,6 +55,7 @@ import { formatLanguage } from "../../shared/language"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels"
import { ProfileValidator } from "../../shared/ProfileValidator"
import { HUGGING_FACE_OAUTH_CLIENT_ID } from "../../shared/oauth-constants"
import { Terminal } from "../../integrations/terminal/Terminal"
import { downloadTask } from "../../integrations/misc/export-markdown"
@ -1424,6 +1425,68 @@ export class ClineProvider
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
}
// HuggingFace
async handleHuggingFaceCallback(code: string, returnedState?: string) {
let { apiConfiguration, currentApiConfigName = "default" } = await this.getState()
try {
// Retrieve stored PKCE verifier and state from extension state
const pkceSecret = await this.context.secrets.get("huggingFacePkce")
const pkceData = pkceSecret ? JSON.parse(pkceSecret) : undefined
if (!pkceData || !pkceData.verifier || !pkceData.state) {
throw new Error("PKCE verifier or state not found in extension state.")
}
// Optional state validation (if state was provided in the callback)
if (returnedState && pkceData.state && returnedState !== pkceData.state) {
// Clear stored data before throwing to avoid reuse
await this.context.secrets.delete("huggingFacePkce")
throw new Error("OAuth state mismatch.")
}
const verifier: string = pkceData.verifier
// Clear PKCE data to prevent reuse
await this.context.secrets.delete("huggingFacePkce")
const redirectUri = `${vscode.env.uriScheme}://${Package.publisher}.${Package.name}/huggingface`
const params = new URLSearchParams()
params.append("grant_type", "authorization_code")
params.append("code", code)
params.append("client_id", HUGGING_FACE_OAUTH_CLIENT_ID)
params.append("code_verifier", verifier)
params.append("redirect_uri", redirectUri)
const response = await axios.post("https://huggingface.co/oauth/token", params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
const accessToken: string | undefined = response.data?.access_token
if (!accessToken) {
throw new Error("Invalid response from Hugging Face token endpoint")
}
const newConfiguration: ProviderSettings = {
...apiConfiguration,
apiProvider: "huggingface",
huggingFaceApiKey: accessToken,
huggingFaceModelId: apiConfiguration?.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct",
huggingFaceInferenceProvider: apiConfiguration?.huggingFaceInferenceProvider || "auto",
}
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
} catch (error) {
this.log(
`Error exchanging code for Hugging Face access token: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
throw error
}
}
// Requesty
async handleRequestyCallback(code: string) {

View file

@ -978,6 +978,19 @@ export const webviewMessageHandler = async (
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
break
case "storeHuggingFacePkce": {
// Store PKCE verifier/state as a secret to avoid typing constraints on ContextProxy keys
const verifier = message.values?.verifier
const state = message.values?.state
if (typeof verifier === "string" && typeof state === "string" && verifier.length > 0 && state.length > 0) {
try {
await provider.context.secrets.store("huggingFacePkce", JSON.stringify({ verifier, state }))
} catch (error) {
console.error("Failed to store Hugging Face PKCE data:", error)
}
}
break
}
case "checkpointDiff":
const result = checkoutDiffPayloadSchema.safeParse(message.payload)

View file

@ -191,6 +191,7 @@ export interface WebviewMessage {
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "openExternal"
| "storeHuggingFacePkce"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
| "installMarketplaceItem"

View file

@ -0,0 +1,10 @@
/**
* OAuth Client IDs and Constants
*
* These are public OAuth client identifiers used in OAuth flows.
* They are safe to be exposed as they identify the application to OAuth providers.
* Unlike client secrets, these are designed to be public in OAuth 2.0 public clients.
*/
// Hugging Face OAuth client ID for PKCE flow
export const HUGGING_FACE_OAUTH_CLIENT_ID = "aba045f7-aceb-4e53-9247-5c85d7c2b7cb"

View file

@ -50,6 +50,7 @@
"lru-cache": "^11.1.0",
"lucide-react": "^0.518.0",
"mermaid": "^11.4.1",
"pkce-challenge": "^5.0.0",
"posthog-js": "^1.227.2",
"pretty-bytes": "^7.0.0",
"react": "^18.3.1",

View file

@ -595,7 +595,11 @@ const ApiOptions = ({
)}
{selectedProvider === "huggingface" && (
<HuggingFace apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
<HuggingFace
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
uriScheme={uriScheme}
/>
)}
{selectedProvider === "cerebras" && (

View file

@ -212,6 +212,49 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
}
}, [settingsImportedAt, extensionState])
// Sync OAuth-driven key updates from extension without requiring a profile name change
// This should only happen when the extension updates the key (e.g., after OAuth callback)
// not when the user is manually editing it
const [hasHuggingFaceSynced, setHasHuggingFaceSynced] = useState(false)
useEffect(() => {
const extApi = extensionState.apiConfiguration ?? {}
const cachedApi = cachedState.apiConfiguration ?? {}
// Only sync if:
// 1. Extension has a key
// 2. It's different from what we started with
// 3. We haven't synced this key yet
if (
extApi.huggingFaceApiKey &&
extApi.huggingFaceApiKey !== cachedApi.huggingFaceApiKey &&
!hasHuggingFaceSynced
) {
setCachedState((prev) => ({
...prev,
apiConfiguration: {
...prev.apiConfiguration,
// Keep provider in sync if extension switched it during callback
apiProvider: extApi.apiProvider ?? prev.apiConfiguration?.apiProvider,
huggingFaceApiKey: extApi.huggingFaceApiKey,
// Preserve/merge model fields from extension if present
huggingFaceModelId: extApi.huggingFaceModelId ?? prev.apiConfiguration?.huggingFaceModelId,
huggingFaceInferenceProvider:
extApi.huggingFaceInferenceProvider ?? prev.apiConfiguration?.huggingFaceInferenceProvider,
},
}))
// Mark that we've synced this key
setHasHuggingFaceSynced(true)
// Receiving fresh state from the extension should not mark the form dirty
setChangeDetected(false)
}
}, [extensionState.apiConfiguration, cachedState.apiConfiguration, hasHuggingFaceSynced])
// Reset the sync flag when the profile changes
useEffect(() => {
setHasHuggingFaceSynced(false)
}, [currentApiConfigName])
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
setCachedState((prevState) => {
if (prevState[field] === value) {

View file

@ -1,16 +1,17 @@
import { useCallback, useState, useEffect, useMemo } from "react"
import { useEvent } from "react-use"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { VSCodeTextField, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import pkceChallenge from "pkce-challenge"
import type { ProviderSettings } from "@roo-code/types"
import { ExtensionMessage } from "@roo/ExtensionMessage"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
import { SearchableSelect, type SearchableSelectOption } from "@src/components/ui"
import { cn } from "@src/lib/utils"
import { formatPrice } from "@/utils/formatPrice"
import { getHuggingFaceAuthUrl } from "@src/oauth/urls"
import { inputEventTransform } from "../transforms"
@ -39,9 +40,10 @@ type HuggingFaceProps = {
value: ProviderSettings[keyof ProviderSettings],
isUserAction?: boolean,
) => void
uriScheme?: string
}
export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: HuggingFaceProps) => {
export const HuggingFace = ({ apiConfiguration, setApiConfigurationField, uriScheme }: HuggingFaceProps) => {
const { t } = useAppTranslation()
const [models, setModels] = useState<HuggingFaceModel[]>([])
const [loading, setLoading] = useState(false)
@ -109,6 +111,28 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
setApiConfigurationField,
])
// Start OAuth with PKCE
const handleStartOauth = useCallback(async () => {
try {
// Generate PKCE challenge using the library
const pkce = await pkceChallenge()
const state = crypto.randomUUID() // Use built-in UUID for state
// Store verifier/state in extension (secrets)
vscode.postMessage({
type: "storeHuggingFacePkce",
values: { verifier: pkce.code_verifier, state },
})
const authUrl = getHuggingFaceAuthUrl(uriScheme, pkce.code_challenge, state)
// Open externally via extension
vscode.postMessage({ type: "openExternal", url: authUrl })
} catch (e) {
console.error("Failed to start Hugging Face OAuth:", e)
}
}, [uriScheme])
const handleModelSelect = (modelId: string) => {
setApiConfigurationField("huggingFaceModelId", modelId)
// Reset provider selection when model changes
@ -180,9 +204,9 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
</div>
{!apiConfiguration?.huggingFaceApiKey && (
<VSCodeButtonLink href="https://huggingface.co/settings/tokens" appearance="secondary">
<VSCodeButton appearance="primary" onClick={handleStartOauth} style={{ width: "100%" }}>
{t("settings:providers.getHuggingFaceApiKey")}
</VSCodeButtonLink>
</VSCodeButton>
)}
<div className="flex flex-col gap-2">

View file

@ -1,4 +1,5 @@
import { Package } from "@roo/package"
import { HUGGING_FACE_OAUTH_CLIENT_ID } from "../../../src/shared/oauth-constants"
export function getCallbackUrl(provider: string, uriScheme?: string) {
return encodeURIComponent(`${uriScheme || "vscode"}://${Package.publisher}.${Package.name}/${provider}`)
@ -15,3 +16,21 @@ export function getOpenRouterAuthUrl(uriScheme?: string) {
export function getRequestyAuthUrl(uriScheme?: string) {
return `https://app.requesty.ai/oauth/authorize?callback_url=${getCallbackUrl("requesty", uriScheme)}`
}
export function getHuggingFaceAuthUrl(uriScheme?: string, codeChallenge?: string, state?: string) {
const callback = getCallbackUrl("huggingface", uriScheme)
const scope = encodeURIComponent("openid profile inference-api")
let url = `https://huggingface.co/oauth/authorize?client_id=${HUGGING_FACE_OAUTH_CLIENT_ID}&redirect_uri=${callback}&response_type=code&scope=${scope}`
// Add PKCE parameters if provided
if (codeChallenge) {
url += `&code_challenge=${codeChallenge}&code_challenge_method=S256`
}
if (state) {
url += `&state=${encodeURIComponent(state)}`
}
return url
}