AiProviderFactory.java

1
package org.egothor.methodatlas.ai;
2
3
/**
4
 * Factory responsible for creating provider-specific AI client implementations.
5
 *
6
 * <p>
7
 * This class centralizes the logic for selecting and constructing concrete
8
 * {@link AiProviderClient} implementations based on the configuration provided
9
 * through {@link AiOptions}. It abstracts provider instantiation from the rest
10
 * of the application so that higher-level components interact only with the
11
 * {@link AiProviderClient} interface.
12
 * </p>
13
 *
14
 * <h2>Provider Resolution</h2>
15
 *
16
 * <p>
17
 * When an explicit provider is configured in {@link AiOptions#provider()}, the
18
 * factory constructs the corresponding client implementation. When
19
 * {@link AiProvider#AUTO} is selected, the factory attempts to determine a
20
 * suitable provider automatically using the following strategy:
21
 * </p>
22
 *
23
 * <ol>
24
 * <li>Attempt to use a locally running {@link OllamaClient}.</li>
25
 * <li>If Ollama is not reachable and an API key is configured, fall back to an
26
 * OpenAI-compatible provider.</li>
27
 * <li>If no provider can be resolved, an {@link AiSuggestionException} is
28
 * thrown.</li>
29
 * </ol>
30
 *
31
 * <h2>Azure OpenAI</h2>
32
 *
33
 * <p>
34
 * {@link AiProvider#AZURE_OPENAI} constructs an {@link AzureOpenAiClient}.
35
 * This provider is never selected automatically; it must be configured
36
 * explicitly. The {@link AiOptions#baseUrl()} must point to the Azure OpenAI
37
 * resource endpoint and {@link AiOptions#modelName()} must match the
38
 * deployment name as shown in the Azure portal.
39
 * </p>
40
 *
41
 * <p>
42
 * The factory ensures that returned clients are usable by verifying provider
43
 * availability when required.
44
 * </p>
45
 *
46
 * <p>
47
 * This class is intentionally non-instantiable and exposes only static factory
48
 * methods.
49
 * </p>
50
 *
51
 * @see AiProviderClient
52
 * @see AiProvider
53
 * @see AiOptions
54
 */
