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:
parent
247de4485b
commit
a222c8a6d7
|
|
@ -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.";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue