Add support for Anthropic, Azure OpenAI, and DeepSeek providers#78
Add support for Anthropic, Azure OpenAI, and DeepSeek providers#78shenxianpeng wants to merge 11 commits intomainfrom
Conversation
…eepSeek; update README and add tests
…; include configuration UI and validation
There was a problem hiding this comment.
Pull request overview
This pull request adds support for three new AI providers to the Explain Error Plugin: Anthropic (Claude), Azure OpenAI, and DeepSeek. These additions follow the established provider pattern using LangChain4j integration and extend the plugin's compatibility with popular enterprise and cost-effective AI services.
Changes:
- Adds three new AI provider implementations (AnthropicProvider, AzureOpenAIProvider, DeepSeekProvider) with complete validation, configuration, and LangChain4j integration
- Adds comprehensive unit tests for all three providers covering validation scenarios and provider creation
- Adds Configuration as Code (CasC) tests for the new providers
- Updates README documentation with detailed provider descriptions, configuration examples, and usage guidance
- Adds UI configuration files (Jelly) with helpful descriptions and autocomplete support
- Updates pom.xml with new LangChain4j dependencies for Anthropic and Azure OpenAI, plus Netty BOM for version consistency
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java | Implements Anthropic Claude provider with API key authentication, model validation, and LangChain4j integration |
| src/main/java/io/jenkins/plugins/explain_error/provider/AzureOpenAIProvider.java | Implements Azure OpenAI provider requiring endpoint URL, deployment name, and API key |
| src/main/java/io/jenkins/plugins/explain_error/provider/DeepSeekProvider.java | Implements DeepSeek provider using OpenAI-compatible API with optional custom endpoint support |
| src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java | Adds comprehensive unit tests for all three providers covering null/empty validation scenarios |
| src/test/java/io/jenkins/plugins/explain_error/CasCTest.java | Adds CasC integration tests for all three providers to verify YAML configuration loading |
| src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/config.jelly | UI configuration with API key, optional URL, and model fields with autocomplete |
| src/main/resources/io/jenkins/plugins/explain_error/provider/AzureOpenAIProvider/config.jelly | UI configuration with required endpoint, deployment name, and API key fields |
| src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/config.jelly | UI configuration with API key, optional URL, and model fields with autocomplete |
| pom.xml | Adds langchain4j-anthropic and langchain4j-azure-open-ai dependencies with proper exclusions, plus Netty BOM for version management |
| README.md | Updates provider list, adds detailed sections for each new provider with configuration examples and usage guidance |
|
|
||
| // ============= Anthropic Provider Tests ============= | ||
|
|
||
| @Test | ||
| void testAnthropicWithNullApiKey() { | ||
| BaseAIProvider provider = new AnthropicProvider(null, "claude-3-5-sonnet-20241022", null); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAnthropicWithEmptyApiKey() { | ||
| BaseAIProvider provider = new AnthropicProvider(null, "claude-3-5-sonnet-20241022", Secret.fromString("")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAnthropicWithNullModel() { | ||
| BaseAIProvider provider = new AnthropicProvider(null, null, Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAnthropicWithEmptyModel() { | ||
| BaseAIProvider provider = new AnthropicProvider(null, "", Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAnthropicProviderCreation() { | ||
| BaseAIProvider provider = new AnthropicProvider(null, "claude-3-5-sonnet-20241022", Secret.fromString("test-key")); | ||
| assertEquals("claude-3-5-sonnet-20241022", provider.getModel()); | ||
| assertEquals(null, provider.getUrl()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAnthropicProviderWithCustomUrl() { | ||
| BaseAIProvider provider = new AnthropicProvider("https://custom-anthropic.example.com", "claude-3-5-sonnet-20241022", Secret.fromString("test-key")); | ||
| assertEquals("claude-3-5-sonnet-20241022", provider.getModel()); | ||
| assertEquals("https://custom-anthropic.example.com", provider.getUrl()); | ||
| } | ||
|
|
||
| // ============= Azure OpenAI Provider Tests ============= | ||
|
|
||
| @Test | ||
| void testAzureOpenAIWithNullApiKey() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider("https://test.openai.azure.com", "gpt-4.1", null); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAzureOpenAIWithEmptyApiKey() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider("https://test.openai.azure.com", "gpt-4.1", Secret.fromString("")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAzureOpenAIWithNullEndpoint() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider(null, "gpt-4.1", Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAzureOpenAIWithEmptyEndpoint() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider("", "gpt-4.1", Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAzureOpenAIWithNullDeployment() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider("https://test.openai.azure.com", null, Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAzureOpenAIWithEmptyDeployment() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider("https://test.openai.azure.com", "", Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testAzureOpenAIProviderCreation() { | ||
| BaseAIProvider provider = new AzureOpenAIProvider("https://test.openai.azure.com", "my-deployment", Secret.fromString("test-key")); | ||
| assertEquals("my-deployment", provider.getModel()); | ||
| assertEquals("https://test.openai.azure.com", provider.getUrl()); | ||
| } | ||
|
|
||
| // ============= DeepSeek Provider Tests ============= | ||
|
|
||
| @Test | ||
| void testDeepSeekWithNullApiKey() { | ||
| BaseAIProvider provider = new DeepSeekProvider(null, "deepseek-chat", null); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testDeepSeekWithEmptyApiKey() { | ||
| BaseAIProvider provider = new DeepSeekProvider(null, "deepseek-chat", Secret.fromString("")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testDeepSeekWithNullModel() { | ||
| BaseAIProvider provider = new DeepSeekProvider(null, null, Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testDeepSeekWithEmptyModel() { | ||
| BaseAIProvider provider = new DeepSeekProvider(null, "", Secret.fromString("test-key")); | ||
| ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); | ||
|
|
||
| assertEquals("The provider is not properly configured.", result.getMessage()); | ||
| } | ||
|
|
||
| @Test | ||
| void testDeepSeekProviderCreation() { | ||
| BaseAIProvider provider = new DeepSeekProvider(null, "deepseek-chat", Secret.fromString("test-key")); | ||
| assertEquals("deepseek-chat", provider.getModel()); | ||
| assertEquals(null, provider.getUrl()); | ||
| } | ||
|
|
||
| @Test | ||
| void testDeepSeekProviderWithCustomUrl() { | ||
| BaseAIProvider provider = new DeepSeekProvider("https://custom-deepseek.example.com", "deepseek-coder", Secret.fromString("test-key")); | ||
| assertEquals("deepseek-coder", provider.getModel()); | ||
| assertEquals("https://custom-deepseek.example.com", provider.getUrl()); | ||
| } |
There was a problem hiding this comment.
The test file uses AnthropicProvider, AzureOpenAIProvider, and DeepSeekProvider classes but does not import them. Add the following imports at the top of the file:
- import io.jenkins.plugins.explain_error.provider.AnthropicProvider;
- import io.jenkins.plugins.explain_error.provider.AzureOpenAIProvider;
- import io.jenkins.plugins.explain_error.provider.DeepSeekProvider;
| @Test | ||
| @ConfiguredWithCode("casc_anthropic.yaml") | ||
| void loadAnthropicConfig(JenkinsConfiguredWithCodeRule jcwcRule) { | ||
| GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); | ||
| BaseAIProvider provider = config.getAiProvider(); | ||
| assertInstanceOf(AnthropicProvider.class, provider); | ||
| assertEquals("claude-3-5-sonnet-20241022", provider.getModel()); | ||
|
|
||
| AnthropicProvider anthropicProvider = (AnthropicProvider) provider; | ||
| assertNotNull(anthropicProvider.getApiKey()); | ||
| assertEquals("test-anthropic-key", anthropicProvider.getApiKey().getPlainText()); | ||
| } | ||
|
|
||
| @Test | ||
| @ConfiguredWithCode("casc_azure.yaml") | ||
| void loadAzureConfig(JenkinsConfiguredWithCodeRule jcwcRule) { | ||
| GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); | ||
| BaseAIProvider provider = config.getAiProvider(); | ||
| assertInstanceOf(AzureOpenAIProvider.class, provider); | ||
| assertEquals("gpt-4.1-deployment", provider.getModel()); | ||
| assertEquals("https://test-resource.openai.azure.com", provider.getUrl()); | ||
|
|
||
| AzureOpenAIProvider azureProvider = (AzureOpenAIProvider) provider; | ||
| assertNotNull(azureProvider.getApiKey()); | ||
| assertEquals("test-azure-key", azureProvider.getApiKey().getPlainText()); | ||
| } | ||
|
|
||
| @Test | ||
| @ConfiguredWithCode("casc_deepseek.yaml") | ||
| void loadDeepSeekConfig(JenkinsConfiguredWithCodeRule jcwcRule) { | ||
| GlobalConfigurationImpl config = GlobalConfigurationImpl.get(); | ||
| BaseAIProvider provider = config.getAiProvider(); | ||
| assertInstanceOf(DeepSeekProvider.class, provider); | ||
| assertEquals("deepseek-chat", provider.getModel()); | ||
|
|
||
| DeepSeekProvider deepseekProvider = (DeepSeekProvider) provider; | ||
| assertNotNull(deepseekProvider.getApiKey()); | ||
| assertEquals("test-deepseek-key", deepseekProvider.getApiKey().getPlainText()); | ||
| } |
There was a problem hiding this comment.
The test references CasC YAML files that are not included in this PR. The files casc_anthropic.yaml, casc_azure.yaml, and casc_deepseek.yaml must be created in src/test/resources/io/jenkins/plugins/explain_error/ for these tests to pass. These files should follow the pattern of existing CasC test files like casc_new.yaml.
src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java
Show resolved
Hide resolved
| if (model.toLowerCase().contains(value.toLowerCase())) { | ||
| c.add(model); | ||
| } | ||
| } | ||
| return c; | ||
| } | ||
|
|
There was a problem hiding this comment.
The autocompletion logic uses contains() instead of startsWith() which is inconsistent with OpenAIProvider (line 101 in OpenAIProvider.java). While contains() is more flexible, it creates an inconsistent user experience across providers. For consistency, consider using startsWith() like OpenAIProvider, or update OpenAIProvider to use contains() for all providers.
src/main/java/io/jenkins/plugins/explain_error/provider/AzureOpenAIProvider.java
Show resolved
Hide resolved
| public AutoCompletionCandidates doAutoCompleteModel(@QueryParameter String value) { | ||
| AutoCompletionCandidates c = new AutoCompletionCandidates(); | ||
| for (String model : COMMON_MODELS) { | ||
| if (model.toLowerCase().contains(value.toLowerCase())) { | ||
| c.add(model); | ||
| } | ||
| } | ||
| return c; |
There was a problem hiding this comment.
The autocompletion logic uses contains() instead of startsWith() which is inconsistent with OpenAIProvider (line 101 in OpenAIProvider.java). While contains() is more flexible, it creates an inconsistent user experience across providers. For consistency, consider using startsWith() like OpenAIProvider, or update OpenAIProvider to use contains() for all providers.
| public AutoCompletionCandidates doAutoCompleteModel(@QueryParameter String value) { | ||
| AutoCompletionCandidates c = new AutoCompletionCandidates(); | ||
| for (String model : MODELS) { | ||
| if (model.toLowerCase().contains(value.toLowerCase())) { | ||
| c.add(model); | ||
| } | ||
| } | ||
| return c; |
There was a problem hiding this comment.
The autocompletion logic uses contains() instead of startsWith() which is inconsistent with OpenAIProvider (line 101 in OpenAIProvider.java). While contains() is more flexible, it creates an inconsistent user experience across providers. For consistency, consider using startsWith() like OpenAIProvider, or update OpenAIProvider to use contains() for all providers.
src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java
Show resolved
Hide resolved
src/main/java/io/jenkins/plugins/explain_error/provider/AzureOpenAIProvider.java
Show resolved
Hide resolved
src/main/java/io/jenkins/plugins/explain_error/provider/DeepSeekProvider.java
Show resolved
Hide resolved
…picProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…penAIProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…picProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…penAIProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ekProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
panicking
left a comment
There was a problem hiding this comment.
@shenxianpeng please try to improve the commit message, and reduce code to avoid copy and paste
src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java
Show resolved
Hide resolved
- Fix error message in AzureOpenAIProvider (line 62) to say 'No Endpoint' instead of 'No API key' - Remove else-if logic in AnthropicProvider validation to show all missing config messages - Standardize autocompletion across all providers to use startsWith() instead of contains() - Rename COMMON_MODELS to COMMON_DEPLOYMENT_NAMES in AzureOpenAIProvider with clarifying comments - Add <optional>true</optional> to langchain4j-anthropic and langchain4j-azure-open-ai dependencies
Testing done
Submitter checklist