Skip to content

Splits out Azure provider from OpenAI Compatible providers #4275

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 2, 2025
Merged
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
14 changes: 7 additions & 7 deletions docs/telemetry-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string,
'output.length': number,
'retry.count': number,
Expand Down Expand Up @@ -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' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string,
'output.length': number,
'retry.count': number,
Expand Down Expand Up @@ -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' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string,
'output.length': number,
'retry.count': number,
Expand Down Expand Up @@ -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' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string,
'output.length': number,
'retry.count': number,
Expand Down Expand Up @@ -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' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string,
'output.length': number,
'retry.count': number,
Expand Down Expand Up @@ -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' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string,
'output.length': number,
'retry.count': number,
Expand All @@ -295,7 +295,7 @@ or
```typescript
{
'model.id': string,
'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.id': 'anthropic' | 'azure' | 'deepseek' | 'gemini' | 'github' | 'gitkraken' | 'huggingface' | 'ollama' | 'openai' | 'openaicompatible' | 'openrouter' | 'vscode' | 'xai',
'model.provider.name': string
}
```
Expand Down
21 changes: 17 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4047,7 +4047,7 @@
"null"
],
"default": null,
"pattern": "^((anthropic|deepseek|gemini|github|huggingface|ollama|openai|openaicompatible|openrouter|xai):([\\w.-:]+)|gitkraken|vscode)$",
"pattern": "^((anthropic|azure|deepseek|gemini|github|huggingface|ollama|openai|openaicompatible|openrouter|xai):([\\w.\\-:]+)|gitkraken|vscode)$",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note, the dash - character was not escaped here, so it was being interpreted as "any character between . and :.

"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,
Expand Down Expand Up @@ -4102,22 +4102,35 @@
"null"
],
"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}",
"markdownDescription": "Specifies a custom URL to use for access to an OpenAI model.",
"scope": "window",
"order": 31,
"tags": [
"preview"
]
},
"gitlens.ai.azure.url": {
"type": [
"string",
"null"
],
"default": null,
"markdownDescription": "Specifies a custom URL to use for access to an Azure OpenAI 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": 32,
"tags": [
"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}",
"markdownDescription": "Specifies a custom URL to use for access to an OpenAI-compatible model.",
"scope": "window",
"order": 31,
"order": 33,
"tags": [
"preview"
]
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ export interface AdvancedConfig {
}

interface AIConfig {
readonly azure: {
readonly url: string | null;
};
readonly explainChanges: {
readonly customInstructions: string;
};
Expand Down
11 changes: 10 additions & 1 deletion src/constants.ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { AIProviderDescriptor } from './plus/ai/models/model';

export type AIProviders =
| 'anthropic'
| 'azure'
| 'deepseek'
| 'gemini'
| 'github'
Expand All @@ -14,6 +15,7 @@ export type AIProviders =
| 'vscode'
| 'xai';
export type AIPrimaryProviders = Extract<AIProviders, 'gitkraken' | 'vscode'>;
export type OpenAIProviders = 'azure' | 'openai' | 'openaicompatible';

export type AIProviderAndModel = `${string}:${string}`;
export type SupportedAIModels = `${Exclude<AIProviders, AIPrimaryProviders>}:${string}` | AIPrimaryProviders;
Expand All @@ -39,9 +41,16 @@ export const openAIProviderDescriptor: AIProviderDescriptor<'openai'> = {
requiresAccount: true,
requiresUserKey: true,
} as const;
export const azureProviderDescriptor: AIProviderDescriptor<'azure'> = {
id: 'azure',
name: 'Azure (Preview)',
primary: false,
requiresAccount: true,
requiresUserKey: true,
} as const;
export const openAICompatibleProviderDescriptor: AIProviderDescriptor<'openaicompatible'> = {
id: 'openaicompatible',
name: 'OpenAI-Compatible Provider (Azure, etc.)',
name: 'OpenAI-Compatible Provider',
primary: false,
requiresAccount: true,
requiresUserKey: true,
Expand Down
8 changes: 8 additions & 0 deletions src/plus/ai/aiProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CancellationTokenSource, env, EventEmitter, window } from 'vscode';
import type { AIPrimaryProviders, AIProviderAndModel, AIProviders, SupportedAIModels } from '../../constants.ai';
import {
anthropicProviderDescriptor,
azureProviderDescriptor,
deepSeekProviderDescriptor,
geminiProviderDescriptor,
githubProviderDescriptor,
Expand Down Expand Up @@ -151,6 +152,13 @@ const supportedAIProviders = new Map<AIProviders, AIProviderDescriptorWithType>(
type: lazy(async () => (await import(/* webpackChunkName: "ai" */ './openaiProvider')).OpenAIProvider),
},
],
[
'azure',
{
...azureProviderDescriptor,
type: lazy(async () => (await import(/* webpackChunkName: "ai" */ './azureProvider')).AzureProvider),
},
],
[
'openaicompatible',
{
Expand Down
115 changes: 115 additions & 0 deletions src/plus/ai/azureProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Disposable } from 'vscode';
import { window } from 'vscode';
import { azureProviderDescriptor as provider } from '../../constants.ai';
import { configuration } from '../../system/-webview/configuration';
import type { AIActionType, AIModel } from './models/model';
import { openAIModels } from './models/model';
import { OpenAICompatibleProviderBase } from './openAICompatibleProviderBase';
import { isAzureUrl } from './utils/-webview/ai.utils';

type AzureModel = AIModel<typeof provider.id>;
const models: AzureModel[] = openAIModels(provider);

export class AzureProvider extends OpenAICompatibleProviderBase<typeof provider.id> {
readonly id = provider.id;
readonly name = provider.name;
protected readonly descriptor = provider;
protected readonly config = {
keyUrl: undefined,
keyValidator: /(?:sk-)?[a-zA-Z0-9]{32,}/,
};

getModels(): Promise<readonly AIModel<typeof provider.id>[]> {
return Promise.resolve(models);
}

protected getUrl(_model?: AIModel<typeof provider.id>): string | undefined {
return configuration.get('ai.azure.url') ?? undefined;
}

private async getOrPromptBaseUrl(silent: boolean): Promise<string | undefined> {
let url: string | undefined = this.getUrl();

if (silent || url != null) return url;

const input = window.createInputBox();
input.ignoreFocusOut = true;

const disposables: Disposable[] = [];

try {
url = await new Promise<string | undefined>(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;
}

try {
new URL(value);
} catch {
input.validationMessage = `Please enter a valid URL`;
return;
}

if (!isAzureUrl(value)) {
input.validationMessage = `Please enter a valid Azure OpenAI URL`;
return;
}

resolve(value);
}),
);

input.title = `Connect to Azure OpenAI Provider`;
input.placeholder = `Please enter your provider's URL to use this feature`;
input.prompt = `Enter your Azure OpenAI Provider URL`;

input.show();
});
} finally {
input.dispose();
disposables.forEach(d => void d.dispose());
}

if (url) {
void configuration.updateEffective('ai.azure.url', url);
}

return url;
}

override async configured(silent: boolean): Promise<boolean> {
const url = await this.getOrPromptBaseUrl(silent);
if (url == null) return false;

return super.configured(silent);
}

protected override getHeaders<TAction extends AIActionType>(
_action: TAction,
apiKey: string,
_model: AIModel<typeof provider.id>,
_url: string,
): Record<string, string> | Promise<Record<string, string>> {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'api-key': apiKey,
};
}
}
Loading