Path: blob/main/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Config, ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';6import { EndpointEditToolName, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider';7import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';8import { ILogService } from '../../../platform/log/common/logService';9import { IFetcherService } from '../../../platform/networking/common/fetcherService';10import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';11import { IStringDictionary } from '../../../util/vs/base/common/collections';12import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';13import { byokKnownModelToAPIInfo, resolveModelInfo } from '../common/byokProvider';14import { OpenAIEndpoint } from '../node/openAIEndpoint';15import { AbstractOpenAICompatibleLMProvider, LanguageModelChatConfiguration, OpenAICompatibleLanguageModelChatInformation } from './abstractLanguageModelChatProvider';16import { IBYOKStorageService } from './byokStorageService';1718export function resolveCustomOAIUrl(modelId: string, url: string): string {19// The fully resolved url was already passed in20if (hasExplicitApiPath(url)) {21return url;22}2324// Remove the trailing slash25if (url.endsWith('/')) {26url = url.slice(0, -1);27}2829// Default to chat completions for base URLs30const defaultApiPath = '/chat/completions';3132// Check if URL already contains any version pattern like /v1, /v2, etc33const versionPattern = /\/v\d+$/;34if (versionPattern.test(url)) {35return `${url}${defaultApiPath}`;36}3738// For standard OpenAI-compatible endpoints, just append the standard path39return `${url}/v1${defaultApiPath}`;40}4142export function hasExplicitApiPath(url: string): boolean {43return url.includes('/responses') || url.includes('/chat/completions');44}4546export interface CustomOAIModelProviderConfig extends LanguageModelChatConfiguration {47url?: string;48models?: CustomOAIModelConfig[];49}5051interface _CustomOAIModelConfig {52name: string;53url: string;54maxInputTokens: number;55maxOutputTokens: number;56toolCalling: boolean;57vision: boolean;58thinking?: boolean;59streaming?: boolean;60editTools?: EndpointEditToolName[];61requestHeaders?: Record<string, string>;62zeroDataRetentionEnabled?: boolean;63}6465export interface CustomOAIModelConfig extends _CustomOAIModelConfig {66id: string;67}6869export abstract class AbstractCustomOAIBYOKModelProvider extends AbstractOpenAICompatibleLMProvider<CustomOAIModelProviderConfig> {7071constructor(72id: string,73name: string,74byokStorageService: IBYOKStorageService,75@ILogService logService: ILogService,76@IFetcherService fetcherService: IFetcherService,77@IInstantiationService instantiationService: IInstantiationService,78@IConfigurationService configurationService: IConfigurationService,79@IExperimentationService expService: IExperimentationService,80@IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext81) {82super(id, name, undefined, byokStorageService, fetcherService, logService, instantiationService, configurationService, expService);83}8485protected async migrateConfig(configKey: Config<IStringDictionary<_CustomOAIModelConfig>>, providerName: string, providerGroupName: string): Promise<void> {86// Check if migration has already been completed87const migrationKey = `copilot-byok-migration-${providerName}-${configKey}`;88const migrationCompleted = this._extensionContext.globalState.get<boolean>(migrationKey, false);89if (migrationCompleted) {90return;91}9293const customOAIModelConfigsByApiKey: Map<string, Array<CustomOAIModelConfig & { requiresAPIKey?: boolean }>> = new Map();94const customOAIModelProviderConfig = this._configurationService.getConfig<IStringDictionary<_CustomOAIModelConfig>>(configKey);95for (const [modelId, modelConfig] of Object.entries(customOAIModelProviderConfig)) {96const apiKey = await this._byokStorageService.getAPIKey(providerName, modelId) ?? '';97const customOAIModelConfigs = customOAIModelConfigsByApiKey.get(apiKey) ?? [];98customOAIModelConfigs.push({ ...modelConfig, id: modelId, requiresAPIKey: undefined });99customOAIModelConfigsByApiKey.set(apiKey, customOAIModelConfigs);100}101if (customOAIModelConfigsByApiKey.size > 0) {102for (const [apiKey, customOAIModelConfigs] of customOAIModelConfigsByApiKey.entries()) {103await this.configureDefaultGroupIfExists(providerGroupName, { models: customOAIModelConfigs, apiKey: apiKey || undefined });104}105// Mark migration as completed instead of deleting the config106await this._extensionContext.globalState.update(migrationKey, true);107}108}109110protected override async configureDefaultGroupWithApiKeyOnly(): Promise<string | undefined> {111// No-op: Custom OAI models are configured separately via migration112return;113}114115protected override async getAllModels(silent: boolean, apiKey: string | undefined, configuration: CustomOAIModelProviderConfig | undefined): Promise<OpenAICompatibleLanguageModelChatInformation<CustomOAIModelProviderConfig>[]> {116if (configuration?.url) {117return super.getAllModels(silent, apiKey, configuration);118}119const models: OpenAICompatibleLanguageModelChatInformation<CustomOAIModelProviderConfig>[] = [];120if (Array.isArray(configuration?.models)) {121for (const modelConfig of configuration.models) {122models.push({123...byokKnownModelToAPIInfo(this._name, modelConfig.id, modelConfig),124url: modelConfig.url125});126}127}128return models;129}130131protected override async createOpenAIEndPoint(model: OpenAICompatibleLanguageModelChatInformation<CustomOAIModelProviderConfig>): Promise<OpenAIEndpoint> {132const url = this.resolveUrl(model.id, model.url);133const modelConfiguration = model.configuration?.models?.find(m => m.id === model.id);134const modelCapabilities = {135maxInputTokens: model.maxInputTokens,136maxOutputTokens: model.maxOutputTokens,137toolCalling: !!model.capabilities?.toolCalling || false,138vision: !!model.capabilities?.imageInput || false,139name: model.name,140url,141thinking: modelConfiguration?.thinking ?? false,142streaming: modelConfiguration?.streaming,143requestHeaders: modelConfiguration?.requestHeaders,144zeroDataRetentionEnabled: modelConfiguration?.zeroDataRetentionEnabled145};146const modelInfo = resolveModelInfo(model.id, this._name, undefined, modelCapabilities);147if (modelCapabilities?.url?.includes('/responses')) {148modelInfo.supported_endpoints = [149ModelSupportedEndpoint.ChatCompletions,150ModelSupportedEndpoint.Responses151];152}153return this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, model.configuration?.apiKey ?? '', url);154}155156protected getModelsBaseUrl(configuration: CustomOAIModelProviderConfig | undefined): string | undefined {157return configuration?.url;158}159160protected abstract resolveUrl(modelId: string, url: string): string;161}162163export class CustomOAIBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider {164165static readonly providerName: string = 'CustomOAI';166private providerName: string = CustomOAIBYOKModelProvider.providerName;167168constructor(169_byokStorageService: IBYOKStorageService,170@ILogService logService: ILogService,171@IFetcherService fetcherService: IFetcherService,172@IInstantiationService instantiationService: IInstantiationService,173@IConfigurationService configurationService: IConfigurationService,174@IExperimentationService expService: IExperimentationService,175@IVSCodeExtensionContext extensionContext: IVSCodeExtensionContext176) {177super(CustomOAIBYOKModelProvider.providerName.toLowerCase(), CustomOAIBYOKModelProvider.providerName, _byokStorageService, logService, fetcherService, instantiationService, configurationService, expService, extensionContext);178this.migrateExistingConfigs();179}180181// TODO: Remove this after 6 months182private async migrateExistingConfigs(): Promise<void> {183await this.migrateConfig(ConfigKey.Deprecated.CustomOAIModels, this.providerName, this.providerName);184}185186protected resolveUrl(modelId: string, url: string): string {187return resolveCustomOAIUrl(modelId, url);188}189}190191