diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a340f74e191..2af858eafe681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - Adds the ability to search for GitHub Enterprise and GitLab Self-Managed pull requests by URL in the main step of Launchpad -- Adds OpenRouter support for GitLens' AI features ([#3906](https://github.com/gitkraken/vscode-gitlens/issues/3906)) +- Adds Ollama and OpenRouter support for GitLens' AI features ([#3311](https://github.com/gitkraken/vscode-gitlens/issues/3311), [#3906](https://github.com/gitkraken/vscode-gitlens/issues/3906)) - Adds Google Gemini 2.5 Flash (Preview) model, and OpenAI GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o4 mini, and o3 models for GitLens' AI features ([#4235](https://github.com/gitkraken/vscode-gitlens/issues/4235)) - Adds _Open File at Revision from Remote_ command to open the specific file revision from a remote file URL diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index d27b4b5f38e52..28bc658e4113f 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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | '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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | '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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | '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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | '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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | '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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | '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' | 'openai' | 'openrouter' | 'vscode' | 'xai', + 'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openrouter' | 'vscode' | 'xai', 'model.provider.name': string } ``` diff --git a/package.json b/package.json index 4bb8e90fc96f6..a1261dc4ed950 100644 --- a/package.json +++ b/package.json @@ -4047,7 +4047,7 @@ "null" ], "default": null, - "pattern": "^((anthropic|deepseek|gemini|github|huggingface|openai|xai):([\\w.-]+)|gitkraken|vscode)$", + "pattern": "^((anthropic|deepseek|gemini|github|huggingface|ollama|openai|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, @@ -4083,6 +4083,19 @@ "preview" ] }, + "gitlens.ai.ollama.url": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies the Ollama URL to use for access", + "scope": "window", + "order": 30, + "tags": [ + "preview" + ] + }, "gitlens.ai.openai.url": { "type": [ "string", @@ -4091,7 +4104,7 @@ "default": null, "markdownDescription": "Specifies a custom URL to use for access to an OpenAI model via Azure. 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": 30, + "order": 31, "tags": [ "preview" ] diff --git a/src/config.ts b/src/config.ts index dba033d2f4fde..264939dcde15b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -243,6 +243,9 @@ interface AIConfig { readonly modelOptions: { readonly temperature: number; }; + readonly ollama: { + readonly url: string | null; + }; readonly openai: { readonly url: string | null; }; diff --git a/src/constants.ai.ts b/src/constants.ai.ts index 2cb49aef94a13..509a4fe68d74c 100644 --- a/src/constants.ai.ts +++ b/src/constants.ai.ts @@ -7,6 +7,7 @@ export type AIProviders = | 'github' | 'gitkraken' | 'huggingface' + | 'ollama' | 'openai' | 'openrouter' | 'vscode' @@ -86,3 +87,10 @@ export const openRouterProviderDescriptor: AIProviderDescriptor<'openrouter'> = requiresAccount: true, requiresUserKey: true, } as const; +export const ollamaProviderDescriptor: AIProviderDescriptor<'ollama'> = { + id: 'ollama', + name: 'Ollama', + primary: false, + requiresAccount: false, + requiresUserKey: false, +} as const; diff --git a/src/plus/ai/aiProviderService.ts b/src/plus/ai/aiProviderService.ts index d19c88181504f..96564b89e3d70 100644 --- a/src/plus/ai/aiProviderService.ts +++ b/src/plus/ai/aiProviderService.ts @@ -8,6 +8,7 @@ import { githubProviderDescriptor, gitKrakenProviderDescriptor, huggingFaceProviderDescriptor, + ollamaProviderDescriptor, openAIProviderDescriptor, openRouterProviderDescriptor, vscodeProviderDescriptor, @@ -183,6 +184,13 @@ const supportedAIProviders = new Map( ), }, ], + [ + 'ollama', + { + ...ollamaProviderDescriptor, + type: lazy(async () => (await import(/* webpackChunkName: "ai" */ './ollamaProvider')).OllamaProvider), + }, + ], ]); export class AIProviderService implements Disposable { diff --git a/src/plus/ai/ollamaProvider.ts b/src/plus/ai/ollamaProvider.ts new file mode 100644 index 0000000000000..b997f8f870ed8 --- /dev/null +++ b/src/plus/ai/ollamaProvider.ts @@ -0,0 +1,288 @@ +import type { CancellationToken, Disposable } from 'vscode'; +import { window } from 'vscode'; +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'; + +type OllamaModel = AIModel; + +const defaultBaseUrl = 'http://localhost:11434'; + +export class OllamaProvider extends OpenAICompatibleProvider { + readonly id = provider.id; + readonly name = provider.name; + protected readonly descriptor = provider; + protected readonly config = { + keyUrl: 'https://ollama.com/download', + }; + + override async configured(silent: boolean): Promise { + // Ollama doesn't require an API key, but we'll check if the base URL is reachable + return this.validateUrl(await this.getOrPromptBaseUrl(silent), silent); + } + + override getApiKey(_silent: boolean): Promise { + // Ollama doesn't require an API key + return Promise.resolve(''); + } + + async getModels(): Promise[]> { + try { + const url = this.getBaseUrl(); + const rsp = await fetch(`${url}/api/tags`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + }); + + if (!rsp.ok) { + throw new Error(`Getting models failed: ${rsp.status} (${rsp.statusText})`); + } + + interface OllamaModelsResponse { + models: { + name: string; + model: string; + modified_at: string; + size: number; + details?: { + parameter_size?: string; + quantization_level?: string; + }; + }[]; + } + + const result: OllamaModelsResponse = (await rsp.json()) as OllamaModelsResponse; + + // If there are models installed on the user's Ollama instance, use those + if (result.models?.length) { + return result.models.map(m => ({ + id: m.name, + name: m.name, + maxTokens: { input: 8192, output: 8192 }, + provider: provider, + default: m.name === 'llama3', + })); + } + } catch {} + + return []; + } + + private async getOrPromptBaseUrl(silent: boolean): Promise { + let url = configuration.get('ai.ollama.url') ?? undefined; + if (url) { + if (silent) return url; + + const valid = await this.validateUrl(url, true); + if (valid) return url; + } + + if (silent) return defaultBaseUrl; + + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + 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(async () => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = `Please enter a valid URL`; + return; + } + + try { + new URL(value); + } catch { + input.validationMessage = `Please enter a valid URL`; + return; + } + + const configured = await this.validateUrl(value, true); + if (!configured) { + input.validationMessage = `Could not connect to Ollama server. Make sure Ollama is installed and running locally.`; + return; + } + + resolve(value); + }), + ); + + input.title = `Connect to Ollama`; + input.placeholder = `Please enter your Ollama server URL to use this feature`; + input.prompt = `Enter your Ollama server URL (default: ${defaultBaseUrl})`; + input.value = defaultBaseUrl; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!url) return defaultBaseUrl; + + void configuration.updateEffective('ai.ollama.url', url); + + return url; + } + + private async validateUrl(url: string, silent: boolean): Promise { + try { + const rsp = await fetch(`${url}/api/tags`, { + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + method: 'GET', + }); + return rsp.ok; + } catch { + // If we reach here, Ollama server is not accessible + if (!silent) { + await window.showErrorMessage( + `Could not connect to Ollama server. Make sure Ollama is installed and running locally.`, + ); + } + return false; + } + } + + private getBaseUrl(): string { + // Get base URL from configuration or use default + return configuration.get('ai.ollama.url') || defaultBaseUrl; + } + + protected getUrl(_model: AIModel): string { + return `${this.getBaseUrl()}/api/chat`; + } + + protected override getHeaders( + _action: TAction, + _apiKey: string, + _model: AIModel, + _url: string, + ): Record { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + } + + protected override 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) { + // Get messages and prepare request payload for Ollama + const chatMessages = await messages(maxInputTokens, retries); + + // Convert to the format expected by Ollama + const ollamaMessages = chatMessages.map(msg => ({ + role: msg.role, + content: msg.content, + })); + + // Ensure temperature is within valid range for Ollama (0.0-1.0) + const temperature = Math.min(Math.max(modelOptions?.temperature ?? 0.7, 0), 1); + + const request: OllamaChatRequest = { + model: model.id, + messages: ollamaMessages, + stream: false, + options: { + temperature: temperature, + // Add num_predict if outputTokens is specified + ...(modelOptions?.outputTokens ? { num_predict: modelOptions.outputTokens } : {}), + }, + }; + + 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; + } + } + + try { + // Parse response from Ollama + const data = (await rsp.json()) as OllamaChatResponse; + + if (!data.message?.content) { + throw new Error(`Empty response from Ollama model: ${model.id}`); + } + + return { + content: data.message.content, + model: model, + usage: { + promptTokens: data.prompt_eval_count ?? 0, + completionTokens: data.eval_count ?? 0, + totalTokens: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0), + }, + }; + } catch (err) { + throw new Error(`Failed to parse Ollama response: ${err instanceof Error ? err.message : String(err)}`); + } + } + } +} + +// Define Ollama API types +interface OllamaChatRequest { + model: string; + messages: Array<{ + role: string; + content: string; + }>; + stream: boolean; + options?: { + temperature?: number; + top_p?: number; + top_k?: number; + num_predict?: number; + }; +} + +interface OllamaChatResponse { + model: string; + created_at: string; + message: { + role: string; + content: string; + }; + done: boolean; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + eval_count?: number; + prompt_eval_duration?: number; + eval_duration?: number; +} diff --git a/src/quickpicks/aiModelPicker.ts b/src/quickpicks/aiModelPicker.ts index 6389661459749..fbb154f40c69d 100644 --- a/src/quickpicks/aiModelPicker.ts +++ b/src/quickpicks/aiModelPicker.ts @@ -2,13 +2,14 @@ import type { Disposable, QuickInputButton, QuickPickItem } from 'vscode'; import { QuickInputButtons, ThemeIcon, window } from 'vscode'; import type { AIProviders } from '../constants.ai'; import type { Container } from '../container'; -import type { AIModel, AIModelDescriptor } from '../plus/ai/models/model'; +import type { AIModel, AIModelDescriptor, AIProviderDescriptorWithConfiguration } from '../plus/ai/models/model'; import { isSubscriptionPaidPlan } from '../plus/gk/utils/subscription.utils'; import { getContext } from '../system/-webview/context'; import { getQuickPickIgnoreFocusOut } from '../system/-webview/vscode'; import { getSettledValue } from '../system/promise'; import { createQuickPickSeparator } from './items/common'; -import { Directive } from './items/directive'; +import type { DirectiveQuickPickItem } from './items/directive'; +import { Directive, isDirectiveQuickPickItem } from './items/directive'; export interface ModelQuickPickItem extends QuickPickItem { model: AIModel; @@ -48,7 +49,7 @@ export async function showAIProviderPicker( container.subscription.getSubscription(), ]); - const providers = getSettledValue(providersResult) ?? new Map(); + const providers = getSettledValue(providersResult) ?? new Map(); const currentModelName = getSettledValue(modelResult)?.name; const subscription = getSettledValue(subscriptionResult)!; const hasPaidPlan = isSubscriptionPaidPlan(subscription.plan.effective.id) && subscription.account?.verified; @@ -150,23 +151,32 @@ export async function showAIModelPicker( const models = (await container.ai.getModels(provider)) ?? []; - const items: ModelQuickPickItem[] = []; - - for (const m of models) { - if (m.hidden) continue; - - const picked = m.provider.id === current?.provider && m.id === current?.model; + const items: Array = []; + if (models.length === 0 && provider === 'ollama') { items.push({ - label: m.name, - description: m.default ? ' recommended' : undefined, - iconPath: picked ? new ThemeIcon('check') : new ThemeIcon('blank'), - model: m, - picked: picked, - } satisfies ModelQuickPickItem); + label: 'No models found', + description: 'Please install a model or check your Ollama server configuration', + iconPath: new ThemeIcon('error'), + directive: Directive.Noop, + } satisfies ModelQuickPickItem | DirectiveQuickPickItem); + } else { + for (const m of models) { + if (m.hidden) continue; + + const picked = m.provider.id === current?.provider && m.id === current?.model; + + items.push({ + label: m.name, + description: m.default ? ' recommended' : undefined, + iconPath: picked ? new ThemeIcon('check') : new ThemeIcon('blank'), + model: m, + picked: picked, + } satisfies ModelQuickPickItem); + } } - const quickpick = window.createQuickPick(); + const quickpick = window.createQuickPick(); quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); const disposables: Disposable[] = []; @@ -177,7 +187,9 @@ export async function showAIModelPicker( quickpick.onDidHide(() => resolve(undefined)), quickpick.onDidAccept(() => { if (quickpick.activeItems.length !== 0) { - resolve(quickpick.activeItems[0]); + if (!isDirectiveQuickPickItem(quickpick.activeItems[0])) { + resolve(quickpick.activeItems[0]); + } } }), quickpick.onDidTriggerButton(e => {