Skip to content

Commit 90dbb1f

Browse files
Add custom base URL support for Anthropic provider (#12718)
## Summary Addresses #8007 Adds custom base URL support for Anthropic. - Adds a Base URL input field to the Configure Language Model Providers modal - Supports both the native Anthropic SDK provider and the Vercel AI SDK provider - Supports `ANTHROPIC_BASE_URL` environment variable for automatic configuration - Handles URL normalization for both SDK flavors (native SDK omits `/v1`, Vercel SDK requires it) - Relaxes API key format validation when a custom base URL is set - Treats 404 from `/v1/models` as a successful connection for custom endpoints that don't expose model listing - Updates the auth extension's Anthropic key validator to use the configured base URL - Migrates Anthropic env variable authentication from Assistant to the Authentication extension. @:assistant
1 parent 68488be commit 90dbb1f

File tree

17 files changed

+531
-28
lines changed

17 files changed

+531
-28
lines changed

extensions/authentication/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
"default": null,
6666
"markdownDescription": "%configuration.aws.inferenceProfileRegion.description%"
6767
},
68+
"authentication.anthropic.baseUrl": {
69+
"type": "string",
70+
"default": "",
71+
"description": "%configuration.anthropic.baseUrl.description%"
72+
},
6873
"authentication.foundry.baseUrl": {
6974
"type": "string",
7075
"default": "",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"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`.",
33
"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'.",
4+
"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.",
45
"configuration.foundry.baseUrl.description": "Microsoft Foundry endpoint URL."
56
}

