Skip to content
Open
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
5 changes: 5 additions & 0 deletions extensions/authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
"default": null,
"markdownDescription": "%configuration.aws.inferenceProfileRegion.description%"
},
"authentication.anthropic.baseUrl": {
"type": "string",
"default": "",
"description": "%configuration.anthropic.baseUrl.description%"
},
"authentication.foundry.baseUrl": {
"type": "string",
"default": "",
Expand Down
1 change: 1 addition & 0 deletions extensions/authentication/package.nls.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"configuration.aws.credentials.description": "Variables used to configure AWS credentials.\n\nExample: to set the AWS region and profile, add items with keys `AWS_REGION` and `AWS_PROFILE`.",
"configuration.aws.inferenceProfileRegion.description": "Override the inference profile region for AWS services.\n\nBy default, the inference profile region is derived from `AWS_REGION` (e.g., 'us-east-1' -> 'us'). Use this setting to explicitly specify a different region.\n\nExamples: 'global', 'us', 'eu', 'apac'.",
"configuration.anthropic.baseUrl.description": "Custom Anthropic API base URL. Overrides the default https://api.anthropic.com endpoint. Also set automatically from the ANTHROPIC_BASE_URL environment variable.",
"configuration.foundry.baseUrl.description": "Microsoft Foundry endpoint URL."
}
25 changes: 25 additions & 0 deletions extensions/authentication/src/authProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,31 @@ export class AuthProvider

