Add OpenAI, OpenRouter and OpenAI-compatible providers

OpenAIProtocolProvider is the shared request/response shape - flat
sampling parameters, native system/user/assistant roles, no top_k (OpenAI
has no equivalent, so it is excluded from supportedParameters rather than
silently ignored) - built on the transport base added earlier. Three
concrete providers sit on it:

  - OpenAIProvider and OpenRouterProvider have a constant endpoint, the
    way any client of either service does; requiresEndpoint() stays false
    and their baseUrl() ignores whatever the credential carries.
  - OpenAICompatibleProvider is the one whose endpoint the user supplies -
    a self-hosted vLLM, LM Studio, a company gateway - so
    requiresEndpoint() is true and baseUrl() reads the credential's
    endpoint, validated through OutboundEndpointGuard before every call.

Registering "OpenAI" as a real provider bean was checked against the
existing tests that used that exact name as a stand-in for an
*unregistered* provider (VaultCredentialGateTest, UserSecretControllerTest)
- none of them break, and the ones that specifically assert the
unregistered-name behaviour now exercise the real bean instead, which is
closer to what they were meant to prove.

The 4xx-carries-its-body improvement from the transport base applies here
from day one: with a free-typed model name, that body is often the only
thing that says whether the model does not exist, the key lacks access to
it, or the endpoint is wrong - OpenAI's own error responses say so
directly ("The model 'x' does not exist or you do not have access to it").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-17 12:28:26 +02:00
parent 247de4485b
commit a222c8a6d7
8 changed files with 682 additions and 0 deletions

View File

@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.OutboundEndpointGuard;
/**
* Any other server that speaks the OpenAI chat-completions API - a self-hosted vLLM, LM Studio, a
* company gateway. Unlike {@link OpenAIProvider} and {@link OpenRouterProvider}, its endpoint is not
* known in advance: it is supplied on the vault credential, which is what {@link #requiresEndpoint()}
* signals to the "Add credential" dialog.
*
* <p>The credential's endpoint is user-supplied, so every call validates it through
* {@link OutboundEndpointGuard} first - see {@code docs/llm-providers-openai-remote-ollama-plan-2026-09-17.md}
* §9. {@link it.cnr.isti.workflow.manager.vault.UserSecretService} validates the same way when the
* credential is saved, which is a courtesy (an immediate, readable error) rather than the actual
* protection: only the check made here, right before each connection, is.
*/
@Service
public class OpenAICompatibleProvider extends OpenAIProtocolProvider {
private final OutboundEndpointGuard endpointGuard;
public OpenAICompatibleProvider(@Qualifier("llmWebClientBuilder") WebClient.Builder webClientBuilder,
OutboundEndpointGuard endpointGuard) {
super(webClientBuilder);
this.endpointGuard = endpointGuard;
}
@Override
public String getName() {
return "OpenAICompatible";
}
@Override
public boolean requiresEndpoint() {
return true;
}
/**
* The only place the "no endpoint" case is rejected - deliberately not duplicated elsewhere,
* including on the {@code String}-authorization path, which reaches here through
* {@link ProviderCredential#ofValue} carrying no endpoint at all.
*/
@Override
protected String baseUrl(ProviderCredential credential) {
String endpoint = credential == null ? null : credential.endpoint();
if (endpoint == null || endpoint.isBlank()) {
throw new IllegalArgumentException(getName() + " requires an endpoint from the saved credential");
}
endpointGuard.validate(endpoint);
return endpoint;
}
@Override
public String authorizationDescription() {
return "API key for the configured endpoint.";
}
}

View File

