| 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 | |
| 12 | * <a href="https://azure.microsoft.com/en-us/products/ai-services/openai-service">Azure | |
| 13 | * OpenAI Service</a> deployments. | |
| 14 | * | |
| 15 | * <p> | |
| 16 | * Azure OpenAI exposes a chat completions API that is structurally similar to | |
| 17 | * the public OpenAI API but differs in three important ways: | |
| 18 | * </p> | |
| 19 | * | |
| 20 | * <ul> | |
| 21 | * <li><strong>Endpoint structure</strong> — the deployment name is embedded in | |
| 22 | * the path rather than supplied as a JSON field: | |
| 23 | * {@code {baseUrl}/openai/deployments/{deployment}/chat/completions?api-version={version}}</li> | |
| 24 | * <li><strong>Authentication header</strong> — requests carry an {@code api-key} | |
| 25 | * header instead of the standard {@code Authorization: Bearer} form used by | |
| 26 | * the public OpenAI API</li> | |
| 27 | * <li><strong>Model identifier</strong> — {@link AiOptions#modelName()} is | |
| 28 | * interpreted as the Azure <em>deployment name</em>, not the underlying model | |
| 29 | * family name; the deployment name is chosen when the resource is configured | |
| 30 | * in the Azure portal</li> | |
| 31 | * </ul> | |
| 32 | * | |
| 33 | * <p> | |
| 34 | * These differences are fully encapsulated within this class. The request and | |
| 35 | * response JSON structures are identical to those used by | |
| 36 | * {@link OpenAiCompatibleClient}, allowing the same prompt builder and response | |
| 37 | * normalization logic to be reused. | |
| 38 | * </p> | |
| 39 | * | |
| 40 | * <h2>Data Residency</h2> | |
| 41 | * | |
| 42 | * <p> | |
| 43 | * Requests are sent to a resource endpoint within the organization's own Azure | |
| 44 | * tenant. Data does not leave the tenant boundary, making this provider | |
| 45 | * suitable for regulated environments where source code must not be transmitted | |
| 46 | * to third-party cloud services. | |
| 47 | * </p> | |
| 48 | * | |
| 49 | * <h2>Operational Responsibilities</h2> | |
| 50 | * | |
| 51 | * <ul> | |
| 52 | * <li>constructing the Azure-specific deployment endpoint URL</li> | |
| 53 | * <li>injecting the {@code api-key} authentication header</li> | |
| 54 | * <li>constructing and submitting chat completion requests</li> | |
| 55 | * <li>extracting JSON content from the model response</li> | |
| 56 | * <li>normalizing the result into {@link AiClassSuggestion}</li> | |
| 57 | * </ul> | |
| 58 | * | |
| 59 | * <p> | |
| 60 | * Instances are typically created through | |
| 61 | * {@link AiProviderFactory#create(AiOptions)}. | |
| 62 | * </p> | |
| 63 | * | |
| 64 | * @see AiProvider#AZURE_OPENAI | |
| 65 | * @see AiProviderClient | |
| 66 | * @see AiProviderFactory | |
| 67 | * @see OpenAiCompatibleClient | |
| 68 | */ | |
| 69 | public final class AzureOpenAiClient implements AiProviderClient { | |
| 70 | ||
| 71 | private static final String SYSTEM_PROMPT = """ | |
| 72 | You are a precise software security classification engine. | |
| 73 | You classify JUnit 5 tests and return strict JSON only. | |
| 74 | Never include markdown fences, explanations, or extra text. | |
| 75 | """; | |
| 76 | ||
| 77 | private final AiOptions options; | |
| 78 | private final HttpSupport httpSupport; | |
| 79 | ||
| 80 | /** | |
| 81 | * Creates a new Azure OpenAI client with no rate-limit notification. | |
| 82 | * | |
| 83 | * <p>Rate-limit pauses are handled transparently. Use | |
| 84 | * {@link #AzureOpenAiClient(AiOptions, RateLimitListener)} when callers | |
| 85 | * need to be notified of such pauses.</p> | |
| 86 | * | |
| 87 | * <p>The supplied configuration must provide:</p> | |
| 88 | * <ul> | |
| 89 | * <li>{@link AiOptions#baseUrl()} — resource endpoint, e.g. | |
| 90 | * {@code https://contoso.openai.azure.com}</li> | |
| 91 | * <li>{@link AiOptions#modelName()} — deployment name as configured in | |
| 92 | * the Azure portal</li> | |
| 93 | * <li>{@link AiOptions#apiVersion()} — REST API version, e.g. | |
| 94 | * {@code 2024-02-01}</li> | |
| 95 | * <li>{@link AiOptions#resolvedApiKey()} — resource-scoped API key</li> | |
| 96 | * </ul> | |
| 97 | * | |
| 98 | * @param options AI runtime configuration | |
| 99 | */ | |
| 100 | public AzureOpenAiClient(AiOptions options) { | |
| 101 | this(options, (w, a, m) -> {}); | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Creates a new Azure OpenAI client that notifies | |
| 106 | * {@code rateLimitListener} before each rate-limit sleep. | |
| 107 | * | |
| 108 | * @param options AI runtime configuration | |
| 109 | * @param rateLimitListener callback invoked before each HTTP 429 | |
| 110 | * pause; must not be {@code null} | |
| 111 | * @see RateLimitListener | |
| 112 | */ | |
| 113 | public AzureOpenAiClient(AiOptions options, RateLimitListener rateLimitListener) { | |
| 114 | this.options = options; | |
| 115 | this.httpSupport = new HttpSupport(options.timeout(), options.maxRetries(), rateLimitListener); | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Determines whether this client can be used in the current runtime | |
| 120 | * environment. | |
| 121 | * | |
| 122 | * <p> | |
| 123 | * Availability requires a non-blank API key resolved through | |
| 124 | * {@link AiOptions#resolvedApiKey()}. | |
| 125 | * </p> | |
| 126 | * | |
| 127 | * @return {@code true} if a usable API key is available | |
| 128 | */ | |
| 129 | @Override | |
| 130 | public boolean isAvailable() { | |
| 131 | String key = options.resolvedApiKey(); | |
| 132 |
5
1. isAvailable : removed conditional - replaced equality check with true → NO_COVERAGE 2. isAvailable : replaced boolean return with true for org/egothor/methodatlas/ai/AzureOpenAiClient::isAvailable → NO_COVERAGE 3. isAvailable : removed conditional - replaced equality check with false → NO_COVERAGE 4. isAvailable : removed conditional - replaced equality check with true → NO_COVERAGE 5. isAvailable : removed conditional - replaced equality check with false → NO_COVERAGE |
return key != null && !key.isBlank(); |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Submits a classification request to the configured Azure OpenAI deployment. | |
| 137 | * | |
| 138 | * <p> | |
| 139 | * The request is sent to the deployment-specific endpoint: | |
| 140 | * </p> | |
| 141 | * | |
| 142 | * <pre> | |
| 143 | * {baseUrl}/openai/deployments/{modelName}/chat/completions?api-version={apiVersion} | |
| 144 | * </pre> | |
| 145 | * | |
| 146 | * <p> | |
| 147 | * The request payload includes: | |
| 148 | * </p> | |
| 149 | * | |
| 150 | * <ul> | |
| 151 | * <li>the deployment name as the {@code model} field</li> | |
| 152 | * <li>a system prompt defining classification rules</li> | |
| 153 | * <li>a user prompt containing the test class source and taxonomy</li> | |
| 154 | * <li>a deterministic temperature setting of {@code 0.0}</li> | |
| 155 | * </ul> | |
| 156 | * | |
| 157 | * <p> | |
| 158 | * Authentication uses the {@code api-key} HTTP header carrying the value | |
| 159 | * returned by {@link AiOptions#resolvedApiKey()}. | |
| 160 | * </p> | |
| 161 | * | |
| 162 | * @param fqcn fully qualified class name being analyzed | |
| 163 | * @param classSource complete source code of the class | |
| 164 | * @param taxonomyText taxonomy definition guiding classification | |
| 165 | * @param targetMethods deterministically extracted JUnit test methods that must | |
| 166 | * be classified | |
| 167 | * @return normalized classification result | |
| 168 | * | |
| 169 | * @throws AiSuggestionException if the provider request fails, the model | |
| 170 | * response is invalid, or JSON deserialization | |
| 171 | * fails | |
| 172 | */ | |
| 173 | @Override | |
| 174 | public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText, | |
| 175 | List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException { | |
| 176 | try { | |
| 177 | String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods, options.confidence()); | |
| 178 | ||
| 179 | ChatRequest payload = new ChatRequest(options.modelName(), | |
| 180 | List.of(new Message("system", SYSTEM_PROMPT), new Message("user", prompt)), 0.0); | |
| 181 | ||
| 182 | String requestBody = httpSupport.objectMapper().writeValueAsString(payload); | |
| 183 | ||
| 184 | String url = options.baseUrl() + "/openai/deployments/" + options.modelName() | |
| 185 | + "/chat/completions?api-version=" + options.apiVersion(); | |
| 186 | URI uri = URI.create(url); | |
| 187 | ||
| 188 | HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout()) | |
| 189 | .header("api-key", options.resolvedApiKey()) | |
| 190 | .build(); | |
| 191 | ||
| 192 | String responseBody = httpSupport.postJson(request); | |
| 193 | ChatResponse response = httpSupport.objectMapper().readValue(responseBody, ChatResponse.class); | |
| 194 | ||
| 195 |
4
1. suggestForClass : removed conditional - replaced equality check with true → NO_COVERAGE 2. suggestForClass : removed conditional - replaced equality check with false → NO_COVERAGE 3. suggestForClass : removed conditional - replaced equality check with true → NO_COVERAGE 4. suggestForClass : removed conditional - replaced equality check with false → NO_COVERAGE |
if (response.choices() == null || response.choices().isEmpty()) { |
| 196 | throw new AiSuggestionException("No choices returned by Azure OpenAI deployment"); | |
| 197 | } | |
| 198 | ||
| 199 | String content = response.choices().get(0).message().content(); | |
| 200 | String json = JsonText.extractFirstJsonObject(content); | |
| 201 | AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class); | |
| 202 |
1
1. suggestForClass : replaced return value with null for org/egothor/methodatlas/ai/AzureOpenAiClient::suggestForClass → NO_COVERAGE |
return normalize(suggestion); |
| 203 | ||
| 204 | } catch (Exception e) { // NOPMD | |
| 205 | throw new AiSuggestionException("Azure OpenAI suggestion failed for " + fqcn, e); | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | /** | |
| 210 | * Normalizes provider results to ensure structural invariants expected by the | |
| 211 | * application. | |
| 212 | * | |
| 213 | * <p> | |
| 214 | * Replaces {@code null} collections with empty lists and removes malformed | |
| 215 | * method entries that do not contain a valid method name. | |
| 216 | * </p> | |
| 217 | * | |
| 218 | * @param input raw suggestion returned by the provider | |
| 219 | * @return normalized suggestion instance | |
| 220 | */ | |
| 221 | private static AiClassSuggestion normalize(AiClassSuggestion input) { | |
| 222 |
2
1. normalize : removed conditional - replaced equality check with false → NO_COVERAGE 2. normalize : removed conditional - replaced equality check with true → NO_COVERAGE |
List<AiMethodSuggestion> methods = input.methods() == null ? List.of() : input.methods(); |
| 223 |
2
1. normalize : removed conditional - replaced equality check with true → NO_COVERAGE 2. normalize : removed conditional - replaced equality check with false → NO_COVERAGE |
List<String> classTags = input.classTags() == null ? List.of() : input.classTags(); |
| 224 | ||
| 225 | List<AiMethodSuggestion> normalizedMethods = methods.stream() | |
| 226 |
7
1. lambda$normalize$1 : replaced boolean return with true for org/egothor/methodatlas/ai/AzureOpenAiClient::lambda$normalize$1 → NO_COVERAGE 2. lambda$normalize$1 : removed conditional - replaced equality check with true → NO_COVERAGE 3. lambda$normalize$1 : removed conditional - replaced equality check with false → NO_COVERAGE 4. lambda$normalize$1 : removed conditional - replaced equality check with true → NO_COVERAGE 5. lambda$normalize$1 : removed conditional - replaced equality check with false → NO_COVERAGE 6. lambda$normalize$1 : removed conditional - replaced equality check with true → NO_COVERAGE 7. lambda$normalize$1 : removed conditional - replaced equality check with false → NO_COVERAGE |
.filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank()) |
| 227 |
1
1. lambda$normalize$2 : replaced return value with null for org/egothor/methodatlas/ai/AzureOpenAiClient::lambda$normalize$2 → NO_COVERAGE |
.map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(), |
| 228 |
2
1. lambda$normalize$2 : removed conditional - replaced equality check with true → NO_COVERAGE 2. lambda$normalize$2 : removed conditional - replaced equality check with false → NO_COVERAGE |
method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason(), |
| 229 | method.confidence(), method.interactionScore())) | |
| 230 | .toList(); | |
| 231 | ||
| 232 |
1
1. normalize : replaced return value with null for org/egothor/methodatlas/ai/AzureOpenAiClient::normalize → NO_COVERAGE |
return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(), |
| 233 | normalizedMethods); | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * Request payload for the Azure OpenAI chat completions API. | |
| 238 | * | |
| 239 | * @param model deployment name used for inference | |
| 240 | * @param messages ordered chat messages sent to the model | |
| 241 | * @param temperature sampling temperature controlling response variability | |
| 242 | */ | |
| 243 | private record ChatRequest(String model, List<Message> messages, @JsonProperty("temperature") Double temperature) { | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Chat message included in the request payload. | |
| 248 | * | |
| 249 | * @param role logical role of the message sender, such as {@code system} or | |
| 250 | * {@code user} | |
| 251 | * @param content textual message content | |
| 252 | */ | |
| 253 | private record Message(String role, String content) { | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Partial response model returned by the chat completions API. | |
| 258 | * | |
| 259 | * <p> | |
| 260 | * Only fields required for extracting the model response are mapped. Unknown | |
| 261 | * properties are ignored to preserve compatibility with API version changes. | |
| 262 | * </p> | |
| 263 | * | |
| 264 | * @param choices list of completion choices returned by the deployment | |
| 265 | */ | |
| 266 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 267 | private record ChatResponse(List<Choice> choices) { | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * Individual completion choice returned by the deployment. | |
| 272 | * | |
| 273 | * @param message the message payload contained in this choice | |
| 274 | */ | |
| 275 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 276 | private record Choice(ResponseMessage message) { | |
| 277 | } | |
| 278 | ||
| 279 | /** | |
| 280 | * Message payload returned inside a completion choice. | |
| 281 | * | |
| 282 | * <p> | |
| 283 | * The {@code content} component is expected to contain the JSON classification | |
| 284 | * result generated by the model. | |
| 285 | * </p> | |
| 286 | * | |
| 287 | * @param content the textual content of the message | |
| 288 | */ | |
| 289 | @JsonIgnoreProperties(ignoreUnknown = true) | |
| 290 | private record ResponseMessage(String content) { | |
| 291 | } | |
| 292 | } | |
Mutations | ||
| 132 |
1.1 2.2 3.3 4.4 5.5 |
|
| 195 |
1.1 2.2 3.3 4.4 |
|
| 202 |
1.1 |
|
| 222 |
1.1 2.2 |
|
| 223 |
1.1 2.2 |
|
| 226 |
1.1 2.2 3.3 4.4 5.5 6.6 7.7 |
|
| 227 |
1.1 |
|
| 228 |
1.1 2.2 |
|
| 232 |
1.1 |