AnthropicClient.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 the Anthropic API.
12
 *
13
 * <p>
14
 * This client submits classification requests to the Anthropic
15
 * <a href="https://docs.anthropic.com/">Claude API</a> and converts the
16
 * returned response into the internal {@link AiClassSuggestion} model used by
17
 * the MethodAtlas AI subsystem.
18
 * </p>
19
 *
20
 * <h2>Operational Responsibilities</h2>
21
 *
22
 * <ul>
23
 * <li>constructing Anthropic message API requests</li>
24
 * <li>injecting the taxonomy-driven classification prompt</li>
25
 * <li>performing authenticated HTTP calls to the Anthropic service</li>
26
 * <li>extracting the JSON result embedded in the model response</li>
27
 * <li>normalizing the result into {@link AiClassSuggestion}</li>
28
 * </ul>
29
 *
30
 * <p>
31
 * The client uses the {@code /v1/messages} endpoint and relies on the Claude
32
 * message format, where a system prompt defines classification rules and the
33
 * user message contains the class source together with the taxonomy
34
 * specification.
35
 * </p>
36
 *
37
 * <p>
38
 * Instances of this class are typically created by
39
 * {@link AiProviderFactory#create(AiOptions)}.
40
 * </p>
41
 *
42
 * <p>
43
 * This implementation is stateless apart from immutable configuration and is
44
 * therefore safe for reuse across multiple requests.
45
 * </p>
46
 *
47
 * @see AiProviderClient
48
 * @see AiSuggestionEngine
49
 * @see AiProviderFactory
50
 */
51
public final class AnthropicClient implements AiProviderClient {
52
    /**
53
     * System prompt used to instruct the model to return strictly formatted JSON
54
     * responses suitable for automated parsing.
55
     *
56
     * <p>
57
     * The prompt enforces deterministic output behavior and prevents the model from
58
     * returning explanations, markdown formatting, or conversational responses that
59
     * would break the JSON extraction pipeline.
60
     * </p>
61
     */
62
    private static final String SYSTEM_PROMPT = """
63
            You are a precise software security classification engine.
64
            You classify JUnit 5 tests and return strict JSON only.
65
            Never include markdown fences, explanations, or extra text.
66
            """;
67
68
    private final AiOptions options;
69
    private final HttpSupport httpSupport;
70
71
    /**
72
     * Creates a new Anthropic client with no rate-limit notification.
73
     *
74
     * <p>Rate-limit pauses caused by HTTP&nbsp;429 responses are handled
75
     * transparently.  Use
76
     * {@link #AnthropicClient(AiOptions, RateLimitListener)} when callers
77
     * need to be notified of such pauses.</p>
78
     *
79
     * @param options AI runtime configuration
80
     */
81
    public AnthropicClient(AiOptions options) {
82
        this(options, (w, a, m) -> {});
83
    }
84
85
    /**
86
     * Creates a new Anthropic client that notifies {@code rateLimitListener}
87
     * before each rate-limit sleep.
88
     *
89
     * @param options             AI runtime configuration
90
     * @param rateLimitListener   callback invoked before each HTTP&nbsp;429
91
     *                            pause; must not be {@code null}
92
     * @see RateLimitListener
93
     */
94
    public AnthropicClient(AiOptions options, RateLimitListener rateLimitListener) {
95
        this.options = options;
96
        this.httpSupport = new HttpSupport(options.timeout(), options.maxRetries(), rateLimitListener);
97
    }
98
99
    /**
100
     * Determines whether the Anthropic provider can be used in the current runtime
101
     * environment.
102
     *
103
     * <p>
104
     * The provider is considered available when a non-empty API key can be resolved
105
     * from {@link AiOptions#resolvedApiKey()}.
106
     * </p>
107
     *
108
     * @return {@code true} if a usable API key is configured
109
     */
110
    @Override
111
    public boolean isAvailable() {
112
        String key = options.resolvedApiKey();
113 5 1. isAvailable : removed conditional - replaced equality check with true → SURVIVED
2. isAvailable : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::isAvailable → KILLED
3. isAvailable : removed conditional - replaced equality check with false → KILLED
4. isAvailable : removed conditional - replaced equality check with true → KILLED
5. isAvailable : removed conditional - replaced equality check with false → KILLED
        return key != null && !key.isBlank();
114
    }
115
116
    /**
117
     * Submits a classification request to the Anthropic API for the specified test
118
     * class.
119
     *
120
     * <p>
121
     * The method constructs a message-based request containing:
122
     * </p>
123
     *
124
     * <ul>
125
     * <li>a system prompt enforcing deterministic JSON output</li>
126
     * <li>a user prompt containing the class source and taxonomy definition</li>
127
     * </ul>
128
     *
129
     * <p>
130
     * The response is parsed to extract the first JSON object returned by the
131
     * model, which is then deserialized into an {@link AiClassSuggestion}.
132
     * </p>
133
     *
134
     * @param fqcn          fully qualified class name being analyzed
135
     * @param classSource   complete source code of the class
136
     * @param taxonomyText  taxonomy definition guiding classification
137
     * @param targetMethods deterministically extracted JUnit test methods that must
138
     *                      be classified
139
     *
140
     * @return normalized AI classification result
141
     *
142
     * @throws AiSuggestionException if the provider request fails, the response
143
     *                               cannot be parsed, or the provider returns
144
     *                               invalid content
145
     */
146
    @Override
147
    public AiClassSuggestion suggestForClass(String fqcn, String classSource, String taxonomyText,
148
            List<PromptBuilder.TargetMethod> targetMethods) throws AiSuggestionException {
149
        try {
150
            String prompt = PromptBuilder.build(fqcn, classSource, taxonomyText, targetMethods, options.confidence());
151
152
            MessageRequest payload = new MessageRequest(options.modelName(), SYSTEM_PROMPT,
153
                    List.of(new ContentMessage("user", List.of(new ContentBlock("text", prompt)))), 0.0, 2_000);
154
155
            String requestBody = httpSupport.objectMapper().writeValueAsString(payload);
156
            URI uri = URI.create(options.baseUrl() + "/v1/messages");
157
158
            HttpRequest request = httpSupport.jsonPost(uri, requestBody, options.timeout())
159
                    .header("x-api-key", options.resolvedApiKey()).header("anthropic-version", "2023-06-01").build();
160
161
            String responseBody = httpSupport.postJson(request);
162
            MessageResponse response = httpSupport.objectMapper().readValue(responseBody, MessageResponse.class);
163
164 4 1. suggestForClass : removed conditional - replaced equality check with false → KILLED
2. suggestForClass : removed conditional - replaced equality check with true → KILLED
3. suggestForClass : removed conditional - replaced equality check with true → KILLED
4. suggestForClass : removed conditional - replaced equality check with false → KILLED
            if (response.content() == null || response.content().isEmpty()) {
165
                throw new AiSuggestionException("No content returned by Anthropic");
166
            }
167
168 2 1. lambda$suggestForClass$1 : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$1 → SURVIVED
2. lambda$suggestForClass$1 : replaced boolean return with false for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$1 → KILLED
            String text = response.content().stream().filter(block -> "text".equals(block.type())).map(ResponseBlock::text)
169 5 1. lambda$suggestForClass$2 : removed conditional - replaced equality check with true → SURVIVED
2. lambda$suggestForClass$2 : removed conditional - replaced equality check with true → SURVIVED
3. lambda$suggestForClass$2 : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$2 → SURVIVED
4. lambda$suggestForClass$2 : removed conditional - replaced equality check with false → KILLED
5. lambda$suggestForClass$2 : removed conditional - replaced equality check with false → KILLED
                    .filter(value -> value != null && !value.isBlank()).findFirst()
170 1 1. lambda$suggestForClass$3 : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$3 → KILLED
                    .orElseThrow(() -> new AiSuggestionException("Anthropic returned no text block"));
171
172
            String json = JsonText.extractFirstJsonObject(text);
173
            AiClassSuggestion suggestion = httpSupport.objectMapper().readValue(json, AiClassSuggestion.class);
174 1 1. suggestForClass : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::suggestForClass → KILLED
            return normalize(suggestion);
175
176
        } catch (Exception e) { // NOPMD
177
            throw new AiSuggestionException("Anthropic suggestion failed for " + fqcn, e);
178
        }
179
    }
180
181
    /**
182
     * Normalizes AI results returned by the provider.
183
     *
184
     * <p>
185
     * This method ensures that collection fields are never {@code null} and removes
186
     * malformed method entries that do not contain a valid method name.
187
     * </p>
188
     *
189
     * <p>
190
     * The normalization step protects the rest of the application from
191
     * provider-side inconsistencies and guarantees that the resulting
192
     * {@link AiClassSuggestion} object satisfies the expected invariants.
193
     * </p>
194
     *
195
     * @param input raw suggestion returned by the provider
196
     * @return normalized suggestion instance
197
     */
198
    private static AiClassSuggestion normalize(AiClassSuggestion input) {
199 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();
200 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();
201
202
        List<AiMethodSuggestion> normalizedMethods = methods.stream()
203 7 1. lambda$normalize$4 : removed conditional - replaced equality check with true → SURVIVED
2. lambda$normalize$4 : removed conditional - replaced equality check with false → KILLED
3. lambda$normalize$4 : removed conditional - replaced equality check with true → KILLED
4. lambda$normalize$4 : removed conditional - replaced equality check with false → KILLED
5. lambda$normalize$4 : removed conditional - replaced equality check with true → KILLED
6. lambda$normalize$4 : removed conditional - replaced equality check with false → KILLED
7. lambda$normalize$4 : replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$normalize$4 → KILLED
                .filter(method -> method != null && method.methodName() != null && !method.methodName().isBlank())
204 1 1. lambda$normalize$5 : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::lambda$normalize$5 → KILLED
                .map(method -> new AiMethodSuggestion(method.methodName(), method.securityRelevant(),
205 2 1. lambda$normalize$5 : removed conditional - replaced equality check with true → SURVIVED
2. lambda$normalize$5 : removed conditional - replaced equality check with false → KILLED
                        method.displayName(), method.tags() == null ? List.of() : method.tags(), method.reason(),
206
                        method.confidence(), method.interactionScore()))
207
                .toList();
208
209 1 1. normalize : replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::normalize → KILLED
        return new AiClassSuggestion(input.className(), input.classSecurityRelevant(), classTags, input.classReason(),
210
                normalizedMethods);
211
    }
212
213
    /**
214
     * Request payload sent to the Anthropic message API.
215
     *
216
     * <p>
217
     * This record models the JSON structure expected by the {@code /v1/messages}
218
     * endpoint and is serialized using Jackson before transmission.
219
     * </p>
220
     *
221
     * @param model       model identifier
222
     * @param system      system prompt controlling model behavior
223
     * @param messages    list of message objects forming the conversation
224
     * @param temperature sampling temperature
225
     * @param maxTokens   maximum token count for the response
226
     */
227
    private record MessageRequest(String model, String system, List<ContentMessage> messages, Double temperature,
228
            @JsonProperty("max_tokens") Integer maxTokens) {
229
    }
230
231
    /**
232
     * Message container used by the Anthropic message API.
233
     *
234
     * @param role    role of the message sender (for example {@code user})
235
     * @param content message content blocks
236
     */
237
    private record ContentMessage(String role, List<ContentBlock> content) {
238
    }
239
240
    /**
241
     * Individual content block within a message payload.
242
     *
243
     * @param type block type (for example {@code text})
244
     * @param text textual content of the block
245
     */
246
    private record ContentBlock(String type, String text) {
247
    }
248
249
    /**
250
     * Partial response model returned by the Anthropic API.
251
     *
252
     * <p>
253
     * Only the fields required by this client are mapped. Additional fields are
254
     * ignored to maintain forward compatibility with API changes.
255
     * </p>
256
     *
257
     * @param content list of content blocks in the response
258
     */
259
    @JsonIgnoreProperties(ignoreUnknown = true)
260
    private record MessageResponse(List<ResponseBlock> content) {
261
    }
262
263
    /**
264
     * Content block returned within a provider response.
265
     *
266
     * <p>
267
     * The client scans these blocks to locate the first text segment containing the
268
     * JSON classification result.
269
     * </p>
270
     *
271
     * @param type block type (for example {@code text})
272
     * @param text textual content of the block
273
     */
274
    @JsonIgnoreProperties(ignoreUnknown = true)
275
    private record ResponseBlock(String type, String text) {
276
    }
277
}

Mutations

113

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

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

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

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

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

164

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

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

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

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

168

1.1
Location : lambda$suggestForClass$1
Killed by : org.egothor.methodatlas.ai.AnthropicClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AnthropicClientTest]/[method:suggestForClass_throwsWhenTextBlockContainsNoJsonObject()]
replaced boolean return with false for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$1 → KILLED

