From bd413d97dd8ad40ac4be5568301e405a9ca9f854 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Wed, 17 May 2023 01:04:00 -0400 Subject: [PATCH] Adds Anthropic AI support - Adds ai.experimental.provider setting to switch between OpenAI & Anthropic --- package.json | 36 +++- src/ai/aiProviderService.ts | 39 +++- src/ai/anthropicProvider.ts | 225 +++++++++++++++++++++ src/ai/openaiProvider.ts | 36 ++-- src/config.ts | 5 + src/constants.ts | 4 +- src/webviews/apps/commitDetails/commitDetails.html | 2 +- 7 files changed, 316 insertions(+), 31 deletions(-) create mode 100644 src/ai/anthropicProvider.ts diff --git a/package.json b/package.json index 2038151..d6b03dd 100644 --- a/package.json +++ b/package.json @@ -2817,6 +2817,21 @@ "scope": "window", "order": 1 }, + "gitlens.ai.experimental.provider": { + "type": "string", + "default": "openai", + "enum": [ + "openai", + "anthropic" + ], + "enumDescriptions": [ + "OpenAI", + "Anthropic" + ], + "markdownDescription": "Specifies the AI provider to use for GitLens' experimental AI features", + "scope": "window", + "order": 100 + }, "gitlens.ai.experimental.openai.model": { "type": "string", "default": "gpt-3.5-turbo", @@ -2838,7 +2853,26 @@ ], "markdownDescription": "Specifies the OpenAI model to use for GitLens' experimental AI features", "scope": "window", - "order": 100 + "order": 101 + }, + "gitlens.ai.experimental.anthropic.model": { + "type": "string", + "default": "claude-v1", + "enum": [ + "claude-v1", + "claude-v1-100k", + "claude-instant-v1", + "claude-instant-v1-100k" + ], + "enumDescriptions": [ + "Claude v1", + "Claude v1 with 100k token context", + "Claude Instant v1", + "Claude Instant v1 with 100k token context" + ], + "markdownDescription": "Specifies the Anthropic model to use for GitLens' experimental AI features", + "scope": "window", + "order": 102 } } }, diff --git a/src/ai/aiProviderService.ts b/src/ai/aiProviderService.ts index 78da6b3..4f22bf2 100644 --- a/src/ai/aiProviderService.ts +++ b/src/ai/aiProviderService.ts @@ -8,7 +8,9 @@ import { uncommittedStaged } from '../git/models/constants'; import type { GitRevisionReference } from '../git/models/reference'; import type { Repository } from '../git/models/repository'; import { isRepository } from '../git/models/repository'; +import { configuration } from '../system/configuration'; import type { Storage } from '../system/storage'; +import { AnthropicProvider } from './anthropicProvider'; import { OpenAIProvider } from './openaiProvider'; export interface AIProvider extends Disposable { @@ -20,14 +22,27 @@ export interface AIProvider extends Disposable { } export class AIProviderService implements Disposable { - private _provider: AIProvider; + private _provider: AIProvider | undefined; - constructor(private readonly container: Container) { - this._provider = new OpenAIProvider(container); + private get provider() { + const providerId = configuration.get('ai.experimental.provider'); + if (providerId === this._provider?.id) return this._provider; + + this._provider?.dispose(); + + if (providerId === 'anthropic') { + this._provider = new AnthropicProvider(this.container); + } else { + this._provider = new OpenAIProvider(this.container); + } + + return this._provider; } + constructor(private readonly container: Container) {} + dispose() { - this._provider.dispose(); + this._provider?.dispose(); } public async generateCommitMessage( @@ -50,15 +65,17 @@ export class AIProviderService implements Disposable { }); if (diff?.diff == null) throw new Error('No staged changes to generate a commit message from.'); - const confirmed = await confirmAIProviderToS(this._provider, this.container.storage); + const provider = this.provider; + + const confirmed = await confirmAIProviderToS(provider, this.container.storage); if (!confirmed) return undefined; if (options?.progress != null) { return window.withProgress(options.progress, async () => - this._provider.generateCommitMessage(diff.diff!, { context: options?.context }), + provider.generateCommitMessage(diff.diff!, { context: options?.context }), ); } - return this._provider.generateCommitMessage(diff.diff, { context: options?.context }); + return provider.generateCommitMessage(diff.diff, { context: options?.context }); } async explainCommit( @@ -95,7 +112,9 @@ export class AIProviderService implements Disposable { }); if (diff?.diff == null) throw new Error('No changes found to explain.'); - const confirmed = await confirmAIProviderToS(this._provider, this.container.storage); + const provider = this.provider; + + const confirmed = await confirmAIProviderToS(provider, this.container.storage); if (!confirmed) return undefined; if (!commit.hasFullDetails()) { @@ -105,10 +124,10 @@ export class AIProviderService implements Disposable { if (options?.progress != null) { return window.withProgress(options.progress, async () => - this._provider.explainChanges(commit!.message!, diff.diff!), + provider.explainChanges(commit!.message!, diff.diff!), ); } - return this._provider.explainChanges(commit.message, diff.diff); + return provider.explainChanges(commit.message, diff.diff); } } diff --git a/src/ai/anthropicProvider.ts b/src/ai/anthropicProvider.ts new file mode 100644 index 0000000..8be62f9 --- /dev/null +++ b/src/ai/anthropicProvider.ts @@ -0,0 +1,225 @@ +import type { Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { fetch } from '@env/fetch'; +import type { Container } from '../container'; +import { configuration } from '../system/configuration'; +import type { Storage } from '../system/storage'; +import { supportedInVSCodeVersion } from '../system/utils'; +import type { AIProvider } from './aiProviderService'; + +export class AnthropicProvider implements AIProvider { + readonly id = 'anthropic'; + readonly name = 'Anthropic'; + + private get model(): AnthropicModels { + return configuration.get('ai.experimental.anthropic.model') || 'claude-v1'; + } + + constructor(private readonly container: Container) {} + + dispose() {} + + async generateCommitMessage(diff: string, options?: { context?: string }): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + const model = this.model; + const maxCodeCharacters = getMaxCharacters(model); + + const code = diff.substring(0, maxCodeCharacters); + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the staged changes had to be truncated to ${maxCodeCharacters} characters to fit within the Anthropic's limits.`, + ); + } + + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + let prompt = + "\n\nHuman: You are an AI programming assistant tasked with writing a meaningful commit message by summarizing code changes.\n- Follow the user's instructions carefully & to the letter!\n- Don't repeat yourself or make anything up!\n- Minimize any other prose."; + prompt += `\n${customPrompt}\n- Avoid phrases like "this commit", "this change", etc.`; + prompt += '\n\nAssistant: OK'; + if (options?.context) { + prompt += `\n\nHuman: Use "${options.context}" to help craft the commit message.\n\nAssistant: OK`; + } + prompt += `\n\nHuman: Write a meaningful commit message for the following code changes:\n\n${code}`; + prompt += '\n\nAssistant:'; + + const request: AnthropicCompletionRequest = { + model: model, + prompt: prompt, + stream: false, + max_tokens_to_sample: 5000, + stop_sequences: ['\n\nHuman:'], + }; + + const rsp = await fetch('https://api.anthropic.com/v1/complete', { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + Client: 'anthropic-typescript/0.4.3', + 'X-API-Key': apiKey, + }, + method: 'POST', + body: JSON.stringify(request), + }); + + if (!rsp.ok) { + debugger; + throw new Error(`Unable to generate commit message: ${rsp.status}: ${rsp.statusText}`); + } + + const data: AnthropicCompletionResponse = await rsp.json(); + const message = data.completion.trim(); + return message; + } + + async explainChanges(message: string, diff: string): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + const model = this.model; + const maxCodeCharacters = getMaxCharacters(model); + + const code = diff.substring(0, maxCodeCharacters); + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the commit changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, + ); + } + + let prompt = + "\n\nHuman: You are an AI programming assistant tasked with providing an easy to understand but detailed explanation of a commit by summarizing the code changes while also using the commit message as additional context and framing.\nDon't make anything up!"; + prompt += `\nUse the following user-provided commit message, which should provide some explanation to why these changes where made, when attempting to generate the rich explanation:\n\n${message}`; + prompt += '\n\nAssistant: OK'; + prompt += `\n\nHuman: Explain the following code changes:\n\n${code}`; + prompt += '\n\nAssistant:'; + + const request: AnthropicCompletionRequest = { + model: model, + prompt: prompt, + stream: false, + max_tokens_to_sample: 5000, + stop_sequences: ['\n\nHuman:'], + }; + + const rsp = await fetch('https://api.anthropic.com/v1/complete', { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + Client: 'anthropic-typescript/0.4.3', + 'X-API-Key': apiKey, + }, + method: 'POST', + body: JSON.stringify(request), + }); + + if (!rsp.ok) { + debugger; + throw new Error(`Unable to explain commit: ${rsp.status}: ${rsp.statusText}`); + } + + const data: AnthropicCompletionResponse = await rsp.json(); + const summary = data.completion.trim(); + return summary; + } +} + +async function getApiKey(storage: Storage): Promise { + let apiKey = await storage.getSecret('gitlens.anthropic.key'); + if (!apiKey) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Anthropic API Key Page', + }; + + apiKey = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(value => { + if (value && !/sk-[a-zA-Z0-9-_]{32,}/.test(value)) { + input.validationMessage = 'Please enter a valid Anthropic API key'; + return; + } + input.validationMessage = undefined; + }), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value || !/sk-[a-zA-Z0-9-_]{32,}/.test(value)) { + input.validationMessage = 'Please enter a valid Anthropic API key'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal(Uri.parse('https://console.anthropic.com/account/keys')); + } + }), + ); + + input.password = true; + input.title = 'Connect to Anthropic'; + input.placeholder = 'Please enter your Anthropic API key to use this feature'; + input.prompt = supportedInVSCodeVersion('input-prompt-links') + ? 'Enter your [Anthropic API Key](https://console.anthropic.com/account/keys "Get your Anthropic API key")' + : 'Enter your Anthropic API Key'; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!apiKey) return undefined; + + void storage.storeSecret('gitlens.anthropic.key', apiKey); + } + + return apiKey; +} + +function getMaxCharacters(model: AnthropicModels): number { + if (model === 'claude-v1-100k' || model === 'claude-instant-v1-100k') { + return 135000; + } + return 12000; +} +export type AnthropicModels = 'claude-v1' | 'claude-v1-100k' | 'claude-instant-v1' | 'claude-instant-v1-100k'; + +interface AnthropicCompletionRequest { + model: string; + prompt: string; + stream: boolean; + + max_tokens_to_sample: number; + stop_sequences: string[]; + + temperature?: number; + top_k?: number; + top_p?: number; + tags?: { [key: string]: string }; +} + +interface AnthropicCompletionResponse { + completion: string; + stop: string | null; + stop_reason: 'stop_sequence' | 'max_tokens'; + truncated: boolean; + exception: string | null; + log_id: string; +} diff --git a/src/ai/openaiProvider.ts b/src/ai/openaiProvider.ts index 4fc4a3d..20084b0 100644 --- a/src/ai/openaiProvider.ts +++ b/src/ai/openaiProvider.ts @@ -20,8 +20,8 @@ export class OpenAIProvider implements AIProvider { dispose() {} async generateCommitMessage(diff: string, options?: { context?: string }): Promise { - const openaiApiKey = await getApiKey(this.container.storage); - if (openaiApiKey == null) return undefined; + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; const model = this.model; const maxCodeCharacters = getMaxCharacters(model); @@ -38,7 +38,7 @@ export class OpenAIProvider implements AIProvider { customPrompt += '.'; } - const data: OpenAIChatCompletionRequest = { + const request: OpenAIChatCompletionRequest = { model: model, messages: [ { @@ -54,23 +54,24 @@ export class OpenAIProvider implements AIProvider { }; if (options?.context) { - data.messages.push({ + request.messages.push({ role: 'user', content: `Use "${options.context}" to help craft the commit message.`, }); } - data.messages.push({ + request.messages.push({ role: 'user', content: `Write a meaningful commit message for the following code changes:\n\n${code}`, }); const rsp = await fetch('https://api.openai.com/v1/chat/completions', { headers: { - Authorization: `Bearer ${openaiApiKey}`, + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, method: 'POST', - body: JSON.stringify(data), + body: JSON.stringify(request), }); if (!rsp.ok) { @@ -78,14 +79,14 @@ export class OpenAIProvider implements AIProvider { throw new Error(`Unable to generate commit message: ${rsp.status}: ${rsp.statusText}`); } - const completion: OpenAIChatCompletionResponse = await rsp.json(); - const message = completion.choices[0].message.content.trim(); + const data: OpenAIChatCompletionResponse = await rsp.json(); + const message = data.choices[0].message.content.trim(); return message; } async explainChanges(message: string, diff: string): Promise { - const openaiApiKey = await getApiKey(this.container.storage); - if (openaiApiKey == null) return undefined; + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; const model = this.model; const maxCodeCharacters = getMaxCharacters(model); @@ -97,13 +98,13 @@ export class OpenAIProvider implements AIProvider { ); } - const data: OpenAIChatCompletionRequest = { + const request: OpenAIChatCompletionRequest = { model: model, messages: [ { role: 'system', content: - "You are an AI programming assistant tasked with providing a detailed explanation of a commit by summarizing the code changes while also using the commit message as additional context and framing.\n\n- Don't make anything up!", + "You are an AI programming assistant tasked with providing an easy to understand but detailed explanation of a commit by summarizing the code changes while also using the commit message as additional context and framing.\n\n- Don't make anything up!", }, { role: 'user', @@ -122,11 +123,12 @@ export class OpenAIProvider implements AIProvider { const rsp = await fetch('https://api.openai.com/v1/chat/completions', { headers: { - Authorization: `Bearer ${openaiApiKey}`, + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, method: 'POST', - body: JSON.stringify(data), + body: JSON.stringify(request), }); if (!rsp.ok) { @@ -134,8 +136,8 @@ export class OpenAIProvider implements AIProvider { throw new Error(`Unable to explain commit: ${rsp.status}: ${rsp.statusText}`); } - const completion: OpenAIChatCompletionResponse = await rsp.json(); - const summary = completion.choices[0].message.content.trim(); + const data: OpenAIChatCompletionResponse = await rsp.json(); + const summary = data.choices[0].message.content.trim(); return summary; } } diff --git a/src/config.ts b/src/config.ts index d8e7f9c..707792c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import type { AnthropicModels } from './ai/anthropicProvider'; import type { OpenAIModels } from './ai/openaiProvider'; import type { DateTimeFormat } from './system/date'; import { LogLevel } from './system/logger.constants'; @@ -5,9 +6,13 @@ import { LogLevel } from './system/logger.constants'; export interface Config { ai: { experimental: { + provider: 'openai' | 'anthropic'; openai: { model?: OpenAIModels; }; + anthropic: { + model?: AnthropicModels; + }; }; }; autolinks: AutolinkReference[] | null; diff --git a/src/constants.ts b/src/constants.ts index dd09e5f..347f5c6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -501,11 +501,11 @@ export type TelemetryEvents = | 'subscription/changed' | 'usage/track'; -export type AIProviders = 'openai'; +export type AIProviders = 'anthropic' | 'openai'; export type SecretKeys = | `gitlens.integration.auth:${string}` - | 'gitlens.openai.key' + | `gitlens.${AIProviders}.key` | `gitlens.plus.auth:${Environment}`; export const enum SyncedStorageKeys { diff --git a/src/webviews/apps/commitDetails/commitDetails.html b/src/webviews/apps/commitDetails/commitDetails.html index 2966403..7295107 100644 --- a/src/webviews/apps/commitDetails/commitDetails.html +++ b/src/webviews/apps/commitDetails/commitDetails.html @@ -233,7 +233,7 @@ Explain (AI)
-

Let OpenAI assist in understanding the changes made with this commit.

+

Let AI assist in understanding the changes made with this commit.