Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ const litellmSchema = baseProviderSettingsSchema.extend({
litellmApiKey: z.string().optional(),
litellmModelId: z.string().optional(),
litellmUsePromptCache: z.boolean().optional(),
// OAuth metadata (optional)
litellmTokenType: z.string().optional(),
litellmTokenExpiresAt: z.string().optional(),
litellmTokenScope: z.string().optional(),
})

const cerebrasSchema = apiModelIdProviderModelSchema.extend({
Expand Down
29 changes: 29 additions & 0 deletions src/activate/handleUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,35 @@ export const handleUri = async (uri: vscode.Uri) => {
}
break
}
case "/litellm": {
const accessToken = query.get("access_token")
const tokenType = query.get("token_type")
const expiresIn = query.get("expires_in")
const scope = query.get("scope")
const error = query.get("error")
const errorDescription = query.get("error_description")

if (error) {
// Handle OAuth error
console.error(`LiteLLM OAuth error: ${error}`, errorDescription)
vscode.window.showErrorMessage(
`LiteLLM authentication failed: ${error}${errorDescription ? ` - ${errorDescription}` : ""}`,
)
return
}

if (accessToken) {
await visibleProvider.handleLiteLLMCallback({
accessToken,
tokenType: tokenType || "Bearer",
expiresIn: expiresIn ? parseInt(expiresIn, 10) : 86400,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 86400 seconds (24 hours) a reasonable default for token expiry? Some OAuth tokens might have much shorter lifespans. Could we consider a more conservative default or make this configurable?

scope: scope || undefined,
})
} else {
vscode.window.showErrorMessage("LiteLLM authentication failed: No access token received")
}
break
}
case "/auth/clerk/callback": {
const code = query.get("code")
const state = query.get("state")
Expand Down
55 changes: 55 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
requestyDefaultModelId,
openRouterDefaultModelId,
glamaDefaultModelId,
litellmDefaultModelId,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice litellmDefaultModelId is imported here. Could you confirm this constant is already defined in the @roo-code/types package? I want to ensure we're not missing any type definitions.

ORGANIZATION_ALLOW_ALL,
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
DEFAULT_WRITE_DELAY_MS,
Expand Down Expand Up @@ -1254,6 +1255,60 @@ export class ClineProvider
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)
}