async removeSession(sessionId: string): Promise<void> {
if (this._chainSession?.id === sessionId) {
// If the chain can still resolve, re-resolve immediately
// instead of leaving an inconsistent state where the
// session is gone but registered models still work.
try {
const token = await this.credentialChain!.resolve();
if (token) {
log.info(
`[${this.displayName}] Chain session ` +
`removal blocked -- credentials still ` +
`available from environment`
);
vscode.window.showInformationMessage(
vscode.l10n.t(
'{0} credentials are configured via ' +
'environment variable and cannot be ' +
'signed out.',
this.displayName
)
);
return;
}
} catch {
// Chain can no longer resolve; allow removal.
}

this.stopRefreshTimer();
const removed = this._chainSession;
this._chainSession = undefined;
Expand Down
19 changes: 17 additions & 2 deletions extensions/authentication/src/configDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,23 @@ async function handleDelete(
return;
}
const sessions = await provider.getSessions();
log.info(`Deleting ${sessions.length} session(s) for provider "${config.provider}"`);
for (const session of sessions) {
// Credential-chain sessions (e.g. env var credentials) use the
// provider ID as their session ID. These cannot be removed via the
// UI -- the user must unset the environment variable and restart.
const deletable = sessions.filter(
s => s.id !== config.provider
);
if (deletable.length === 0 && sessions.length > 0) {
throw new Error(
vscode.l10n.t(
'This credential was configured via an environment variable ' +
'and cannot be removed from the UI. Unset the environment ' +
'variable and restart Positron.'
)
);
}
log.info(`Deleting ${deletable.length} session(s) for provider "${config.provider}"`);
for (const session of deletable) {
await provider.removeSession(session.id);
}
}
1 change: 0 additions & 1 deletion extensions/authentication/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import * as vscode from 'vscode';
export const IS_RUNNING_ON_PWB =
!!process.env.RS_SERVER_URL && vscode.env.uiKind === vscode.UIKind.Web;

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;
61 changes: 58 additions & 3 deletions extensions/authentication/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { registerMigrateApiKeyCommand } from './migration/apiKey';
export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(log);

registerAnthropicProvider(context);
await registerAnthropicProvider(context);
registerFoundryProvider(context);

// Migrate settings before registering the AWS provider so it
Expand All @@ -43,9 +43,47 @@ export async function activate(context: vscode.ExtensionContext) {
registerMigrateApiKeyCommand(context);
}

function registerAnthropicProvider(context: vscode.ExtensionContext): void {
async function registerAnthropicProvider(
context: vscode.ExtensionContext
): Promise<void> {
// Sync ANTHROPIC_BASE_URL env var to the config setting before
// chain resolution so validation uses the correct endpoint.
const envBaseUrl = process.env.ANTHROPIC_BASE_URL;
if (envBaseUrl) {
await vscode.workspace
.getConfiguration('authentication.anthropic')
.update(
'baseUrl', envBaseUrl,
vscode.ConfigurationTarget.Global
).then(undefined, err =>
log.error(`Failed to sync Anthropic base URL: ${err}`)
);
}

const provider = new AuthProvider(
'anthropic-api', 'Anthropic', context
'anthropic-api', 'Anthropic', context,
undefined,
{
resolve: async () => {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY not set');
}
const baseUrl = vscode.workspace
.getConfiguration('authentication.anthropic')
.get<string>('baseUrl') || undefined;
await validateAnthropicApiKey(apiKey, {
provider: 'anthropic-api',
name: 'Anthropic',
model: '',
type: positron.PositronLanguageModelType.Chat,
...(baseUrl && { baseUrl }),
});
return apiKey;
},
// No refresh needed -- env vars are static for the
// process lifetime.
}
);
context.subscriptions.push(
vscode.authentication.registerAuthenticationProvider(
Expand All @@ -56,7 +94,24 @@ function registerAnthropicProvider(context: vscode.ExtensionContext): void {
);
registerAuthProvider('anthropic-api', provider, {
validateApiKey: validateAnthropicApiKey,
onSave: async (config) => {
if (config.baseUrl) {
await vscode.workspace
.getConfiguration('authentication.anthropic')
.update(
'baseUrl', config.baseUrl,
vscode.ConfigurationTarget.Global
);
}
},
});

// Eagerly resolve env var credentials so the session is
// available before positron-assistant registers models.
await provider.resolveChainCredentials().catch(err =>
log.debug(`[Anthropic] Initial credential resolution: ${err}`)
);

log.info('Registered auth provider: anthropic-api');
}

Expand Down
16 changes: 15 additions & 1 deletion extensions/authentication/src/test/authProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,23 @@ suite('AuthProvider (credential chain)', () => {
assert.strictEqual(event.added!.length, 1);
});

test('removeSession clears chain session and fires removed event', async () => {
test('removeSession is blocked while chain can still resolve', async () => {
await chainProvider.resolveChainCredentials();

await chainProvider.removeSession('test-chain');

// Session should still exist because the chain can resolve
const sessions = await chainProvider.getSessions();
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].id, 'test-chain');
});

test('removeSession clears chain session when chain fails', async () => {
await chainProvider.resolveChainCredentials();

// Make the chain fail so removal is allowed
resolveShouldFail = true;

const eventPromise = new Promise<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>(
resolve => {
const disposable = chainProvider.onDidChangeSessions(e => {
Expand Down
48 changes: 47 additions & 1 deletion extensions/authentication/src/test/configDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ suite('configDialog', () => {
} as unknown as vscode.ExtensionContext;
provider = new AuthProvider('anthropic-api', 'Anthropic', mockContext);
registerAuthProvider('anthropic-api', provider, {
validateApiKey: async (apiKey) => validateAnthropicApiKey(apiKey),
validateApiKey: async (apiKey, config) => validateAnthropicApiKey(apiKey, config),
});
});

Expand Down Expand Up @@ -125,6 +125,52 @@ suite('configDialog', () => {
assert.strictEqual(stored, false);
});

test('delete rejects when only chain session exists', async () => {
const chainProvider = new AuthProvider(
'anthropic-api', 'Anthropic',
{
secrets: {
get: () => Promise.resolve(undefined),
store: () => Promise.resolve(),
delete: () => Promise.resolve(),
},
globalState: {
get: () => undefined,
update: () => Promise.resolve(),
},
} as unknown as vscode.ExtensionContext,
undefined,
{
resolve: async () => 'sk-ant-test-key',
}
);
authProviders.clear();
registerAuthProvider('anthropic-api', chainProvider);
await chainProvider.resolveChainCredentials();

const source = {
type: positron.PositronLanguageModelType.Chat,
provider: { id: 'anthropic-api', displayName: 'Anthropic', settingName: 'anthropic-api' },
signedIn: true,
defaults: { name: 'Anthropic', model: 'claude-sonnet-4-0' },
supportedOptions: [],
} as unknown as positron.ai.LanguageModelSource;

positron.ai.showLanguageModelConfig = async (_sources, onAction) => {
await onAction({ provider: 'anthropic-api', type: positron.PositronLanguageModelType.Chat, name: 'Anthropic', model: 'claude-sonnet-4-0' }, 'delete');
};

await assert.rejects(
showConfigurationDialog([source]),
(error: Error) => error.message.includes('environment variable')
);

// Chain session should still exist
const sessions = await chainProvider.getSessions();
assert.strictEqual(sessions.length, 1);
chainProvider.dispose();
});

test('save without apiKey calls createSession for chain provider', async () => {
const chainProvider = new AuthProvider(
'test-chain', 'Test Chain',
Expand Down
12 changes: 9 additions & 3 deletions extensions/authentication/src/validation/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { ANTHROPIC_API_VERSION, ANTHROPIC_MODELS_ENDPOINT, KEY_VALIDATION_TIMEOUT_MS } from '../constants';
import * as positron from 'positron';
import { ANTHROPIC_API_VERSION, KEY_VALIDATION_TIMEOUT_MS } from '../constants';

class ApiKeyValidationError extends Error {
constructor(message: string) {
Expand All @@ -13,11 +14,16 @@ class ApiKeyValidationError extends Error {
}
}

export async function validateAnthropicApiKey(apiKey: string): Promise<void> {
export async function validateAnthropicApiKey(apiKey: string, config: positron.ai.LanguageModelConfig): Promise<void> {
const rawBaseUrl = (config.baseUrl ?? 'https://api.anthropic.com')
.replace(/\/v1\/?$/, '')
.replace(/\/+$/, '');
const modelsEndpoint = `${rawBaseUrl}/v1/models`;

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), KEY_VALIDATION_TIMEOUT_MS);
try {
const response = await fetch(ANTHROPIC_MODELS_ENDPOINT, {
const response = await fetch(modelsEndpoint, {
method: 'GET',
headers: {
'x-api-key': apiKey,
Expand Down
28 changes: 26 additions & 2 deletions extensions/positron-assistant/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,37 @@ export async function showConfigurationDialog(
// Resolve environment variables
if (source.defaults.autoconfigure.type === positron.ai.LanguageModelAutoconfigureType.EnvVariable) {
const envVarName = source.defaults.autoconfigure.key;
const envVarValue = process.env[envVarName];
const providerId = source.provider.id;

// For providers migrated to the auth extension,
// check for a credential chain session.
let signedIn = false;
let baseUrlValue: string | undefined;
if (isAuthExtProvider(providerId)) {
try {
const session = await vscode.authentication.getSession(
providerId, [], { silent: true }
);
signedIn = !!session?.accessToken;
} catch {
signedIn = false;
}
const configKey = providerId.replace(/-.*$/, '');
baseUrlValue = vscode.workspace
.getConfiguration(`authentication.${configKey}`)
.get<string>('baseUrl') || undefined;
} else {
signedIn = !!process.env[envVarName];
const baseUrlEnvVar = `${envVarName.replace(/_API_KEY$/, '')}_BASE_URL`;
baseUrlValue = process.env[baseUrlEnvVar];
}

return {
...source,
defaults: {
...source.defaults,
autoconfigure: { type: positron.ai.LanguageModelAutoconfigureType.EnvVariable, key: envVarName, signedIn: !!envVarValue }
...(baseUrlValue && { baseUrl: baseUrlValue }),
autoconfigure: { type: positron.ai.LanguageModelAutoconfigureType.EnvVariable, key: envVarName, signedIn }
},
};
} else if (source.defaults.autoconfigure.type === positron.ai.LanguageModelAutoconfigureType.Custom) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ type CacheControllableBlockParam = Anthropic.TextBlockParam |
* **Configuration:**
* - Provider ID: `anthropic-api` (not `anthropic` which is used by Copilot Chat)
* - Required: API key from Anthropic Console
* - Optional: Model selection, tool calling toggle
* - Supports: Environment variable autoconfiguration (ANTHROPIC_API_KEY)
* - Optional: Base URL (for custom deployments/proxies), model selection, tool calling toggle
* - Supports: Environment variable autoconfiguration (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL)
*
* @see {@link ModelProvider} for base class documentation
* @see https://docs.anthropic.com/ for Anthropic API documentation
Expand All @@ -73,27 +73,44 @@ export class AnthropicModelProvider extends ModelProvider implements positron.ai
static source: positron.ai.LanguageModelSource = {
type: positron.PositronLanguageModelType.Chat,
provider: PROVIDER_METADATA.anthropic,
supportedOptions: ['apiKey', 'autoconfigure'],
supportedOptions: ['apiKey', 'baseUrl', 'autoconfigure'],
defaults: {
name: DEFAULT_ANTHROPIC_MODEL_NAME,
model: DEFAULT_ANTHROPIC_MODEL_MATCH + '-latest',
baseUrl: 'https://api.anthropic.com',
toolCalls: true,
autoconfigure: { type: positron.ai.LanguageModelAutoconfigureType.EnvVariable, key: 'ANTHROPIC_API_KEY', signedIn: false }
},
};

get baseUrl(): string | undefined {
return (this._config.baseUrl
?? AnthropicModelProvider.source.defaults.baseUrl)
?.replace(/\/v1\/?$/, '')
.replace(/\/+$/, '');
}

constructor(
_config: ModelConfig,
_context?: vscode.ExtensionContext,
client?: Anthropic, // For testing only - production uses constructor initialization
) {
super(_config, _context);
this._client = client ?? new Anthropic({ apiKey: _config.apiKey });
this._client = client ?? new Anthropic({
apiKey: _config.apiKey,
baseURL: this.baseUrl,
});
}

protected override async validateCredentials() {
// Validate Anthropic API key format
return !!this._config.apiKey && this._config.apiKey.startsWith('sk-ant-');
if (!this._config.apiKey?.trim()) {
return false;
}
// Custom endpoints may use non-standard key formats
if (this._config.baseUrl) {
return true;
}
return this._config.apiKey.startsWith('sk-ant-');
}

protected override getDefaultMatch(): string {
Expand All @@ -106,6 +123,10 @@ export class AnthropicModelProvider extends ModelProvider implements positron.ai
try {
await this._client.withOptions({ timeout: timeoutMs }).models.list();
} catch (error) {
// Custom endpoints may not expose /v1/models; treat 404 as connected
if (this._config.baseUrl && error instanceof Anthropic.APIError && error.status === 404) {
return;
}
return error as Error;
}
}
Expand Down
Loading
Loading