55
public final class AiProviderFactory {
56
    /**
57
     * Prevents instantiation of this utility class.
58
     */
59
    private AiProviderFactory() {
60
    }
61
62
    /**
63
     * Creates a provider-specific {@link AiProviderClient} based on the supplied
64
     * configuration.
65
     *
66
     * <p>Rate-limit events are silently discarded.  Use
67
     * {@link #create(AiOptions, RateLimitListener)} when the caller needs to
68
     * be informed of HTTP&nbsp;429 pauses.</p>
69
     *
70
     * <p>
71
     * The selected provider determines which concrete implementation is
72
     * instantiated and how availability checks are performed. When
73
     * {@link AiProvider#AUTO} is configured, the method delegates provider
74
     * selection to {@link #auto(AiOptions, RateLimitListener)}.
75
     * </p>
76
     *
77
     * @param options AI configuration describing provider, model, endpoint,
78
     *                authentication, and runtime limits
79
     * @return initialized provider client ready to perform inference requests
80
     *
81
     * @throws AiSuggestionException if the provider cannot be initialized, required
82
     *                               authentication is missing, or no suitable
83
     *                               provider can be resolved
84
     * @see #create(AiOptions, RateLimitListener)
85
     */
86
    public static AiProviderClient create(AiOptions options) throws AiSuggestionException {
87 1 1. create : replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::create → KILLED
        return create(options, (w, a, m) -> {});
88
    }
89
90
    /**
91
     * Creates a provider-specific {@link AiProviderClient} based on the supplied
92
     * configuration, notifying {@code rateLimitListener} before each
93
     * HTTP&nbsp;429 pause.
94
     *
95
     * <p>
96
     * The selected provider determines which concrete implementation is
97
     * instantiated and how availability checks are performed. When
98
     * {@link AiProvider#AUTO} is configured, the method delegates provider
99
     * selection to {@link #auto(AiOptions, RateLimitListener)}.
100
     * </p>
101
     *
102
     * @param options             AI configuration describing provider, model,
103
     *                            endpoint, authentication, and runtime limits
104
     * @param rateLimitListener   callback invoked before each rate-limit sleep;
105
     *                            must not be {@code null}
106
     * @return initialized provider client ready to perform inference requests
107
     *
108
     * @throws AiSuggestionException if the provider cannot be initialized, required
109
     *                               authentication is missing, or no suitable
110
     *                               provider can be resolved
111
     * @see RateLimitListener
112
     */
113
    public static AiProviderClient create(AiOptions options, RateLimitListener rateLimitListener)
114
            throws AiSuggestionException {
115 2 1. create : Changed switch default to be first case → KILLED
2. create : replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::create → KILLED
        return switch (options.provider()) {
116
            case OLLAMA -> new OllamaClient(options, rateLimitListener);
117
            case OPENAI -> requireAvailable(new OpenAiCompatibleClient(options, rateLimitListener), "OpenAI API key missing");
118
            case OPENROUTER -> requireAvailable(new OpenAiCompatibleClient(options, rateLimitListener), "OpenRouter API key missing");
119
            case ANTHROPIC -> requireAvailable(new AnthropicClient(options, rateLimitListener), "Anthropic API key missing");
120
            case AZURE_OPENAI -> requireAvailable(new AzureOpenAiClient(options, rateLimitListener), "Azure OpenAI API key missing");
121
            case GROQ -> requireAvailable(new OpenAiCompatibleClient(options, rateLimitListener), "Groq API key missing");
122
            case XAI -> requireAvailable(new OpenAiCompatibleClient(options, rateLimitListener), "xAI API key missing");
123
            case GITHUB_MODELS -> requireAvailable(new OpenAiCompatibleClient(options, rateLimitListener), "GitHub token missing");
124
            case MISTRAL -> requireAvailable(new OpenAiCompatibleClient(options, rateLimitListener), "Mistral API key missing");
125
            case AUTO -> auto(options, rateLimitListener);
126
        };
127
    }
128
129
    /**
130
     * Performs automatic provider discovery when {@link AiProvider#AUTO} is
131
     * selected.
132
     *
133
     * <p>
134
     * The discovery process prioritizes locally available inference services to
135
     * enable operation without external dependencies whenever possible.
136
     * </p>
137
     *
138
     * <p>
139
     * The current discovery strategy is:
140
     * </p>
141
     * <ol>
142
     * <li>Attempt to connect to a local {@link OllamaClient}.</li>
143
     * <li>If Ollama is not available but an API key is configured, create an
144
     * {@link OpenAiCompatibleClient}.</li>
145
     * <li>If neither provider can be used, throw an exception.</li>
146
     * </ol>
147
     *
148
     * @param options             AI configuration used to construct candidate
149
     *                            providers
150
     * @param rateLimitListener   callback threaded through to the resolved
151
     *                            provider client; must not be {@code null}
152
     * @return resolved provider client
153
     *
154
     * @throws AiSuggestionException if no suitable provider can be discovered
155
     */
156
    private static AiProviderClient auto(AiOptions options, RateLimitListener rateLimitListener)
157
            throws AiSuggestionException {
158
        AiOptions ollamaOptions = AiOptions.builder()
159
                .enabled(options.enabled())
160
                .provider(AiProvider.OLLAMA)
161
                .modelName(options.modelName())
162
                .baseUrl(options.baseUrl())
163
                .apiKey(options.apiKey())
164
                .apiKeyEnv(options.apiKeyEnv())
165
                .taxonomyFile(options.taxonomyFile())
166
                .taxonomyMode(options.taxonomyMode())
167
                .maxClassChars(options.maxClassChars())
168
                .timeout(options.timeout())
169
                .maxRetries(options.maxRetries())
170
                .build();
171
172
        OllamaClient ollamaClient = new OllamaClient(ollamaOptions, rateLimitListener);
173 2 1. auto : removed conditional - replaced equality check with false → KILLED
2. auto : removed conditional - replaced equality check with true → KILLED
        if (ollamaClient.isAvailable()) {
174 1 1. auto : replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::auto → KILLED
            return ollamaClient;
175
        }
176
177
        String apiKey = options.resolvedApiKey();
178 4 1. auto : removed conditional - replaced equality check with true → SURVIVED
2. auto : removed conditional - replaced equality check with true → KILLED
3. auto : removed conditional - replaced equality check with false → KILLED
4. auto : removed conditional - replaced equality check with false → KILLED
        if (apiKey != null && !apiKey.isBlank()) {
179 1 1. auto : replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::auto → KILLED
            return new OpenAiCompatibleClient(options, rateLimitListener);
180
        }
181
182
        throw new AiSuggestionException(
183
                "No AI provider available. Ollama is not reachable and no API key is configured.");
184
    }
185
186
    /**
187
     * Ensures that a provider client is available before returning it.
188
     *
189
     * <p>
190
     * This helper method invokes {@link AiProviderClient#isAvailable()} and throws
191
     * an {@link AiSuggestionException} if the provider cannot be used. It is
192
     * primarily used when constructing clients that require external services or
193
     * authentication to function correctly.
194
     * </p>
195
     *
196
     * @param client  provider client to verify
197
     * @param message error message used if the provider is unavailable
198
     * @return the supplied client if it is available
199
     *
200
     * @throws AiSuggestionException if the provider reports that it is not
201
     *                               available
202
     */
203
    private static AiProviderClient requireAvailable(AiProviderClient client, String message)
204
            throws AiSuggestionException {
205 2 1. requireAvailable : removed conditional - replaced equality check with true → KILLED
2. requireAvailable : removed conditional - replaced equality check with false → KILLED
        if (!client.isAvailable()) {
206
            throw new AiSuggestionException(message);
207
        }
208 1 1. requireAvailable : replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::requireAvailable → KILLED
        return client;
209
    }
210
}

Mutations

87

1.1
Location : create
Killed by : org.egothor.methodatlas.ai.AiProviderFactoryTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AiProviderFactoryTest]/[method:create_withOllamaProvider_returnsOllamaClientWithoutAvailabilityCheck()]
replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::create → KILLED

115

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

2.2
Location : create
Killed by : org.egothor.methodatlas.ai.AiProviderFactoryTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AiProviderFactoryTest]/[method:create_withOllamaProvider_returnsOllamaClientWithoutAvailabilityCheck()]
replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::create → KILLED

173

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

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

174

1.1
Location : auto
Killed by : org.egothor.methodatlas.ai.AiProviderFactoryTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AiProviderFactoryTest]/[method:create_withAutoProvider_returnsOllamaWhenAvailable()]
replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::auto → KILLED

178

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

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

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

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

179

1.1
Location : auto
Killed by : org.egothor.methodatlas.ai.AiProviderFactoryTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AiProviderFactoryTest]/[method:create_withAutoProvider_fallsBackToOpenAiCompatibleWhenOllamaUnavailableAndApiKeyPresent()]
replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::auto → KILLED

205

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

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

208

1.1
Location : requireAvailable
Killed by : org.egothor.methodatlas.ai.AiProviderFactoryTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.ai.AiProviderFactoryTest]/[method:create_withMistralProvider_returnsOpenAiCompatibleClientWhenAvailable()]
replaced return value with null for org/egothor/methodatlas/ai/AiProviderFactory::requireAvailable → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1