OpenAiCompatibleClient.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 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&nbsp;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
Location : isAvailable
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:isAvailable_returnsTrueWhenApiKeyIsConfigured()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : isAvailable
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:isAvailable_returnsFalseWhenApiKeyIsMissing()]
removed conditional - replaced equality check with true → KILLED

3.3
Location : isAvailable
Killed by : none
removed conditional - replaced equality check with true → SURVIVED
Covering tests

4.4
Location : isAvailable
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:isAvailable_returnsTrueWhenApiKeyIsConfigured()]
removed conditional - replaced equality check with false → KILLED

5.5
Location : isAvailable
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:isAvailable_returnsFalseWhenApiKeyIsMissing()]
replaced boolean return with true for org/egothor/methodatlas/ai/OpenAiCompatibleClient::isAvailable → KILLED

171

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

2.2
Location : suggestForClass
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_addsOpenRouterHeaders()]
removed conditional - replaced equality check with false → KILLED

179

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

2.2
Location : suggestForClass
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_throwsWhenNoChoicesAreReturned()]
removed conditional - replaced equality check with false → KILLED

3.3
Location : suggestForClass
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_throwsWhenModelReturnsTextWithoutJsonObject()]
removed conditional - replaced equality check with false → KILLED

4.4
Location : suggestForClass
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_throwsWhenModelReturnsTextWithoutJsonObject()]
removed conditional - replaced equality check with true → KILLED

186

1.1
Location : suggestForClass
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_addsOpenRouterHeaders()]
replaced return value with null for org/egothor/methodatlas/ai/OpenAiCompatibleClient::suggestForClass → KILLED

206

1.1
Location : normalize
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with true → KILLED

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

207

1.1
Location : normalize
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with false → KILLED

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

210

1.1
Location : lambda$normalize$1
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : lambda$normalize$1
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with true → KILLED

3.3
Location : lambda$normalize$1
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with false → KILLED

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

5.5
Location : lambda$normalize$1
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with false → KILLED

6.6
Location : lambda$normalize$1
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
replaced boolean return with true for org/egothor/methodatlas/ai/OpenAiCompatibleClient::lambda$normalize$1 → KILLED

7.7
Location : lambda$normalize$1
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with true → KILLED

211

1.1
Location : lambda$normalize$2
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
replaced return value with null for org/egothor/methodatlas/ai/OpenAiCompatibleClient::lambda$normalize$2 → KILLED

212

1.1
Location : lambda$normalize$2
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequestBody()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : lambda$normalize$2
Killed by : none
removed conditional - replaced equality check with true → SURVIVED
Covering tests

216

1.1
Location : normalize
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_addsOpenRouterHeaders()]
replaced return value with null for org/egothor/methodatlas/ai/OpenAiCompatibleClient::normalize → KILLED

237

1.1
Location : chatCompletionsPath
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_usesCorrectPathForGitHubModels()]
Changed switch default to be first case → KILLED

2.2
Location : chatCompletionsPath
Killed by : org.egothor.methodatlas.ai.OpenAiCompatibleClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.OpenAiCompatibleClientTest]/[method:suggestForClass_usesCorrectPathForGitHubModels()]
replaced return value with "" for org/egothor/methodatlas/ai/OpenAiCompatibleClient::chatCompletionsPath → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1