diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 564e9ba689b..ca2e16b8a65 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -63,7 +63,7 @@ import { ChatResponseClearToPreviousToolInvocationReason, IChatContentInlineRefe import { IChatNewSessionRequest, IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; +import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; import { IPreparedToolInvocation, IStreamedToolInvocation, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolProgressStep, IToolResult, ToolDataSource } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IPromptFileContext, IPromptFileResource } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; @@ -1394,7 +1394,7 @@ export interface MainThreadLanguageModelsShape extends IDisposable { export interface ExtHostLanguageModelsShape { $provideLanguageModelChatInfo(vendor: string, options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void; - $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: { [name: string]: any }, token: CancellationToken): Promise; + $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; $acceptResponsePart(requestId: number, chunk: SerializableObjectWithBuffers): Promise; $acceptResponseDone(requestId: number, error: SerializedError | undefined): Promise; $provideTokenLength(modelId: string, value: string | IChatMessage, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 860c7277766..12df8ec2edd 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -675,6 +675,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS request, location, model, + request.modelConfiguration, this.getDiagnosticsWhenEnabled(detector.extension), tools, detector.extension, @@ -776,6 +777,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS request, location, model, + request.modelConfiguration, this.getDiagnosticsWhenEnabled(agent.extension), tools, agent.extension, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 62ff2c6c102..c7e15f34f8a 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -640,7 +640,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return {}; } - const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, await this.getModelForRequest(request, entry.sessionObj.extension), [], new Map(), entry.sessionObj.extension, this._logService); + const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, await this.getModelForRequest(request, entry.sessionObj.extension), request.modelConfiguration, [], new Map(), entry.sessionObj.extension, this._logService); const stream = entry.sessionObj.getActiveRequestStream(request); await entry.sessionObj.session.requestHandler(chatRequest, { history, yieldRequested: false }, stream.apiObject, token); diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index cc76961ab15..9d2d8bc68c6 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -11,13 +11,14 @@ import { SerializedError, transformErrorForSerialization, transformErrorFromSeri import { Emitter, Event } from '../../../base/common/event.js'; import { Iterable } from '../../../base/common/iterator.js'; import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IJSONSchema } from '../../../base/common/jsonSchema.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionIdentifierSet, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { Progress } from '../../../platform/progress/common/progress.js'; -import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../contrib/chat/common/languageModels.js'; +import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions } from '../../contrib/chat/common/languageModels.js'; import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../contrib/chat/common/widget/input/modelPickerWidget.js'; import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -229,6 +230,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { isUserSelectable: m.isUserSelectable, statusIcon: m.statusIcon, targetChatSessionType: m.targetChatSessionType, + configurationSchema: m.configurationSchema as IJSONSchema | undefined, modelPickerCategory: m.category ?? DEFAULT_MODEL_PICKER_CATEGORY, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, @@ -258,7 +260,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { return modelMetadataAndIdentifier; } - async $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: vscode.LanguageModelChatRequestOptions, token: CancellationToken): Promise { + async $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise { const knownModel = this._localModels.get(modelId); if (!knownModel) { throw new Error('Model not found'); @@ -320,7 +322,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { knownModel.info, messages.value.map(typeConvert.LanguageModelChatMessage2.to), // todo@connor4312: move `core` -> `undefined` after 1.111 Insiders is out - { ...options, modelOptions: options.modelOptions ?? {}, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, + { ...options, modelOptions: options.modelOptions ?? {}, modelConfiguration: options.configuration, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, progress, token ); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index ae28e6be0ee..97ccc59fb08 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -6,6 +6,7 @@ import type * as vscode from 'vscode'; import { asArray, coalesce, isNonEmptyArray } from '../../../base/common/arrays.js'; import { VSBuffer, encodeBase64 } from '../../../base/common/buffer.js'; +import { IStringDictionary } from '../../../base/common/collections.js'; import { IDataTransferFile, IDataTransferItem, UriList } from '../../../base/common/dataTransfer.js'; import { createSingleCallFunction } from '../../../base/common/functional.js'; import * as htmlContent from '../../../base/common/htmlContent.js'; @@ -3403,7 +3404,7 @@ export namespace ChatResponsePart { } export namespace ChatAgentRequest { - export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: Map, extension: IRelaxedExtensionDescription, logService: ILogService): vscode.ChatRequest { + export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, modelConfiguration: IStringDictionary | undefined, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: Map, extension: IRelaxedExtensionDescription, logService: ILogService): vscode.ChatRequest { const toolReferences: IChatRequestVariableEntry[] = []; const variableReferences: IChatRequestVariableEntry[] = []; @@ -3438,6 +3439,7 @@ export namespace ChatAgentRequest { toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource }) as never, tools, model, + modelConfiguration, editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 3e5e9bf244c..f362516fa9a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -792,6 +792,21 @@ class ActionsColumnRenderer extends ModelsTableColumnRenderer this.languageModelsService.configureModel(entry.model.identifier) + })); + + templateData.actionBar.setActions([], secondaryActions); } } @@ -1181,6 +1196,15 @@ export class ChatModelsWidget extends Disposable { run: () => this.viewModel.setModelsVisibility(selectedModelEntries, true) })); + // Show per-model configuration actions for a single model + if (selectedModelEntries.length === 1) { + const configActions = this.languageModelsService.getModelConfigurationActions(selectedModelEntries[0].model.identifier); + if (configActions.length) { + actions.push(new Separator()); + actions.push(...configActions); + } + } + // Show configure action if all models are from the same group configureGroup = selectedModelEntries[0].model.provider.group.name; configureVendor = selectedModelEntries[0].model.provider.vendor; diff --git a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts index f89cca85b67..e5ad21a9bb5 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -315,6 +316,32 @@ export class ChatLanguageModelsDataContribution extends Disposable implements IW private updateSchema(registry: IJSONContributionRegistry): void { const vendors = this.languageModelsService.getVendors(); + // Build per-model configuration schemas + const modelSchemas: IJSONSchema[] = []; + const modelIds = this.languageModelsService.getLanguageModelIds(); + for (const modelId of modelIds) { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + if (metadata?.configurationSchema) { + modelSchemas.push({ + if: { + properties: { + vendor: { const: metadata.vendor } + } + }, + then: { + properties: { + settings: { + type: 'object', + properties: { + [metadata.id]: metadata.configurationSchema + } + } + } + } + }); + } + } + const schema: IJSONSchema = { type: 'array', items: { @@ -323,16 +350,23 @@ export class ChatLanguageModelsDataContribution extends Disposable implements IW type: 'string', enum: vendors.map(v => v.vendor) }, - name: { type: 'string' } + name: { type: 'string' }, + settings: { + type: 'object', + description: localize('settings.perModelConfig', "Per-model settings"), + } }, - allOf: vendors.map(vendor => ({ - if: { - properties: { - vendor: { const: vendor.vendor } - } - }, - then: vendor.configuration - })), + allOf: [ + ...vendors.map(vendor => ({ + if: { + properties: { + vendor: { const: vendor.vendor } + } + }, + then: vendor.configuration + })), + ...modelSchemas + ], required: ['vendor', 'name'] } }; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1ee23e9326f..4d159c616b2 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1129,6 +1129,7 @@ export class ChatService extends Disposable implements IChatService { acceptedConfirmationData: options?.acceptedConfirmationData, rejectedConfirmationData: options?.rejectedConfirmationData, userSelectedModelId: options?.userSelectedModelId, + modelConfiguration: options?.userSelectedModelId ? this.languageModelsService.getModelConfiguration(options.userSelectedModelId) : undefined, userSelectedTools: options?.userSelectedTools?.get(), modeInstructions: options?.modeInfo?.modeInstructions, permissionLevel: options?.modeInfo?.permissionLevel, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index d88d5a917f2..9347996e4b5 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -18,6 +18,7 @@ import { equals } from '../../../../base/common/objects.js'; import Severity from '../../../../base/common/severity.js'; import { format, isFalsyOrWhitespace } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IAction, SubmenuAction } from '../../../../base/common/actions.js'; import { isObject, isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -169,6 +170,17 @@ export type IChatResponsePart = IChatResponseTextPart | IChatResponseToolUsePart export type IExtendedChatResponsePart = IChatResponsePullRequestPart; +export interface ILanguageModelConfigurationSchema extends IJSONSchema { + properties?: { + [key: string]: IJSONSchema & { + /** When set to `'navigation'`, the property is shown as a primary action in the model picker. */ + group?: string; + /** Labels for enum values. If provided, these are shown instead of the raw enum values. */ + enumItemLabels?: string[]; + }; + }; +} + export interface ILanguageModelChatMetadata { readonly extension: ExtensionIdentifier; @@ -204,6 +216,11 @@ export interface ILanguageModelChatMetadata { * when the user is in a session matching this type. */ readonly targetChatSessionType?: string; + /** + * An optional JSON schema describing the per-model configuration options. + * Used to validate user-provided per-model configuration in `chatLanguageModels.json`. + */ + readonly configurationSchema?: ILanguageModelConfigurationSchema; } export namespace ILanguageModelChatMetadata { @@ -263,13 +280,13 @@ export async function getTextResponseFromStream(response: ILanguageModelChatResp export interface ILanguageModelChatProvider { readonly onDidChange: Event; provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; - sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: { [name: string]: unknown }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; provideTokenCount(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; } export interface ILanguageModelChat { metadata: ILanguageModelChatMetadata; - sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: { [name: string]: unknown }, token: CancellationToken): Promise; + sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; provideTokenCount(message: string | IChatMessage, token: CancellationToken): Promise; } @@ -313,6 +330,13 @@ export interface ILanguageModelChatInfoOptions { readonly configuration?: IStringDictionary; } +export interface ILanguageModelChatRequestOptions { + readonly modelOptions?: IStringDictionary; + readonly configuration?: IStringDictionary; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly [name: string]: any; +} + export interface ILanguageModelsGroup { readonly group?: ILanguageModelsProviderGroup; readonly modelIdentifiers: string[]; @@ -354,17 +378,42 @@ export interface ILanguageModelsService { deltaLanguageModelChatProviderDescriptors(added: IUserFriendlyLanguageModel[], removed: IUserFriendlyLanguageModel[]): void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; computeTokenLength(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; + /** + * Returns the resolved per-model configuration for the given model identifier. + * Includes schema defaults with user overrides applied on top. + * Returns undefined if the model has no configuration schema and no user config. + */ + getModelConfiguration(modelId: string): IStringDictionary | undefined; + + /** + * Updates the per-model configuration for the given model. + * Merges the provided values into the existing configuration. + */ + setModelConfiguration(modelId: string, values: IStringDictionary): Promise; + + /** + * Returns actions for configuring the given model based on its configuration schema. + * For enum properties, returns submenu actions with checkable values. + * Returns an empty array if the model has no configuration schema. + */ + getModelConfigurationActions(modelId: string): IAction[]; + addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise; removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise; configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; + /** + * Opens the language models configuration file and navigates to + * or creates the per-model configuration for the given model. + */ + configureModel(modelId: string): Promise; + migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; /** @@ -531,6 +580,7 @@ export class LanguageModelsService implements ILanguageModelsService { private readonly _modelCache = new Map(); private readonly _resolveLMSequencer = new SequencerByKey(); private _modelPickerUserPreferences: IStringDictionary = {}; + private readonly _modelConfigurations = new Map>(); private readonly _hasUserSelectableModels: IContextKey; private readonly _onLanguageModelChange = this._store.add(new Emitter()); @@ -821,11 +871,29 @@ export class LanguageModelsService implements ILanguageModelsService { } const groups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + const perModelConfigurations = new Map>(); for (const group of groups) { if (group.vendor !== vendorId) { continue; } + // For the default vendor, groups that only have per-model config + // should not trigger a separate model resolution call. + // Instead, apply the per-model config to the already-resolved models. + if (vendor.isDefault && !vendor.configuration) { + if (group.settings) { + for (const model of allModels) { + const modelConfig = group.settings[model.metadata.id]; + if (modelConfig) { + // Store raw config (without resolving secrets) to avoid leaking secrets on persist + perModelConfigurations.set(model.identifier, { ...modelConfig }); + } + } + } + languageModelsGroups.push({ group, modelIdentifiers: [] }); + continue; + } + const configuration = await this._resolveConfiguration(group, vendor.configuration); try { @@ -834,6 +902,17 @@ export class LanguageModelsService implements ILanguageModelsService { allModels.push(...models); languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); } + + // Collect per-model configurations from the group + if (group.settings) { + for (const model of models) { + const modelConfig = group.settings[model.metadata.id]; + if (modelConfig) { + // Store raw config (without resolving secrets) to avoid leaking secrets on persist + perModelConfigurations.set(model.identifier, { ...modelConfig }); + } + } + } } catch (error) { languageModelsGroups.push({ group, @@ -861,6 +940,14 @@ export class LanguageModelsService implements ILanguageModelsService { this._logService.trace(`[LM] Resolved language models for vendor ${vendorId}`, allModels); hasChanges = hasChanges || oldModels.size > 0; + // Update per-model configurations for this vendor + this._clearModelConfigurations(vendorId); + for (const [identifier, config] of perModelConfigurations) { + if (this._modelCache.has(identifier)) { + this._modelConfigurations.set(identifier, config); + } + } + if (hasChanges) { this._onLanguageModelChange.fire(vendorId); } else { @@ -926,13 +1013,41 @@ export class LanguageModelsService implements ILanguageModelsService { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { - const provider = this._providers.get(this._modelCache.get(modelId)?.vendor || ''); + async sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise { + const metadata = this._modelCache.get(modelId); + const provider = this._providers.get(metadata?.vendor || ''); if (!provider) { throw new Error(`Chat provider for model ${modelId} is not registered.`); } - return provider.sendChatRequest(modelId, messages, from, options, token); + const configuration = this.getModelConfiguration(modelId); + const mergedOptions = configuration ? { ...options, configuration: { ...configuration, ...options.configuration } } : options; + return provider.sendChatRequest(modelId, messages, from, mergedOptions, token); + } + + private _resolveModelConfigurationWithDefaults(modelId: string, metadata: ILanguageModelChatMetadata | undefined): IStringDictionary | undefined { + const userConfig = this._modelConfigurations.get(modelId); + const schema = metadata?.configurationSchema; + + if (!schema?.properties && !userConfig) { + return undefined; + } + + // Start with schema defaults + const defaults: IStringDictionary = {}; + if (schema?.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (propSchema.default !== undefined) { + defaults[key] = propSchema.default; + } + } + } + + if (!userConfig && Object.keys(defaults).length === 0) { + return undefined; + } + + // User config overrides defaults + return { ...defaults, ...userConfig }; } computeTokenLength(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise { @@ -947,6 +1062,124 @@ export class LanguageModelsService implements ILanguageModelsService { return provider.provideTokenCount(modelId, message, token); } + getModelConfiguration(modelId: string): IStringDictionary | undefined { + const metadata = this._modelCache.get(modelId); + return this._resolveModelConfigurationWithDefaults(modelId, metadata); + } + + async setModelConfiguration(modelId: string, values: IStringDictionary): Promise { + const metadata = this._modelCache.get(modelId); + if (!metadata) { + return; + } + + // Find the group from the configuration service (source of truth) + const allGroups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + let group: ILanguageModelsProviderGroup | undefined; + + // First try to find a group that already has config for this model + group = allGroups.find(g => g.vendor === metadata.vendor && g.settings?.[metadata.id] !== undefined); + + // If not found, find any group for this vendor + if (!group) { + group = allGroups.find(g => g.vendor === metadata.vendor); + } + + // Merge new values into existing config, removing properties set to their schema default + const existingConfig = this._modelConfigurations.get(modelId) ?? {}; + const updatedConfig = { ...existingConfig, ...values }; + const schema = metadata.configurationSchema; + if (schema?.properties) { + for (const [key, value] of Object.entries(updatedConfig)) { + const propSchema = schema.properties[key]; + if (propSchema?.default !== undefined && propSchema.default === value) { + delete updatedConfig[key]; + } + } + } + + if (group) { + const existingSettings = (group.settings as IStringDictionary> | undefined) ?? {}; + let updatedSettings: IStringDictionary>; + if (Object.keys(updatedConfig).length === 0) { + updatedSettings = { ...existingSettings }; + delete updatedSettings[metadata.id]; + } else { + updatedSettings = { ...existingSettings, [metadata.id]: updatedConfig }; + } + const updatedGroup: ILanguageModelsProviderGroup = { + ...group, + settings: Object.keys(updatedSettings).length > 0 ? updatedSettings : undefined + }; + if (!updatedGroup.settings && Object.keys(updatedGroup).filter(k => k !== 'name' && k !== 'vendor' && k !== 'range' && k !== 'settings').length === 0) { + // Remove the group entirely if it only had model config + await this._languageModelsConfigurationService.removeLanguageModelsProviderGroup(group); + } else { + await this._languageModelsConfigurationService.updateLanguageModelsProviderGroup(group, updatedGroup); + } + } else if (Object.keys(updatedConfig).length > 0) { + // Only create a new group if there's non-default config + const vendor = this.getVendors().find(v => v.vendor === metadata.vendor); + if (!vendor) { + return; + } + const newGroup: ILanguageModelsProviderGroup = { + name: vendor.displayName, + vendor: metadata.vendor, + settings: { [metadata.id]: updatedConfig } + }; + await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(newGroup); + } + + // Update the in-memory cache + if (Object.keys(updatedConfig).length > 0) { + this._modelConfigurations.set(modelId, updatedConfig); + } else { + this._modelConfigurations.delete(modelId); + } + } + + getModelConfigurationActions(modelId: string): IAction[] { + const metadata = this._modelCache.get(modelId); + const schema = metadata?.configurationSchema; + if (!schema?.properties) { + return []; + } + + const actions: IAction[] = []; + const currentConfig = this._modelConfigurations.get(modelId) ?? {}; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (!propSchema.enum || !Array.isArray(propSchema.enum)) { + continue; + } + const currentValue = currentConfig[key] ?? propSchema.default; + const label = (typeof propSchema.title === 'string' ? propSchema.title : undefined) + ?? key.replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/^./, s => s.toUpperCase()); + const defaultValue = propSchema.default; + const enumItemLabels = propSchema.enumItemLabels; + const enumDescriptions = propSchema.enumDescriptions; + const enumActions: IAction[] = propSchema.enum.map((value: unknown, index: number) => { + const itemLabel = enumItemLabels?.[index] ?? String(value); + const displayLabel = value === defaultValue ? localize('models.enumDefault', "{0} (default)", itemLabel) : itemLabel; + const tooltip = enumDescriptions?.[index] ?? ''; + return { + id: `configureModel.${key}.${value}`, + label: displayLabel, + class: undefined, + enabled: true, + tooltip, + checked: currentValue === value, + run: () => this.setModelConfiguration(modelId, { [key]: value }) + }; + }); + actions.push(new SubmenuAction(`configureModel.${key}`, label, enumActions)); + } + + return actions; + } + async configureLanguageModelsProviderGroup(vendorId: string, providerGroupName?: string): Promise { const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); @@ -992,6 +1225,63 @@ export class LanguageModelsService implements ILanguageModelsService { } } + async configureModel(modelId: string): Promise { + const metadata = this._modelCache.get(modelId); + if (!metadata || !metadata.configurationSchema) { + return; + } + + // Find the group that contains this model + const vendorGroups = this._modelsGroups.get(metadata.vendor); + let group: ILanguageModelsProviderGroup | undefined; + if (vendorGroups) { + for (const vg of vendorGroups) { + if (vg.modelIdentifiers.includes(modelId) && vg.group) { + group = vg.group; + break; + } + } + } + + // If the model doesn't belong to any configured group, create one + if (!group) { + const vendor = this.getVendors().find(v => v.vendor === metadata.vendor); + if (!vendor) { + return; + } + const groupName = vendor.displayName; + const newGroup: ILanguageModelsProviderGroup = { name: groupName, vendor: metadata.vendor, settings: { [metadata.id]: {} } }; + group = await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(newGroup); + await this._resolveAllLanguageModels(metadata.vendor, true); + } + + // Generate a snippet for the model's configuration schema + const snippet = this._getModelConfigurationSnippet(metadata.id, metadata.configurationSchema); + await this._languageModelsConfigurationService.configureLanguageModels({ group, snippet }); + } + + private _getModelConfigurationSnippet(modelId: string, schema: ILanguageModelConfigurationSchema): string { + const properties: string[] = []; + if (schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (propSchema.defaultSnippets?.[0]) { + const snippet = propSchema.defaultSnippets[0]; + let bodyText = snippet.bodyText ?? JSON.stringify(snippet.body, null, '\t\t\t'); + bodyText = bodyText.replace(/"(\^[^"]*)"/g, (_, value) => value.substring(1)); + properties.push(`\t\t\t"${key}": ${bodyText}`); + } else if (propSchema.default !== undefined) { + properties.push(`\t\t\t"${key}": ${JSON.stringify(propSchema.default)}`); + } else { + properties.push(`\t\t\t"${key}": $\{${key}\}`); + } + } + } + const modelContent = properties.length > 0 + ? `{\n${properties.join(',\n')}\n\t\t}` + : '{\n\t\t\t$0\n\t\t}'; + return `"settings": {\n\t\t"${modelId}": ${modelContent}\n\t}`; + } + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); if (!vendor) { @@ -1292,6 +1582,14 @@ export class LanguageModelsService implements ILanguageModelsService { return removed; } + private _clearModelConfigurations(vendor: string): void { + for (const [id] of this._modelConfigurations) { + if (this._modelCache.get(id)?.vendor === vendor || id.startsWith(`${vendor}/`)) { + this._modelConfigurations.delete(id); + } + } + } + private async _resolveConfiguration(group: ILanguageModelsProviderGroup, schema: IJSONSchema | undefined): Promise> { if (!schema) { return {}; @@ -1299,7 +1597,7 @@ export class LanguageModelsService implements ILanguageModelsService { const result: IStringDictionary = {}; for (const key in group) { - if (key === 'vendor' || key === 'name' || key === 'range') { + if (key === 'vendor' || key === 'name' || key === 'range' || key === 'settings') { continue; } let value = group[key]; diff --git a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts index 4f0dfa41691..86c0d307537 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts @@ -38,4 +38,5 @@ export interface ILanguageModelsProviderGroup extends IStringDictionary readonly name: string; readonly vendor: string; readonly range?: IRange; + readonly settings?: IStringDictionary>; } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 96c673ab22c..df479623ad9 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -5,6 +5,7 @@ import { findLast } from '../../../../../base/common/arraysFind.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; @@ -147,6 +148,7 @@ export interface IChatAgentRequest { acceptedConfirmationData?: unknown[]; rejectedConfirmationData?: unknown[]; userSelectedModelId?: string; + modelConfiguration?: IStringDictionary; userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 2710319a7de..2dcdda1d4ce 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -289,6 +289,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { subAgentInvocationId: invocation.callId, subAgentName: subAgentName, userSelectedModelId: modeModelId, + modelConfiguration: modeModelId ? this.languageModelsService.getModelConfiguration(modeModelId) : undefined, userSelectedTools: modeTools, modeInstructions, parentRequestId: invocation.chatRequestId, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 46e8316eef2..307e6955d7a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IAction } from '../../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; @@ -123,9 +124,23 @@ class MockLanguageModelsService implements ILanguageModelsService { throw new Error('Method not implemented.'); } + getModelConfiguration(_modelId: string): IStringDictionary | undefined { + return undefined; + } + + async setModelConfiguration(_modelId: string, _values: IStringDictionary): Promise { + } + + getModelConfigurationActions(_modelId: string): IAction[] { + return []; + } + async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { } + async configureModel(_modelId: string): Promise { + } + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { } diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 5790fab95dd..6908d48f9d4 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -993,3 +993,153 @@ suite('LanguageModels - Vendor Change Events', function () { assert.strictEqual(eventFired, false, 'Should not fire event when vendor list is unchanged'); }); }); + +suite('LanguageModels - Per-Model Configuration', function () { + + let languageModelsService: LanguageModelsService; + const disposables = new DisposableStore(); + let receivedOptions: { [name: string]: unknown } | undefined; + + setup(async function () { + receivedOptions = undefined; + + languageModelsService = new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + new TestStorageService(), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [{ + vendor: 'config-vendor', + name: 'default', + settings: { + 'model-a': { temperature: 0.7, reasoningEffort: 'high' }, + 'model-b': { temperature: 0.2 } + } + }]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + ); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + { vendor: 'config-vendor', displayName: 'Config Vendor', configuration: undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('config-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (options.group) { + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model A', + vendor: 'config-vendor', + family: 'family-a', + version: '1.0', + id: 'model-a', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {}, + configurationSchema: { + type: 'object', + properties: { + temperature: { type: 'number', default: 0.5 }, + reasoningEffort: { type: 'string', default: 'medium' }, + maxTokens: { type: 'number', default: 4096 } + } + } + } satisfies ILanguageModelChatMetadata, + identifier: 'config-vendor/default/model-a' + }, { + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model B', + vendor: 'config-vendor', + family: 'family-b', + version: '1.0', + id: 'model-b', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'config-vendor/default/model-b' + }]; + } + return []; + }, + sendChatRequest: async (_modelId, _messages, _from, options) => { + receivedOptions = options; + const defer = new DeferredPromise(); + const stream = new AsyncIterableSource(); + stream.resolve(); + defer.complete(undefined); + return { stream: stream.asyncIterable, result: defer.p }; + }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + }); + + teardown(function () { + languageModelsService.dispose(); + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getModelConfiguration returns per-model config from group', function () { + const configA = languageModelsService.getModelConfiguration('config-vendor/default/model-a'); + assert.deepStrictEqual(configA, { temperature: 0.7, reasoningEffort: 'high', maxTokens: 4096 }); + + const configB = languageModelsService.getModelConfiguration('config-vendor/default/model-b'); + assert.deepStrictEqual(configB, { temperature: 0.2 }); + }); + + test('getModelConfiguration returns undefined for unknown model', function () { + const config = languageModelsService.getModelConfiguration('config-vendor/default/model-c'); + assert.strictEqual(config, undefined); + }); + + test('sendChatRequest merges schema defaults with user config', async function () { + const cts = disposables.add(new CancellationTokenSource()); + const request = await languageModelsService.sendChatRequest( + 'config-vendor/default/model-a', + nullExtensionDescription.identifier, + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: 'hello' }] }], + {}, + cts.token + ); + await request.result; + + // User config overrides defaults: temperature=0.7 (not 0.5), reasoningEffort='high' (not 'medium') + // Schema default maxTokens=4096 is included since user didn't override it + assert.deepStrictEqual(receivedOptions, { configuration: { temperature: 0.7, reasoningEffort: 'high', maxTokens: 4096 } }); + }); + + test('sendChatRequest passes user config when model has no schema', async function () { + const cts = disposables.add(new CancellationTokenSource()); + const request = await languageModelsService.sendChatRequest( + 'config-vendor/default/model-b', + nullExtensionDescription.identifier, + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: 'hello' }] }], + {}, + cts.token + ); + await request.result; + + assert.deepStrictEqual(receivedOptions, { configuration: { temperature: 0.2 } }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 8ead7eab0a9..51ac576ae1a 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -8,8 +8,9 @@ import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { IAction } from '../../../../../base/common/actions.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatMessage, IModelsControlManifest, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { IChatMessage, IModelsControlManifest, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; export class NullLanguageModelsService implements ILanguageModelsService { @@ -66,8 +67,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { return []; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(identifier: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { + sendChatRequest(identifier: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } @@ -75,10 +75,24 @@ export class NullLanguageModelsService implements ILanguageModelsService { throw new Error('Method not implemented.'); } + getModelConfiguration(_modelId: string): IStringDictionary | undefined { + return undefined; + } + + async setModelConfiguration(_modelId: string, _values: IStringDictionary): Promise { + } + + getModelConfigurationActions(_modelId: string): IAction[] { + return []; + } + async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { } + async configureModel(_modelId: string): Promise { + } + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { } diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index b19b106205b..c20711a3305 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -17,6 +17,15 @@ declare module 'vscode' { * `undefined` if the request was initiated by other functionality in the editor. */ readonly requestInitiator: string; + + /** + * Per-model configuration provided by the user. This contains values configured + * in the user's language models configuration file, validated against the model's + * {@linkcode LanguageModelChatInformation.configurationSchema configurationSchema}. + */ + readonly modelConfiguration?: { + readonly [key: string]: any; + }; } /** @@ -67,6 +76,14 @@ declare module 'vscode' { readonly statusIcon?: ThemeIcon; + /** + * An optional JSON schema describing the configuration options for this model. + * When set, users can specify per-model configuration in their language models + * configuration file. The configured values are merged into the request options + * when sending chat requests to this model. + */ + readonly configurationSchema?: LanguageModelConfigurationSchema; + /** * When set, this model is only shown in the model picker for the specified chat session type. * Models with this property are excluded from the general model picker and only appear @@ -98,6 +115,28 @@ declare module 'vscode' { export type LanguageModelResponsePart2 = LanguageModelResponsePart | LanguageModelDataPart | LanguageModelThinkingPart; + /** + * A [JSON Schema](https://json-schema.org) describing configuration options for a language model. + * Each property in `properties` defines a configurable option using standard JSON Schema fields + * plus additional display hints. + */ + export type LanguageModelConfigurationSchema = { + readonly properties?: { + readonly [key: string]: Record & { + /** + * Human-readable labels for enum values, shown instead of the raw values. + * Must have the same length and order as `enum`. + */ + readonly enumItemLabels?: string[]; + /** + * The group this property belongs to. When set to `'navigation'`, the property + * is shown as a primary action in the model picker. + */ + readonly group?: string; + }; + }; + }; + export interface LanguageModelChatProvider { provideLanguageModelChatInformation(options: PrepareLanguageModelChatModelOptions, token: CancellationToken): ProviderResult; provideLanguageModelChatResponse(model: T, messages: readonly LanguageModelChatRequestMessage[], options: ProvideLanguageModelChatResponseOptions, progress: Progress, token: CancellationToken): Thenable; @@ -115,4 +154,16 @@ declare module 'vscode' { readonly [key: string]: any; }; } + + export interface ChatRequest { + /** + * Per-model configuration provided by the user. Contains resolved values based on the model's + * {@linkcode LanguageModelChatInformation.configurationSchema configurationSchema}, + * with user overrides applied on top of schema defaults. + * + * This is the same data that is sent as {@linkcode ProvideLanguageModelChatResponseOptions.configuration} + * when the model is invoked via the language model API. + */ + readonly modelConfiguration?: { readonly [key: string]: any }; + } }