extensions/authentication/src/authProvider.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export interface WorkbenchCredentialConfig {
3131
export interface CredentialChainConfig {
3232
readonly resolve: () => Promise<string>;
3333
readonly refreshIntervalMs?: number;
34+
/**
35+
* When true, prevents the user from signing out while the chain
36+
* can still resolve. Use for static env var credentials that will
37+
* just reappear immediately after removal.
38+
*/
39+
readonly preventSignOut?: boolean;
3440
}
3541

3642
/**
@@ -60,6 +66,11 @@ export class AuthProvider
6066
private readonly credentialChain?: CredentialChainConfig,
6167
) { }
6268

69+
/** Whether this provider blocks sign-out for chain sessions. */
70+
get chainPreventsSignOut(): boolean {
71+
return !!this.credentialChain?.preventSignOut;
72+
}
73+
6374
/** Expose session-change events to subclasses. */
6475
protected fireSessionsChanged(
6576
event: vscode.AuthenticationProviderAuthenticationSessionsChangeEvent
@@ -199,6 +210,33 @@ export class AuthProvider
199210

200211
async removeSession(sessionId: string): Promise<void> {
201212
if (this._chainSession?.id === sessionId) {
213+
// If the chain can still resolve, re-resolve immediately
214+
// instead of leaving an inconsistent state where the
215+
// session is gone but registered models still work.
216+
if (this.credentialChain?.preventSignOut) {
217+
try {
218+
const token = await this.credentialChain.resolve();
219+
if (token) {
220+
log.info(
221+
`[${this.displayName}] Chain session ` +
222+
`removal blocked -- credentials still ` +
223+
`available from environment`
224+
);
225+
vscode.window.showInformationMessage(
226+
vscode.l10n.t(
227+
'{0} credentials are configured via ' +
228+
'environment variable and cannot be ' +
229+
'signed out.',
230+
this.displayName
231+
)
232+
);
233+
return;
234+
}
235+
} catch {
236+
// Chain can no longer resolve; allow removal.
237+
}
238+
}
239+
202240
this.stopRefreshTimer();
203241
const removed = this._chainSession;
204242
this._chainSession = undefined;

extensions/authentication/src/configDialog.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,23 @@ async function handleDelete(
257257
return;
258258
}
259259
const sessions = await provider.getSessions();
260-
log.info(`Deleting ${sessions.length} session(s) for provider "${config.provider}"`);
261-
for (const session of sessions) {
260+
// Credential-chain sessions (e.g. env var credentials) use the
261+
// provider ID as their session ID. These cannot be removed via the
262+
// UI -- the user must unset the environment variable and restart.
263+
const deletable = provider.chainPreventsSignOut
264+
? sessions.filter(s => s.id !== config.provider)
265+
: sessions;
266+
if (deletable.length === 0 && sessions.length > 0) {
267+
throw new Error(
268+
vscode.l10n.t(
269+
'This credential was configured via an environment variable ' +
270+
'and cannot be removed from the UI. Unset the environment ' +
271+
'variable and restart Positron.'
272+
)
273+
);
274+
}
275+
log.info(`Deleting ${deletable.length} session(s) for provider "${config.provider}"`);
276+
for (const session of deletable) {
262277
await provider.removeSession(session.id);
263278
}
264279
}

extensions/authentication/src/constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import * as vscode from 'vscode';
88
export const IS_RUNNING_ON_PWB =
99
!!process.env.RS_SERVER_URL && vscode.env.uiKind === vscode.UIKind.Web;
1010

11-
export const ANTHROPIC_MODELS_ENDPOINT = 'https://api.anthropic.com/v1/models';
1211
export const ANTHROPIC_API_VERSION = '2023-06-01';
1312
export const KEY_VALIDATION_TIMEOUT_MS = 5000;
1413
export const CREDENTIAL_REFRESH_INTERVAL_MS = 10 * 60 * 1000;

extensions/authentication/src/extension.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { registerMigrateApiKeyCommand } from './migration/apiKey';
1919
export async function activate(context: vscode.ExtensionContext) {
2020
context.subscriptions.push(log);
2121

22-
registerAnthropicProvider(context);
22+
await registerAnthropicProvider(context);
2323
registerPositAIProvider(context);
2424
registerFoundryProvider(context);
2525

@@ -45,9 +45,46 @@ export async function activate(context: vscode.ExtensionContext) {
4545
registerMigrateApiKeyCommand(context);
4646
}
4747

48-
function registerAnthropicProvider(context: vscode.ExtensionContext): void {
48+
async function registerAnthropicProvider(
49+
context: vscode.ExtensionContext
50+
): Promise<void> {
51+
// Sync ANTHROPIC_BASE_URL env var to the config setting before
52+
// chain resolution so validation uses the correct endpoint.
53+
const envBaseUrl = process.env.ANTHROPIC_BASE_URL;
54+
if (envBaseUrl) {
55+
await vscode.workspace
56+
.getConfiguration('authentication.anthropic')
57+
.update(
58+
'baseUrl', envBaseUrl,
59+
vscode.ConfigurationTarget.Global
60+
).then(undefined, err =>
61+
log.error(`Failed to sync Anthropic base URL: ${err}`)
62+
);
63+
}
64+
4965
const provider = new AuthProvider(
50-
ANTHROPIC_AUTH_PROVIDER_ID, 'Anthropic', context
66+
ANTHROPIC_AUTH_PROVIDER_ID, 'Anthropic', context,
67+
undefined,
68+
{
69+
resolve: async () => {
70+
const apiKey = process.env.ANTHROPIC_API_KEY;
71+
if (!apiKey) {
72+
throw new Error('ANTHROPIC_API_KEY not set');
73+
}
74+
const baseUrl = vscode.workspace
75+
.getConfiguration('authentication.anthropic')
76+
.get<string>('baseUrl') || undefined;
77+
await validateAnthropicApiKey(apiKey, {
78+
provider: ANTHROPIC_AUTH_PROVIDER_ID,
79+
name: 'Anthropic',
80+
model: '',
81+
type: positron.PositronLanguageModelType.Chat,
82+
...(baseUrl && { baseUrl }),
83+
});
84+
return apiKey;
85+
},
86+
preventSignOut: true,
87+
}
5188
);
5289
context.subscriptions.push(
5390
vscode.authentication.registerAuthenticationProvider(
@@ -58,7 +95,24 @@ function registerAnthropicProvider(context: vscode.ExtensionContext): void {
5895
);
5996
registerAuthProvider(ANTHROPIC_AUTH_PROVIDER_ID, provider, {
6097
validateApiKey: validateAnthropicApiKey,
98+
onSave: async (config) => {
99+
if (config.baseUrl) {
100+
await vscode.workspace
101+
.getConfiguration('authentication.anthropic')
102+
.update(
103+
'baseUrl', config.baseUrl,
104+
vscode.ConfigurationTarget.Global
105+
);
106+
}
107+
},
61108
});
109+
110+
// Eagerly resolve env var credentials so the session is
111+
// available before positron-assistant registers models.
112+
await provider.resolveChainCredentials().catch(err =>
113+
log.debug(`[Anthropic] Initial credential resolution: ${err}`)
114+
);
115+
62116
log.info(`Registered auth provider: ${ANTHROPIC_AUTH_PROVIDER_ID}`);
63117
}
64118

extensions/authentication/src/test/authProvider.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ suite('AuthProvider (credential chain)', () => {
191191
assert.strictEqual(event.added!.length, 1);
192192
});
193193

194-
test('removeSession clears chain session and fires removed event', async () => {
194+
test('removeSession clears chain session by default', async () => {
195195
await chainProvider.resolveChainCredentials();
196196

197197
const eventPromise = new Promise<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>(
@@ -206,6 +206,49 @@ suite('AuthProvider (credential chain)', () => {
206206
await chainProvider.removeSession('test-chain');
207207
const event = await eventPromise;
208208

209+
assert.strictEqual(event.removed!.length, 1);
210+
const sessions = await chainProvider.getSessions();
211+
assert.strictEqual(sessions.length, 0);
212+
});
213+
214+
test('removeSession is blocked when preventSignOut is set and chain resolves', async () => {
215+
const protectedProvider = new AuthProvider(
216+
'test-protected', 'Test Protected', createMockContext(),
217+
undefined,
218+
{
219+
resolve: async () => resolveResult,
220+
preventSignOut: true,
221+
}
222+
);
223+
await protectedProvider.resolveChainCredentials();
224+
225+
await protectedProvider.removeSession('test-protected');
226+
227+
// Session should still exist because preventSignOut is set
228+
const sessions = await protectedProvider.getSessions();
229+
assert.strictEqual(sessions.length, 1);
230+
assert.strictEqual(sessions[0].id, 'test-protected');
231+
protectedProvider.dispose();
232+
});
233+
234+
test('removeSession clears chain session when chain fails', async () => {
235+
await chainProvider.resolveChainCredentials();
236+
237+
// Make the chain fail so removal is allowed
238+
resolveShouldFail = true;
239+
240+
const eventPromise = new Promise<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>(
241+
resolve => {
242+
const disposable = chainProvider.onDidChangeSessions(e => {
243+
disposable.dispose();
244+
resolve(e);
245+
});
246+
}
247+
);
248+
249+
await chainProvider.removeSession('test-chain');
250+
const event = await eventPromise;
251+
209252
assert.strictEqual(event.removed!.length, 1);
210253
assert.strictEqual(event.removed![0].id, 'test-chain');
211254

extensions/authentication/src/test/configDialog.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ suite('configDialog', () => {
4343
} as unknown as vscode.ExtensionContext;
4444
provider = new AuthProvider('anthropic-api', 'Anthropic', mockContext);
4545
registerAuthProvider('anthropic-api', provider, {
46-
validateApiKey: async (apiKey) => validateAnthropicApiKey(apiKey),
46+
validateApiKey: async (apiKey, config) => validateAnthropicApiKey(apiKey, config),
4747
});
4848
});
4949

@@ -125,6 +125,53 @@ suite('configDialog', () => {
125125
assert.strictEqual(stored, false);
126126
});
127127

128+
test('delete rejects when only chain session exists', async () => {
129+
const chainProvider = new AuthProvider(
130+
'anthropic-api', 'Anthropic',
131+
{
132+
secrets: {
133+
get: () => Promise.resolve(undefined),
134+
store: () => Promise.resolve(),
135+
delete: () => Promise.resolve(),
136+
},
137+
globalState: {
138+
get: () => undefined,
139+
update: () => Promise.resolve(),
140+
},
141+
} as unknown as vscode.ExtensionContext,
142+
undefined,
143+
{
144+
resolve: async () => 'sk-ant-test-key',
145+
preventSignOut: true,
146+
}
147+
);
148+
authProviders.clear();
149+
registerAuthProvider('anthropic-api', chainProvider);
150+
await chainProvider.resolveChainCredentials();
151+
152+
const source = {
153+
type: positron.PositronLanguageModelType.Chat,
154+
provider: { id: 'anthropic-api', displayName: 'Anthropic', settingName: 'anthropic-api' },
155+
signedIn: true,
156+
defaults: { name: 'Anthropic', model: 'claude-sonnet-4-0' },
157+
supportedOptions: [],
158+
} as unknown as positron.ai.LanguageModelSource;
159+
160+
positron.ai.showLanguageModelConfig = async (_sources, onAction) => {
161+
await onAction({ provider: 'anthropic-api', type: positron.PositronLanguageModelType.Chat, name: 'Anthropic', model: 'claude-sonnet-4-0' }, 'delete');
162+
};
163+
164+
await assert.rejects(
165+
showConfigurationDialog([source]),
166+
(error: Error) => error.message.includes('environment variable')
167+
);
168+
169+
// Chain session should still exist
170+
const sessions = await chainProvider.getSessions();
171+
assert.strictEqual(sessions.length, 1);
172+
chainProvider.dispose();
173+
});
174+
128175
test('save without apiKey calls createSession for chain provider', async () => {
129176
const chainProvider = new AuthProvider(
130177
'test-chain', 'Test Chain',

extensions/authentication/src/validation/anthropic.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7-
import { ANTHROPIC_API_VERSION, ANTHROPIC_MODELS_ENDPOINT, KEY_VALIDATION_TIMEOUT_MS } from '../constants';
7+
import * as positron from 'positron';
8+
import { ANTHROPIC_API_VERSION, KEY_VALIDATION_TIMEOUT_MS } from '../constants';
89

910
class ApiKeyValidationError extends Error {
1011
constructor(message: string) {
@@ -13,11 +14,16 @@ class ApiKeyValidationError extends Error {
1314
}
1415
}
1516

16-
export async function validateAnthropicApiKey(apiKey: string): Promise<void> {
17+
export async function validateAnthropicApiKey(apiKey: string, config: positron.ai.LanguageModelConfig): Promise<void> {
18+
const rawBaseUrl = (config.baseUrl ?? 'https://api.anthropic.com')
19+
.replace(/\/v1\/?$/, '')
20+
.replace(/\/+$/, '');
21+
const modelsEndpoint = `${rawBaseUrl}/v1/models`;
22+
1723
const controller = new AbortController();
1824
const timeout = setTimeout(() => controller.abort(), KEY_VALIDATION_TIMEOUT_MS);
1925
try {
20-
const response = await fetch(ANTHROPIC_MODELS_ENDPOINT, {
26+
const response = await fetch(modelsEndpoint, {
2127
method: 'GET',
2228
headers: {
2329
'x-api-key': apiKey,

extensions/positron-assistant/src/config.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,38 @@ export async function showConfigurationDialog(
128128
// Resolve environment variables
129129
if (source.defaults.autoconfigure.type === positron.ai.LanguageModelAutoconfigureType.EnvVariable) {
130130
const envVarName = source.defaults.autoconfigure.key;
131-
const envVarValue = process.env[envVarName];
131+
const providerId = source.provider.id;
132+
133+
// For providers migrated to the auth extension,
134+
// check for a credential chain session.
135+
let signedIn = false;
136+
let baseUrlValue: string | undefined;
137+
if (isAuthExtProvider(providerId)) {
138+
try {
139+
const session = await vscode.authentication.getSession(
140+
providerId, [], { silent: true }
141+
);
142+
signedIn = !!session?.accessToken
143+
&& session.id === providerId;
144+
} catch {
145+
signedIn = false;
146+
}
147+
const configKey = providerId.replace(/-.*$/, '');
148+
baseUrlValue = vscode.workspace
149+
.getConfiguration(`authentication.${configKey}`)
150+
.get<string>('baseUrl') || undefined;
151+
} else {
152+
signedIn = !!process.env[envVarName];
153+
const baseUrlEnvVar = `${envVarName.replace(/_API_KEY$/, '')}_BASE_URL`;
154+
baseUrlValue = process.env[baseUrlEnvVar];
155+
}
132156

133157
return {
134158
...source,
135159
defaults: {
136160
...source.defaults,
137-
autoconfigure: { type: positron.ai.LanguageModelAutoconfigureType.EnvVariable, key: envVarName, signedIn: !!envVarValue }
161+
...(baseUrlValue && { baseUrl: baseUrlValue }),
162+
autoconfigure: { type: positron.ai.LanguageModelAutoconfigureType.EnvVariable, key: envVarName, signedIn }
138163
},
139164
};
140165
} else if (source.defaults.autoconfigure.type === positron.ai.LanguageModelAutoconfigureType.Custom) {

0 commit comments

Comments
 (0)