diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 28bc658e4113f..01b783f1f7a8f 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -124,7 +124,7 @@ 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', 'input.length': number, 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string, 'output.length': number, 'retry.count': number, @@ -155,7 +155,7 @@ 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', 'input.length': number, 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string, 'output.length': number, 'retry.count': number, @@ -185,7 +185,7 @@ or 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', 'input.length': number, 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string, 'output.length': number, 'retry.count': number, @@ -214,7 +214,7 @@ or 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', 'input.length': number, 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string, 'output.length': number, 'retry.count': number, @@ -243,7 +243,7 @@ or 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', 'input.length': number, 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string, 'output.length': number, 'retry.count': number, @@ -272,7 +272,7 @@ or 'failed.reason': 'user-declined' | 'user-cancelled' | 'error', 'input.length': number, 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string, 'output.length': number, 'retry.count': number, @@ -295,7 +295,7 @@ or ```typescript { 'model.id': string, - 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string } ``` diff --git a/package.json b/package.json index 1e7ca5a8c4106..49791dabb33f7 100644 --- a/package.json +++ b/package.json @@ -4047,7 +4047,7 @@ "null" ], "default": null, - "pattern": "^((anthropic|deepseek|gemini|github|huggingface|ollama|openai|openrouter|xai):([\\w.-:]+)|gitkraken|vscode)$", + "pattern": "^((anthropic|deepseek|gemini|github|huggingface|ollama|openai|openaicompatible|openrouter|xai):([\\w.-:]+)|gitkraken|vscode)$", "markdownDescription": "Specifies the AI provider and model to use for GitLens' AI features. Should be formatted as `provider:model` (e.g. `openai:gpt-4o` or `anthropic:claude-3-5-sonnet-latest`), `gitkraken` for GitKraken AI provided models, or `vscode` for models provided by the VS Code extension API (e.g. Copilot)", "scope": "window", "order": 10, @@ -4109,6 +4109,19 @@ "preview" ] }, + "gitlens.ai.openaicompatible.url": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies a custom URL to use for access to an OpenAI-compatible model. Azure URLs should be in the following format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/chat/completions?api-version={api-version}", + "scope": "window", + "order": 31, + "tags": [ + "preview" + ] + }, "gitlens.ai.largePromptWarningThreshold": { "type": "number", "default": 20000, diff --git a/src/config.ts b/src/config.ts index 264939dcde15b..2503062ac5373 100644 --- a/src/config.ts +++ b/src/config.ts @@ -249,6 +249,9 @@ interface AIConfig { readonly openai: { readonly url: string | null; }; + readonly openaicompatible: { + readonly url: string | null; + }; readonly vscode: { readonly model: AIProviderAndModel | null; }; diff --git a/src/constants.ai.ts b/src/constants.ai.ts index 509a4fe68d74c..d2067f5f9d671 100644 --- a/src/constants.ai.ts +++ b/src/constants.ai.ts @@ -9,6 +9,7 @@ export type AIProviders = | 'huggingface' | 'ollama' | 'openai' + | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai'; @@ -38,6 +39,13 @@ export const openAIProviderDescriptor: AIProviderDescriptor<'openai'> = { requiresAccount: true, requiresUserKey: true, } as const; +export const openAICompatibleProviderDescriptor: AIProviderDescriptor<'openaicompatible'> = { + id: 'openaicompatible', + name: 'OpenAI-Compatible Provider (Azure, etc.)', + primary: false, + requiresAccount: true, + requiresUserKey: true, +} as const; export const anthropicProviderDescriptor: AIProviderDescriptor<'anthropic'> = { id: 'anthropic', name: 'Anthropic', diff --git a/src/plus/ai/aiProviderService.ts b/src/plus/ai/aiProviderService.ts index d3e9ccecaf0bb..107d0b8e63836 100644 --- a/src/plus/ai/aiProviderService.ts +++ b/src/plus/ai/aiProviderService.ts @@ -9,6 +9,7 @@ import { gitKrakenProviderDescriptor, huggingFaceProviderDescriptor, ollamaProviderDescriptor, + openAICompatibleProviderDescriptor, openAIProviderDescriptor, openRouterProviderDescriptor, vscodeProviderDescriptor, @@ -148,6 +149,16 @@ const supportedAIProviders = new Map( type: lazy(async () => (await import(/* webpackChunkName: "ai" */ './openaiProvider')).OpenAIProvider), }, ], + [ + 'openaicompatible', + { + ...openAICompatibleProviderDescriptor, + type: lazy( + async () => + (await import(/* webpackChunkName: "ai" */ './openAICompatibleProvider')).OpenAICompatibleProvider, + ), + }, + ], [ 'ollama', { diff --git a/src/plus/ai/anthropicProvider.ts b/src/plus/ai/anthropicProvider.ts index 433d5f030de4f..858db3a51add8 100644 --- a/src/plus/ai/anthropicProvider.ts +++ b/src/plus/ai/anthropicProvider.ts @@ -3,7 +3,7 @@ import type { Response } from '@env/fetch'; import { anthropicProviderDescriptor as provider } from '../../constants.ai'; import { AIError, AIErrorReason } from '../../errors'; import type { AIActionType, AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type AnthropicModel = AIModel; const models: AnthropicModel[] = [ @@ -103,7 +103,7 @@ const models: AnthropicModel[] = [ }, ]; -export class AnthropicProvider extends OpenAICompatibleProvider { +export class AnthropicProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/deepSeekProvider.ts b/src/plus/ai/deepSeekProvider.ts index 5ae5ef035e081..d291c414dab1e 100644 --- a/src/plus/ai/deepSeekProvider.ts +++ b/src/plus/ai/deepSeekProvider.ts @@ -1,6 +1,6 @@ import { deepSeekProviderDescriptor as provider } from '../../constants.ai'; import type { AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type DeepSeekModel = AIModel; const models: DeepSeekModel[] = [ @@ -21,7 +21,7 @@ const models: DeepSeekModel[] = [ }, ]; -export class DeepSeekProvider extends OpenAICompatibleProvider { +export class DeepSeekProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/geminiProvider.ts b/src/plus/ai/geminiProvider.ts index 27962f4de254c..62b2f8800b751 100644 --- a/src/plus/ai/geminiProvider.ts +++ b/src/plus/ai/geminiProvider.ts @@ -2,7 +2,7 @@ import type { CancellationToken } from 'vscode'; import type { Response } from '@env/fetch'; import { geminiProviderDescriptor as provider } from '../../constants.ai'; import type { AIActionType, AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type GeminiModel = AIModel; const models: GeminiModel[] = [ @@ -111,7 +111,7 @@ const models: GeminiModel[] = [ }, ]; -export class GeminiProvider extends OpenAICompatibleProvider { +export class GeminiProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/githubModelsProvider.ts b/src/plus/ai/githubModelsProvider.ts index 8e83b71d3426b..b8a89af630903 100644 --- a/src/plus/ai/githubModelsProvider.ts +++ b/src/plus/ai/githubModelsProvider.ts @@ -3,11 +3,11 @@ import { fetch } from '@env/fetch'; import { githubProviderDescriptor as provider } from '../../constants.ai'; import { AIError, AIErrorReason } from '../../errors'; import type { AIActionType, AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type GitHubModelsModel = AIModel; -export class GitHubModelsProvider extends OpenAICompatibleProvider { +export class GitHubModelsProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/gitkrakenProvider.ts b/src/plus/ai/gitkrakenProvider.ts index 3be05ed26b455..1669ac7169534 100644 --- a/src/plus/ai/gitkrakenProvider.ts +++ b/src/plus/ai/gitkrakenProvider.ts @@ -6,12 +6,12 @@ import { debug } from '../../system/decorators/log'; import { Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; import type { AIActionType, AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; import { ensureAccount } from './utils/-webview/ai.utils'; type GitKrakenModel = AIModel; -export class GitKrakenProvider extends OpenAICompatibleProvider { +export class GitKrakenProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/huggingFaceProvider.ts b/src/plus/ai/huggingFaceProvider.ts index 148b2e2c3beaa..4d8345bfd9ba7 100644 --- a/src/plus/ai/huggingFaceProvider.ts +++ b/src/plus/ai/huggingFaceProvider.ts @@ -1,11 +1,11 @@ import { fetch } from '@env/fetch'; import { huggingFaceProviderDescriptor as provider } from '../../constants.ai'; import type { AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type HuggingFaceModel = AIModel; -export class HuggingFaceProvider extends OpenAICompatibleProvider { +export class HuggingFaceProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/ollamaProvider.ts b/src/plus/ai/ollamaProvider.ts index b997f8f870ed8..65f461b06acc5 100644 --- a/src/plus/ai/ollamaProvider.ts +++ b/src/plus/ai/ollamaProvider.ts @@ -4,13 +4,13 @@ import { ollamaProviderDescriptor as provider } from '../../constants.ai'; import { configuration } from '../../system/-webview/configuration'; import type { AIActionType, AIModel } from './models/model'; import type { AIChatMessage, AIRequestResult } from './models/provider'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type OllamaModel = AIModel; const defaultBaseUrl = 'http://localhost:11434'; -export class OllamaProvider extends OpenAICompatibleProvider { +export class OllamaProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/openAICompatibleProvider.ts b/src/plus/ai/openAICompatibleProvider.ts index b63e20678408e..6e6fc08e50f17 100644 --- a/src/plus/ai/openAICompatibleProvider.ts +++ b/src/plus/ai/openAICompatibleProvider.ts @@ -1,277 +1,382 @@ -import type { CancellationToken } from 'vscode'; -import type { Response } from '@env/fetch'; -import { fetch } from '@env/fetch'; -import type { Role } from '../../@types/vsls'; -import type { AIProviders } from '../../constants.ai'; -import type { Container } from '../../container'; -import { AIError, AIErrorReason, CancellationError } from '../../errors'; -import { getLoggableName, Logger } from '../../system/logger'; -import { startLogScope } from '../../system/logger.scope'; -import type { ServerConnection } from '../gk/serverConnection'; -import type { AIActionType, AIModel, AIProviderDescriptor } from './models/model'; -import type { AIChatMessage, AIChatMessageRole, AIProvider, AIRequestResult } from './models/provider'; -import { getActionName, getOrPromptApiKey, getValidatedTemperature } from './utils/-webview/ai.utils'; +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { openAICompatibleProviderDescriptor as provider } from '../../constants.ai'; +import { configuration } from '../../system/-webview/configuration'; +import type { AIActionType, AIModel } from './models/model'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; -export interface AIProviderConfig { - url: string; - keyUrl: string; - keyValidator?: RegExp; -} - -export abstract class OpenAICompatibleProvider implements AIProvider { - constructor( - protected readonly container: Container, - protected readonly connection: ServerConnection, - ) {} - - dispose(): void {} +type OpenAICompatibleModel = AIModel; +const models: OpenAICompatibleModel[] = [ + { + id: 'gpt-4.1', + name: 'GPT-4.1', + maxTokens: { input: 1047576, output: 32768 }, + provider: provider, + }, + { + id: 'gpt-4.1-2025-04-14', + name: 'GPT-4.1 (2025-04-14)', + maxTokens: { input: 1047576, output: 32768 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4.1-mini', + name: 'GPT-4.1 mini', + maxTokens: { input: 1047576, output: 32768 }, + provider: provider, + }, + { + id: 'gpt-4.1-mini-2025-04-14', + name: 'GPT-4.1 mini (2025-04-14)', + maxTokens: { input: 1047576, output: 32768 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4.1-nano', + name: 'GPT-4.1 nano', + maxTokens: { input: 1047576, output: 32768 }, + provider: provider, + }, + { + id: 'gpt-4.1-nano-2025-04-14', + name: 'GPT-4.1 nano (2025-04-14)', + maxTokens: { input: 1047576, output: 32768 }, + provider: provider, + hidden: true, + }, + { + id: 'o4-mini', + name: 'o4 mini', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + }, + { + id: 'o4-mini-2025-04-16', + name: 'o4 mini (2025-04-16)', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'o3', + name: 'o3', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + }, + { + id: 'o3-2025-04-16', + name: 'o3 (2025-04-16)', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'o3-mini', + name: 'o3 mini', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + }, + { + id: 'o3-mini-2025-01-31', + name: 'o3 mini', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'o1', + name: 'o1', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + }, + { + id: 'o1-2024-12-17', + name: 'o1', + maxTokens: { input: 200000, output: 100000 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'o1-preview', + name: 'o1 preview', + maxTokens: { input: 128000, output: 32768 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'o1-preview-2024-09-12', + name: 'o1 preview', + maxTokens: { input: 128000, output: 32768 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'o1-mini', + name: 'o1 mini', + maxTokens: { input: 128000, output: 65536 }, + provider: provider, + temperature: null, + }, + { + id: 'o1-mini-2024-09-12', + name: 'o1 mini', + maxTokens: { input: 128000, output: 65536 }, + provider: provider, + temperature: null, + hidden: true, + }, + { + id: 'gpt-4o', + name: 'GPT-4o', + maxTokens: { input: 128000, output: 16384 }, + provider: provider, + default: true, + }, + { + id: 'gpt-4o-2024-11-20', + name: 'GPT-4o', + maxTokens: { input: 128000, output: 16384 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4o-2024-08-06', + name: 'GPT-4o', + maxTokens: { input: 128000, output: 16384 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4o-2024-05-13', + name: 'GPT-4o', + maxTokens: { input: 128000, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'chatgpt-4o-latest', + name: 'GPT-4o', + maxTokens: { input: 128000, output: 16384 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4o-mini', + name: 'GPT-4o mini', + maxTokens: { input: 128000, output: 16384 }, + provider: provider, + }, + { + id: 'gpt-4o-mini-2024-07-18', + name: 'GPT-4o mini', + maxTokens: { input: 128000, output: 16384 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + maxTokens: { input: 128000, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-turbo-2024-04-09', + name: 'GPT-4 Turbo preview (2024-04-09)', + maxTokens: { input: 128000, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-turbo-preview', + name: 'GPT-4 Turbo preview', + maxTokens: { input: 128000, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-0125-preview', + name: 'GPT-4 0125 preview', + maxTokens: { input: 128000, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-1106-preview', + name: 'GPT-4 1106 preview', + maxTokens: { input: 128000, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4', + name: 'GPT-4', + maxTokens: { input: 8192, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-0613', + name: 'GPT-4 0613', + maxTokens: { input: 8192, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-32k', + name: 'GPT-4 32k', + maxTokens: { input: 32768, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-32k-0613', + name: 'GPT-4 32k 0613', + maxTokens: { input: 32768, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + maxTokens: { input: 16385, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-0125', + name: 'GPT-3.5 Turbo 0125', + maxTokens: { input: 16385, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-1106', + name: 'GPT-3.5 Turbo 1106', + maxTokens: { input: 16385, output: 4096 }, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-16k', + name: 'GPT-3.5 Turbo 16k', + maxTokens: { input: 16385, output: 4096 }, + provider: provider, + hidden: true, + }, +]; - abstract readonly id: T; - abstract readonly name: string; - protected abstract readonly descriptor: AIProviderDescriptor; - protected abstract readonly config: { keyUrl?: string; keyValidator?: RegExp }; +export class OpenAICompatibleProvider extends OpenAICompatibleProviderBase { + readonly id = provider.id; + readonly name = provider.name; + protected readonly descriptor = provider; + protected readonly config = { + keyUrl: undefined, + keyValidator: /(?:sk-)?[a-zA-Z0-9]{32,}/, + }; - async configured(silent: boolean): Promise { - return (await this.getApiKey(silent)) != null; + getModels(): Promise[]> { + return Promise.resolve(models); } - async getApiKey(silent: boolean): Promise { - const { keyUrl, keyValidator } = this.config; - - return getOrPromptApiKey( - this.container, - { - id: this.id, - name: this.name, - requiresAccount: this.descriptor.requiresAccount, - validator: keyValidator != null ? v => keyValidator.test(v) : () => true, - url: keyUrl, - }, - silent, - ); + protected getUrl(_model?: AIModel): string | undefined { + return configuration.get('ai.openaicompatible.url') ?? undefined; } - abstract getModels(): Promise[]>; + private async getOrPromptBaseUrl(silent: boolean): Promise { + let url: string | undefined = this.getUrl(); - protected abstract getUrl(_model: AIModel): string; + if (silent || url != null) return url; - protected getHeaders( - _action: TAction, - apiKey: string, - _model: AIModel, - _url: string, - ): Record | Promise> { - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }; - } + const input = window.createInputBox(); + input.ignoreFocusOut = true; - async sendRequest( - action: TAction, - model: AIModel, - apiKey: string, - getMessages: (maxCodeCharacters: number, retries: number) => Promise, - options: { cancellation: CancellationToken; modelOptions?: { outputTokens?: number; temperature?: number } }, - ): Promise { - using scope = startLogScope(`${getLoggableName(this)}.sendRequest`, false); + const disposables: Disposable[] = []; try { - const result = await this.fetch( - action, - model, - apiKey, - getMessages, - options.modelOptions, - options.cancellation, - ); - return result; - } catch (ex) { - if (ex instanceof CancellationError) { - Logger.error(ex, scope, `Cancelled request to ${getActionName(action)}: (${model.provider.name})`); - throw ex; - } + url = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(value => { + if (value) { + try { + new URL(value); + } catch { + input.validationMessage = `Please enter a valid URL`; + return; + } + } + input.validationMessage = undefined; + }), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = `Please enter a valid URL`; + return; + } - Logger.error(ex, scope, `Unable to ${getActionName(action)}: (${model.provider.name})`); - if (ex instanceof AIError) throw ex; + try { + new URL(value); + } catch { + input.validationMessage = `Please enter a valid URL`; + return; + } - debugger; - throw new Error(`Unable to ${getActionName(action)}: (${model.provider.name}) ${ex.message}`); - } - } - - protected async fetch( - action: TAction, - model: AIModel, - apiKey: string, - messages: (maxInputTokens: number, retries: number) => Promise, - modelOptions?: { outputTokens?: number; temperature?: number }, - cancellation?: CancellationToken, - ): Promise { - let retries = 0; - let maxInputTokens = model.maxTokens.input; + resolve(value); + }), + ); - while (true) { - const request: ChatCompletionRequest = { - model: model.id, - messages: await messages(maxInputTokens, retries), - stream: false, - max_completion_tokens: model.maxTokens.output - ? Math.min(modelOptions?.outputTokens ?? Infinity, model.maxTokens.output) - : modelOptions?.outputTokens, - temperature: getValidatedTemperature(modelOptions?.temperature ?? model.temperature), - }; - - const rsp = await this.fetchCore(action, model, apiKey, request, cancellation); - if (!rsp.ok) { - const result = await this.handleFetchFailure(rsp, action, model, retries, maxInputTokens); - if (result.retry) { - maxInputTokens = result.maxInputTokens; - retries++; - continue; - } - } + input.title = `Connect to OpenAI-Compatible Provider`; + input.placeholder = `Please enter your provider's URL to use this feature`; + input.prompt = `Enter your OpenAI-Compatible Provider URL`; - const data: ChatCompletionResponse = await rsp.json(); - const result: AIRequestResult = { - id: data.id, - content: data.choices?.[0].message.content?.trim() ?? data.content?.[0]?.text?.trim() ?? '', - model: model, - usage: { - promptTokens: data.usage?.prompt_tokens ?? data.usage?.input_tokens, - completionTokens: data.usage?.completion_tokens ?? data.usage?.output_tokens, - totalTokens: data.usage?.total_tokens, - limits: - data?.usage?.gk != null - ? { - used: data.usage.gk.used, - limit: data.usage.gk.limit, - resetsOn: new Date(data.usage.gk.resets_on), - } - : undefined, - }, - }; - return result; + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); } - } - protected async handleFetchFailure( - rsp: Response, - _action: TAction, - model: AIModel, - retries: number, - maxInputTokens: number, - ): Promise<{ retry: true; maxInputTokens: number }> { - if (rsp.status === 404) { - throw new AIError( - AIErrorReason.Unauthorized, - new Error(`Your API key doesn't seem to have access to the selected '${model.id}' model`), - ); + if (url) { + void configuration.updateEffective('ai.openaicompatible.url', url); } - if (rsp.status === 429) { - throw new AIError( - AIErrorReason.RateLimitOrFundsExceeded, - new Error( - `(${this.name}) ${rsp.status}: Too many requests (rate limit exceeded) or your account is out of funds`, - ), - ); - } - - let json; - try { - json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; - } catch {} - if (json?.error?.code === 'context_length_exceeded') { - if (retries < 2) { - return { retry: true, maxInputTokens: maxInputTokens - 200 * (retries || 1) }; - } + return url; + } - throw new AIError( - AIErrorReason.RequestTooLarge, - new Error(`(${this.name}) ${rsp.status}: ${json?.error?.message || rsp.statusText}`), - ); - } + override async configured(silent: boolean): Promise { + const url = await this.getOrPromptBaseUrl(silent); + if (url == null) return false; - throw new Error(`(${this.name}) ${rsp.status}: ${json?.error?.message || rsp.statusText}`); + return super.configured(silent); } - protected async fetchCore( + protected override getHeaders( action: TAction, - model: AIModel, apiKey: string, - request: object, - cancellation: CancellationToken | undefined, - ): Promise { - let aborter: AbortController | undefined; - if (cancellation != null) { - aborter = new AbortController(); - cancellation.onCancellationRequested(() => aborter?.abort()); + model: AIModel, + url: string, + ): Record | Promise> { + if (url.includes('.azure.com')) { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'api-key': apiKey, + }; } - const url = this.getUrl(model); - try { - return await fetch(url, { - headers: await this.getHeaders(action, apiKey, model, url), - method: 'POST', - body: JSON.stringify(request), - signal: aborter?.signal, - }); - } catch (ex) { - if (ex.name === 'AbortError') throw new CancellationError(ex); - throw ex; - } + return super.getHeaders(action, apiKey, model, url); } } - -interface ChatCompletionRequest { - model: string; - messages: AIChatMessage[]; - - /** @deprecated but used by Anthropic & Gemini */ - max_tokens?: number; - /** Currently can't be used for Anthropic & Gemini */ - max_completion_tokens?: number; - metadata?: Record; - stream?: boolean; - temperature?: number; - top_p?: number; - - /** Not supported by many models/providers */ - reasoning_effort?: 'low' | 'medium' | 'high'; -} - -interface ChatCompletionResponse { - id: string; - model: string; - /** OpenAI compatible output */ - choices?: { - index: number; - message: { - role: Role; - content: string | null; - refusal: string | null; - }; - finish_reason: string; - }[]; - /** Anthropic output */ - content?: { type: 'text'; text: string }[]; - usage: { - /** OpenAI compatible */ - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - - /** Anthropic */ - input_tokens?: number; - output_tokens?: number; - - /** GitKraken */ - gk: { - used: number; - limit: number; - resets_on: string; - }; - }; -} diff --git a/src/plus/ai/openAICompatibleProviderBase.ts b/src/plus/ai/openAICompatibleProviderBase.ts new file mode 100644 index 0000000000000..0197de3aca0a0 --- /dev/null +++ b/src/plus/ai/openAICompatibleProviderBase.ts @@ -0,0 +1,281 @@ +import type { CancellationToken } from 'vscode'; +import type { Response } from '@env/fetch'; +import { fetch } from '@env/fetch'; +import type { Role } from '../../@types/vsls'; +import type { AIProviders } from '../../constants.ai'; +import type { Container } from '../../container'; +import { AIError, AIErrorReason, CancellationError } from '../../errors'; +import { getLoggableName, Logger } from '../../system/logger'; +import { startLogScope } from '../../system/logger.scope'; +import type { ServerConnection } from '../gk/serverConnection'; +import type { AIActionType, AIModel, AIProviderDescriptor } from './models/model'; +import type { AIChatMessage, AIChatMessageRole, AIProvider, AIRequestResult } from './models/provider'; +import { getActionName, getOrPromptApiKey, getValidatedTemperature } from './utils/-webview/ai.utils'; + +export interface AIProviderConfig { + url: string; + keyUrl: string; + keyValidator?: RegExp; +} + +export abstract class OpenAICompatibleProviderBase implements AIProvider { + constructor( + protected readonly container: Container, + protected readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + abstract readonly id: T; + abstract readonly name: string; + protected abstract readonly descriptor: AIProviderDescriptor; + protected abstract readonly config: { keyUrl?: string; keyValidator?: RegExp }; + + async configured(silent: boolean): Promise { + return (await this.getApiKey(silent)) != null; + } + + async getApiKey(silent: boolean): Promise { + const { keyUrl, keyValidator } = this.config; + + return getOrPromptApiKey( + this.container, + { + id: this.id, + name: this.name, + requiresAccount: this.descriptor.requiresAccount, + validator: keyValidator != null ? v => keyValidator.test(v) : () => true, + url: keyUrl, + }, + silent, + ); + } + + abstract getModels(): Promise[]>; + + protected abstract getUrl(_model: AIModel): string | undefined; + + protected getHeaders( + _action: TAction, + apiKey: string, + _model: AIModel, + _url: string, + ): Record | Promise> { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }; + } + + async sendRequest( + action: TAction, + model: AIModel, + apiKey: string, + getMessages: (maxCodeCharacters: number, retries: number) => Promise, + options: { cancellation: CancellationToken; modelOptions?: { outputTokens?: number; temperature?: number } }, + ): Promise { + using scope = startLogScope(`${getLoggableName(this)}.sendRequest`, false); + + try { + const result = await this.fetch( + action, + model, + apiKey, + getMessages, + options.modelOptions, + options.cancellation, + ); + return result; + } catch (ex) { + if (ex instanceof CancellationError) { + Logger.error(ex, scope, `Cancelled request to ${getActionName(action)}: (${model.provider.name})`); + throw ex; + } + + Logger.error(ex, scope, `Unable to ${getActionName(action)}: (${model.provider.name})`); + if (ex instanceof AIError) throw ex; + + debugger; + throw new Error(`Unable to ${getActionName(action)}: (${model.provider.name}) ${ex.message}`); + } + } + + protected async fetch( + action: TAction, + model: AIModel, + apiKey: string, + messages: (maxInputTokens: number, retries: number) => Promise, + modelOptions?: { outputTokens?: number; temperature?: number }, + cancellation?: CancellationToken, + ): Promise { + let retries = 0; + let maxInputTokens = model.maxTokens.input; + + while (true) { + const request: ChatCompletionRequest = { + model: model.id, + messages: await messages(maxInputTokens, retries), + stream: false, + max_completion_tokens: model.maxTokens.output + ? Math.min(modelOptions?.outputTokens ?? Infinity, model.maxTokens.output) + : modelOptions?.outputTokens, + temperature: getValidatedTemperature(modelOptions?.temperature ?? model.temperature), + }; + + const rsp = await this.fetchCore(action, model, apiKey, request, cancellation); + if (!rsp.ok) { + const result = await this.handleFetchFailure(rsp, action, model, retries, maxInputTokens); + if (result.retry) { + maxInputTokens = result.maxInputTokens; + retries++; + continue; + } + } + + const data: ChatCompletionResponse = await rsp.json(); + const result: AIRequestResult = { + id: data.id, + content: data.choices?.[0].message.content?.trim() ?? data.content?.[0]?.text?.trim() ?? '', + model: model, + usage: { + promptTokens: data.usage?.prompt_tokens ?? data.usage?.input_tokens, + completionTokens: data.usage?.completion_tokens ?? data.usage?.output_tokens, + totalTokens: data.usage?.total_tokens, + limits: + data?.usage?.gk != null + ? { + used: data.usage.gk.used, + limit: data.usage.gk.limit, + resetsOn: new Date(data.usage.gk.resets_on), + } + : undefined, + }, + }; + return result; + } + } + + protected async handleFetchFailure( + rsp: Response, + _action: TAction, + model: AIModel, + retries: number, + maxInputTokens: number, + ): Promise<{ retry: true; maxInputTokens: number }> { + if (rsp.status === 404) { + throw new AIError( + AIErrorReason.Unauthorized, + new Error(`Your API key doesn't seem to have access to the selected '${model.id}' model`), + ); + } + if (rsp.status === 429) { + throw new AIError( + AIErrorReason.RateLimitOrFundsExceeded, + new Error( + `(${this.name}) ${rsp.status}: Too many requests (rate limit exceeded) or your account is out of funds`, + ), + ); + } + + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + if (json?.error?.code === 'context_length_exceeded') { + if (retries < 2) { + return { retry: true, maxInputTokens: maxInputTokens - 200 * (retries || 1) }; + } + + throw new AIError( + AIErrorReason.RequestTooLarge, + new Error(`(${this.name}) ${rsp.status}: ${json?.error?.message || rsp.statusText}`), + ); + } + + throw new Error(`(${this.name}) ${rsp.status}: ${json?.error?.message || rsp.statusText}`); + } + + protected async fetchCore( + action: TAction, + model: AIModel, + apiKey: string, + request: object, + cancellation: CancellationToken | undefined, + ): Promise { + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } + + const url = this.getUrl(model); + if (!url) { + throw new Error(`(${this.name}) ${getActionName(action)}: No URL configured`); + } + + try { + return await fetch(url, { + headers: await this.getHeaders(action, apiKey, model, url), + method: 'POST', + body: JSON.stringify(request), + signal: aborter?.signal, + }); + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); + throw ex; + } + } +} + +interface ChatCompletionRequest { + model: string; + messages: AIChatMessage[]; + + /** @deprecated but used by Anthropic & Gemini */ + max_tokens?: number; + /** Currently can't be used for Anthropic & Gemini */ + max_completion_tokens?: number; + metadata?: Record; + stream?: boolean; + temperature?: number; + top_p?: number; + + /** Not supported by many models/providers */ + reasoning_effort?: 'low' | 'medium' | 'high'; +} + +interface ChatCompletionResponse { + id: string; + model: string; + /** OpenAI compatible output */ + choices?: { + index: number; + message: { + role: Role; + content: string | null; + refusal: string | null; + }; + finish_reason: string; + }[]; + /** Anthropic output */ + content?: { type: 'text'; text: string }[]; + usage: { + /** OpenAI compatible */ + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + + /** Anthropic */ + input_tokens?: number; + output_tokens?: number; + + /** GitKraken */ + gk: { + used: number; + limit: number; + resets_on: string; + }; + }; +} diff --git a/src/plus/ai/openRouterProvider.ts b/src/plus/ai/openRouterProvider.ts index b24bf639d5e07..f05c10a2e09bb 100644 --- a/src/plus/ai/openRouterProvider.ts +++ b/src/plus/ai/openRouterProvider.ts @@ -1,11 +1,11 @@ import { fetch } from '@env/fetch'; import { openRouterProviderDescriptor as provider } from '../../constants.ai'; import type { AIActionType, AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type OpenRouterModel = AIModel; -export class OpenRouterProvider extends OpenAICompatibleProvider { +export class OpenRouterProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/openaiProvider.ts b/src/plus/ai/openaiProvider.ts index 1474dc547a825..5aa03bafec7d1 100644 --- a/src/plus/ai/openaiProvider.ts +++ b/src/plus/ai/openaiProvider.ts @@ -1,7 +1,7 @@ import { openAIProviderDescriptor as provider } from '../../constants.ai'; import { configuration } from '../../system/-webview/configuration'; import type { AIActionType, AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type OpenAIModel = AIModel; const models: OpenAIModel[] = [ @@ -276,7 +276,7 @@ const models: OpenAIModel[] = [ }, ]; -export class OpenAIProvider extends OpenAICompatibleProvider { +export class OpenAIProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider; diff --git a/src/plus/ai/utils/-webview/ai.utils.ts b/src/plus/ai/utils/-webview/ai.utils.ts index c108ad0f68759..bb0265c2c0cf9 100644 --- a/src/plus/ai/utils/-webview/ai.utils.ts +++ b/src/plus/ai/utils/-webview/ai.utils.ts @@ -105,7 +105,11 @@ export async function getOrPromptApiKey( input.password = true; input.title = `Connect to ${provider.name}`; input.placeholder = `Please enter your ${provider.name} API key to use this feature`; - input.prompt = `Enter your [${provider.name} API Key](${provider.url} "Get your ${provider.name} API key")`; + input.prompt = `Enter your ${ + provider.url + ? `[${provider.name} API Key](${provider.url} "Get your ${provider.name} API key")` + : `${provider.name} API Key` + }`; if (provider.url) { input.buttons = [infoButton]; } diff --git a/src/plus/ai/xaiProvider.ts b/src/plus/ai/xaiProvider.ts index a71fe9e012292..a713ac0e5cafa 100644 --- a/src/plus/ai/xaiProvider.ts +++ b/src/plus/ai/xaiProvider.ts @@ -1,6 +1,6 @@ import { xAIProviderDescriptor as provider } from '../../constants.ai'; import type { AIModel } from './models/model'; -import { OpenAICompatibleProvider } from './openAICompatibleProvider'; +import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase'; type XAIModel = AIModel; const models: XAIModel[] = [ @@ -13,7 +13,7 @@ const models: XAIModel[] = [ }, ]; -export class XAIProvider extends OpenAICompatibleProvider { +export class XAIProvider extends OpenAICompatibleProviderBase { readonly id = provider.id; readonly name = provider.name; protected readonly descriptor = provider;