diff --git a/CHANGELOG.md b/CHANGELOG.md index d037f68..71a2504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Code actions: Support for JSONC files - Code lens: Support for JSONC files +- Diagnostics: Added warnings for plugins that require `languageModel.enabled: true` when not configured +- Code actions: Added quick-fix to automatically add or update `languageModel.enabled: true` configuration +- Commands: Added `dev-proxy-toolkit.addLanguageModelConfig` command to add language model configuration +- Plugin constants: Added `requiresLanguageModel` property to identify plugins requiring language model functionality - Snippets: Added `devproxy-plugin-language-model-failure` - LanguageModelFailurePlugin instance - Snippets: Added `devproxy-plugin-language-model-failure-config` - LanguageModelFailurePlugin config section - Snippets: Added `devproxy-plugin-language-model-rate-limiting` - LanguageModelRateLimitingPlugin instance diff --git a/src/codeactions.ts b/src/codeactions.ts index da4d14a..f60c8fc 100644 --- a/src/codeactions.ts +++ b/src/codeactions.ts @@ -1,5 +1,7 @@ import * as vscode from 'vscode'; import {DevProxyInstall} from './types'; +import parse from 'json-to-ast'; +import { getASTNode, getRangeFromASTNode } from './helpers'; export const registerCodeActions = (context: vscode.ExtensionContext) => { const devProxyInstall = @@ -15,6 +17,7 @@ export const registerCodeActions = (context: vscode.ExtensionContext) => { registerInvalidSchemaFixes(devProxyVersion, context); registerDeprecatedPluginPathFixes(context); + registerLanguageModelFixes(context); }; function registerInvalidSchemaFixes( @@ -130,3 +133,126 @@ function registerDeprecatedPluginPathFixes(context: vscode.ExtensionContext) { ), ); } + +function registerLanguageModelFixes(context: vscode.ExtensionContext) { + const languageModelMissing: vscode.CodeActionProvider = { + provideCodeActions: (document, range, context, token) => { + // Check if the current range intersects with a missing language model diagnostic + const currentDiagnostic = context.diagnostics.find(diagnostic => { + return ( + diagnostic.code === 'missingLanguageModel' && + diagnostic.range.intersection(range) + ); + }); + + // Only provide language model actions if user is on a missing language model diagnostic + if (!currentDiagnostic) { + return []; + } + + const fixes: vscode.CodeAction[] = []; + + // Fix to add languageModel configuration + const addLanguageModelFix = new vscode.CodeAction( + 'Add languageModel configuration', + vscode.CodeActionKind.QuickFix, + ); + + addLanguageModelFix.edit = new vscode.WorkspaceEdit(); + + try { + // Parse the document using json-to-ast for accurate insertion + const documentNode = parse(document.getText()) as parse.ObjectNode; + + // Check if languageModel already exists + const existingLanguageModel = getASTNode( + documentNode.children, + 'Identifier', + 'languageModel' + ); + + if (existingLanguageModel) { + // languageModel exists but enabled might be false or missing + const languageModelObjectNode = existingLanguageModel.value as parse.ObjectNode; + const enabledNode = getASTNode( + languageModelObjectNode.children, + 'Identifier', + 'enabled' + ); + + if (enabledNode) { + // Replace the enabled value + addLanguageModelFix.edit.replace( + document.uri, + getRangeFromASTNode(enabledNode.value), + 'true' + ); + } else { + // Add enabled property + const insertPosition = new vscode.Position( + languageModelObjectNode.loc!.end.line - 1, + languageModelObjectNode.loc!.end.column - 1 + ); + addLanguageModelFix.edit.insert( + document.uri, + insertPosition, + '\n "enabled": true' + ); + } + } else { + // Add new languageModel object + // Find the last property to insert after it + const lastProperty = documentNode.children[documentNode.children.length - 1] as parse.PropertyNode; + const insertPosition = new vscode.Position( + lastProperty.loc!.end.line - 1, + lastProperty.loc!.end.column + ); + + addLanguageModelFix.edit.insert( + document.uri, + insertPosition, + ',\n "languageModel": {\n "enabled": true\n }' + ); + } + } catch (error) { + // Fallback to simple text-based insertion + const documentText = document.getText(); + const lines = documentText.split('\n'); + + // Find where to insert the languageModel config + let insertLine = lines.length - 1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].includes('}')) { + insertLine = i; + break; + } + } + + const hasContentBefore = lines.slice(0, insertLine).some(line => + line.trim() && !line.trim().startsWith('{') && !line.trim().startsWith('/*') && !line.trim().startsWith('*') + ); + + const languageModelConfig = hasContentBefore ? + ',\n "languageModel": {\n "enabled": true\n }' : + ' "languageModel": {\n "enabled": true\n }'; + + const insertPosition = new vscode.Position(insertLine, 0); + addLanguageModelFix.edit.insert(document.uri, insertPosition, languageModelConfig + '\n'); + } + + addLanguageModelFix.isPreferred = true; + fixes.push(addLanguageModelFix); + + return fixes; + }, + }; + + // Code action for missing language model configuration + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider('json', languageModelMissing), + ); + + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider('jsonc', languageModelMissing), + ); +} diff --git a/src/commands.ts b/src/commands.ts index ebb6603..4d7a996 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,8 +1,9 @@ import * as vscode from 'vscode'; import { pluginDocs } from './constants'; import { VersionPreference } from './enums'; -import { executeCommand, isConfigFile, openUpgradeDocumentation, upgradeDevProxyWithPackageManager } from './helpers'; +import { executeCommand, isConfigFile, openUpgradeDocumentation, upgradeDevProxyWithPackageManager, getASTNode, getRangeFromASTNode } from './helpers'; import { isDevProxyRunning, getDevProxyExe } from './detect'; +import parse from 'json-to-ast'; export const registerCommands = (context: vscode.ExtensionContext, configuration: vscode.WorkspaceConfiguration) => { const versionPreference = configuration.get('version') as VersionPreference; @@ -78,6 +79,101 @@ export const registerCommands = (context: vscode.ExtensionContext, configuration ) ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'dev-proxy-toolkit.addLanguageModelConfig', + async (uri: vscode.Uri) => { + const document = await vscode.workspace.openTextDocument(uri); + const edit = new vscode.WorkspaceEdit(); + + try { + // Parse the document using json-to-ast for accurate insertion + const documentNode = parse(document.getText()) as parse.ObjectNode; + + // Check if languageModel already exists + const existingLanguageModel = getASTNode( + documentNode.children, + 'Identifier', + 'languageModel' + ); + + if (existingLanguageModel) { + // languageModel exists but enabled might be false or missing + const languageModelObjectNode = existingLanguageModel.value as parse.ObjectNode; + const enabledNode = getASTNode( + languageModelObjectNode.children, + 'Identifier', + 'enabled' + ); + + if (enabledNode) { + // Replace the enabled value + edit.replace( + uri, + getRangeFromASTNode(enabledNode.value), + 'true' + ); + } else { + // Add enabled property + const insertPosition = new vscode.Position( + languageModelObjectNode.loc!.end.line - 1, + languageModelObjectNode.loc!.end.column - 1 + ); + edit.insert( + uri, + insertPosition, + '\n "enabled": true' + ); + } + } else { + // Add new languageModel object + // Find the last property to insert after it + const lastProperty = documentNode.children[documentNode.children.length - 1] as parse.PropertyNode; + const insertPosition = new vscode.Position( + lastProperty.loc!.end.line - 1, + lastProperty.loc!.end.column + ); + + edit.insert( + uri, + insertPosition, + ',\n "languageModel": {\n "enabled": true\n }' + ); + } + } catch (error) { + // Fallback to simple text-based insertion + const documentText = document.getText(); + const lines = documentText.split('\n'); + + // Find where to insert the languageModel config + let insertLine = lines.length - 1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].includes('}')) { + insertLine = i; + break; + } + } + + const hasContentBefore = lines.slice(0, insertLine).some(line => + line.trim() && !line.trim().startsWith('{') && !line.trim().startsWith('/*') && !line.trim().startsWith('*') + ); + + const languageModelConfig = hasContentBefore ? + ',\n "languageModel": {\n "enabled": true\n }' : + ' "languageModel": {\n "enabled": true\n }'; + + const insertPosition = new vscode.Position(insertLine, 0); + edit.insert(uri, insertPosition, languageModelConfig + '\n'); + } + + await vscode.workspace.applyEdit(edit); + await document.save(); + + vscode.window.showInformationMessage('Language model configuration added'); + } + ) + ); + context.subscriptions.push( vscode.commands.registerCommand('dev-proxy-toolkit.upgrade', async () => { const platform = process.platform; diff --git a/src/constants.ts b/src/constants.ts index 8b35883..eb024a7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,14 +116,16 @@ export const pluginSnippets: PluginSnippets = { config: { name: 'devproxy-plugin-language-model-failure-config', required: true, - } + }, + requiresLanguageModel: true, }, LanguageModelRateLimitingPlugin: { instance: 'devproxy-plugin-language-model-rate-limiting', config: { name: 'devproxy-plugin-language-model-rate-limiting-config', required: true, - } + }, + requiresLanguageModel: true, }, LatencyPlugin: { instance: 'devproxy-plugin-latency', diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 7f077b0..8026be4 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -22,6 +22,7 @@ export const updateConfigFileDiagnostics = ( checkSchemaCompatibility(documentNode, devProxyInstall, diagnostics); checkPlugins(pluginsNode, diagnostics, documentNode, devProxyInstall); checkConfigSection(documentNode, diagnostics); + checkLanguageModelRequirements(documentNode, diagnostics); collection.set(document.uri, diagnostics); }; @@ -495,3 +496,76 @@ function checkDeprecatedPluginPath( } }); } + +function checkLanguageModelRequirements( + documentNode: parse.ObjectNode, + diagnostics: vscode.Diagnostic[], +) { + const pluginsNode = getPluginsNode(documentNode); + + if (!pluginsNode || pluginsNode.value.type !== 'Array') { + return; + } + + const pluginNodes = (pluginsNode.value as parse.ArrayNode) + .children as parse.ObjectNode[]; + + // Check if languageModel is enabled + const languageModelNode = getASTNode( + documentNode.children, + 'Identifier', + 'languageModel' + ); + let isLanguageModelEnabled = false; + + if (languageModelNode && languageModelNode.value.type === 'Object') { + const languageModelObjectNode = languageModelNode.value as parse.ObjectNode; + const enabledNode = getASTNode( + languageModelObjectNode.children, + 'Identifier', + 'enabled' + ); + if (enabledNode && enabledNode.value.type === 'Literal') { + isLanguageModelEnabled = (enabledNode.value as parse.LiteralNode).value as boolean; + } + } + + // Check each plugin that requires language model + pluginNodes.forEach((pluginNode: parse.ObjectNode) => { + const pluginNameNode = getASTNode( + pluginNode.children, + 'Identifier', + 'name', + ); + + if (!pluginNameNode) { + return; + } + + const pluginName = (pluginNameNode.value as parse.LiteralNode).value as string; + const pluginSnippet = pluginSnippets[pluginName]; + + if (!pluginSnippet?.requiresLanguageModel) { + return; + } + + // Check if plugin is enabled + const enabledNode = getASTNode( + pluginNode.children, + 'Identifier', + 'enabled', + ); + const isPluginEnabled = enabledNode ? + (enabledNode.value as parse.LiteralNode).value as boolean : false; + + if (isPluginEnabled && !isLanguageModelEnabled) { + const diagnostic = new vscode.Diagnostic( + getRangeFromASTNode(pluginNameNode.value), + `${pluginName} requires languageModel.enabled to be set to true.`, + vscode.DiagnosticSeverity.Warning, + ); + diagnostic.code = 'missingLanguageModel'; + diagnostics.push(diagnostic); + } + }); +} diff --git a/src/test/.devproxy/devproxyrc.json b/src/test/.devproxy/devproxyrc.json new file mode 100644 index 0000000..d023841 --- /dev/null +++ b/src/test/.devproxy/devproxyrc.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.29.2/rc.schema.json", + "plugins": [ + { + "name": "LanguageModelFailurePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", + "configSection": "languageModelFailurePlugin" + } + ], + "urlsToWatch": [ + "https://*.openai.com/*" + ], + "languageModelFailurePlugin": { + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v1.0.0/languagemodelfailureplugin.schema.json", + "failures": ["rate-limit-reached", "quota-exceeded"] + }, + "logLevel": "information", + "newVersionNotification": "stable", + "showSkipMessages": true, + "languageModel": { + "enabled": true + } +} diff --git a/src/test/examples/config-language-model-disabled.json b/src/test/examples/config-language-model-disabled.json new file mode 100644 index 0000000..2703a8b --- /dev/null +++ b/src/test/examples/config-language-model-disabled.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.29.0/rc.schema.json", + "plugins": [ + { + "name": "LanguageModelFailurePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", + "configSection": "devproxy-plugin-language-model-failure-config" + } + ], + "urlsToWatch": ["https://api.openai.com/*"], + "logLevel": "information", + "languageModel": { + "enabled": false + }, + "devproxy-plugin-language-model-failure-config": { + "failureRate": 10 + } +} diff --git a/src/test/examples/config-language-model-enabled.json b/src/test/examples/config-language-model-enabled.json new file mode 100644 index 0000000..6641015 --- /dev/null +++ b/src/test/examples/config-language-model-enabled.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.29.0/rc.schema.json", + "plugins": [ + { + "name": "LanguageModelFailurePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", + "configSection": "devproxy-plugin-language-model-failure-config" + }, + { + "name": "LanguageModelRateLimitingPlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", + "configSection": "devproxy-plugin-language-model-rate-limiting-config" + } + ], + "urlsToWatch": ["https://api.openai.com/*"], + "logLevel": "information", + "languageModel": { + "enabled": true + }, + "devproxy-plugin-language-model-failure-config": { + "failureRate": 10 + }, + "devproxy-plugin-language-model-rate-limiting-config": { + "requestsPerMinute": 100 + } +} diff --git a/src/test/examples/config-language-model-required.json b/src/test/examples/config-language-model-required.json new file mode 100644 index 0000000..f615726 --- /dev/null +++ b/src/test/examples/config-language-model-required.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.29.0/rc.schema.json", + "plugins": [ + { + "name": "LanguageModelFailurePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", + "configSection": "devproxy-plugin-language-model-failure-config" + }, + { + "name": "LanguageModelRateLimitingPlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", + "configSection": "devproxy-plugin-language-model-rate-limiting-config" + } + ], + "urlsToWatch": ["https://api.openai.com/*"], + "logLevel": "information", + "devproxy-plugin-language-model-failure-config": { + "failureRate": 10 + }, + "devproxy-plugin-language-model-rate-limiting-config": { + "requestsPerMinute": 100 + } +} diff --git a/src/types.ts b/src/types.ts index 6ce7234..4d3b675 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export type PluginSnippets = { [key: string]: { instance: string; config?: PluginConfig; + requiresLanguageModel?: boolean; }; };