@ -0,0 +1,217 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;
import tools.jackson.databind.JsonNode;
import it.cnr.isti.workflow.manager.app.ObjectMapperHolder;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
import it.cnr.isti.workflow.manager.llms.ModelParameter;
import it.cnr.isti.workflow.manager.llms.ModelParameters;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.AbstractHttpLLMProvider;
import reactor.core.publisher.Mono;
/**
* The OpenAI chat-completions request/response shape, shared by every provider that speaks it -
* OpenAI itself, OpenRouter, and any other OpenAI-compatible gateway. Only what differs between
* concrete providers is left out: the base URL ({@link #baseUrl(ProviderCredential)}) and how each
* names itself.
*
* <p>Not extended by {@code InternalOllamaLLMProvider}: Ollama's native API is a different shape
* entirely (nested {@code options}, {@code think}, {@code top_k}) even though Ollama also exposes
* an OpenAI-compatible endpoint - see {@code docs/llm-providers-openai-remote-ollama-plan-2026-09-17.md}
* for why that path was rejected for our own Ollama.
*/
public abstract class OpenAIProtocolProvider extends AbstractHttpLLMProvider {
protected OpenAIProtocolProvider(WebClient.Builder webClientBuilder) {
super(webClientBuilder);
}
/**
* The base URL to call: a constant for a provider whose endpoint is known in advance (OpenAI,
* OpenRouter), or read from {@code credential.endpoint()} for one whose endpoint the user
* supplies. A provider of the second kind throws here when the credential carries none, which
* is also what happens automatically when this class is reached through a {@code String}
* overload - see the class-level note on {@link #call}.
*/
protected abstract String baseUrl(ProviderCredential credential);
@Override
public boolean requiresAuthorization() {
return true;
}
@Override
public boolean canListModels() {
return false;
}
/**
* Empty on purpose: a hosted catalogue is not listable without a credential the editor does not
* have, and an OpenAI-compatible gateway's model names are whatever its operator chose to serve
* under - so the model field is free text (see {@link #canListModels()}), the same choice
* already made for Gemini.
*/
@Override
public List<String> getRegisteredModels() {
return List.of();
}
/**
* OpenAI's API has no equivalent of Ollama's {@code top_k}. Left out rather than silently
* ignored: a value set there is reported to the run log instead of doing nothing unannounced.
*/
@Override
public Set<ModelParameter> supportedParameters() {
return EnumSet.of(ModelParameter.TEMPERATURE, ModelParameter.TOP_P, ModelParameter.MAX_TOKENS,
ModelParameter.SEED);
}
@Override
public String generate(String model, String prompt) {
throw new IllegalArgumentException("Authorization is required for " + getName());
}
@Override
public String generate(String model, String prompt, String authorization) {
return call(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), false,
ProviderCredential.ofValue(authorization), null);
}
@Override
public String generateJson(String model, String prompt, String authorization) {
return call(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), true,
ProviderCredential.ofValue(authorization), null);
}
@Override
public String chat(String model, List<ChatMessage> messages, String authorization) {
return call(model, messages, false, ProviderCredential.ofValue(authorization), null);
}
/**
* The interface's own default for this signature would drop {@code parameters} by falling back
* to the 3-arg overload above - exactly the silent-loss trap {@link it.cnr.isti.workflow.manager.llms.providers.LLMProvider}'s
* own javadoc warns about. Overridden here so a caller still on the {@code String} path (not
* yet migrated to {@link ProviderCredential}) gets sampling parameters honoured all the same.
*/
@Override
public String generate(String model, String prompt, String authorization, ModelParameters parameters) {
return call(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), false,
ProviderCredential.ofValue(authorization), parameters);
}
@Override
public String generateJson(String model, String prompt, String authorization, ModelParameters parameters) {
return call(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), true,
ProviderCredential.ofValue(authorization), parameters);
}
@Override
public String chat(String model, List<ChatMessage> messages, String authorization, ModelParameters parameters) {
return call(model, messages, false, ProviderCredential.ofValue(authorization), parameters);
}
@Override
public String generate(String model, String prompt, ProviderCredential credential, ModelParameters parameters) {
return call(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), false, credential, parameters);
}
@Override
public String generateJson(String model, String prompt, ProviderCredential credential, ModelParameters parameters) {
return call(model, List.of(new ChatMessage(ChatMessage.Role.USER, prompt)), true, credential, parameters);
}
@Override
public String chat(String model, List<ChatMessage> messages, ProviderCredential credential,
ModelParameters parameters) {
return call(model, messages, false, credential, parameters);
}
/**
* Every entry point converges here. A provider whose {@code requiresEndpoint()} is true and
* whose credential carries none fails inside {@link #baseUrl(ProviderCredential)} - including
* when reached through a {@code String} overload, since {@link ProviderCredential#ofValue}
* leaves the endpoint null - rather than needing that case handled twice.
*/
private String call(String model, List<ChatMessage> messages, boolean jsonResponse, ProviderCredential credential,
ModelParameters parameters) {
if (credential == null || credential.value() == null) {
throw new IllegalArgumentException(getName() + " requires a credential");
}
Map<String, Object> body = buildRequestBody(model, messages, jsonResponse, parameters);
WebClient client = clientFor(baseUrl(credential));
Mono<String> request = withErrorHandling(client.post()
.uri("/chat/completions")
.header("Authorization", "Bearer " + credential.value())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(body)
.retrieve());
return extractGeneratedText(blockingCall(request, "chat/completions", model));
}
/** Package-private so a body test can pin the request shape without making a call. */
Map<String, Object> buildRequestBody(String model, List<ChatMessage> messages, boolean jsonResponse,
ModelParameters parameters) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", model);
body.put("messages", messages.stream()
.map(message -> Map.of("role", toOpenAiRole(message.role()), "content", message.content()))
.toList());
if (jsonResponse) {
body.put("response_format", Map.of("type", "json_object"));
}
if (!ModelParameters.isEmpty(parameters)) {
if (parameters.temperature() != null) body.put("temperature", parameters.temperature());
if (parameters.topP() != null) body.put("top_p", parameters.topP());
if (parameters.maxTokens() != null) body.put("max_tokens", parameters.maxTokens());
if (parameters.seed() != null) body.put("seed", parameters.seed());
}
return body;
}
private String toOpenAiRole(ChatMessage.Role role) {
return switch (role) {
case SYSTEM -> "system";
case USER -> "user";
case ASSISTANT -> "assistant";
};
}
/**
* {@code choices[0].message.content} can be absent or null - a tool call, a refusal, a content
* filter - and that must not read as an empty answer: silently returning "" is indistinguishable
* from "the model said nothing" versus "we parsed the wrong field", which is exactly the
* ambiguity the MCP bridge integration was bitten by. The raw body (truncated) travels with the
* failure either way, since with a free-typed model name it is often the only diagnostic that
* says whether the model does not exist, the key lacks access, or the endpoint is wrong.
*/
String extractGeneratedText(String responseBody) {
JsonNode root;
try {
root = ObjectMapperHolder.mapper.readTree(responseBody);
} catch (RuntimeException e) {
throw new IllegalStateException("Unable to parse " + getName() + " response: " + abbreviate(responseBody), e);
}
JsonNode content = root.path("choices").path(0).path("message").path("content");
String text = content.isString() ? content.stringValueOpt().orElse(null) : null;
if (text == null || text.isBlank()) {
throw new IllegalStateException(
getName() + " response contains no generated text: " + abbreviate(responseBody));
}
return text;
}
}

