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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// *****************************************************************************
// Copyright (C) 2024 EclipseSource GmbH.
// Copyright (C) 2024, 2025 EclipseSource GmbH.
Copy link
Member

Choose a reason for hiding this comment

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

We don't update the year in the Theia project and just leave the header alone once a file is created.

//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
Expand All @@ -17,7 +17,7 @@
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { inject, injectable } from '@theia/core/shared/inversify';
import { AnthropicLanguageModelsManager, AnthropicModelDescription } from '../common';
import { API_KEY_PREF, MODELS_PREF } from '../common/anthropic-preferences';
import { API_KEY_PREF, CUSTOM_ENDPOINTS_PREF, MODELS_PREF } from '../common/anthropic-preferences';
import { AICorePreferences, PREFERENCE_NAME_MAX_RETRIES } from '@theia/ai-core/lib/common/ai-core-preferences';
import { PreferenceService } from '@theia/core';

Expand Down Expand Up @@ -50,6 +50,7 @@ export class AnthropicFrontendApplicationContribution implements FrontendApplica
protected aiCorePreferences: AICorePreferences;

protected prevModels: string[] = [];
protected prevCustomModels: Partial<AnthropicModelDescription>[] = [];

onStart(): void {
this.preferenceService.ready.then(() => {
Expand All @@ -63,6 +64,10 @@ export class AnthropicFrontendApplicationContribution implements FrontendApplica
this.manager.createOrUpdateLanguageModels(...models.map(modelId => this.createAnthropicModelDescription(modelId)));
this.prevModels = [...models];

const customModels = this.preferenceService.get<Partial<AnthropicModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []);
this.manager.createOrUpdateLanguageModels(...this.createCustomModelDescriptionsFromPreferences(customModels));
this.prevCustomModels = [...customModels];

this.preferenceService.onPreferenceChanged(event => {
if (event.preferenceName === API_KEY_PREF) {
this.manager.setApiKey(event.newValue as string);
Expand All @@ -71,6 +76,8 @@ export class AnthropicFrontendApplicationContribution implements FrontendApplica
this.handleModelChanges(event.newValue as string[]);
} else if (event.preferenceName === 'http.proxy') {
this.manager.setProxyUrl(event.newValue as string);
} else if (event.preferenceName === CUSTOM_ENDPOINTS_PREF) {
this.handleCustomModelChanges(event.newValue as Partial<AnthropicModelDescription>[]);
}
});

Expand All @@ -94,9 +101,32 @@ export class AnthropicFrontendApplicationContribution implements FrontendApplica
this.prevModels = newModels;
}

protected handleCustomModelChanges(newCustomModels: Partial<AnthropicModelDescription>[]): void {
const oldModels = this.createCustomModelDescriptionsFromPreferences(this.prevCustomModels);
const newModels = this.createCustomModelDescriptionsFromPreferences(newCustomModels);

const modelsToRemove = oldModels.filter(model => !newModels.some(newModel => newModel.id === model.id));
const modelsToAddOrUpdate = newModels.filter(newModel =>
!oldModels.some(model =>
model.id === newModel.id &&
model.model === newModel.model &&
model.url === newModel.url &&
model.apiKey === newModel.apiKey &&
model.maxRetries === newModel.maxRetries &&
model.useCaching === newModel.useCaching &&
model.enableStreaming === newModel.enableStreaming));

this.manager.removeLanguageModels(...modelsToRemove.map(model => model.id));
this.manager.createOrUpdateLanguageModels(...modelsToAddOrUpdate);
this.prevCustomModels = [...newCustomModels];
}

protected updateAllModels(): void {
const models = this.preferenceService.get<string[]>(MODELS_PREF, []);
this.manager.createOrUpdateLanguageModels(...models.map(modelId => this.createAnthropicModelDescription(modelId)));

const customModels = this.preferenceService.get<Partial<AnthropicModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []);
this.manager.createOrUpdateLanguageModels(...this.createCustomModelDescriptionsFromPreferences(customModels));
}

protected createAnthropicModelDescription(modelId: string): AnthropicModelDescription {
Expand All @@ -122,4 +152,25 @@ export class AnthropicFrontendApplicationContribution implements FrontendApplica
return description;
}

