mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Add PKCE integration for Hugging Face
This commit is contained in:
parent
7cd6520302
commit
912d57c692
11 changed files with 197 additions and 22 deletions
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export interface WebviewMessage {
|
|||
| "profileThresholds"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "openExternal"
|
||||
| "storeHuggingFacePkce"
|
||||
| "filterMarketplaceItems"
|
||||
| "marketplaceButtonClicked"
|
||||
| "installMarketplaceItem"
|
||||
|
|
|
|||
10
src/shared/oauth-constants.ts
Normal file
10
src/shared/oauth-constants.ts
Normal 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"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -595,7 +595,11 @@ const ApiOptions = ({
|
|||
)}
|
||||
|
||||
{selectedProvider === "huggingface" && (
|
||||
<HuggingFace apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
<HuggingFace
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
uriScheme={uriScheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "cerebras" && (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue