diff --git a/extensions/authentication/package.json b/extensions/authentication/package.json index 6dc1676ce644..d012de083ee1 100644 --- a/extensions/authentication/package.json +++ b/extensions/authentication/package.json @@ -11,6 +11,7 @@ "activationEvents": [ "onStartupFinished", "onAuthenticationRequest:anthropic-api", + "onAuthenticationRequest:posit-ai", "onAuthenticationRequest:ms-foundry", "onAuthenticationRequest:amazon-bedrock", "onCommand:authentication.configureProviders", @@ -27,6 +28,10 @@ "id": "anthropic-api", "label": "Anthropic" }, + { + "id": "posit-ai", + "label": "Posit AI" + }, { "id": "ms-foundry", "label": "Microsoft Foundry" diff --git a/extensions/authentication/src/authProvider.ts b/extensions/authentication/src/authProvider.ts index 918aa260897e..17b933d83bc2 100644 --- a/extensions/authentication/src/authProvider.ts +++ b/extensions/authentication/src/authProvider.ts @@ -55,11 +55,18 @@ export class AuthProvider constructor( private readonly providerId: string, private readonly displayName: string, - private readonly context: vscode.ExtensionContext, + protected readonly context: vscode.ExtensionContext, private readonly workbench?: WorkbenchCredentialConfig, private readonly credentialChain?: CredentialChainConfig, ) { } + /** Expose session-change events to subclasses. */ + protected fireSessionsChanged( + event: vscode.AuthenticationProviderAuthenticationSessionsChangeEvent + ): void { + this._onDidChangeSessions.fire(event); + } + dispose(): void { this._disposed = true; this.stopRefreshTimer(); diff --git a/extensions/authentication/src/configDialog.ts b/extensions/authentication/src/configDialog.ts index 8d8f083283c8..a9f884e13b51 100644 --- a/extensions/authentication/src/configDialog.ts +++ b/extensions/authentication/src/configDialog.ts @@ -7,6 +7,8 @@ import * as vscode from 'vscode'; import * as positron from 'positron'; import { randomUUID } from 'crypto'; import { AuthProvider } from './authProvider'; +import { PositOAuthProvider } from './positOAuthProvider'; +import { FOUNDRY_AUTH_PROVIDER_ID } from './constants'; import { log } from './log'; import { FOUNDRY_MANAGED_CREDENTIALS, hasManagedCredentials } from './managedCredentials'; @@ -75,7 +77,7 @@ async function enrichWithCredentialState( try { const sessions = await provider.getSessions(); const signedIn = sessions.length > 0; - if (signedIn && source.provider.id === 'ms-foundry' && hasManagedCredentials(FOUNDRY_MANAGED_CREDENTIALS)) { + if (signedIn && source.provider.id === FOUNDRY_AUTH_PROVIDER_ID && hasManagedCredentials(FOUNDRY_MANAGED_CREDENTIALS)) { return { ...source, signedIn, @@ -156,23 +158,32 @@ export async function showConfigurationDialog( await applyConfig(); } break; - case 'oauth-signin': + case 'oauth-signin': { if (hasAuthProvider) { - addResult({ action, config }); + const accountId = await handleSave(config); + addResult({ action: 'save', config, accountId }); } else { await applyConfig(); } break; - case 'oauth-signout': + } + case 'oauth-signout': { if (hasAuthProvider) { - addResult({ action, config }); + await handleDelete(config); + addResult({ action: 'delete', config }); } else { await applyConfig(); } break; - case 'cancel': + } + case 'cancel': { + const provider = authProviders.get(config.provider); + if (provider instanceof PositOAuthProvider) { + provider.cancelSignIn(); + } await applyConfig(); break; + } default: throw new Error( vscode.l10n.t('Invalid action: {0}', action) diff --git a/extensions/authentication/src/constants.ts b/extensions/authentication/src/constants.ts index 98de0224c07f..71342bc50349 100644 --- a/extensions/authentication/src/constants.ts +++ b/extensions/authentication/src/constants.ts @@ -12,3 +12,8 @@ export const ANTHROPIC_MODELS_ENDPOINT = 'https://api.anthropic.com/v1/models'; export const ANTHROPIC_API_VERSION = '2023-06-01'; export const KEY_VALIDATION_TIMEOUT_MS = 5000; export const CREDENTIAL_REFRESH_INTERVAL_MS = 10 * 60 * 1000; + +export const ANTHROPIC_AUTH_PROVIDER_ID = 'anthropic-api'; +export const POSIT_AUTH_PROVIDER_ID = 'posit-ai'; +export const AWS_AUTH_PROVIDER_ID = 'amazon-bedrock'; +export const FOUNDRY_AUTH_PROVIDER_ID = 'ms-foundry'; diff --git a/extensions/authentication/src/extension.ts b/extensions/authentication/src/extension.ts index feb2d126abb2..ecdd050b563e 100644 --- a/extensions/authentication/src/extension.ts +++ b/extensions/authentication/src/extension.ts @@ -6,11 +6,12 @@ import * as vscode from 'vscode'; import * as positron from 'positron'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { ANTHROPIC_AUTH_PROVIDER_ID, AWS_AUTH_PROVIDER_ID, CREDENTIAL_REFRESH_INTERVAL_MS, FOUNDRY_AUTH_PROVIDER_ID, POSIT_AUTH_PROVIDER_ID } from './constants'; import { AuthProvider } from './authProvider'; import { registerAuthProvider, showConfigurationDialog } from './configDialog'; import { normalizeToV1Url, validateAnthropicApiKey, validateFoundryApiKey } from './validation'; import { FOUNDRY_MANAGED_CREDENTIALS, hasManagedCredentials } from './managedCredentials'; -import { CREDENTIAL_REFRESH_INTERVAL_MS } from './constants'; +import { PositOAuthProvider } from './positOAuthProvider'; import { log } from './log'; import { migrateAwsSettings } from './migration/aws'; import { registerMigrateApiKeyCommand } from './migration/apiKey'; @@ -19,6 +20,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(log); registerAnthropicProvider(context); + registerPositAIProvider(context); registerFoundryProvider(context); // Migrate settings before registering the AWS provider so it @@ -45,19 +47,31 @@ export async function activate(context: vscode.ExtensionContext) { function registerAnthropicProvider(context: vscode.ExtensionContext): void { const provider = new AuthProvider( - 'anthropic-api', 'Anthropic', context + ANTHROPIC_AUTH_PROVIDER_ID, 'Anthropic', context ); context.subscriptions.push( vscode.authentication.registerAuthenticationProvider( - 'anthropic-api', 'Anthropic', provider, + ANTHROPIC_AUTH_PROVIDER_ID, 'Anthropic', provider, { supportsMultipleAccounts: true } ), provider ); - registerAuthProvider('anthropic-api', provider, { + registerAuthProvider(ANTHROPIC_AUTH_PROVIDER_ID, provider, { validateApiKey: validateAnthropicApiKey, }); - log.info('Registered auth provider: anthropic-api'); + log.info(`Registered auth provider: ${ANTHROPIC_AUTH_PROVIDER_ID}`); +} + +function registerPositAIProvider(context: vscode.ExtensionContext): void { + const provider = new PositOAuthProvider(context); + context.subscriptions.push( + vscode.authentication.registerAuthenticationProvider( + POSIT_AUTH_PROVIDER_ID, 'Posit AI', provider + ), + provider + ); + registerAuthProvider(POSIT_AUTH_PROVIDER_ID, provider); + log.info(`Registered auth provider: ${POSIT_AUTH_PROVIDER_ID}`); } function registerAwsProvider( @@ -84,7 +98,7 @@ function registerAwsProvider( ); const provider = new AuthProvider( - 'amazon-bedrock', 'AWS', context, + AWS_AUTH_PROVIDER_ID, 'AWS', context, undefined, { resolve: async () => { @@ -100,21 +114,21 @@ function registerAwsProvider( ); context.subscriptions.push( vscode.authentication.registerAuthenticationProvider( - 'amazon-bedrock', 'AWS', provider, + AWS_AUTH_PROVIDER_ID, 'AWS', provider, { supportsMultipleAccounts: false } ), provider ); - registerAuthProvider('amazon-bedrock', provider); + registerAuthProvider(AWS_AUTH_PROVIDER_ID, provider); provider.resolveChainCredentials().catch(err => log.debug(`[AWS] Initial credential resolution failed: ${err}`) ); - log.info('Registered auth provider: amazon-bedrock'); + log.info(`Registered auth provider: ${AWS_AUTH_PROVIDER_ID}`); } function registerFoundryProvider(context: vscode.ExtensionContext): void { const provider = new AuthProvider( - 'ms-foundry', 'Microsoft Foundry', context, + FOUNDRY_AUTH_PROVIDER_ID, 'Microsoft Foundry', context, { authProviderId: FOUNDRY_MANAGED_CREDENTIALS.authProvider.id, scopes: FOUNDRY_MANAGED_CREDENTIALS.authProvider.scopes, @@ -123,12 +137,12 @@ function registerFoundryProvider(context: vscode.ExtensionContext): void { ); context.subscriptions.push( vscode.authentication.registerAuthenticationProvider( - 'ms-foundry', 'Microsoft Foundry', provider, + FOUNDRY_AUTH_PROVIDER_ID, 'Microsoft Foundry', provider, { supportsMultipleAccounts: false } ), provider ); - registerAuthProvider('ms-foundry', provider, { + registerAuthProvider(FOUNDRY_AUTH_PROVIDER_ID, provider, { validateApiKey: validateFoundryApiKey, onSave: async (config) => { if (config.baseUrl) { @@ -139,7 +153,7 @@ function registerFoundryProvider(context: vscode.ExtensionContext): void { } }, }); - log.info('Registered auth provider: ms-foundry'); + log.info(`Registered auth provider: ${FOUNDRY_AUTH_PROVIDER_ID}`); // Sync Workbench endpoint to auth extension setting if (hasManagedCredentials(FOUNDRY_MANAGED_CREDENTIALS)) { diff --git a/extensions/authentication/src/positOAuthProvider.ts b/extensions/authentication/src/positOAuthProvider.ts new file mode 100644 index 000000000000..76df5f3e934a --- /dev/null +++ b/extensions/authentication/src/positOAuthProvider.ts @@ -0,0 +1,288 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import { POSIT_AUTH_PROVIDER_ID, CREDENTIAL_REFRESH_INTERVAL_MS } from './constants'; +import { AuthProvider } from './authProvider'; +import { log } from './log'; + + +/** + * Posit AI authentication provider using OAuth 2.0 Device Authorization + * Grant (RFC 8628). + * + * Extends AuthProvider so the config dialog can treat it uniformly + * alongside API-key and credential-chain providers. + * + * Sign-in and sign-out are routed through `createSession`/`removeSession` + * so the config dialog can use the standard AuthenticationProvider API. + */ +export class PositOAuthProvider extends AuthProvider { + + private _cancellationToken: vscode.CancellationTokenSource | null = null; + + constructor(context: vscode.ExtensionContext) { + super(POSIT_AUTH_PROVIDER_ID, 'Posit AI', context); + } + + private async signIn(): Promise { + log.info('[Posit AI] Signing in.'); + + const params = this.getOAuthParameters(); + const response = await fetch( + `${params.authHost}/oauth/device/authorize?scope=${params.scope}&client_id=${params.clientId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + } + ); + + if (!response.ok) { + throw new Error(`Failed to start device authorization: ${response.statusText}`); + } + + const data = await response.json() as { + verification_uri_complete: string; + interval: number; + user_code: string; + device_code: string; + }; + const { verification_uri_complete, interval, user_code, device_code } = data; + + await vscode.env.clipboard.writeText(user_code); + await positron.methods.showDialog( + 'Posit AI Sign In', + `You will need this code to sign in: ${user_code}. It has been copied to your clipboard.`, + ); + await vscode.env.openExternal(vscode.Uri.parse(verification_uri_complete)); + + const cancellationToken = new vscode.CancellationTokenSource(); + this._cancellationToken = cancellationToken; + + cancellationToken.token.onCancellationRequested(() => { + vscode.window.showInformationMessage(vscode.l10n.t('Posit AI sign-in cancelled.')); + }); + + try { + let currentInterval = Math.max(interval ?? 5, 5); + while (true) { + if (cancellationToken.token.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + await new Promise(resolve => setTimeout(resolve, currentInterval * 1000)); + + const tokenResponse = await fetch( + `${params.authHost}/oauth/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + scope: params.scope, + client_id: params.clientId, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: device_code + }).toString() + } + ); + + if (tokenResponse.status === 200) { + const tokenData = await tokenResponse.json() as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + const { access_token, refresh_token, expires_in } = tokenData; + const expiryTime = Date.now() + expires_in * 1000; + + await this.context.secrets.store('posit-ai.access_token', access_token); + await this.context.secrets.store('posit-ai.refresh_token', refresh_token); + await this.context.secrets.store('posit-ai.token_expiry', expiryTime.toString()); + + log.info('[Posit AI] Sign-in successful.'); + return; + } + + if (tokenResponse.status === 400) { + const errorData = await tokenResponse.json() as { error: string }; + switch (errorData.error) { + case 'authorization_pending': + continue; + case 'slow_down': + currentInterval += 5; + continue; + case 'expired_token': + vscode.window.showErrorMessage(vscode.l10n.t('Your verification code has expired. Please try signing in again.')); + throw new Error('Verification code expired.'); + case 'access_denied': + vscode.window.showErrorMessage(vscode.l10n.t('Authorization request was denied.')); + throw new Error('Authorization denied.'); + default: + throw new Error(`Unexpected error during token exchange: ${errorData.error}`); + } + } else { + throw new Error(`Unexpected response from token endpoint: ${tokenResponse.statusText}`); + } + } + } finally { + cancellationToken.dispose(); + this._cancellationToken = null; + } + } + + private async signOut(): Promise { + log.info('[Posit AI] Signing out.'); + await this.context.secrets.delete('posit-ai.access_token'); + await this.context.secrets.delete('posit-ai.refresh_token'); + await this.context.secrets.delete('posit-ai.token_expiry'); + } + + cancelSignIn(): void { + this._cancellationToken?.cancel(); + this._cancellationToken?.dispose(); + this._cancellationToken = null; + } + + // --- AuthProvider overrides --- + + override async getSessions( + _scopes?: readonly string[], + _options?: vscode.AuthenticationProviderSessionOptions + ): Promise { + try { + const accessToken = await this.getAccessToken(); + return [{ + id: POSIT_AUTH_PROVIDER_ID, + accessToken, + account: { label: 'Posit AI', id: POSIT_AUTH_PROVIDER_ID }, + scopes: [], + }]; + } catch { + return []; + } + } + + override async createSession( + _scopes: readonly string[], + _options?: vscode.AuthenticationProviderSessionOptions + ): Promise { + await this.signIn(); + + const accessToken = await this.getAccessToken(); + + const session: vscode.AuthenticationSession = { + id: POSIT_AUTH_PROVIDER_ID, + accessToken, + account: { label: 'Posit AI', id: POSIT_AUTH_PROVIDER_ID }, + scopes: [], + }; + + this.fireSessionsChanged({ + added: [session], removed: [], changed: [], + }); + + return session; + } + + override async removeSession(_sessionId: string): Promise { + const sessions = await this.getSessions(); + await this.signOut(); + + this.fireSessionsChanged({ + added: [], removed: sessions, changed: [], + }); + } + + override dispose(): void { + this.cancelSignIn(); + super.dispose(); + } + + // --- Token management --- + + /** + * Get the current access token, refreshing if needed. + */ + async getAccessToken(): Promise { + let accessToken = await this.context.secrets.get('posit-ai.access_token'); + const tokenExpiry = await this.context.secrets.get('posit-ai.token_expiry'); + + if (!accessToken || !tokenExpiry) { + throw new Error('No Posit AI access token found. Please sign in.'); + } + + const expiry = parseInt(tokenExpiry) - CREDENTIAL_REFRESH_INTERVAL_MS; + if (Date.now() >= expiry) { + const result = await this.refreshAccessToken(); + if (!result.success) { + throw new Error('Failed to refresh Posit AI access token. Please sign in again.'); + } + accessToken = result.accessToken; + } + + return accessToken; + } + + private async refreshAccessToken(): Promise<{ success: false } | { success: true; accessToken: string }> { + log.info('[Posit AI] Refreshing access token.'); + const params = this.getOAuthParameters(); + + const refreshToken = await this.context.secrets.get('posit-ai.refresh_token'); + if (!refreshToken) { + log.error('[Posit AI] No refresh token found.'); + return { success: false }; + } + + const response = await fetch( + `${params.authHost}/oauth/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + scope: params.scope, + client_id: params.clientId, + grant_type: 'refresh_token', + refresh_token: refreshToken + }).toString() + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) as { error_description?: string }; + const errorMsg = errorData.error_description || response.statusText; + log.error(`[Posit AI] Failed to refresh token: ${errorMsg}`); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to refresh Posit AI access token: {0}', errorMsg)); + return { success: false }; + } + + const tokenData = await response.json() as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + const { access_token, refresh_token, expires_in } = tokenData; + const expiryTime = Date.now() + expires_in * 1000; + + await this.context.secrets.store('posit-ai.access_token', access_token); + await this.context.secrets.store('posit-ai.refresh_token', refresh_token); + await this.context.secrets.store('posit-ai.token_expiry', expiryTime.toString()); + + log.info('[Posit AI] Access token refreshed successfully.'); + return { success: true, accessToken: access_token }; + } + + private getOAuthParameters(): { authHost: string; scope: string; clientId: string } { + const config = vscode.workspace.getConfiguration('authentication.positai'); + const authHost = config.inspect('authHost')?.globalValue + ?? 'https://login.posit.cloud'; + const scope = config.inspect('scope')?.globalValue + ?? 'prism'; + const clientId = config.inspect('clientId')?.globalValue + ?? 'positron'; + + return { authHost, scope, clientId }; + } +} diff --git a/extensions/authentication/src/test/positOAuthProvider.test.ts b/extensions/authentication/src/test/positOAuthProvider.test.ts new file mode 100644 index 000000000000..963c8bf06cd1 --- /dev/null +++ b/extensions/authentication/src/test/positOAuthProvider.test.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PositOAuthProvider } from '../positOAuthProvider'; + +function storeValidTokens(secrets: Map, overrides?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; +}): void { + secrets.set('posit-ai.access_token', overrides?.accessToken ?? 'test-access-token'); + secrets.set('posit-ai.refresh_token', overrides?.refreshToken ?? 'test-refresh-token'); + secrets.set('posit-ai.token_expiry', String(overrides?.expiresAt ?? Date.now() + 3600 * 1000)); +} + +function makeMockContext(): { context: vscode.ExtensionContext; secrets: Map } { + const secrets = new Map(); + const globalState = new Map(); + const context = { + secrets: { + get: (key: string) => Promise.resolve(secrets.get(key)), + store: (key: string, value: string) => { + secrets.set(key, value); + return Promise.resolve(); + }, + delete: (key: string) => { + secrets.delete(key); + return Promise.resolve(); + }, + }, + globalState: { + get: (key: string) => globalState.get(key) as T | undefined, + update: (key: string, value: unknown) => { + globalState.set(key, value); + return Promise.resolve(); + }, + }, + } as unknown as vscode.ExtensionContext; + return { context, secrets }; +} + +suite('PositOAuthProvider', () => { + let provider: PositOAuthProvider; + let secrets: Map; + let originalFetch: typeof globalThis.fetch; + + setup(() => { + originalFetch = globalThis.fetch; + const mock = makeMockContext(); + secrets = mock.secrets; + provider = new PositOAuthProvider(mock.context); + }); + + teardown(() => { + provider.dispose(); + globalThis.fetch = originalFetch; + }); + + suite('getAccessToken', () => { + test('returns token when not expired', async () => { + storeValidTokens(secrets, { + accessToken: 'fresh-token', + expiresAt: Date.now() + 30 * 60 * 1000, + }); + + const token = await provider.getAccessToken(); + assert.strictEqual(token, 'fresh-token'); + }); + + test('throws when no tokens stored', async () => { + await assert.rejects( + () => provider.getAccessToken(), + (err: Error) => err.message.includes('No Posit AI access token found') + ); + }); + + test('refreshes when token is near expiry', async () => { + storeValidTokens(secrets, { + expiresAt: Date.now() + 5 * 60 * 1000, // 5 min (within 10 min buffer) + }); + + globalThis.fetch = async () => { + return new Response(JSON.stringify({ + access_token: 'new-token', + refresh_token: 'new-refresh', + expires_in: 3600, + }), { status: 200 }); + }; + + const token = await provider.getAccessToken(); + assert.strictEqual(token, 'new-token'); + + // Verify new tokens were stored + assert.strictEqual(secrets.get('posit-ai.access_token'), 'new-token'); + assert.strictEqual(secrets.get('posit-ai.refresh_token'), 'new-refresh'); + }); + + test('returns failure when refresh fails', async () => { + storeValidTokens(secrets, { expiresAt: Date.now() - 1000 }); + + globalThis.fetch = async () => { + return new Response( + JSON.stringify({ error_description: 'bad token' }), + { status: 401 } + ); + }; + + await assert.rejects( + () => provider.getAccessToken(), + (err: Error) => err.message.includes('Failed to refresh') + ); + }); + }); + +}); diff --git a/extensions/positron-assistant/src/authExtRouting.ts b/extensions/positron-assistant/src/authExtRouting.ts index ed7c412def73..aeec86a91659 100644 --- a/extensions/positron-assistant/src/authExtRouting.ts +++ b/extensions/positron-assistant/src/authExtRouting.ts @@ -22,6 +22,7 @@ export interface ConfigDialogResult { /** Providers whose credentials are managed by the authentication extension. */ const AUTH_EXT_PROVIDERS = new Set([ 'anthropic-api', + 'posit-ai', 'amazon-bedrock', 'ms-foundry', ]); diff --git a/extensions/positron-assistant/src/config.ts b/extensions/positron-assistant/src/config.ts index 5d9da9453c0d..ea5f94c46606 100644 --- a/extensions/positron-assistant/src/config.ts +++ b/extensions/positron-assistant/src/config.ts @@ -13,7 +13,6 @@ import { clearTokenUsage } from './tokens.js'; import { disposeModels, getAutoconfiguredModels, registerModel, removeAutoconfiguredModel } from './modelRegistration.js'; import { CopilotService } from './copilot.js'; import { PositronAssistantApi } from './api.js'; -import { PositModelProvider } from './providers/posit/positProvider.js'; import { PROVIDER_ENABLE_SETTINGS_SEARCH } from './constants.js'; import { StoredModelConfig, ModelConfig } from './configTypes.js'; import { isAuthExtProvider, resolveApiKey, delegateConfigDialog } from './authExtRouting.js'; @@ -207,14 +206,71 @@ export async function applyConfigAction( await oauthSignout(config, sources, context); break; case 'cancel': - // User cancelled the dialog, clean up any pending operations. - PositModelProvider.cancelCurrentSignIn(); + // No-op for auth-extension-managed providers. + // Cancellation handled by the auth extension. break; default: throw new Error(vscode.l10n.t('Invalid Language Model action: {0}', action)); } } +async function oauthSignin( + userConfig: positron.ai.LanguageModelConfig, + sources: positron.ai.LanguageModelSource[], + context: vscode.ExtensionContext +) { + try { + switch (userConfig.provider) { + case 'copilot-auth': + await CopilotService.instance().signIn(); + break; + default: + throw new Error(vscode.l10n.t('OAuth sign-in is not supported for provider {0}', userConfig.provider)); + } + + if (userConfig.provider !== 'copilot-auth') { + await saveModel(userConfig, sources, context); + } + + PositronAssistantApi.get().notifySignIn(userConfig.provider); + + } catch (error) { + if (error instanceof vscode.CancellationError) { + return; + } + + const err = error instanceof Error ? error : new Error(JSON.stringify(error)); + throw new Error(vscode.l10n.t('Failed to sign in to provider {0}: {1}', userConfig.provider, err.message)); + } +} + +async function oauthSignout( + userConfig: positron.ai.LanguageModelConfig, + sources: positron.ai.LanguageModelSource[], + context: vscode.ExtensionContext +) { + let oauthCompleted = false; + try { + switch (userConfig.provider) { + case 'copilot-auth': + oauthCompleted = await CopilotService.instance().signOut(); + break; + default: + throw new Error(vscode.l10n.t('OAuth sign-out is not supported for provider {0}', userConfig.provider)); + } + + if (oauthCompleted) { + await deleteConfigurationByProvider(context, userConfig.provider); + } else { + throw new Error(vscode.l10n.t('OAuth sign-out was not completed successfully.')); + } + + } catch (error) { + const err = error instanceof Error ? error : new Error(JSON.stringify(error)); + throw new Error(vscode.l10n.t('Failed to sign out of provider {0}: {1}', userConfig.provider, err.message)); + } +} + async function saveModel( userConfig: positron.ai.LanguageModelConfig, sources: positron.ai.LanguageModelSource[], @@ -324,63 +380,6 @@ export async function deleteConfigurationByProvider(context: vscode.ExtensionCon } } -async function oauthSignin(userConfig: positron.ai.LanguageModelConfig, sources: positron.ai.LanguageModelSource[], context: vscode.ExtensionContext) { - try { - switch (userConfig.provider) { - case 'copilot-auth': - await CopilotService.instance().signIn(); - break; - case 'posit-ai': - await PositModelProvider.signIn(context); - break; - default: - throw new Error(vscode.l10n.t('OAuth sign-in is not supported for provider {0}', userConfig.provider)); - } - - // Special case: Copilot handles saving its own configuration internally - if (userConfig.provider !== 'copilot-auth') { - await saveModel(userConfig, sources, context); - } - - PositronAssistantApi.get().notifySignIn(userConfig.provider); - - } catch (error) { - if (error instanceof vscode.CancellationError) { - return; - } - - const err = error instanceof Error ? error : new Error(JSON.stringify(error)); - throw new Error(vscode.l10n.t(`Failed to sign in to provider {0}: {1}`, userConfig.provider, err.message)); - } -} - -async function oauthSignout(userConfig: positron.ai.LanguageModelConfig, sources: positron.ai.LanguageModelSource[], context: vscode.ExtensionContext) { - let oauthCompleted = false; - try { - switch (userConfig.provider) { - case 'copilot-auth': - oauthCompleted = await CopilotService.instance().signOut(); - break; - case 'posit-ai': - oauthCompleted = await PositModelProvider.signOut(context); - break; - default: - throw new Error(vscode.l10n.t('OAuth sign-out is not supported for provider {0}', userConfig.provider)); - } - - if (oauthCompleted) { - await deleteConfigurationByProvider(context, userConfig.provider); - } else { - throw new Error(vscode.l10n.t('OAuth sign-out was not completed successfully.')); - } - - } catch (error) { - const err = error instanceof Error ? error : new Error(JSON.stringify(error)); - throw new Error(vscode.l10n.t(`Failed to sign out of provider {0}: {1}`, userConfig.provider, err.message)); - } - -} - /** * Reconstructs a LanguageModelSource from a stored model configuration. * diff --git a/extensions/positron-assistant/src/extension.ts b/extensions/positron-assistant/src/extension.ts index 0e8a58bc279a..a7f81870643d 100644 --- a/extensions/positron-assistant/src/extension.ts +++ b/extensions/positron-assistant/src/extension.ts @@ -27,8 +27,8 @@ import { collectDiagnostics } from './diagnostics.js'; import { log } from './log.js'; import { resetAssistantState } from './reset.js'; import { performSettingsMigrations } from './providerMigration.js'; -import { disposeModels, registerModels, registerModelsForProvider } from './modelRegistration'; -import { registerPositAuthProvider } from './providers/posit/positProvider.js'; +import { addAutoconfiguredModel, disposeModels, getAutoconfiguredModels, registerModelWithAPI, registerModels, registerModelsForProvider } from './modelRegistration'; +import { getModelProviders } from './providers/index.js'; import { PROVIDER_METADATA } from './providerMetadata.js'; import { ModelConfig } from './configTypes.js'; import { isAuthExtProvider } from './authExtRouting.js'; @@ -219,7 +219,7 @@ async function toggleInlineCompletions() { let keyToToggle: string; let currentValue: boolean; - if (currentLanguageId && (currentLanguageId in currentSettings)) { + if (currentLanguageId && Object.prototype.hasOwnProperty.call(currentSettings, currentLanguageId)) { // If current file type has an explicit setting, toggle it keyToToggle = currentLanguageId; currentValue = currentSettings[currentLanguageId]; @@ -276,9 +276,6 @@ async function reconcileAuthProviderModels( } function registerAssistant(context: vscode.ExtensionContext) { - // Register Posit AI authentication provider - registerPositAuthProvider(context); - // Register Copilot service registerCopilotService(context); diff --git a/extensions/positron-assistant/src/providers/posit/positProvider.ts b/extensions/positron-assistant/src/providers/posit/positProvider.ts index 5bfa93f07c6d..e168402afb9a 100644 --- a/extensions/positron-assistant/src/providers/posit/positProvider.ts +++ b/extensions/positron-assistant/src/providers/posit/positProvider.ts @@ -23,8 +23,6 @@ import { PROVIDER_METADATA } from '../../providerMetadata.js'; export const DEFAULT_POSITAI_MODEL_NAME = 'Claude Sonnet 4.5'; export const DEFAULT_POSITAI_MODEL_MATCH = 'claude-sonnet-4-5'; -const POSIT_AUTH_PROVIDER_ID = 'posit-ai'; - interface PositModelsResponse { chat: { display_name: string; @@ -50,9 +48,6 @@ interface PositModelsResponse { * - Managed through workspace settings for authHost, scope, clientId, and baseUrl */ export class PositModelProvider extends VercelModelProvider { - /** The cancellation token for the current operation. */ - private static _cancellationToken: vscode.CancellationTokenSource | null = null; - private _anthropicClient!: Anthropic; private _useNativeSdk!: boolean; public readonly maxOutputTokens = DEFAULT_MAX_TOKEN_OUTPUT; @@ -69,215 +64,6 @@ export class PositModelProvider extends VercelModelProvider { }, }; - private static getOAuthParameters() { - const config = vscode.workspace.getConfiguration('positron.assistant.positai'); - const authHost = config.inspect('authHost')?.globalValue ?? 'https://login.posit.cloud'; - const scope = config.inspect('scope')?.globalValue ?? 'prism'; - const clientId = config.inspect('clientId')?.globalValue ?? 'positron'; - const baseUrl = config.inspect('baseUrl')?.globalValue ?? 'https://gateway.posit.ai'; - - if (!authHost || !scope || !clientId || !baseUrl) { - throw new Error('OAuth parameters are not configured.'); - } - - return { authHost, scope, clientId, baseUrl }; - } - - public static async signIn(context: vscode.ExtensionContext): Promise { - log.info('[Posit AI] Signing in.'); - - const params = PositModelProvider.getOAuthParameters(); - const response = await fetch( - `${params.authHost}/oauth/device/authorize?scope=${params.scope}&client_id=${params.clientId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - } - ); - - if (!response.ok) { - throw new Error(`Failed to start device authorization: ${response.statusText}`); - } - - const data = await response.json(); - const { verification_uri_complete, interval, user_code, device_code } = data; - await vscode.env.clipboard.writeText(user_code); - await positron.methods.showDialog( - 'Posit AI Sign In', - `You will need this code to sign in: ${user_code}. It has been copied to your clipboard.`, - ); - await vscode.env.openExternal(vscode.Uri.parse(verification_uri_complete)); - - const cancellationToken = new vscode.CancellationTokenSource(); - PositModelProvider._cancellationToken = cancellationToken; - - cancellationToken.token.onCancellationRequested(() => { - vscode.window.showInformationMessage(vscode.l10n.t('Posit AI sign-in cancelled.')); - }); - - try { - let currentInterval = interval; - while (true) { - if (cancellationToken.token.isCancellationRequested) { - throw new Error('Posit AI sign-in cancelled.'); - } - - const tokenResponse = await fetch( - `${params.authHost}/oauth/token`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - scope: params.scope, - client_id: params.clientId, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - device_code: device_code - }).toString() - } - ); - - if (tokenResponse.status === 200) { - const tokenData = await tokenResponse.json(); - const { access_token, refresh_token, expires_in } = tokenData; - log.info('[Posit AI] Sign-in successful.'); - - const expiryTime = Date.now() + expires_in * 1000; - context.secrets.store('positron.assistant.positai.access_token', access_token); - context.secrets.store('positron.assistant.positai.refresh_token', refresh_token); - context.secrets.store('positron.assistant.positai.token_expiry', expiryTime.toString()); - break; - } - - if (tokenResponse.status === 400) { - const errorData = await tokenResponse.json(); - switch (errorData.error) { - case 'authorization_pending': - await new Promise(resolve => setTimeout(resolve, currentInterval * 1000)); - continue; - case 'slow_down': - currentInterval += 5; - await new Promise(resolve => setTimeout(resolve, currentInterval * 1000)); - continue; - case 'expired_token': - vscode.window.showErrorMessage(vscode.l10n.t('Your verification code has expired. Please try signing in again.')); - throw new Error('Verification code expired.'); - case 'access_denied': - vscode.window.showErrorMessage(vscode.l10n.t('Authorization request was denied.')); - throw new Error('Authorization denied.'); - default: - throw new Error(`Unexpected error during token exchange: ${errorData.error}`); - } - } else { - throw new Error(`Unexpected response from token endpoint: ${tokenResponse.statusText}`); - } - } - } finally { - cancellationToken.dispose(); - } - - return; - } - - public static async signOut(context: vscode.ExtensionContext): Promise { - log.info('[Posit AI] Signing out.'); - - try { - // Sign-out is considered successful when the model is deleted in the config service - context.secrets.delete('positron.assistant.positai.access_token'); - context.secrets.delete('positron.assistant.positai.refresh_token'); - context.secrets.delete('positron.assistant.positai.token_expiry'); - return true; - } catch (error) { - if (error instanceof Error) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to sign out of Posit AI: {0}', error.message)); - } else { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to sign out of Posit AI.')); - } - return false; - } - } - - public static cancelCurrentSignIn(): void { - PositModelProvider._cancellationToken?.cancel(); - PositModelProvider._cancellationToken?.dispose(); - PositModelProvider._cancellationToken = null; - } - - public static async refreshAccessToken(context: vscode.ExtensionContext): Promise<{ success: false } | { success: true; accessToken: string }> { - log.info('[Posit AI] Refreshing access token.'); - const params = PositModelProvider.getOAuthParameters(); - - const refreshToken = await context.secrets.get('positron.assistant.positai.refresh_token'); - if (!refreshToken) { - log.error('[Posit AI] No refresh token found.'); - return { success: false }; - } - - const response = await fetch( - `${params.authHost}/oauth/token`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - scope: params.scope, - client_id: params.clientId, - grant_type: 'refresh_token', - refresh_token: refreshToken - }).toString() - } - ); - - if (!response.ok) { - const errorData = await response.json(); - const errorMsg = errorData.error_description || response.statusText; - log.error(`[Posit AI] Failed to refresh token: ${errorMsg}`); - vscode.window.showErrorMessage(vscode.l10n.t('Failed to refresh Posit AI access token: {0}', errorMsg)); - return { success: false }; - } - - const tokenData = await response.json(); - const { access_token, refresh_token, expires_in } = tokenData; - const expiryTime = Date.now() + expires_in * 1000; - - await context.secrets.store('positron.assistant.positai.access_token', access_token); - await context.secrets.store('positron.assistant.positai.refresh_token', refresh_token); - await context.secrets.store('positron.assistant.positai.token_expiry', expiryTime.toString()); - - log.info('[Posit AI] Access token refreshed successfully.'); - return { success: true, accessToken: access_token }; - } - - public static async getAccessToken(context: vscode.ExtensionContext): Promise { - let accessToken = await context.secrets.get('positron.assistant.positai.access_token'); - const tokenExpiry = await context.secrets.get('positron.assistant.positai.token_expiry'); - const now = Date.now(); - - log.debug(`[Posit AI] Token expiry at ${tokenExpiry}. Current time is ${now}.`); - - if (!accessToken || !tokenExpiry) { - throw new Error('No Posit AI access token found. Please sign in.'); - } - - const tenMin = 10 * 60 * 1000; - const expiry = parseInt(tokenExpiry) - tenMin; - if (tokenExpiry && now >= expiry) { - log.info('Access token has expired.'); - const result = await PositModelProvider.refreshAccessToken(context); - if (!result.success) { - throw new Error('Failed to refresh Posit AI access token. Please sign in again.'); - } - accessToken = result.accessToken; - } - - return accessToken; - } - constructor( _config: ModelConfig, _context?: vscode.ExtensionContext, @@ -285,12 +71,19 @@ export class PositModelProvider extends VercelModelProvider { super(_config, _context); } + private get baseUrl(): string { + return vscode.workspace + .getConfiguration('authentication.positai') + .inspect('baseUrl')?.globalValue + ?? 'https://gateway.posit.ai'; + } + /** * Initializes the Posit AI provider with OAuth-authenticated Anthropic client. * Uses either native Anthropic SDK or Vercel AI SDK based on the useAnthropicSdk preference. */ protected override initializeProvider() { - const params = PositModelProvider.getOAuthParameters(); + const baseUrl = this.baseUrl; // Check preference: true (default) = native SDK, false = Vercel SDK this._useNativeSdk = vscode.workspace.getConfiguration('positron.assistant') @@ -302,14 +95,14 @@ export class PositModelProvider extends VercelModelProvider { authToken: '_', // Actual token is set in authFetch apiKey: '_', // API key is not used fetch: this.authFetch.bind(this), - baseURL: `${params.baseUrl}/anthropic`, + baseURL: `${baseUrl}/anthropic`, }); } else { // Initialize Vercel AI SDK provider with OAuth fetch // Note: Vercel SDK expects baseURL to include /v1 (default is https://api.anthropic.com/v1) this.aiProvider = createAnthropic({ apiKey: '_', // API key is not used - baseURL: `${params.baseUrl}/anthropic/v1`, + baseURL: `${baseUrl}/anthropic/v1`, fetch: this.authFetch.bind(this), }); } @@ -317,6 +110,7 @@ export class PositModelProvider extends VercelModelProvider { /** * Custom fetch implementation that adds OAuth Bearer token to requests. + * Only attaches the token for URLs matching the configured baseUrl. */ private async authFetch(input: RequestInfo, init?: RequestInit): Promise { const token = await this.getAccessToken(); @@ -326,13 +120,19 @@ export class PositModelProvider extends VercelModelProvider { } /** - * Gets the current access token, refreshing if necessary. + * Gets a fresh access token via the authentication extension. */ async getAccessToken(): Promise { try { - return await PositModelProvider.getAccessToken(this._context!); + const session = await vscode.authentication.getSession( + 'posit-ai', [], { silent: true } + ); + if (!session?.accessToken) { + throw new Error('No Posit AI access token found. Please sign in.'); + } + return session.accessToken; } catch (error) { - // On refresh failure, also clean up the model configuration + // On auth failure, clean up the model configuration deleteConfiguration(this._context, this.providerId); throw error; } @@ -554,13 +354,13 @@ export class PositModelProvider extends VercelModelProvider { protected override async retrieveModelsFromApi(): Promise { try { - const params = PositModelProvider.getOAuthParameters(); + const baseUrl = this.baseUrl; const modelListing: vscode.LanguageModelChatInformation[] = []; const knownPositModels = getAllModelDefinitions(this.providerId); log.trace(`[${this.providerName}] Fetching models from Posit API...`); - const response = await this.authFetch(`${params.baseUrl}/models`); + const response = await this.authFetch(`${baseUrl}/models`); if (!response.ok) { throw new Error(`API returned ${response.status}`); @@ -611,81 +411,3 @@ function isPositModelsResponse(data: unknown): data is PositModelsResponse { ); } -/** - * VS Code Authentication Provider for Posit AI. - * - * Allows other extensions to obtain Posit AI credentials via - * `vscode.authentication.getSession()` without managing their own OAuth flow. - * Delegates all authentication operations to PositModelProvider. - */ -export class PositAuthProvider implements vscode.AuthenticationProvider { - private _didChangeSessions = - new vscode.EventEmitter(); - onDidChangeSessions = this._didChangeSessions.event; - - private readonly _disposable: vscode.Disposable; - - constructor(private readonly _context: vscode.ExtensionContext) { - this._disposable = vscode.authentication.registerAuthenticationProvider( - POSIT_AUTH_PROVIDER_ID, - 'Posit AI', - this - ); - } - - async getSessions(scopes?: string[]): Promise { - try { - const accessToken = await PositModelProvider.getAccessToken(this._context); - return [{ - id: POSIT_AUTH_PROVIDER_ID, - accessToken, - account: { label: 'Posit AI', id: POSIT_AUTH_PROVIDER_ID }, - scopes: scopes ?? [], - }]; - } catch { - log.trace('[PositAuthProvider] No valid session found.'); - return []; - } - } - - async createSession(scopes: string[]): Promise { - await PositModelProvider.signIn(this._context); - - const accessToken = await PositModelProvider.getAccessToken(this._context); - const newSession: vscode.AuthenticationSession = { - id: POSIT_AUTH_PROVIDER_ID, - accessToken, - account: { label: 'Posit AI', id: POSIT_AUTH_PROVIDER_ID }, - scopes, - }; - - this._didChangeSessions.fire({ - added: [newSession], - removed: [], - changed: [], - }); - - return newSession; - } - - async removeSession(): Promise { - const sessions = await this.getSessions(); - await PositModelProvider.signOut(this._context); - - this._didChangeSessions.fire({ - added: [], - removed: sessions, - changed: [], - }); - } - - dispose() { - this._didChangeSessions.dispose(); - this._disposable.dispose(); - } -} - -// Register Posit AI authentication provider -export function registerPositAuthProvider(context: vscode.ExtensionContext) { - context.subscriptions.push(new PositAuthProvider(context)); -} diff --git a/extensions/positron-assistant/src/test/positProvider.test.ts b/extensions/positron-assistant/src/test/positProvider.test.ts index 339ec36a5c16..2d4af3f40860 100644 --- a/extensions/positron-assistant/src/test/positProvider.test.ts +++ b/extensions/positron-assistant/src/test/positProvider.test.ts @@ -43,7 +43,7 @@ suite('PositModelProvider', () => { }; sinon.stub(vscode.workspace, 'getConfiguration').callsFake((section?: string) => { - if (section === 'positron.assistant.positai' || section === 'positron.assistant') { + if (section === 'authentication.positai' || section === 'positron.assistant') { return mockConfig as any; } return { get: () => undefined } as any; diff --git a/product.json b/product.json index 553e3feb0d0d..879ac2864482 100644 --- a/product.json +++ b/product.json @@ -659,6 +659,10 @@ "positron.authentication", "positron.positron-assistant" ], + "posit-ai": [ + "positron.authentication", + "positron.positron-assistant" + ], "ms-foundry": [ "positron.authentication", "positron.positron-assistant"