// LiteLLM OAuth2 SSO integration
// Flow: User clicks SSO button -> LiteLLM OAuth page -> redirect back with access_token
// Token is stored as API key for the LiteLLM provider. Based on LiteLLM PR #13227.
async handleLiteLLMCallback(oauthResponse: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding inline documentation explaining the OAuth flow or linking to LiteLLM's OAuth documentation. This would help future maintainers understand the integration better. For example:

Suggested change
async handleLiteLLMCallback(oauthResponse: {
// LiteLLM OAuth2 SSO integration
// This implements the OAuth2 flow described in LiteLLM PR #13227
// The flow: User clicks SSO button -> Redirected to LiteLLM OAuth page ->
// LiteLLM redirects back with access_token -> Token stored as API key
async handleLiteLLMCallback(oauthResponse: {

accessToken: string
tokenType: string
expiresIn: number
scope?: string
}) {
let { apiConfiguration, currentApiConfigName } = await this.getState()

// Store the OAuth response metadata
const { accessToken, tokenType, expiresIn, scope } = oauthResponse
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token expiry time is calculated and logged but doesn't appear to be used elsewhere. Consider implementing token refresh logic or at least warning users when their token is about to expire. This would improve the user experience by preventing unexpected authentication failures.


this.log(
`LiteLLM OAuth success: ${tokenType} token expires in ${expiresIn}s (${expiresAt})${scope ? ` with scope: ${scope}` : ""}`,
)

const newConfiguration: ProviderSettings = {
...apiConfiguration,
apiProvider: "litellm",
litellmApiKey: accessToken,
litellmModelId: apiConfiguration?.litellmModelId || litellmDefaultModelId,
litellmTokenType: tokenType,
litellmTokenExpiresAt: expiresAt,
litellmTokenScope: scope,
}
await this.upsertProviderProfile(currentApiConfigName, newConfiguration)

// Notify webview of the configuration update
await this.postStateToWebview()

// Show success message to user including token expiry information
vscode.window.showInformationMessage(
`Successfully authenticated with LiteLLM via SSO! Token expires in ${expiresIn} seconds.`,
)

// Schedule a pre-expiry warning if expiry is reasonable
try {
const msUntilExpiry = expiresIn * 1000
const warnMs = Math.max(0, msUntilExpiry - 5 * 60 * 1000) // 5 minutes before
if (warnMs > 0 && warnMs < 7 * 24 * 60 * 60 * 1000) {
setTimeout(() => {
void vscode.window.showWarningMessage(
"Your LiteLLM OAuth token is about to expire. Please re-authenticate via SSO to avoid interruptions.",
)
}, warnMs)
}
} catch {
// ignore scheduling errors
}
}

// Glama

async handleGlamaCallback(code: string) {
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ const ApiOptions = ({
setApiConfigurationField={setApiConfigurationField}
organizationAllowList={organizationAllowList}
modelValidationError={modelValidationError}
uriScheme={uriScheme}
/>
)}

Expand Down
15 changes: 15 additions & 0 deletions webview-ui/src/components/settings/providers/LiteLLM.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Button } from "@src/components/ui"
import { getLiteLLMAuthUrl } from "@src/oauth/urls"
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"

import { inputEventTransform } from "../transforms"
import { ModelPicker } from "../ModelPicker"
Expand All @@ -19,13 +21,15 @@ type LiteLLMProps = {
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
organizationAllowList: OrganizationAllowList
modelValidationError?: string
uriScheme?: string
}

export const LiteLLM = ({
apiConfiguration,
setApiConfigurationField,
organizationAllowList,
modelValidationError,
uriScheme,
}: LiteLLMProps) => {
const { t } = useAppTranslation()
const { routerModels } = useExtensionState()
Expand Down Expand Up @@ -111,6 +115,17 @@ export const LiteLLM = ({
{t("settings:providers.apiKeyStorageNotice")}
</div>

{(() => {
if (!apiConfiguration?.litellmBaseUrl || apiConfiguration?.litellmApiKey) return null
const authUrl = getLiteLLMAuthUrl(apiConfiguration.litellmBaseUrl, uriScheme)
if (!authUrl) return null
return (
<VSCodeButtonLink href={authUrl} style={{ width: "100%" }} appearance="primary">
{t("settings:providers.getLiteLLMApiKey")}
</VSCodeButtonLink>
)
})()}

<Button
variant="outline"
onClick={handleRefreshModels}
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage",
"glamaApiKey": "Glama API Key",
"getGlamaApiKey": "Get Glama API Key",
"getLiteLLMApiKey": "Get LiteLLM API Key via SSO",
"useCustomBaseUrl": "Use custom base URL",
"useReasoning": "Enable reasoning",
"useHostHeader": "Use custom Host header",
Expand Down
12 changes: 12 additions & 0 deletions webview-ui/src/oauth/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ export function getOpenRouterAuthUrl(uriScheme?: string) {
export function getRequestyAuthUrl(uriScheme?: string) {
return `https://app.requesty.ai/oauth/authorize?callback_url=${getCallbackUrl("requesty", uriScheme)}`
}

export function getLiteLLMAuthUrl(baseUrl: string, uriScheme?: string) {
// Validate URL format to avoid malformed links
try {
// Throws on invalid URL
new URL(baseUrl)
} catch {
return ""
}
const cleanBaseUrl = baseUrl.replace(/\/+$/, "")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding URL validation before constructing the OAuth URL. Something like:

Suggested change
const cleanBaseUrl = baseUrl.replace(/\/+$/, "")
export function getLiteLLMAuthUrl(baseUrl: string, uriScheme?: string) {
try {
new URL(baseUrl); // Validate URL format
} catch (error) {
throw new Error('Invalid LiteLLM base URL');
}
const cleanBaseUrl = baseUrl.replace(/\/+$/, "")
return `${cleanBaseUrl}/sso/key/generate?response_type=oauth_token&redirect_uri=${getCallbackUrl("litellm", uriScheme)}`
}

This would prevent malformed URLs from causing issues downstream.

return `${cleanBaseUrl}/sso/key/generate?response_type=oauth_token&redirect_uri=${getCallbackUrl("litellm", uriScheme)}`
}
Loading