AzureOpenAiClient.java

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&nbsp;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
Location : isAvailable
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

2.2
Location : isAvailable
Killed by : none
replaced boolean return with true for org/egothor/methodatlas/ai/AzureOpenAiClient::isAvailable → NO_COVERAGE

3.3
Location : isAvailable
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

4.4
Location : isAvailable
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

5.5
Location : isAvailable
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

195

1.1
Location : suggestForClass
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

2.2
Location : suggestForClass
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

3.3
Location : suggestForClass
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

4.4
Location : suggestForClass
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

202

1.1
Location : suggestForClass
Killed by : none
replaced return value with null for org/egothor/methodatlas/ai/AzureOpenAiClient::suggestForClass → NO_COVERAGE

222

1.1
Location : normalize
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

2.2
Location : normalize
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

223

1.1
Location : normalize
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

2.2
Location : normalize
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

226

1.1
Location : lambda$normalize$1
Killed by : none
replaced boolean return with true for org/egothor/methodatlas/ai/AzureOpenAiClient::lambda$normalize$1 → NO_COVERAGE

2.2
Location : lambda$normalize$1
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

3.3
Location : lambda$normalize$1
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

4.4
Location : lambda$normalize$1
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

5.5
Location : lambda$normalize$1
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

6.6
Location : lambda$normalize$1
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

7.7
Location : lambda$normalize$1
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

227

1.1
Location : lambda$normalize$2
Killed by : none
replaced return value with null for org/egothor/methodatlas/ai/AzureOpenAiClient::lambda$normalize$2 → NO_COVERAGE

228

1.1
Location : lambda$normalize$2
Killed by : none
removed conditional - replaced equality check with true → NO_COVERAGE

2.2
Location : lambda$normalize$2
Killed by : none
removed conditional - replaced equality check with false → NO_COVERAGE

232

1.1
Location : normalize
Killed by : none
replaced return value with null for org/egothor/methodatlas/ai/AzureOpenAiClient::normalize → NO_COVERAGE

Active mutators

Tests examined


Report generated by PIT 1.22.1