2.2
Location : lambda$suggestForClass$1
Killed by : none
replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$1 → SURVIVED
Covering tests

169

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

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

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

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

5.5
Location : lambda$suggestForClass$2
Killed by : none
replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$2 → SURVIVED Covering tests

170

1.1
Location : lambda$suggestForClass$3
Killed by : org.egothor.methodatlas.ai.AnthropicClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AnthropicClientTest]/[method:suggestForClass_throwsWhenNoTextBlockIsPresent()]
replaced return value with null for org/egothor/methodatlas/ai/AnthropicClient::lambda$suggestForClass$3 → KILLED

174

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

199

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

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

200

1.1
Location : normalize
Killed by : org.egothor.methodatlas.ai.AnthropicClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AnthropicClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequest()]
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

203

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

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

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

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

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

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

7.7
Location : lambda$normalize$4
Killed by : org.egothor.methodatlas.ai.AnthropicClientTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AnthropicClientTest]/[method:suggestForClass_parsesWrappedJson_normalizesInvalidEntries_andBuildsExpectedRequest()]
replaced boolean return with true for org/egothor/methodatlas/ai/AnthropicClient::lambda$normalize$4 → KILLED

204

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

205

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

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

209

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

Active mutators

Tests examined


Report generated by PIT 1.22.1