From a222c8a6d775d1478e08d152227870c2dfc17e0b Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 17 Sep 2026 12:28:26 +0200 Subject: [PATCH] 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 --- .../openai/OpenAICompatibleProvider.java | 67 ++++++ .../openai/OpenAIProtocolProvider.java | 217 ++++++++++++++++++ .../llms/providers/openai/OpenAIProvider.java | 38 +++ .../providers/openai/OpenRouterProvider.java | 38 +++ .../openai/OpenAICompatibleProviderTest.java | 72 ++++++ .../OpenAIProtocolProviderBodyTest.java | 174 ++++++++++++++ .../providers/openai/OpenAIProviderTest.java | 44 ++++ .../openai/OpenRouterProviderTest.java | 32 +++ 8 files changed, 682 insertions(+) create mode 100644 src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProvider.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProvider.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProvider.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProvider.java create mode 100644 src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProviderTest.java create mode 100644 src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProviderBodyTest.java create mode 100644 src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProviderTest.java create mode 100644 src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProviderTest.java diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProvider.java b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProvider.java new file mode 100644 index 0000000..d3c673f --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProvider.java @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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. + * + *

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."; + } + +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProvider.java b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProvider.java new file mode 100644 index 0000000..b971ea8 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProvider.java @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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. + * + *

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 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 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 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 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 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 messages, boolean jsonResponse, ProviderCredential credential, + ModelParameters parameters) { + if (credential == null || credential.value() == null) { + throw new IllegalArgumentException(getName() + " requires a credential"); + } + Map body = buildRequestBody(model, messages, jsonResponse, parameters); + WebClient client = clientFor(baseUrl(credential)); + Mono 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 buildRequestBody(String model, List messages, boolean jsonResponse, + ModelParameters parameters) { + Map 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; + } + +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProvider.java b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProvider.java new file mode 100644 index 0000000..49038b4 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProvider.java @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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."; + } + +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProvider.java b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProvider.java new file mode 100644 index 0000000..67c8a66 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProvider.java @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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."; + } + +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProviderTest.java b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProviderTest.java new file mode 100644 index 0000000..99d4e04 --- /dev/null +++ b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAICompatibleProviderTest.java @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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")); + } + +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProviderBodyTest.java b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProviderBodyTest.java new file mode 100644 index 0000000..7c2a40d --- /dev/null +++ b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProtocolProviderBodyTest.java @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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 messages = List.of(new ChatMessage(ChatMessage.Role.USER, "hi")); + + @Test + void nothingSetSendsOnlyModelAndMessages() { + Map 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 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 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 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 conversation = List.of( + new ChatMessage(ChatMessage.Role.SYSTEM, "s"), + new ChatMessage(ChatMessage.Role.USER, "u"), + new ChatMessage(ChatMessage.Role.ASSISTANT, "a")); + + Map body = provider.buildRequestBody("m", conversation, false, null); + + @SuppressWarnings("unchecked") + List> sent = (List>) 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")); + } + +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProviderTest.java b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProviderTest.java new file mode 100644 index 0000000..6fe95ec --- /dev/null +++ b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenAIProviderTest.java @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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)); + } + +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProviderTest.java b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProviderTest.java new file mode 100644 index 0000000..e708177 --- /dev/null +++ b/src/test/java/it/cnr/isti/workflow/manager/llms/providers/openai/OpenRouterProviderTest.java @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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)); + } + +}