From 54ee322905e670bd5234983554d1a15d0b328803 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 15 Feb 2026 16:27:53 +0200 Subject: [PATCH 1/4] feat: add support for Anthropic and DeepSeek AI providers with configuration options --- .github/copilot-instructions.md | 4 +- README.md | 36 +++++ pom.xml | 16 +++ .../provider/AnthropicProvider.java | 121 +++++++++++++++++ .../provider/DeepSeekProvider.java | 126 ++++++++++++++++++ .../provider/AnthropicProvider/config.jelly | 17 +++ .../provider/DeepSeekProvider/config.jelly | 17 +++ .../plugins/explain_error/CasCTest.java | 22 +++ .../explain_error/provider/ProviderTest.java | 64 +++++++++ .../provider/TestAnthropicProvider.java | 102 ++++++++++++++ .../provider/TestDeepSeekProvider.java | 102 ++++++++++++++ .../plugins/explain_error/casc_anthropic.yaml | 8 ++ .../plugins/explain_error/casc_deepseek.yaml | 8 ++ 13 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/provider/DeepSeekProvider.java create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/config.jelly create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/config.jelly create mode 100644 src/test/java/io/jenkins/plugins/explain_error/provider/TestAnthropicProvider.java create mode 100644 src/test/java/io/jenkins/plugins/explain_error/provider/TestDeepSeekProvider.java create mode 100644 src/test/resources/io/jenkins/plugins/explain_error/casc_anthropic.yaml create mode 100644 src/test/resources/io/jenkins/plugins/explain_error/casc_deepseek.yaml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b2b3f89..91e87a3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,7 +10,7 @@ The Explain Error Plugin is a Jenkins plugin that provides AI-powered explanatio - **GlobalConfigurationImpl**: Main plugin configuration class with `@Symbol("explainError")` for Configuration as Code support, handles migration from legacy enum-based configuration - **BaseAIProvider**: Abstract base class for AI provider implementations with nested `Assistant` interface and `BaseProviderDescriptor` for extensibility -- **OpenAIProvider** / **GeminiProvider** / **OllamaProvider**: LangChain4j-based AI service implementations with provider-specific configurations +- **OpenAIProvider** / **GeminiProvider** / **AnthropicProvider** / **DeepSeekProvider** / **OllamaProvider**: LangChain4j-based AI service implementations with provider-specific configurations - **ExplainErrorStep**: Pipeline step implementation for `explainError()` function - **ConsoleExplainErrorAction**: Adds "Explain Error" button to console output for manual triggering - **ConsoleExplainErrorActionFactory**: TransientActionFactory that dynamically injects ConsoleExplainErrorAction into all runs (new and existing) @@ -39,6 +39,8 @@ src/main/java/io/jenkins/plugins/explain_error/ ├── BaseAIProvider.java # Abstract AI service with Assistant interface ├── OpenAIProvider.java # OpenAI/LangChain4j implementation ├── GeminiProvider.java # Google Gemini/LangChain4j implementation + ├── AnthropicProvider.java # Anthropic Claude/LangChain4j implementation + ├── DeepSeekProvider.java # DeepSeek/LangChain4j implementation └── OllamaProvider.java # Ollama/LangChain4j implementation ``` diff --git a/README.md b/README.md index 4541b79..e4dd9df 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,30 @@ unclassified: enableExplanation: true ``` +**Anthropic Configuration:** +```yaml +unclassified: + explainError: + aiProvider: + anthropic: + apiKey: "${AI_API_KEY}" + model: "claude-3-5-sonnet-20241022" + # url: "" # Optional, leave empty for default + enableExplanation: true +``` + +**DeepSeek Configuration:** +```yaml +unclassified: + explainError: + aiProvider: + deepseek: + apiKey: "${AI_API_KEY}" + model: "deepseek-chat" + # url: "" # Optional, leave empty for default (https://api.deepseek.com) + enableExplanation: true +``` + **Ollama Configuration:** ```yaml unclassified: @@ -159,6 +183,18 @@ This allows you to manage the plugin configuration alongside your other Jenkins - **Endpoint**: Leave empty for official Google AI API, or specify custom URL for Gemini-compatible services - **Best for**: Fast, efficient analysis with competitive quality +### Anthropic +- **Models**: `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-20241022`, `claude-3-opus-20240229`, etc. +- **API Key**: Get from [Anthropic Console](https://console.anthropic.com/) +- **Endpoint**: Leave empty for official Anthropic API, or specify custom URL for Claude-compatible services +- **Best for**: Advanced reasoning, complex error analysis with Claude models + +### DeepSeek +- **Models**: `deepseek-chat`, `deepseek-coder`, `deepseek-reasoner` +- **API Key**: Get from [DeepSeek Platform](https://platform.deepseek.com/) +- **Endpoint**: Leave empty for official DeepSeek API (`https://api.deepseek.com`), or specify custom URL +- **Best for**: Cost-effective, coding-focused analysis + ### Ollama (Local/Private LLM) - **Models**: `gemma3:1b`, `gpt-oss`, `deepseek-r1`, and any model available in your Ollama instance - **API Key**: Not required by default (unless your Ollama server is secured) diff --git a/pom.xml b/pom.xml index 3cef0cd..01e9ded 100644 --- a/pom.xml +++ b/pom.xml @@ -208,5 +208,21 @@ + + dev.langchain4j + langchain4j-anthropic + ${langchain4j.version} + + + org.slf4j + * + + + com.fasterxml.jackson.core + * + + + + diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java new file mode 100644 index 0000000..7b14676 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/AnthropicProvider.java @@ -0,0 +1,121 @@ +package io.jenkins.plugins.explain_error.provider; + +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.service.AiServices; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.model.AutoCompletionCandidates; +import hudson.model.TaskListener; +import hudson.util.FormValidation; +import hudson.util.Secret; +import io.jenkins.plugins.explain_error.ExplanationException; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.GET; +import org.kohsuke.stapler.verb.POST; + +public class AnthropicProvider extends BaseAIProvider { + + private static final Logger LOGGER = Logger.getLogger(AnthropicProvider.class.getName()); + + protected Secret apiKey; + + @DataBoundConstructor + public AnthropicProvider(String url, String model, Secret apiKey) { + super(url, model); + this.apiKey = apiKey; + } + + public Secret getApiKey() { + return apiKey; + } + + @Override + public Assistant createAssistant() { + ChatModel model = AnthropicChatModel.builder() + .baseUrl(Util.fixEmptyAndTrim(getUrl())) // Will use default if null + .apiKey(getApiKey().getPlainText()) + .modelName(getModel()) + .temperature(0.3) + .logRequests(LOGGER.isLoggable(Level.FINE)) + .logResponses(LOGGER.isLoggable(Level.FINE)) + .build(); + + return AiServices.create(Assistant.class, model); + } + + @Override + public boolean isNotValid(@CheckForNull TaskListener listener) { + if (listener != null) { + if (Util.fixEmptyAndTrim(Secret.toString(getApiKey())) == null) { + listener.getLogger().println("No Api key configured for Anthropic."); + } else if (Util.fixEmptyAndTrim(getModel()) == null) { + listener.getLogger().println("No Model configured for Anthropic."); + } + } + return Util.fixEmptyAndTrim(Secret.toString(getApiKey())) == null || + Util.fixEmptyAndTrim(getModel()) == null; + } + + @Extension + @Symbol("anthropic") + public static class DescriptorImpl extends BaseProviderDescriptor { + + private static final String[] MODELS = new String[]{ + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-5-haiku-20241022", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307" + }; + + @NonNull + @Override + public String getDisplayName() { + return "Anthropic"; + } + + public String getDefaultModel() { + return "claude-3-5-sonnet-20241022"; + } + + @GET + @SuppressWarnings("lgtm[jenkins/no-permission-check]") + public AutoCompletionCandidates doAutoCompleteModel(@QueryParameter String value) { + AutoCompletionCandidates c = new AutoCompletionCandidates(); + for (String model : MODELS) { + if (model.toLowerCase().startsWith(value.toLowerCase())) { + c.add(model); + } + } + return c; + } + + /** + * Method to test the AI API configuration. + * This is called when the "Test Configuration" button is clicked. + */ + @POST + public FormValidation doTestConfiguration(@QueryParameter("apiKey") Secret apiKey, + @QueryParameter("url") String url, + @QueryParameter("model") String model) throws ExplanationException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + + AnthropicProvider provider = new AnthropicProvider(url, model, apiKey); + try { + provider.explainError("Send 'Configuration test successful' to me.", null); + return FormValidation.ok("Configuration test successful! API connection is working properly."); + } catch (ExplanationException e) { + return FormValidation.error("Configuration test failed: " + e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/DeepSeekProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/DeepSeekProvider.java new file mode 100644 index 0000000..6fa6894 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/DeepSeekProvider.java @@ -0,0 +1,126 @@ +package io.jenkins.plugins.explain_error.provider; + +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.service.AiServices; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.model.AutoCompletionCandidates; +import hudson.model.TaskListener; +import hudson.util.FormValidation; +import hudson.util.Secret; +import io.jenkins.plugins.explain_error.ExplanationException; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.GET; +import org.kohsuke.stapler.verb.POST; + +public class DeepSeekProvider extends BaseAIProvider { + + private static final Logger LOGGER = Logger.getLogger(DeepSeekProvider.class.getName()); + private static final String DEFAULT_BASE_URL = "https://api.deepseek.com"; + + protected Secret apiKey; + + @DataBoundConstructor + public DeepSeekProvider(String url, String model, Secret apiKey) { + super(url, model); + this.apiKey = apiKey; + } + + public Secret getApiKey() { + return apiKey; + } + + @Override + public Assistant createAssistant() { + String baseUrl = Util.fixEmptyAndTrim(getUrl()); + if (baseUrl == null) { + baseUrl = DEFAULT_BASE_URL; + } + + ChatModel model = OpenAiChatModel.builder() + .baseUrl(baseUrl) + .apiKey(getApiKey().getPlainText()) + .modelName(getModel()) + .temperature(0.3) + .responseFormat(ResponseFormat.JSON) + .logRequests(LOGGER.isLoggable(Level.FINE)) + .logResponses(LOGGER.isLoggable(Level.FINE)) + .build(); + + return AiServices.create(Assistant.class, model); + } + + @Override + public boolean isNotValid(@CheckForNull TaskListener listener) { + if (listener != null) { + if (Util.fixEmptyAndTrim(Secret.toString(getApiKey())) == null) { + listener.getLogger().println("No Api key configured for DeepSeek."); + } else if (Util.fixEmptyAndTrim(getModel()) == null) { + listener.getLogger().println("No Model configured for DeepSeek."); + } + } + return Util.fixEmptyAndTrim(Secret.toString(getApiKey())) == null || + Util.fixEmptyAndTrim(getModel()) == null; + } + + @Extension + @Symbol("deepseek") + public static class DescriptorImpl extends BaseProviderDescriptor { + + private static final String[] MODELS = new String[]{ + "deepseek-chat", + "deepseek-coder", + "deepseek-reasoner" + }; + + @NonNull + @Override + public String getDisplayName() { + return "DeepSeek"; + } + + public String getDefaultModel() { + return "deepseek-chat"; + } + + @GET + @SuppressWarnings("lgtm[jenkins/no-permission-check]") + public AutoCompletionCandidates doAutoCompleteModel(@QueryParameter String value) { + AutoCompletionCandidates c = new AutoCompletionCandidates(); + for (String model : MODELS) { + if (model.toLowerCase().startsWith(value.toLowerCase())) { + c.add(model); + } + } + return c; + } + + /** + * Method to test the AI API configuration. + * This is called when the "Test Configuration" button is clicked. + */ + @POST + public FormValidation doTestConfiguration(@QueryParameter("apiKey") Secret apiKey, + @QueryParameter("url") String url, + @QueryParameter("model") String model) throws ExplanationException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + + DeepSeekProvider provider = new DeepSeekProvider(url, model, apiKey); + try { + provider.explainError("Send 'Configuration test successful' to me.", null); + return FormValidation.ok("Configuration test successful! API connection is working properly."); + } catch (ExplanationException e) { + return FormValidation.error("Configuration test failed: " + e.getMessage(), e); + } + } + } +} diff --git a/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/config.jelly b/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/config.jelly new file mode 100644 index 0000000..c59d400 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/config.jelly @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/config.jelly b/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/config.jelly new file mode 100644 index 0000000..c59d400 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/config.jelly @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/src/test/java/io/jenkins/plugins/explain_error/CasCTest.java b/src/test/java/io/jenkins/plugins/explain_error/CasCTest.java index 1198fe1..14d3ead 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/CasCTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/CasCTest.java @@ -6,7 +6,9 @@ import io.jenkins.plugins.casc.misc.ConfiguredWithCode; import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule; import io.jenkins.plugins.casc.misc.junit.jupiter.WithJenkinsConfiguredWithCode; +import io.jenkins.plugins.explain_error.provider.AnthropicProvider; import io.jenkins.plugins.explain_error.provider.BaseAIProvider; +import io.jenkins.plugins.explain_error.provider.DeepSeekProvider; import io.jenkins.plugins.explain_error.provider.OllamaProvider; import org.junit.jupiter.api.Test; @@ -32,4 +34,24 @@ void loadNewConfig(JenkinsConfiguredWithCodeRule jcwcRule) { assertEquals("gemma3:1b", provider.getModel()); assertEquals("http://localhost:11434", provider.getUrl()); } + + @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()); + assertEquals("https://api.anthropic.com", provider.getUrl()); + } + + @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()); + assertEquals("https://api.deepseek.com", provider.getUrl()); + } } diff --git a/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java b/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java index 03195e8..853cff0 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java @@ -155,4 +155,68 @@ void testOllamaNullUrl() { assertEquals("The provider is not properly configured.", result.getMessage()); } + + @Test + void testAnthropicWithNullApiKey() { + BaseAIProvider provider = new AnthropicProvider(null, "test-model", 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, "test-model", 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 testDeepSeekWithNullApiKey() { + BaseAIProvider provider = new DeepSeekProvider(null, "test-model", 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, "test-model", 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()); + } } diff --git a/src/test/java/io/jenkins/plugins/explain_error/provider/TestAnthropicProvider.java b/src/test/java/io/jenkins/plugins/explain_error/provider/TestAnthropicProvider.java new file mode 100644 index 0000000..a82d101 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/provider/TestAnthropicProvider.java @@ -0,0 +1,102 @@ +package io.jenkins.plugins.explain_error.provider; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.util.Secret; +import io.jenkins.plugins.explain_error.JenkinsLogAnalysis; + +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +public class TestAnthropicProvider extends AnthropicProvider { + + private boolean throwError = false; + private JenkinsLogAnalysis answerMessage = new JenkinsLogAnalysis( + "Request was successful", null, null, null); + private int callCount = 0; + private String providerName = "TestAnthropic"; + + // Captured parameters from last analyzeLogs call + private String lastErrorLogs; + private String lastLanguage; + private String lastCustomContext; + + @DataBoundConstructor + public TestAnthropicProvider() { + super("https://localhost:1234", "test-anthropic-model", Secret.fromString("test-api-key")); + } + + @Override + public Assistant createAssistant() { + return new Assistant() { + @Override + public JenkinsLogAnalysis analyzeLogs(String errorLogs, String language, String customContext) { + if (throwError) { + throw new RuntimeException("Request failed."); + } + // Capture parameters for test verification + lastErrorLogs = errorLogs; + lastLanguage = language; + lastCustomContext = customContext; + callCount++; + return answerMessage; + } + }; + } + + public void setThrowError(boolean throwError) { + this.throwError = throwError; + } + + public void setApiKey(Secret apiKey) { + this.apiKey = apiKey; + } + + public void setModel(String model) { + this.model = model; + } + + public void setAnswerMessage(String answerMessage) { + this.answerMessage = new JenkinsLogAnalysis(answerMessage, null, null, null); + } + + public int getCallCount() { + return callCount; + } + + public String getLastErrorLogs() { + return lastErrorLogs; + } + + public String getLastLanguage() { + return lastLanguage; + } + + public String getLastCustomContext() { + return lastCustomContext; + } + + public void setProviderName(String providerName) { + this.providerName = providerName; + } + + @Override + public String getProviderName() { + return providerName; + } + + @Extension + @Symbol("testAnthropic") + public static class DescriptorImpl extends BaseProviderDescriptor { + + @NonNull + @Override + public String getDisplayName() { + return "TestAnthropic"; + } + + public String getDefaultModel() { + return "test-anthropic-model"; + } + } +} diff --git a/src/test/java/io/jenkins/plugins/explain_error/provider/TestDeepSeekProvider.java b/src/test/java/io/jenkins/plugins/explain_error/provider/TestDeepSeekProvider.java new file mode 100644 index 0000000..e4fdf36 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/provider/TestDeepSeekProvider.java @@ -0,0 +1,102 @@ +package io.jenkins.plugins.explain_error.provider; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.util.Secret; +import io.jenkins.plugins.explain_error.JenkinsLogAnalysis; + +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +public class TestDeepSeekProvider extends DeepSeekProvider { + + private boolean throwError = false; + private JenkinsLogAnalysis answerMessage = new JenkinsLogAnalysis( + "Request was successful", null, null, null); + private int callCount = 0; + private String providerName = "TestDeepSeek"; + + // Captured parameters from last analyzeLogs call + private String lastErrorLogs; + private String lastLanguage; + private String lastCustomContext; + + @DataBoundConstructor + public TestDeepSeekProvider() { + super("https://localhost:1234", "test-deepseek-model", Secret.fromString("test-api-key")); + } + + @Override + public Assistant createAssistant() { + return new Assistant() { + @Override + public JenkinsLogAnalysis analyzeLogs(String errorLogs, String language, String customContext) { + if (throwError) { + throw new RuntimeException("Request failed."); + } + // Capture parameters for test verification + lastErrorLogs = errorLogs; + lastLanguage = language; + lastCustomContext = customContext; + callCount++; + return answerMessage; + } + }; + } + + public void setThrowError(boolean throwError) { + this.throwError = throwError; + } + + public void setApiKey(Secret apiKey) { + this.apiKey = apiKey; + } + + public void setModel(String model) { + this.model = model; + } + + public void setAnswerMessage(String answerMessage) { + this.answerMessage = new JenkinsLogAnalysis(answerMessage, null, null, null); + } + + public int getCallCount() { + return callCount; + } + + public String getLastErrorLogs() { + return lastErrorLogs; + } + + public String getLastLanguage() { + return lastLanguage; + } + + public String getLastCustomContext() { + return lastCustomContext; + } + + public void setProviderName(String providerName) { + this.providerName = providerName; + } + + @Override + public String getProviderName() { + return providerName; + } + + @Extension + @Symbol("testDeepSeek") + public static class DescriptorImpl extends BaseProviderDescriptor { + + @NonNull + @Override + public String getDisplayName() { + return "TestDeepSeek"; + } + + public String getDefaultModel() { + return "test-deepseek-model"; + } + } +} diff --git a/src/test/resources/io/jenkins/plugins/explain_error/casc_anthropic.yaml b/src/test/resources/io/jenkins/plugins/explain_error/casc_anthropic.yaml new file mode 100644 index 0000000..c04fc90 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/explain_error/casc_anthropic.yaml @@ -0,0 +1,8 @@ +unclassified: + explainError: + aiProvider: + anthropic: + apiKey: "test-anthropic-api-key" + model: "claude-3-5-sonnet-20241022" + url: "https://api.anthropic.com" + enableExplanation: true diff --git a/src/test/resources/io/jenkins/plugins/explain_error/casc_deepseek.yaml b/src/test/resources/io/jenkins/plugins/explain_error/casc_deepseek.yaml new file mode 100644 index 0000000..3e7c792 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/explain_error/casc_deepseek.yaml @@ -0,0 +1,8 @@ +unclassified: + explainError: + aiProvider: + deepseek: + apiKey: "test-deepseek-api-key" + model: "deepseek-chat" + url: "https://api.deepseek.com" + enableExplanation: true From 161bf851339e41303bc8e89bd4e4d521addfe8eb Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 15 Feb 2026 18:11:59 +0200 Subject: [PATCH 2/4] feat: add help documentation for Anthropic and DeepSeek providers --- .../explain_error/provider/AnthropicProvider/help-model.html | 4 ++++ .../explain_error/provider/AnthropicProvider/help-url.html | 4 ++++ .../explain_error/provider/DeepSeekProvider/help-model.html | 4 ++++ .../explain_error/provider/DeepSeekProvider/help-url.html | 4 ++++ 4 files changed, 16 insertions(+) create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-model.html create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-url.html create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-model.html create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-url.html diff --git a/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-model.html b/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-model.html new file mode 100644 index 0000000..ef2cb0e --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-model.html @@ -0,0 +1,4 @@ +
+ A valid name for an Anthropic Claude model, e.g. claude-3-5-sonnet-20241022, claude-3-opus-20240229, claude-3-haiku-20240307. + See Anthropic Models for a list of available models. +
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-url.html b/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-url.html new file mode 100644 index 0000000..636c280 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/provider/AnthropicProvider/help-url.html @@ -0,0 +1,4 @@ +
+