protected createCustomModelDescriptionsFromPreferences(preferences: Partial<AnthropicModelDescription>[]): AnthropicModelDescription[] {
const maxRetries = this.aiCorePreferences.get(PREFERENCE_NAME_MAX_RETRIES) ?? 3;
return preferences.reduce((acc, pref) => {
if (!pref.model || !pref.url || typeof pref.model !== 'string' || typeof pref.url !== 'string') {
return acc;
}
return [
...acc,
{
id: pref.id && typeof pref.id === 'string' ? pref.id : pref.model,
model: pref.model,
url: pref.url,
apiKey: typeof pref.apiKey === 'string' || pref.apiKey === true ? pref.apiKey : undefined,
enableStreaming: pref.enableStreaming ?? true,
useCaching: pref.useCaching ?? true,
maxRetries: pref.maxRetries ?? maxRetries
}
];
}, []);
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// *****************************************************************************
// Copyright (C) 2024 EclipseSource GmbH.
// Copyright (C) 2024, 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
Expand All @@ -24,6 +24,10 @@ export interface AnthropicModelDescription {
* The model ID as used by the Anthropic API.
*/
model: string;
/**
* The Anthropic API compatible endpoint where the model is hosted. If not provided the default Anthropic endpoint will be used.
*/
url?: string;
/**
* The key for the model. If 'true' is provided the global Anthropic API key will be used.
*/
Expand Down
59 changes: 58 additions & 1 deletion packages/ai-anthropic/src/common/anthropic-preferences.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// *****************************************************************************
// Copyright (C) 2024 EclipseSource GmbH.
// Copyright (C) 2024, 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
Expand All @@ -19,6 +19,7 @@ import { nls, PreferenceSchema } from '@theia/core';

export const API_KEY_PREF = 'ai-features.anthropic.AnthropicApiKey';
export const MODELS_PREF = 'ai-features.anthropic.AnthropicModels';
export const CUSTOM_ENDPOINTS_PREF = 'ai-features.AnthropicCustom.customAnthropicModels';
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
export const CUSTOM_ENDPOINTS_PREF = 'ai-features.AnthropicCustom.customAnthropicModels';
export const CUSTOM_ENDPOINTS_PREF = 'ai-features.anthropicCustom.customAnthropicModels';


export const AnthropicPreferencesSchema: PreferenceSchema = {
properties: {
Expand All @@ -38,5 +39,61 @@ export const AnthropicPreferencesSchema: PreferenceSchema = {
type: 'string'
}
},
[CUSTOM_ENDPOINTS_PREF]: {
type: 'array',
title: AI_CORE_PREFERENCES_TITLE,
markdownDescription: nls.localize('theia/ai/anthropic/useResponseApi/mdDescription',
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
markdownDescription: nls.localize('theia/ai/anthropic/useResponseApi/mdDescription',
markdownDescription: nls.localize('theia/ai/anthropic/customEndpoints/mdDescription',

'Integrate custom models compatible with the Anthropic API. The required attributes are `model` and `url`.\
\n\
Optionally, you can\
\n\
- specify a unique `id` to identify the custom model in the UI. If none is given `model` will be used as `id`.\
\n\
- provide an `apiKey` to access the API served at the given url. Use `true` to indicate the use of the global anthropic API key.\
\n\
- specify `enableStreaming: false` to indicate that streaming shall not be used.\
\n\
- specify `useCaching: false` to indicate that prompt caching shall not be used.\
\n\
- specify `maxRetries: <number>` to indicate the maximum number of retries when a request fails. 3 by default.'),
default: [],
items: {
type: 'object',
properties: {
model: {
type: 'string',
title: nls.localize('theia/ai/anthropic/customEndpoints/modelId/title', 'Model ID')
},
url: {
type: 'string',
title: nls.localize('theia/ai/anthropic/customEndpoints/url/title', 'The Anthropic API compatible endpoint where the model is hosted')
},
id: {
type: 'string',
title: nls.localize('theia/ai/anthropic/customEndpoints/id/title', 'A unique identifier which is used in the UI to identify the custom model'),
},
apiKey: {
type: ['string', 'boolean'],
title: nls.localize('theia/ai/anthropic/customEndpoints/apiKey/title',
'Either the key to access the API served at the given url or `true` to use the global Anthropic API key'),
},
enableStreaming: {
type: 'boolean',
title: nls.localize('theia/ai/anthropic/customEndpoints/enableStreaming/title',
'Indicates whether the streaming API shall be used. `true` by default.'),
},
useCaching: {
type: 'boolean',
title: nls.localize('theia/ai/anthropic/customEndpoints/useCaching/title',
'Indicates whether the model supports prompt caching. `true` by default'),
},
maxRetries: {
type: 'number',
title: nls.localize('theia/ai/anthropic/customEndpoints/maxRetries/title',
'Maximum number of retries when a request fails. 3 by default'),
}
}
}
}
}
};
21 changes: 21 additions & 0 deletions packages/ai-anthropic/src/node/anthropic-language-model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('AnthropicModel', () => {
true,
true,
() => 'test-api-key',
undefined,
DEFAULT_MAX_TOKENS
);

Expand All @@ -47,6 +48,7 @@ describe('AnthropicModel', () => {
true,
true,
() => 'test-api-key',
undefined,
DEFAULT_MAX_TOKENS,
customMaxRetries
);
Expand All @@ -64,6 +66,7 @@ describe('AnthropicModel', () => {
true,
true,
() => 'test-api-key',
undefined,
DEFAULT_MAX_TOKENS,
5
);
Expand All @@ -74,6 +77,24 @@ describe('AnthropicModel', () => {
expect(model.maxTokens).to.equal(DEFAULT_MAX_TOKENS);
expect(model.maxRetries).to.equal(5);
});

it('should set custom url when provided', () => {
const model = new AnthropicModel(
'test-id',
'claude-3-opus-20240229',
{
status: 'ready'
},
true,
true,
() => 'test-api-key',
'custom-url',
DEFAULT_MAX_TOKENS,
5
);

expect(model.url).to.equal('custom-url');
});
});

describe('addCacheControlToLastMessage', () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/ai-anthropic/src/node/anthropic-language-model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// *****************************************************************************
// Copyright (C) 2024 EclipseSource GmbH.
// Copyright (C) 2024, 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
Expand Down Expand Up @@ -201,6 +201,7 @@ export class AnthropicModel implements LanguageModel {
public enableStreaming: boolean,
public useCaching: boolean,
public apiKey: () => string | undefined,
public url: string | undefined,
public maxTokens: number = DEFAULT_MAX_TOKENS,
public maxRetries: number = 3,
protected readonly tokenUsageService?: TokenUsageService,
Expand Down Expand Up @@ -444,6 +445,6 @@ export class AnthropicModel implements LanguageModel {
};
}

return new Anthropic({ apiKey, fetchOptions: fo });
return new Anthropic({ apiKey, baseURL: this.url, fetchOptions: fo });
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// *****************************************************************************
// Copyright (C) 2024 EclipseSource GmbH.
// Copyright (C) 2024, 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
Expand Down Expand Up @@ -57,9 +57,8 @@ export class AnthropicLanguageModelsManagerImpl implements AnthropicLanguageMode
return process.env['https_proxy'];
};

// Determine status based on API key presence
const effectiveApiKey = apiKeyProvider();
const status = this.getStatusForApiKey(effectiveApiKey);
// Determine status based on API key and custom url presence
const status = this.calculateStatus(modelDescription, apiKeyProvider());

if (model) {
if (!(model instanceof AnthropicModel)) {
Expand All @@ -69,6 +68,8 @@ export class AnthropicLanguageModelsManagerImpl implements AnthropicLanguageMode
await this.languageModelRegistry.patchLanguageModel<AnthropicModel>(modelDescription.id, {
model: modelDescription.model,
enableStreaming: modelDescription.enableStreaming,
url: modelDescription.url,
useCaching: modelDescription.useCaching,
apiKey: apiKeyProvider,
status,
maxTokens: modelDescription.maxTokens !== undefined ? modelDescription.maxTokens : DEFAULT_MAX_TOKENS,
Expand All @@ -83,6 +84,7 @@ export class AnthropicLanguageModelsManagerImpl implements AnthropicLanguageMode
modelDescription.enableStreaming,
modelDescription.useCaching,
apiKeyProvider,
modelDescription.url,
modelDescription.maxTokens,
modelDescription.maxRetries,
this.tokenUsageService,
Expand Down Expand Up @@ -114,9 +116,13 @@ export class AnthropicLanguageModelsManagerImpl implements AnthropicLanguageMode
}

/**
* Returns the status for a language model based on the presence of an API key.
* Returns the status for a language model based on the presence of an API key or custom url.
*/
protected getStatusForApiKey(effectiveApiKey: string | undefined): LanguageModelStatus {
protected calculateStatus(modelDescription: AnthropicModelDescription, effectiveApiKey: string | undefined): LanguageModelStatus {
// Always mark custom models (models with url) as ready for now as we do not know about API Key requirements
if (modelDescription.url) {
return { status: 'ready' };
}
Comment on lines +122 to +125
Copy link
Member

Choose a reason for hiding this comment

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

This change is not sufficient. Within the AnthropicModel we check for the api key and if it does not exist, the model will immediately throw an error before even trying to use the SDK, see here.
If we allow apiKey-less models to report ready, then there must be a successful path for them.

Please check what is required for this. In the OpenAI SDK we always need to hand over a key, so for keyless models we hand over a "stub key". I don't know whether this is also required for the Anthropic SDK or whether it actually supports not handing over a key if the endpoint does not need one.

return effectiveApiKey
? { status: 'ready' }
: { status: 'unavailable', message: 'No API key set' };
Expand Down
Loading