| 1 | package org.egothor.methodatlas.ai; | |
| 2 | ||
| 3 | import java.net.URI; | |
| 4 | import java.net.http.HttpRequest; | |
| 5 | import java.util.List; | |
| 6 | ||
| 7 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | |
| 8 | import com.fasterxml.jackson.annotation.JsonProperty; | |
| 9 | ||
| 10 | /** | |
| 11 | * {@link AiProviderClient} implementation for AI providers that expose an | |
| 12 | * OpenAI-compatible chat completion API. | |
| 13 | * | |
| 14 | * <p> | |
| 15 | * This client supports providers that expose an OpenAI-compatible chat | |
| 16 | * completions endpoint. The path appended to the configured base URL is | |
| 17 | * provider-specific: most providers use {@code /v1/chat/completions}, while | |
| 18 | * {@link AiProvider#GITHUB_MODELS}, {@link AiProvider#XAI}, and | |
| 19 | * {@link AiProvider#MISTRAL} use {@code /chat/completions} because their | |
| 20 | * default base URLs already include the {@code /v1} segment (or omit it | |
| 21 | * entirely, as is the case for GitHub Models). | |
| 22 | * </p> | |
| 23 | * | |
| 24 | * <p> | |
| 25 | * The client constructs a chat-style prompt consisting of a system message | |
| 26 | * defining the classification rules and a user message containing the test | |
| 27 | * class source together with the taxonomy definition. The model response is | |
| 28 | * expected to contain a JSON object describing the security classification. | |
| 29 | * </p> | |
| 30 | * | |
| 31 | * <h2>Operational Responsibilities</h2> | |
| 32 | * | |
| 33 | * <ul> | |
| 34 | * <li>constructing OpenAI-compatible chat completion requests</li> | |
| 35 | * <li>injecting the taxonomy-driven classification prompt</li> | |
| 36 | * <li>performing authenticated HTTP requests</li> | |
| 37 | * <li>extracting JSON content from the model response</li> | |
| 38 | * <li>normalizing the result into {@link AiClassSuggestion}</li> | |
| 39 | * </ul> | |
| 40 | * | |
| 41 | * <p> | |
| 42 | * The implementation is provider-neutral for APIs that follow the OpenAI | |
| 43 | * protocol, which allows reuse across multiple compatible services such as | |
| 44 | * OpenRouter. | |
| 45 | * </p> | |
| 46 | * | |
| 47 | * <p> | |
| 48 | * Instances are typically created through | |
| 49 | * {@link AiProviderFactory#create(AiOptions)}. | |
| 50 | * </p> | |
| 51 | * | |
| 52 | * @see AiProvider | |
| 53 | * @see AiProviderClient | |
| 54 | * @see AiProviderFactory | |
| 55 | */ | |
| 56 | public final class OpenAiCompatibleClient implements AiProviderClient { | |
| 57 | /** | |
| 58 | * System prompt instructing the model to operate strictly as a classification | |
| 59 | * engine and to return machine-readable JSON output. | |
| 60 | * | |
| 61 | * <p> | |
| 62 | * The prompt intentionally forbids explanatory text and markdown formatting to | |
| 63 | * ensure that the returned content can be parsed reliably by the application. | |
| 64 | * </p> | |
| 65 | */ | |
| 66 | private static final String SYSTEM_PROMPT = """ | |
| 67 | You are a precise software security classification engine. | |
| 68 | You classify JUnit 5 tests and return strict JSON only. | |
| 69 | Never include markdown fences, explanations, or extra text. | |
| 70 | """; | |
| 71 | ||
| 72 | private final AiOptions options; | |
| 73 | private final HttpSupport httpSupport; | |
| 74 | ||
| 75 | /** | |
| 76 | * Creates a new client for an OpenAI-compatible provider with no | |
| 77 | * rate-limit notification. | |
| 78 | * | |
| 79 | * <p>Rate-limit pauses are handled transparently. Use | |
| 80 | * {@link #OpenAiCompatibleClient(AiOptions, RateLimitListener)} when | |
| 81 | * callers need to be notified of such pauses.</p> | |
| 82 | * | |
| 83 | * @param options AI runtime configuration | |
| 84 | */ | |
| 85 | public OpenAiCompatibleClient(AiOptions options) { | |
| 86 | this(options, (w, a, m) -> {}); | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Creates a new client for an OpenAI-compatible provider that notifies | |
| 91 | * {@code rateLimitListener} before each rate-limit sleep. | |
| 92 | * | |
| 93 | * @param options AI runtime configuration | |
| 94 | * @param rateLimitListener callback invoked before each HTTP 429 | |
| 95 | * pause; must not be {@code null} | |
| 96 | * @see RateLimitListener | |
| 97 | */ | |
| 98 | public OpenAiCompatibleClient(AiOptions options, RateLimitListener rateLimitListener) { | |
| 99 | this.options = options; | |
| 100 | this.httpSupport = new HttpSupport(options.timeout(), options.maxRetries(), rateLimitListener); | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Determines whether the configured provider can be used in the current runtime | |
| 105 | * environment. | |
| 106 | * | |
| 107 | * <p> | |
| 108 | * For OpenAI-compatible providers, availability is determined by the presence | |
| 109 | * of a usable API key resolved through {@link AiOptions#resolvedApiKey()}. | |
| 110 | * </p> | |
| 111 | * | |
| 112 | * @return {@code true} if a usable API key is available | |
| 113 | */ | |
| 114 | @Override | |
| 115 | public boolean isAvailable() { | |
| 116 | String key = options.resolvedApiKey(); | |
| 117 |
5
1. isAvailable : removed conditional - replaced equality check with true → SURVIVED 2. isAvailable : removed conditional - replaced equality check with false → KILLED 3. isAvailable : removed conditional - replaced equality check with true → KILLED 4. isAvailable : removed conditional - replaced equality check with false → KILLED 5. isAvailable : replaced boolean return with true for org/egothor/methodatlas/ai/OpenAiCompatibleClient::isAvailable → KILLED |
return key != null && !key.isBlank(); |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * Submits a classification request to an OpenAI-compatible chat completion API. | |
| 122 | * | |
| 123 | * <p> | |
| 124 | * The request payload includes: | |
| 125 | * </p> | |
| 126 | * | |
| 127 | * <ul> | |
| 128 | * <li>the configured model identifier</li> | |
| 129 | * <li>a system prompt defining classification rules</li> | |
| 130 | * <li>a user prompt containing the test class source and taxonomy</li> | |
| 131 | * <li>a deterministic temperature setting</li> | |
| 132 | * </ul> | |
| 133 | * | |
| 134 | * <p> | |
| 135 | * When the selected provider is {@link AiProvider#OPENROUTER}, additional HTTP | |
| 136 | * headers are included to identify the calling application. | |
| 137 | * </p> | |
| 138 | * | |
| 139 | * <p> | |
| 140 | * The response is expected to contain a JSON object in the message content | |
| 141 | * field. The JSON text is extracted and deserialized into an | |
| 142 | * {@link AiClassSuggestion}. | |
| 143 | * </p> | |
| 144 | * | |
| 145 | * @param fqcn fully qualified class name being analyzed | |
| 146 | * @param classSource complete source code of the class | |
| 147 | * @param taxonomyText taxonomy definition guiding classification | |
| 148 | * @param targetMethods deterministically extracted JUnit test methods that must | |
| 149 | * be classified | |
| 150 | * @return normalized classification result | |
| 151 | * | |
| 152 | * @throws AiSuggestionException if the provider request fails, the model | |
| 153 | * response is invalid, or JSON deserialization | |
| 154 | * fails | |
| 155 | */ | |
| 156 | @Override | |
| 157 | public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText, | |
| 158 | List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException { | |
| 159 | try { | |
| 160 | String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods, options.confidence()); | |
| 161 | ||
| 162 | ChatRequest payload = new ChatRequest(options.modelName(), | |
| 163 | List.of(new Message("system", SYSTEM_PROMPT), new Message("user", prompt)), 0.0); | |
| 164 | ||
| 165 | String requestBody = httpSupport.objectMapper().writeValueAsString(payload); | |
| 166 | ||
| 167 | URI uri = URI.create(options.baseUrl() + chatCompletionsPath(options.provider())); | |
| 168 | HttpRequest.Builder requestBuilder = httpSupport.jsonPost(uri, requestBody, options.timeout()) | |
| 169 | .header("Authorization", "Bearer " + options.resolvedApiKey()); | |
| 170 | ||
| 171 |
2
1. suggestForClass : removed conditional - replaced equality check with true → SURVIVED 2. suggestForClass : removed conditional - replaced equality check with false → KILLED |
if (options.provider() == AiProvider.OPENROUTER) { |
| 172 | requestBuilder.header("HTTP-Referer", "https://methodatlas.local"); | |
| 173 | requestBuilder.header("X-Title", "MethodAtlas"); | |
| 174 | } | |
| 175 | ||
| 176 | String responseBody = httpSupport.postJson(requestBuilder.build()); | |
| 177 | ChatResponse response = httpSupport.objectMapper().readValue(responseBody, ChatResponse.class); | |
| 178 | ||
| 179 |
4
1. suggestForClass : removed conditional - replaced equality check with true → SURVIVED 2. suggestForClass : removed conditional - replaced equality check with false → KILLED 3. suggestForClass : removed conditional - replaced equality check with false → KILLED 4. suggestForClass : removed conditional - replaced equality check with true → KILLED |
if (response.choices() == null || response.choices().isEmpty()) { |
| 180 | throw new AiSuggestionException("No choices returned by model"); | |
| 181 | } | |
| 182 | ||
| 183 | String content = response.choices().get(0).message().content(); | |
| 184 | String json = JsonText.extractFirstJsonObject(content); | |
| 185 | AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class); | |
| 186 |
1
1. suggestForClass : replaced return value with null for org/egothor/methodatlas/ai/OpenAiCompatibleClient::suggestForClass → KILLED |
return normalize(suggestion); |
| 187 | ||
| 188 | } catch (Exception e) { // NOPMD | |
| 189 | throw new AiSuggestionException("OpenAI-compatible suggestion failed for " + fqcn, e); | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * Normalizes provider results to ensure structural invariants expected by the | |
| 195 | * application. | |
| 196 | * | |
| 197 | * <p> | |
| 198 | * The method replaces {@code null} collections with empty lists and removes | |
| 199 | * malformed method entries that do not contain a valid method name. | |
| 200 | * </p> | |
| 201 | * | |
| 202 | * @param input raw suggestion returned by the provider | |
| 203 | * @return normalized suggestion instance | |
| 204 | */ | |
| 205 | private static AiClassSuggestion normalize(AiClassSuggestion input) { | |
| 206 |
2
1. normalize : removed conditional - replaced equality check with false → SURVIVED 2. normalize : removed conditional - replaced equality check with true → KILLED |
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods(); |
| 207 |
2
1. normalize : removed conditional - replaced equality check with true → SURVIVED 2. normalize : removed conditional - replaced equality check with false → KILLED |
List<String> classTags = input.classTags() == null ? List.of() : input.classTags(); |
| 208 | ||
| 209 | List<AiMethodSuggestion> normalizedMethods = methods.stream() | |
| 210 |
7
1. lambda$normalize$1 : removed conditional - replaced equality check with true → SURVIVED 2. lambda$normalize$1 : removed conditional - replaced equality check with false → KILLED 3. lambda$normalize$1 : removed conditional - replaced equality check with true → KILLED 4. lambda$normalize$1 : removed conditional - replaced equality check with false → KILLED 5. lambda$normalize$1 : removed conditional - replaced equality check with false → KILLED 6. lambda$normalize$1 : replaced boolean return with true for org/egothor/methodatlas/ai/OpenAiCompatibleClient::lambda$normalize$1 → KILLED 7. lambda$normalize$1 : removed conditional - replaced equality check with true → KILLED |
.filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank()) |
| 211 |
1
1. lambda$normalize$2 : replaced return value with null for org/egothor/methodatlas/ai/OpenAiCompatibleClient::lambda$normalize$2 → KILLED |
.map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(), |
| 212 |
2
1. lambda$normalize$2 : removed conditional - replaced equality check with true → SURVIVED 2. lambda$normalize$2 : removed conditional - replaced equality check with false → KILLED |
method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason(), |
| 213 | method.confidence(), method.interactionScore())) | |
| 214 | .toList(); | |
| 215 | ||
| 216 |
1
1. normalize : replaced return value with null for org/egothor/methodatlas/ai/OpenAiCompatibleClient::normalize → KILLED |
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(), |
| 217 | normalizedMethods); | |
| 218 | } | |
| 219 | ||
| 220 | /** | |
| 221 | * Returns the chat completions URL path for the given provider. | |
| 222 | * | |
| 223 | * <p> | |
| 224 | * Most OpenAI-compatible providers expose their completions endpoint at | |
| 225 | * {@code /v1/chat/completions} relative to their base URL. A small number | |
| 226 | * of providers omit the {@code /v1} prefix — for example, the GitHub Models | |
| 227 | * inference API ({@code models.inference.ai.azure.com}). Providers such as | |
| 228 | * {@link AiProvider#XAI} and {@link AiProvider#MISTRAL} already embed | |
| 229 | * {@code /v1} in their default base URL, so they also use the shorter path | |
| 230 | * here to avoid producing a double-versioned URL. | |
| 231 | * </p> | |
| 232 | * | |
| 233 | * @param provider the active provider | |
| 234 | * @return the path component to append to the base URL | |
| 235 | */ | |
| 236 | private static String chatCompletionsPath(AiProvider provider) { | |
| 237 |
2
1. chatCompletionsPath : Changed switch default to be first case → KILLED 2. chatCompletionsPath : replaced return value with "" for org/egothor/methodatlas/ai/OpenAiCompatibleClient::chatCompletionsPath → KILLED |
return switch (provider) { |
| 238 | case GITHUB_MODELS, XAI, MISTRAL -> "/chat/completions"; | |
| 239 | default -> "/v1/chat/completions"; | |
| 240 | }; | |
| 241 | } | |
| 242 | ||
| 243 | /** | |
| 244 | * Request payload for an OpenAI-compatible chat completion request. | |
| 245 | * | |
| 246 | * @param model model identifier used for inference | |
| 247 | * @param messages ordered chat messages sent to the model | |
| 248 | * @param temperature sampling temperature controlling response variability | |
| 249 | */ | |
| 250 | private record ChatRequest(String model, List<Message> messages, @JsonProperty("temperature") Double temperature) { | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * Chat message included in the request payload. | |
| 255 | * | |
| 256 | * @param role logical role of the message sender, such as {@code system} or | |
| 257 | * {@code user} | |
| 258 | * @param content textual message content | |
| 259 | */ | |
| 260 | private record Message(String role, String content) { | |
| 261 | } | |
| 262 | ||
| 263 | /** | |
| 264 | * Partial response model returned by the chat completion API. | |
| 265 | * | |
| 266 | * <p> | |
| 267 | * Only fields required for extracting the model response are mapped. Unknown | |
| 268 | * properties are ignored to preserve compatibility with provider API changes. | |
| 269 | * </p> | |
| 270 | * | |
| 271 | * @param choices list of completion choices returned by the provider | |
| 272 | */ | |
| 273 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 274 | private record ChatResponse(List<Choice> choices) { | |
| 275 | } | |
| 276 | ||
| 277 | /** | |
| 278 | * Individual completion choice returned by the provider. | |
| 279 | * | |
| 280 | * @param message the message payload contained in this choice | |
| 281 | */ | |
| 282 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 283 | private record Choice(ResponseMessage message) { | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * Message payload returned inside a completion choice. | |
| 288 | * | |
| 289 | * <p> | |
| 290 | * The {@code content} component is expected to contain the JSON classification | |
| 291 | * result generated by the model. | |
| 292 | * </p> | |
| 293 | * | |
| 294 | * @param content the textual content of the message | |
| 295 | */ | |
| 296 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 297 | private record ResponseMessage(String content) { | |
| 298 | } | |
| 299 | } | |
Mutations | ||
| 117 |
1.1 2.2 3.3 4.4 5.5 |
|
| 171 |
1.1 2.2 |
|
| 179 |
1.1 2.2 3.3 4.4 |
|
| 186 |
1.1 |
|
| 206 |
1.1 2.2 |
|
| 207 |
1.1 2.2 |
|
| 210 |
1.1 2.2 3.3 4.4 5.5 6.6 7.7 |
|
| 211 |
1.1 |
|
| 212 |
1.1 2.2 |
|
| 216 |
1.1 |
|
| 237 |
1.1 2.2 |