Leave this field empty for standard Anthropic API usage.

+

Specify a custom URL when using custom Anthropic proxies, enterprise endpoints or Anthropic compatible services.

+
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-model.html b/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-model.html new file mode 100644 index 0000000..c0e9fee --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-model.html @@ -0,0 +1,4 @@ +
+ A valid name for a DeepSeek model, e.g. deepseek-chat, deepseek-coder. + See DeepSeek Models for a list of available models. +
diff --git a/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-url.html b/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-url.html new file mode 100644 index 0000000..bb7c42a --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/provider/DeepSeekProvider/help-url.html @@ -0,0 +1,4 @@ +
+

Leave this field empty for standard DeepSeek API usage.

+

Specify a custom URL when using custom DeepSeek proxies, enterprise endpoints or DeepSeek compatible services.

+
From 869e63910df7b8da1bd6a75ce66c50fef299bf16 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 15 Feb 2026 18:17:43 +0200 Subject: [PATCH 3/4] feat: refine dependency exclusions for Jackson and SLF4J in pom.xml --- pom.xml | 60 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/pom.xml b/pom.xml index 01e9ded..7d781cd 100644 --- a/pom.xml +++ b/pom.xml @@ -151,11 +151,19 @@ org.slf4j - * + slf4j-api com.fasterxml.jackson.core - * + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations @@ -167,11 +175,19 @@ org.slf4j - * + slf4j-api com.fasterxml.jackson.core - * + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations @@ -183,11 +199,19 @@ org.slf4j - * + slf4j-api + + + com.fasterxml.jackson.core + jackson-databind com.fasterxml.jackson.core - * + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations @@ -199,11 +223,19 @@ org.slf4j - * + slf4j-api + + + com.fasterxml.jackson.core + jackson-databind com.fasterxml.jackson.core - * + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations @@ -215,11 +247,19 @@ org.slf4j - * + slf4j-api + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core com.fasterxml.jackson.core - * + jackson-annotations From 5382082b1721fa778a06a13a0e745eb697350345 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 18 Feb 2026 01:27:20 +0200 Subject: [PATCH 4/4] fix: update ProviderTest to fix test failure --- .../plugins/explain_error/provider/ProviderTest.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java b/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java index eea3e16..5d66b75 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/provider/ProviderTest.java @@ -159,7 +159,11 @@ void testOllamaNullUrl() { @Test void testAnthropicWithNullApiKey() { BaseAIProvider provider = new AnthropicProvider(null, "test-model", null); - + ExplanationException result = assertThrows(ExplanationException.class, () -> provider.explainError("Test error", null)); + + assertEquals("The provider is not properly configured.", result.getMessage()); + } + @Test void testBedrockNullModel() { BaseAIProvider provider = new BedrockProvider(null, null, "eu-west-1"); @@ -219,7 +223,11 @@ void testDeepSeekWithNullModel() { @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 testBedrockEmptyModel() { BaseAIProvider provider = new BedrockProvider(null, "", "eu-west-1");