View File

@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
/** OpenAI itself. Its endpoint is a constant of the provider, not something the user supplies. */
@Service
public class OpenAIProvider extends OpenAIProtocolProvider {
private static final String BASE_URL = "https://api.openai.com/v1";
public OpenAIProvider(@Qualifier("llmWebClientBuilder") WebClient.Builder webClientBuilder) {
super(webClientBuilder);
}
@Override
public String getName() {
return "OpenAI";
}
@Override
protected String baseUrl(ProviderCredential credential) {
return BASE_URL;
}
@Override
public String authorizationDescription() {
return "OpenAI API key.";
}
}

View File

@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
/** OpenRouter, a gateway to many hosted models behind one OpenAI-shaped API and one key. */
@Service
public class OpenRouterProvider extends OpenAIProtocolProvider {
private static final String BASE_URL = "https://openrouter.ai/api/v1";
public OpenRouterProvider(@Qualifier("llmWebClientBuilder") WebClient.Builder webClientBuilder) {
super(webClientBuilder);
}
@Override
public String getName() {
return "OpenRouter";
}
@Override
protected String baseUrl(ProviderCredential credential) {
return BASE_URL;
}
@Override
public String authorizationDescription() {
return "OpenRouter API key.";
}
}

View File

@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
import it.cnr.isti.workflow.manager.llms.providers.OutboundEndpointGuard;
class OpenAICompatibleProviderTest {
// Permissive: this class is about baseUrl()'s own logic, not the guard's - see
// OutboundEndpointGuardTest for that.
private final OpenAICompatibleProvider provider = new OpenAICompatibleProvider(WebClient.builder(),
new OutboundEndpointGuard(true, ""));
@Test
void identifiesItselfAsOpenAICompatible() {
assertEquals("OpenAICompatible", provider.getName());
}
@Test
void requiresAnEndpointUnlikeTheOtherTwoProviders() {
assertTrue(provider.requiresEndpoint());
}
@Test
void resolvesTheUrlFromTheCredential() {
assertEquals("https://gateway.example.com/v1",
provider.baseUrl(new ProviderCredential("key", "https://gateway.example.com/v1")));
}
@Test
void refusesToCallWithNoEndpointOnTheCredential() {
assertThrows(IllegalArgumentException.class, () -> provider.baseUrl(new ProviderCredential("key", null)));
}
@Test
void refusesToCallWithNoCredentialAtAll() {
assertThrows(IllegalArgumentException.class, () -> provider.baseUrl(null));
}
@Test
void routesTheCredentialsEndpointThroughTheOutboundGuard() {
// With a non-permissive guard in place, a literal address in a private range is rejected -
// proving baseUrl() actually consults the guard rather than trusting the endpoint outright.
OpenAICompatibleProvider guarded = new OpenAICompatibleProvider(WebClient.builder(),
new OutboundEndpointGuard(false, ""));
assertThrows(IllegalArgumentException.class,
() -> guarded.baseUrl(new ProviderCredential("key", "http://10.0.0.5:8000/v1")));
}
@Test
void theStringOnlyAuthorizationPathFailsLoudlyRatherThanSilentlyMisbehaving() {
// This is the path an executor still on the deferred, endpoint-unaware wiring would use
// (see the plan's step 11): ProviderCredential.ofValue(authorization) carries no endpoint,
// so baseUrl() rejects it here - with a message that says what is missing, not an NPE deep
// inside URL building.
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> provider.generate("m", "hi", "bare-key-with-no-endpoint"));
assertTrue(error.getMessage().contains("endpoint"));
}
}

View File

@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import tools.jackson.databind.json.JsonMapper;
import it.cnr.isti.workflow.manager.app.ObjectMapperHolder;
import it.cnr.isti.workflow.manager.llms.ChatMessage;
import it.cnr.isti.workflow.manager.llms.ModelParameter;
import it.cnr.isti.workflow.manager.llms.ModelParameters;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
/**
* The request/response shape shared by every provider under {@link OpenAIProtocolProvider},
* exercised through a minimal concrete subclass with no network involved - the same approach as
* {@code InternalOllamaLLMProviderBodyTest} and {@code GeminiLLMProviderBodyTest}.
*/
class OpenAIProtocolProviderBodyTest {
/** Normally populated by Spring; needed here to parse a response body with no application context. */
@BeforeAll
static void setUpObjectMapper() {
ObjectMapperHolder.mapper = JsonMapper.builder().build();
}
private static class TestProtocolProvider extends OpenAIProtocolProvider {
TestProtocolProvider() {
super(WebClient.builder());
}
@Override
public String getName() {
return "TestProtocol";
}
@Override
protected String baseUrl(ProviderCredential credential) {
return "http://localhost/v1";
}
}
private final TestProtocolProvider provider = new TestProtocolProvider();
private final List<ChatMessage> messages = List.of(new ChatMessage(ChatMessage.Role.USER, "hi"));
@Test
void nothingSetSendsOnlyModelAndMessages() {
Map<String, Object> body = provider.buildRequestBody("m", messages, false, null);
assertEquals("m", body.get("model"));
assertEquals(List.of(Map.of("role", "user", "content", "hi")), body.get("messages"));
assertFalse(body.containsKey("response_format"));
assertFalse(body.containsKey("temperature"));
}
@Test
void jsonResponseAddsTheResponseFormat() {
Map<String, Object> body = provider.buildRequestBody("m", messages, true, null);
assertEquals(Map.of("type", "json_object"), body.get("response_format"));
}
@Test
void mapsEveryParameterToOpenAIsOwnNames() {
ModelParameters parameters = ModelParameters.builder()
.temperature(0.2).topP(0.8).maxTokens(512).seed(7L).build();
Map<String, Object> body = provider.buildRequestBody("m", messages, false, parameters);
assertEquals(0.2, body.get("temperature"));
assertEquals(0.8, body.get("top_p"));
assertEquals(512, body.get("max_tokens"));
assertEquals(7L, body.get("seed"));
// top_k has no OpenAI equivalent and must never be sent, even if somehow set.
assertFalse(body.containsKey("top_k"));
}
@Test
void sendsOnlyTheParametersThatWereSet() {
Map<String, Object> body = provider.buildRequestBody("m", messages, false,
ModelParameters.builder().seed(3L).build());
assertEquals(3L, body.get("seed"));
assertFalse(body.containsKey("temperature"));
assertFalse(body.containsKey("top_p"));
assertFalse(body.containsKey("max_tokens"));
}
@Test
void mapsRolesNativelyUnlikeGemini() {
// Unlike Gemini, OpenAI has a real system role and a real assistant role - nothing is
// collapsed into "user".
List<ChatMessage> conversation = List.of(
new ChatMessage(ChatMessage.Role.SYSTEM, "s"),
new ChatMessage(ChatMessage.Role.USER, "u"),
new ChatMessage(ChatMessage.Role.ASSISTANT, "a"));
Map<String, Object> body = provider.buildRequestBody("m", conversation, false, null);
@SuppressWarnings("unchecked")
List<Map<String, Object>> sent = (List<Map<String, Object>>) body.get("messages");
assertEquals(List.of("system", "user", "assistant"), sent.stream().map(m -> m.get("role")).toList());
}
@Test
void topKHasNoOpenAiEquivalentAndIsExcluded() {
assertEquals(
EnumSet.of(ModelParameter.TEMPERATURE, ModelParameter.TOP_P, ModelParameter.MAX_TOKENS,
ModelParameter.SEED),
provider.supportedParameters());
assertFalse(provider.supportedParameters().contains(ModelParameter.TOP_K));
}
@Test
void requiresACredentialAndCannotListModels() {
assertTrue(provider.requiresAuthorization());
assertFalse(provider.canListModels());
assertTrue(provider.getRegisteredModels().isEmpty());
}
@Test
void theTwoArgumentGenerateRefusesToRunWithoutACredential() {
assertThrows(IllegalArgumentException.class, () -> provider.generate("m", "hi"));
}
@Test
void extractsTheFirstChoicesMessageContent() {
String response = """
{"choices":[{"message":{"role":"assistant","content":"hello"}}]}""";
assertEquals("hello", provider.extractGeneratedText(response));
}
@Test
void failsLoudlyWhenContentIsNull() {
// A tool call or a refusal leaves "content" null rather than absent - this must not read
// as an empty answer, which would be indistinguishable from "the model said nothing".
String response = """
{"choices":[{"message":{"role":"assistant","content":null}}]}""";
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> provider.extractGeneratedText(response));
assertTrue(error.getMessage().contains("no generated text"));
}
@Test
void failsLoudlyWhenChoicesIsEmpty() {
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> provider.extractGeneratedText("""
{"choices":[]}"""));
assertTrue(error.getMessage().contains("no generated text"));
}
@Test
void failsLoudlyOnUnparsableJsonAndKeepsTheRawBody() {
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> provider.extractGeneratedText("not json at all"));
assertTrue(error.getMessage().contains("not json at all"));
}
}

View File

@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
import it.cnr.isti.workflow.manager.llms.ProviderCredential;
class OpenAIProviderTest {
private final OpenAIProvider provider = new OpenAIProvider(WebClient.builder());
@Test
void identifiesItselfAsOpenAI() {
assertEquals("OpenAI", provider.getName());
}
@Test
void doesNotRequireAnEndpoint() {
assertFalse(provider.requiresEndpoint());
}
@Test
void alwaysCallsTheConstantUrlEvenIfACredentialCarriesADifferentEndpoint() {
// OpenAI's endpoint is a constant of the provider, never user-supplied - a credential
// that somehow carries an endpoint anyway (it should not, since requiresEndpoint() is
// false and the vault does not ask for one) must still be ignored, not honoured.
String baseUrl = provider.baseUrl(new ProviderCredential("key", "https://not-openai.example.com"));
assertEquals("https://api.openai.com/v1", baseUrl);
}
@Test
void resolvesTheConstantUrlEvenWithNoCredentialAtAll() {
assertEquals("https://api.openai.com/v1", provider.baseUrl(null));
}
}

View File

@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
package it.cnr.isti.workflow.manager.llms.providers.openai;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test;
import org.springframework.web.reactive.function.client.WebClient;
class OpenRouterProviderTest {
private final OpenRouterProvider provider = new OpenRouterProvider(WebClient.builder());
@Test
void identifiesItselfAsOpenRouter() {
assertEquals("OpenRouter", provider.getName());
}
@Test
void doesNotRequireAnEndpoint() {
assertFalse(provider.requiresEndpoint());
}
@Test
void resolvesToItsOwnConstantUrl() {
assertEquals("https://openrouter.ai/api/v1", provider.baseUrl(null));
}
}