From bb86ffd04839306580368011639e3e93e506e3f6 Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 16:37:42 -0600 Subject: [PATCH 1/4] feat(ai): chat completions and streaming at /api/inference/v1 User Stories 1 and 2 of #37431: an unmodified OpenAI-compatible client can run a multi-turn, tool-calling conversation against dotCMS with only a base URL and an API token, streamed or not. 71 tests green: 60 unit, 4 integration (tool round trip), 7 integration (streaming), all against a live instance. Structure - New top-level package com.dotcms.inference, sibling to com.dotcms.ai rather than nested inside what it supersedes. - com.dotcms.inference.model holds the internal representation and carries NO Jackson annotations: FR-038 asks that it not be a binding of the wire JSON, and making serialization structurally impossible there is the only way to guarantee it. InferenceStreamEvent is a sealed interface, so the SSE serializer is a total function and a sixth variant breaks the build rather than falling through a default branch. Provider access - New InferenceAIClient owns the standard-bound semantics, which are driven by an external standard and will change when it does, separately from the dotAI endpoints which evolve on dotCMS's terms. - It does NOT own model construction, caching or eviction. AIAppListener flushes a site's cached providers on credential rotation through LangChain4jAIClient alone; a second cache would keep serving a revoked key until the TTL expired, with no symptom. So the new client borrows models through two additive accessors, withChatModel/withStreamingChatModel. - executeWithFallback generalised to a typed variant the original delegates to, so the fallback chain, cache keying and logging have one implementation and shipped behaviour is bit-identical. Notable behaviour - A failed stream withholds the [DONE] marker, so it can never be mistaken for a finished one. Verified against both provider error and connection fault. - Tool-call identity is announced once, on the first fragment. langchain4j repeats it on every fragment; passing that through made one call read as two. Caught by an integration test. - Streamed usage is emitted only when the client asks, and suppressed when a provider volunteers it unasked -- its empty choices array is what breaks readers assuming every chunk carries one. - stream_options is never forwarded: four of seven providers do not understand it. dotCMS builds the chunk from the counts the unified provider abstraction returns, which works for all of them. Also - com.dotcms.inference.rest registered in BOTH DotRestApplication (Jersey) and swagger-maven-plugin resourcePackages. Missing the second is silent: the endpoint works, the contract omits it, CI still passes. - openapi.yaml regenerated and committed. - Integration fixtures grant DOTCMS_BACK_END_USER explicitly; the role check matches by key and does not walk inheritance, so admin does not imply it. Refs #37431 Co-Authored-By: Claude Opus 5 (1M context) --- dotCMS/pom.xml | 1 + .../client/langchain4j/InferenceAIClient.java | 718 ++++++++++++++++++ .../langchain4j/LangChain4jAIClient.java | 102 ++- .../com/dotcms/ai/rest/AiHostResolver.java | 95 ++- .../com/dotcms/ai/rest/ResolvedAiContext.java | 51 ++ .../dotcms/inference/model/FinishReason.java | 23 + .../inference/model/InferenceError.java | 61 ++ .../inference/model/InferenceLimits.java | 55 ++ .../inference/model/InferenceMessage.java | 86 +++ .../inference/model/InferenceRequest.java | 166 ++++ .../inference/model/InferenceResponse.java | 42 + .../inference/model/InferenceStreamEvent.java | 118 +++ .../inference/model/InferenceToolCall.java | 44 ++ .../inference/model/InferenceToolSpec.java | 31 + .../inference/model/InferenceUsage.java | 26 + .../inference/model/ResponseFormat.java | 39 + .../java/com/dotcms/inference/model/Role.java | 20 + .../dotcms/inference/model/ToolChoice.java | 49 ++ .../rest/ChatCompletionsResource.java | 527 +++++++++++++ .../inference/rest/InferenceEndpoint.java | 18 + .../rest/InferenceRequestAttributes.java | 18 + .../rest/RequestSizeLimitFilter.java | 51 ++ .../rest/ResolvedSiteHeaderFilter.java | 48 ++ .../rest/mapper/ChatCompletionMapper.java | 397 ++++++++++ .../inference/rest/mapper/SseSerializer.java | 324 ++++++++ .../rest/view/ChatCompletionRequestView.java | 91 +++ .../rest/view/ChatCompletionView.java | 98 +++ .../rest/view/InferenceErrorView.java | 52 ++ .../rest/config/DotRestApplication.java | 1 + .../main/webapp/WEB-INF/openapi/openapi.yaml | 229 ++++++ .../ChatCompletionRequestMapperTest.java | 408 ++++++++++ .../ChatCompletionResponseMapperTest.java | 335 ++++++++ .../InferenceRequestValidationTest.java | 276 +++++++ .../com/dotcms/inference/SseFailureTest.java | 274 +++++++ .../dotcms/inference/SseSerializerTest.java | 256 +++++++ .../inference/SseToolCallStreamTest.java | 234 ++++++ .../dotcms/inference/SseUsageGatingTest.java | 246 ++++++ .../src/test/java/com/dotcms/MainSuite2b.java | 4 + .../rest/ChatCompletionsStreamingTest.java | 624 +++++++++++++++ .../inference/rest/ChatCompletionsTest.java | 428 +++++++++++ .../postman/AI.postman_collection.json | 275 ++++++- .../contracts/inference-v1.md | 158 ++++ .../data-model.md | 148 ++++ .../37431-openai-compatible-inference/spec.md | 4 +- 44 files changed, 7245 insertions(+), 6 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java create mode 100644 dotCMS/src/main/java/com/dotcms/ai/rest/ResolvedAiContext.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/FinishReason.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceMessage.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceRequest.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceResponse.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceStreamEvent.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolCall.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolSpec.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/InferenceUsage.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/ResponseFormat.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/Role.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/ToolChoice.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/InferenceEndpoint.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/RequestSizeLimitFilter.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/ResolvedSiteHeaderFilter.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/mapper/ChatCompletionMapper.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/mapper/SseSerializer.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionRequestView.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionView.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/InferenceErrorView.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/SseFailureTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/SseToolCallStreamTest.java create mode 100644 dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsStreamingTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsTest.java create mode 100644 specs/37431-openai-compatible-inference/contracts/inference-v1.md create mode 100644 specs/37431-openai-compatible-inference/data-model.md diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml index 6e8a6cd1adaf..c1c49188b3aa 100644 --- a/dotCMS/pom.xml +++ b/dotCMS/pom.xml @@ -2162,6 +2162,7 @@ com.dotcms.contenttype.model.field com.dotcms.rendering.js com.dotcms.ai.rest + com.dotcms.inference.rest com.dotcms.health com.dotcms.auth.dotAuth.rest diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java new file mode 100644 index 000000000000..28da307c825e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java @@ -0,0 +1,718 @@ +package com.dotcms.ai.client.langchain4j; + +import com.dotcms.ai.app.AppConfig; +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.model.InferenceMessage; +import com.dotcms.inference.model.InferenceRequest; +import com.dotcms.inference.model.InferenceResponse; +import com.dotcms.inference.model.InferenceStreamEvent; +import com.dotcms.inference.model.InferenceToolCall; +import com.dotcms.inference.model.InferenceToolSpec; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.model.Role; +import com.dotmarketing.util.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonRawSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.CompleteToolCall; +import dev.langchain4j.model.chat.response.PartialToolCall; +import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.output.TokenUsage; +import io.vavr.Lazy; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Drives completions for the {@code /api/inference/v1} family against LangChain4J. + * + *

dotCMS has two AI surfaces and they are under different pressures. {@code /api/v1/ai/*} + * evolves when dotCMS decides it should. This family is pinned to an external standard that + * nobody here controls, so it changes when that standard changes — on someone else's schedule. + * Keeping its request and response semantics in a class of their own is what lets that happen + * without editing a line that a shipped endpoint depends on.

+ * + *

What deliberately did not move here is model construction, caching and the + * per-site fallback chain. Those stay in {@link LangChain4jAIClient} because + * {@code LangChain4jAIClient.flushCachesForHost(String)} is what evicts a site's cached providers + * when its credentials are rotated, and {@code AIAppListener} is wired to that one class. A second + * client keeping its own cache would go on serving a revoked key until its TTL expired, with + * nothing to notice. So this class borrows models through + * {@link LangChain4jAIClient#withChatModel} and {@link LangChain4jAIClient#withStreamingChatModel} + * and owns no state of its own.

+ * + *

Those accessors hand over the name of the model that actually served, which after a fallback + * hop is not the one that was asked for. That name — not {@link InferenceRequest#model()} — is + * what {@link InferenceResponse#model()} reports, so a caller can tell a fallback happened. For + * the same reason the requested model is never set on the outgoing {@link ChatRequest}: the site's + * configuration, not the caller, decides which provider model runs.

+ * + *

Nothing here logs message content, tool arguments or provider error envelopes: they carry + * customer data, and an error event is given a description written here rather than whatever the + * provider echoed back.

+ */ +public final class InferenceAIClient { + + private static final Lazy INSTANCE = Lazy.of(InferenceAIClient::new); + + private static final String RESPONSE_ID_PREFIX = "chatcmpl-"; + private static final String RESPONSE_SCHEMA_NAME = "response"; + private static final String UPSTREAM_FAILURE_MESSAGE = + "The model provider failed to complete the request"; + private static final String SCHEMA_PROPERTIES = "properties"; + private static final String SCHEMA_REQUIRED = "required"; + private static final String SCHEMA_DESCRIPTION = "description"; + private static final String SCHEMA_ADDITIONAL_PROPERTIES = "additionalProperties"; + private static final String SCHEMA_DEFS = "$defs"; + private static final String SCHEMA_DEFINITIONS = "definitions"; + + private InferenceAIClient() { + } + + /** + * @return the single instance; mirrors {@link LangChain4jAIClient#get()} because this class, + * like that one, holds no per-request state + */ + public static InferenceAIClient get() { + return INSTANCE.get(); + } + + /** + * Runs one completion and waits for the whole answer. + * + *

The model is borrowed from {@link LangChain4jAIClient}, so a site's fallback chain applies + * and the returned {@link InferenceResponse#model()} names the model that actually served. + * Usage is reported only when the provider reported it; when it did not, the response carries + * {@link InferenceUsage#UNREPORTED} rather than counts invented here.

+ * + * @param appConfig the resolved site's configuration + * @param request the completion to run + * @return the assistant's turn, which may ask for tools instead of carrying text + * @throws RuntimeException if every model in the site's chain failed; the exception is the last + * failure, left for the REST layer to turn into a status + */ + public InferenceResponse complete(final AppConfig appConfig, final InferenceRequest request) { + final ChatRequest chatRequest = toChatRequest(request); + return LangChain4jAIClient.get().withChatModel( + appConfig, + (model, servingModel) -> toInferenceResponse(model.chat(chatRequest), servingModel)); + } + + /** + * Runs one completion and hands each event to {@code sink} as it arrives. + * + *

The sink sees content fragments, tool-call fragments, and exactly one terminal event: a + * {@link InferenceStreamEvent.Finish} when generation ended, or an + * {@link InferenceStreamEvent.Error} when it did not. Never both — after the first event is + * written the HTTP status is already gone, so which variant ends the stream is the only way a + * caller can tell a failed answer from a complete one. A + * {@link InferenceStreamEvent.Usage} event follows the finish only when the caller set + * {@link InferenceRequest#includeUsageInStream()}; sending it unasked breaks readers that + * reject the empty choices array it serializes to.

+ * + *

{@link InferenceLimits#completionTimeoutSeconds()} is a hard ceiling: a provider that + * stops producing events ends the stream with an error rather than parking the calling thread + * indefinitely. Failures are reported through the sink rather than thrown, because once + * streaming has begun the sink is the only channel left.

+ * + * @param appConfig the resolved site's configuration + * @param request the completion to run + * @param sink receives every event, in order + */ + public void stream(final AppConfig appConfig, + final InferenceRequest request, + final Consumer sink) { + final ChatRequest chatRequest = toChatRequest(request); + final StreamState state = new StreamState(sink, request.includeUsageInStream()); + try { + LangChain4jAIClient.get().withStreamingChatModel( + appConfig, + (model, servingModel) -> streamWithModel(model, chatRequest, state)); + } catch (final RuntimeException e) { + // Nothing reached the sink — every model in the chain failed to start — so the sink + // still has to learn that this stream will not finish. + Logger.warn(InferenceAIClient.class, + "Inference stream could not be started: " + e.getClass().getSimpleName()); + state.fail(toInferenceError(e)); + } + } + + /** + * Drives one streaming exchange to its terminal event. + * + *

Nothing is thrown out of here once an event has been emitted: the fallback chain in + * {@link LangChain4jAIClient} retries on a thrown exception, and a retry after the sink has + * seen part of an answer would splice two streams together. Before the first event there is + * nothing to splice, so a failure there is rethrown and the next model gets its turn.

+ * + * @param model the streaming model handed over by the accessor + * @param chatRequest the mapped request + * @param state collects events and guarantees a single terminal one + */ + private void streamWithModel(final StreamingChatModel model, + final ChatRequest chatRequest, + final StreamState state) { + final int timeoutSeconds = InferenceLimits.current().completionTimeoutSeconds(); + try { + model.chat(chatRequest, new StreamingChatResponseHandler() { + + @Override + public void onPartialResponse(final String token) { + state.contentDelta(token); + } + + @Override + public void onPartialToolCall(final PartialToolCall partialToolCall) { + state.partialToolCall(partialToolCall); + } + + @Override + public void onCompleteToolCall(final CompleteToolCall completeToolCall) { + state.completeToolCall(completeToolCall); + } + + @Override + public void onCompleteResponse(final ChatResponse response) { + state.finish(response); + } + + @Override + public void onError(final Throwable throwable) { + Logger.warn(InferenceAIClient.class, + "Inference stream failed: " + throwable.getClass().getSimpleName()); + state.fail(toInferenceError(throwable)); + } + }); + } catch (final RuntimeException e) { + if (!state.hasEmitted()) { + throw e; + } + Logger.warn(InferenceAIClient.class, + "Inference stream aborted: " + e.getClass().getSimpleName()); + state.fail(toInferenceError(e)); + return; + } + + if (!state.awaitTerminal(timeoutSeconds)) { + Logger.warn(InferenceAIClient.class, + "Inference stream exceeded the " + timeoutSeconds + " second ceiling"); + state.fail(InferenceError.upstream( + "The completion exceeded the configured ceiling of " + timeoutSeconds + " seconds")); + } + } + + /** + * Maps a completion request into the provider-facing request. + * + *

The requested model is not mapped: the site's configuration chooses which provider model + * runs, and overriding it here would defeat the fallback chain the accessors apply.

+ * + * @param request the completion to run + * @return the request to hand a chat model + */ + private static ChatRequest toChatRequest(final InferenceRequest request) { + final ChatRequest.Builder builder = ChatRequest.builder() + .messages(toChatMessages(request.messages())) + .temperature(request.temperature()) + .maxOutputTokens(request.maxOutputTokens()) + .topP(request.topP()); + + if (!request.stopSequences().isEmpty()) { + builder.stopSequences(request.stopSequences()); + } + if (request.hasTools()) { + builder.toolSpecifications(toToolSpecifications(request.tools(), request.toolChoice())); + } + if (request.toolChoice() != null) { + builder.toolChoice(toToolChoice(request.toolChoice())); + } + if (request.responseFormat() != null) { + builder.responseFormat(toResponseFormat(request.responseFormat())); + } + return builder.build(); + } + + /** + * Maps the conversation, turn by turn, preserving order and tool-call identity. + * + * @param messages the conversation, oldest first + * @return the provider-facing messages + */ + private static List toChatMessages(final List messages) { + final List chatMessages = new ArrayList<>(messages.size()); + for (final InferenceMessage message : messages) { + chatMessages.add(toChatMessage(message)); + } + return chatMessages; + } + + /** + * Maps one turn. + * + * @param message the turn + * @return the provider-facing message + */ + private static ChatMessage toChatMessage(final InferenceMessage message) { + final Role role = message.role(); + if (role == Role.SYSTEM) { + return new SystemMessage(nullToEmpty(message.content())); + } + if (role == Role.TOOL) { + return new ToolExecutionResultMessage( + message.toolCallId(), message.name(), nullToEmpty(message.content())); + } + if (role == Role.ASSISTANT) { + if (!message.hasToolCalls()) { + return new AiMessage(nullToEmpty(message.content())); + } + final List toolRequests = new ArrayList<>(message.toolCalls().size()); + for (final InferenceToolCall toolCall : message.toolCalls()) { + toolRequests.add(ToolExecutionRequest.builder() + .id(toolCall.id()) + .name(toolCall.name()) + .arguments(toolCall.arguments()) + .build()); + } + return message.content() == null + ? AiMessage.from(toolRequests) + : AiMessage.from(message.content(), toolRequests); + } + return new UserMessage(nullToEmpty(message.content())); + } + + /** + * Maps the declared tools. + * + *

When the caller forced one named tool, only that tool is offered. LangChain4J's shared + * chat API has no "call this exact tool" choice — its strongest is "call some tool" — so + * narrowing the offer is what turns that into the caller's actual instruction. If the forced + * name matches nothing declared, every tool is offered and the provider is left to reject it, + * which is a clearer failure than silently sending no tools at all.

+ * + * @param tools the declared tools + * @param toolChoice the caller's preference, possibly null + * @return the provider-facing tool specifications + */ + private static List toToolSpecifications( + final List tools, + final com.dotcms.inference.model.ToolChoice toolChoice) { + final boolean forcesOne = toolChoice != null + && toolChoice.mode() == com.dotcms.inference.model.ToolChoice.Mode.FUNCTION; + final boolean forcedIsDeclared = forcesOne + && tools.stream().anyMatch(tool -> tool.name().equals(toolChoice.function())); + + final List specifications = new ArrayList<>(tools.size()); + for (final InferenceToolSpec tool : tools) { + if (forcedIsDeclared && !tool.name().equals(toolChoice.function())) { + continue; + } + specifications.add(ToolSpecification.builder() + .name(tool.name()) + .description(tool.description()) + .parameters(toObjectSchema(tool.parameters())) + .build()); + } + return specifications; + } + + /** + * Maps the caller's tool preference. + * + *

{@code FUNCTION} becomes {@code REQUIRED}, paired with the narrowed tool list built by + * {@link #toToolSpecifications}; together they say what the caller meant.

+ * + * @param toolChoice the caller's preference + * @return the provider-facing choice + */ + private static dev.langchain4j.model.chat.request.ToolChoice toToolChoice( + final com.dotcms.inference.model.ToolChoice toolChoice) { + switch (toolChoice.mode()) { + case NONE: + return dev.langchain4j.model.chat.request.ToolChoice.NONE; + case REQUIRED: + case FUNCTION: + return dev.langchain4j.model.chat.request.ToolChoice.REQUIRED; + case AUTO: + default: + return dev.langchain4j.model.chat.request.ToolChoice.AUTO; + } + } + + /** + * Maps the requested output shape. + * + * @param responseFormat the caller's requested shape + * @return the provider-facing response format + */ + private static dev.langchain4j.model.chat.request.ResponseFormat toResponseFormat( + final com.dotcms.inference.model.ResponseFormat responseFormat) { + switch (responseFormat.type()) { + case JSON_OBJECT: + return dev.langchain4j.model.chat.request.ResponseFormat.JSON; + case JSON_SCHEMA: + return dev.langchain4j.model.chat.request.ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .jsonSchema(JsonSchema.builder() + .name(RESPONSE_SCHEMA_NAME) + .rootElement(toObjectSchema(responseFormat.schema())) + .build()) + .build(); + case TEXT: + default: + return dev.langchain4j.model.chat.request.ResponseFormat.TEXT; + } + } + + /** + * Wraps a caller-supplied JSON Schema in the object schema LangChain4J expects. + * + *

Only the envelope — properties, required, description, additionalProperties, definitions — + * is read. Each property's schema is carried through verbatim as a raw element, because dotCMS + * neither interprets nor validates it: the schema is the caller's contract with their own tool, + * and re-modelling it here would quietly drop the keywords this mapping does not know about.

+ * + * @param schema the caller's JSON Schema document + * @return the equivalent object schema + */ + private static JsonObjectSchema toObjectSchema(final JsonNode schema) { + final JsonObjectSchema.Builder builder = JsonObjectSchema.builder(); + + final JsonNode description = schema.get(SCHEMA_DESCRIPTION); + if (description != null && description.isTextual()) { + builder.description(description.asText()); + } + + final Map properties = toRawElements(schema.get(SCHEMA_PROPERTIES)); + if (!properties.isEmpty()) { + builder.addProperties(properties); + } + + final JsonNode required = schema.get(SCHEMA_REQUIRED); + if (required != null && required.isArray()) { + final List names = new ArrayList<>(required.size()); + required.forEach(name -> names.add(name.asText())); + builder.required(names); + } + + final JsonNode additionalProperties = schema.get(SCHEMA_ADDITIONAL_PROPERTIES); + if (additionalProperties != null && additionalProperties.isBoolean()) { + builder.additionalProperties(additionalProperties.asBoolean()); + } + + final JsonNode definitionsNode = schema.has(SCHEMA_DEFS) + ? schema.get(SCHEMA_DEFS) + : schema.get(SCHEMA_DEFINITIONS); + final Map definitions = toRawElements(definitionsNode); + if (!definitions.isEmpty()) { + builder.definitions(definitions); + } + + return builder.build(); + } + + /** + * Carries each member of a JSON object through as an uninterpreted schema element. + * + * @param node the object whose members are schemas, possibly null or not an object + * @return the members keyed by name, in document order; empty if there are none + */ + private static Map toRawElements(final JsonNode node) { + if (node == null || !node.isObject()) { + return Map.of(); + } + final Map elements = new LinkedHashMap<>(); + final Iterator> fields = node.fields(); + while (fields.hasNext()) { + final Map.Entry field = fields.next(); + elements.put(field.getKey(), JsonRawSchema.from(field.getValue().toString())); + } + return elements; + } + + /** + * Maps a finished provider response into the answer this family returns. + * + * @param response the provider's response + * @param servingModel the model that actually served, after any fallback hop + * @return the completed answer + */ + private static InferenceResponse toInferenceResponse(final ChatResponse response, + final String servingModel) { + final AiMessage aiMessage = response.aiMessage(); + return new InferenceResponse( + RESPONSE_ID_PREFIX + UUID.randomUUID().toString().replace("-", ""), + servingModel, + Instant.now().getEpochSecond(), + toInferenceMessage(aiMessage), + toFinishReason(response.finishReason(), aiMessage), + toInferenceUsage(response.tokenUsage())); + } + + /** + * Maps the assistant's turn, carrying tool-call identities through untouched. + * + * @param aiMessage the provider's assistant message + * @return the assistant turn + */ + private static InferenceMessage toInferenceMessage(final AiMessage aiMessage) { + if (aiMessage == null) { + return InferenceMessage.of(Role.ASSISTANT, ""); + } + if (!aiMessage.hasToolExecutionRequests()) { + return InferenceMessage.of(Role.ASSISTANT, nullToEmpty(aiMessage.text())); + } + final List requests = aiMessage.toolExecutionRequests(); + final List toolCalls = new ArrayList<>(requests.size()); + for (int index = 0; index < requests.size(); index++) { + final ToolExecutionRequest request = requests.get(index); + toolCalls.add(new InferenceToolCall( + request.id(), request.name(), request.arguments(), index)); + } + return InferenceMessage.ofToolCalls(aiMessage.text(), toolCalls); + } + + /** + * Maps why generation stopped. + * + *

A provider that reports nothing is read from what it returned instead: an assistant turn + * asking for tools stopped to have them executed, whatever the provider forgot to say.

+ * + * @param finishReason the provider's reason, possibly null + * @param aiMessage the assistant turn, used when the provider was silent + * @return the reason to report + */ + private static com.dotcms.inference.model.FinishReason toFinishReason( + final dev.langchain4j.model.output.FinishReason finishReason, + final AiMessage aiMessage) { + if (finishReason == null) { + return aiMessage != null && aiMessage.hasToolExecutionRequests() + ? com.dotcms.inference.model.FinishReason.TOOL_CALLS + : com.dotcms.inference.model.FinishReason.STOP; + } + switch (finishReason) { + case LENGTH: + return com.dotcms.inference.model.FinishReason.LENGTH; + case TOOL_EXECUTION: + return com.dotcms.inference.model.FinishReason.TOOL_CALLS; + case CONTENT_FILTER: + return com.dotcms.inference.model.FinishReason.CONTENT_FILTER; + case STOP: + case OTHER: + default: + return com.dotcms.inference.model.FinishReason.STOP; + } + } + + /** + * Maps reported token counts. + * + * @param tokenUsage the provider's counts, possibly null or partly absent + * @return the counts, or {@link InferenceUsage#UNREPORTED} when the provider reported none + */ + private static InferenceUsage toInferenceUsage(final TokenUsage tokenUsage) { + if (tokenUsage == null) { + return InferenceUsage.UNREPORTED; + } + final InferenceUsage usage = new InferenceUsage( + tokenUsage.inputTokenCount(), + tokenUsage.outputTokenCount(), + tokenUsage.totalTokenCount()); + return usage.isReported() ? usage : InferenceUsage.UNREPORTED; + } + + /** + * Turns a failure into an error safe to hand a client. + * + *

A provider's own message is never passed through: it can quote the prompt back. Messages + * dotCMS wrote — a misconfigured site, for instance — are safe and are kept, because they name + * something the caller or an operator can actually fix.

+ * + * @param throwable what went wrong + * @return the error to report + */ + private static InferenceError toInferenceError(final Throwable throwable) { + if (throwable instanceof IllegalArgumentException) { + return InferenceError.invalidRequest(nullToEmpty(throwable.getMessage()).isBlank() + ? "The request could not be served with this site's configuration" + : throwable.getMessage(), null); + } + return InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE); + } + + /** + * @param value a possibly null string + * @return the value, or an empty string + */ + private static String nullToEmpty(final String value) { + return value == null ? "" : value; + } + + /** + * Guards the one guarantee a streamed completion makes: exactly one terminal event. + * + *

Provider callbacks arrive on the provider's own threads, so every emission is serialized + * here and the terminal flag is checked under the same lock. That is also what keeps a timeout + * racing a late completion from producing both an error and a finish.

+ */ + private static final class StreamState { + + private final Consumer sink; + private final boolean includeUsage; + private final CountDownLatch terminalLatch = new CountDownLatch(1); + private final AtomicBoolean terminated = new AtomicBoolean(); + private final AtomicBoolean emitted = new AtomicBoolean(); + private final Set fragmentedToolCalls = Collections.synchronizedSet(new HashSet<>()); + + private StreamState(final Consumer sink, final boolean includeUsage) { + this.sink = sink; + this.includeUsage = includeUsage; + } + + /** @return whether anything has reached the sink yet */ + private boolean hasEmitted() { + return emitted.get(); + } + + /** + * @param timeoutSeconds the ceiling on the whole completion + * @return whether a terminal event was reached within the ceiling + */ + private boolean awaitTerminal(final int timeoutSeconds) { + try { + return terminalLatch.await(timeoutSeconds, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + fail(InferenceError.upstream("The completion was interrupted before it finished")); + return true; + } + } + + /** + * @param token a fragment of the answer's text + */ + private void contentDelta(final String token) { + emit(new InferenceStreamEvent.ContentDelta(token)); + } + + /** + * @param partialToolCall a fragment of one tool call + */ + private void partialToolCall(final PartialToolCall partialToolCall) { + // The provider repeats the id and name on every fragment of a call. The wire format + // does not: identity is announced once, and a client seeing an id again reads it as a + // second tool call starting. So only the first fragment of each index carries them. + final boolean firstFragment = fragmentedToolCalls.add(partialToolCall.index()); + emit(new InferenceStreamEvent.ToolCallDelta( + partialToolCall.index(), + firstFragment ? partialToolCall.id() : null, + firstFragment ? partialToolCall.name() : null, + partialToolCall.partialArguments())); + } + + /** + * Emits a whole tool call as one fragment, but only for a provider that never streamed it. + * + *

Providers that do stream tool calls send this after the fragments, and re-emitting the + * arguments here would have a reader concatenate them twice into malformed JSON.

+ * + * @param completeToolCall the finished tool call + */ + private void completeToolCall(final CompleteToolCall completeToolCall) { + if (fragmentedToolCalls.contains(completeToolCall.index())) { + return; + } + final ToolExecutionRequest request = completeToolCall.toolExecutionRequest(); + emit(new InferenceStreamEvent.ToolCallDelta( + completeToolCall.index(), request.id(), request.name(), request.arguments())); + } + + /** + * Ends the stream normally, with the usage event only if the caller asked for one. + * + * @param response the provider's finished response + */ + private synchronized void finish(final ChatResponse response) { + if (terminated.get()) { + return; + } + final AiMessage aiMessage = response == null ? null : response.aiMessage(); + emit(new InferenceStreamEvent.Finish(toFinishReason( + response == null ? null : response.finishReason(), aiMessage))); + if (includeUsage) { + emit(new InferenceStreamEvent.Usage( + toInferenceUsage(response == null ? null : response.tokenUsage()))); + } + terminate(); + } + + /** + * Ends the stream as failed. Ignored once the stream has already ended, so a finish and an + * error can never both be reported. + * + * @param error what went wrong + */ + private synchronized void fail(final InferenceError error) { + if (terminated.get()) { + return; + } + emit(new InferenceStreamEvent.Error(error)); + terminate(); + } + + /** + * Hands one event to the sink. + * + *

A sink that throws is a caller that has gone away — a disconnected client, typically. + * There is nowhere left to report anything, so the stream is ended silently and the waiting + * thread released rather than held until the timeout.

+ * + * @param event the event to emit + */ + private synchronized void emit(final InferenceStreamEvent event) { + if (terminated.get()) { + return; + } + try { + sink.accept(event); + emitted.set(true); + } catch (final RuntimeException e) { + Logger.warn(InferenceAIClient.class, + "Inference stream consumer rejected an event, ending the stream: " + + e.getClass().getSimpleName()); + terminate(); + } + } + + /** Marks the stream ended and releases whoever is waiting on it. */ + private void terminate() { + terminated.set(true); + terminalLatch.countDown(); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 7abab2c6a0cc..7820a9f259c8 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -45,6 +45,8 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -72,6 +74,7 @@ public class LangChain4jAIClient implements AIClient { private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); private static final long MODEL_CACHE_TTL_HOURS = 1; private static final long STREAMING_TIMEOUT_SECONDS = 300; + private static final String CHAT_SECTION = "chat"; private final Cache chatModelCache = Caffeine.newBuilder() .maximumSize(128) @@ -113,6 +116,73 @@ public void flushCachesForHost(final String hostname) { imageModelCache.asMap().keySet().removeIf(key -> key.startsWith(prefix)); } + /** + * Hands a caller a chat model for a site, with the fallback chain and cache already applied. + * + *

Exists so the {@code /api/inference/v1} family can own its own request and response + * semantics — which are dictated by an external standard and will change when that standard + * changes — without owning model construction, caching or eviction. Those stay here, in one + * place, for one reason: {@link #flushCachesForHost(String)} is what evicts a site's cached + * providers when its credentials are rotated, and it is wired to this class alone + * ({@code AIAppListener}). A second client holding its own cache would keep serving a revoked + * key until the TTL expired, with no symptom to notice.

+ * + *

The executor receives the model's name alongside the model because a fallback hop means + * the model that served is not the model that was asked for, and a caller reporting back to a + * client has to say which one actually ran.

+ * + * @param appConfig the resolved site's configuration + * @param executor receives a chat model and its name, and produces the result + * @param the result type + * @return whatever the executor returned for the first model that succeeded + */ + public R withChatModel(final AppConfig appConfig, final BiFunction executor) { + return executeWithFallbackTyped( + cacheKeyPrefix(appConfig), + CHAT_SECTION, + parseSection(appConfig.getProviderConfig(), CHAT_SECTION), + chatModelCache, + LangChain4jModelFactory::buildChatModel, + executor); + } + + /** + * Hands a caller a streaming chat model for a site, with the fallback chain and cache applied. + * + *

The streaming counterpart of {@link #withChatModel}; see that method for why model + * acquisition stays in this class rather than moving to the caller.

+ * + * @param appConfig the resolved site's configuration + * @param executor receives a streaming chat model and its name + */ + public void withStreamingChatModel(final AppConfig appConfig, + final BiConsumer executor) { + executeWithFallbackTyped( + cacheKeyPrefix(appConfig), + CHAT_SECTION, + parseSection(appConfig.getProviderConfig(), CHAT_SECTION), + streamingChatModelCache, + LangChain4jModelFactory::buildStreamingChatModel, + (model, modelName) -> { + executor.accept(model, modelName); + return null; + }); + } + + /** + * The cache key prefix for a site's models. + * + *

Derived from the configuration rather than from the request, which is what makes two + * sites with different providers get separate model instances for free, and what makes a + * credential rotation change the key so the old instance is no longer reachable.

+ * + * @param appConfig the resolved site's configuration + * @return the prefix shared by every cache entry for that site and configuration + */ + private static String cacheKeyPrefix(final AppConfig appConfig) { + return appConfig.getHost() + ":" + appConfig.getProviderConfigHash(); + } + @Override public AIProvider getProvider() { return AIProvider.LANGCHAIN4J; @@ -336,6 +406,36 @@ String executeWithFallback( final Cache modelCache, final Function modelBuilder, final Function executor) { + return executeWithFallbackTyped(cacheKeyPrefix, section, baseConfig, modelCache, modelBuilder, + (model, modelName) -> executor.apply(model)); + } + + /** + * Runs {@code executor} against each configured model in turn until one succeeds. + * + *

Generalised from the String-returning variant above, which now delegates here, so the + * fallback chain, the cache keying and the per-attempt logging have exactly one + * implementation. The executor additionally receives the name of the model it was handed, + * because a caller reporting results back to a client needs to say which model actually + * served — after a fallback hop that is not the one the caller asked for.

+ * + * @param cacheKeyPrefix the site-and-config-derived cache key prefix + * @param section the providerConfig section, e.g. {@code chat} + * @param baseConfig the parsed section config + * @param modelCache the cache for this model type + * @param modelBuilder builds a model from a config naming one model + * @param executor receives the model and the model's name, and produces the result + * @param the provider model type + * @param the result type + * @return the first successful result + */ + R executeWithFallbackTyped( + final String cacheKeyPrefix, + final String section, + final ProviderConfig baseConfig, + final Cache modelCache, + final Function modelBuilder, + final BiFunction executor) { final List models = effectiveModels(baseConfig); if (models.isEmpty()) { throw new IllegalArgumentException( @@ -361,7 +461,7 @@ String executeWithFallback( } try { final long start = System.currentTimeMillis(); - final String result = executor.apply(model); + final R result = executor.apply(model, modelName); Logger.info(LangChain4jAIClient.class, section + " model '" + modelName + "' responded in " + (System.currentTimeMillis() - start) + "ms"); diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java index 492827d8a318..de56760390c1 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java @@ -1,5 +1,7 @@ package com.dotcms.ai.rest; +import com.dotcms.ai.app.AppConfig; +import com.dotcms.ai.app.ConfigService; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.web.WebAPILocator; @@ -9,6 +11,7 @@ import org.apache.commons.lang3.StringUtils; import javax.servlet.http.HttpServletRequest; +import java.util.Optional; /** * Resolves the target {@link Host} for a dotAI REST request from an optional {@code siteId} @@ -16,11 +19,99 @@ * absent or unresolvable. Shared by every dotAI endpoint that reads or tests a per-site * {@code providerConfig} ({@link CompletionsResource}, {@link AiProviderResource}). */ -final class AiHostResolver { +public final class AiHostResolver { private AiHostResolver() { } + /** + * Resolves the caller, the target site and that site's dotAI configuration in one step. + * + *

This is the single entry point the {@code /api/inference/v1} family uses to obtain an + * {@link AppConfig}; resolving a host and fetching a configuration separately is what allowed + * two shipped dotAI endpoints to drift to opposite policies, and returning the three together + * removes the opportunity.

+ * + *

Resolution is deliberately standard. When {@code siteOverride} is blank + * the site comes from the request exactly as it does everywhere else in dotCMS, default-site + * fallback included, because the server-side callers this family exists for routinely present + * a host name that is no site alias — internal DNS, container service names, {@code + * localhost}. Refusing those would have broken the primary supported deployment. The risk the + * fallback carries is that nobody can tell which site paid, so a fallback is logged here and + * the resolved site is reported back on every response.

+ * + *

An explicit override is the one path that enforces site READ: {@link #findHost} resolves + * it as the caller, so a site they cannot see raises {@link DotSecurityException}.

+ * + * @param request the inbound request + * @param siteOverride an explicit site id or host name, or null/blank to resolve from the request + * @param user the authenticated caller + * @return the caller, the resolved site and its configuration + * @throws DotSecurityException when an explicit override names a site the caller cannot read + * @throws IllegalArgumentException when an explicit override names no known site + */ + public static ResolvedAiContext resolve(final HttpServletRequest request, + final String siteOverride, + final User user) throws DotSecurityException { + final Host host = StringUtils.isNotBlank(siteOverride) + ? requireOverriddenHost(siteOverride, user) + : resolveFromRequest(request); + return new ResolvedAiContext(user, host, ConfigService.INSTANCE.config(host)); + } + + /** + * Resolves an explicitly requested site, as the caller, so permissions apply. + * + * @param siteOverride the site id or host name the caller asked for + * @param user the authenticated caller + * @return the site + * @throws DotSecurityException when the caller cannot read it + */ + private static Host requireOverriddenHost(final String siteOverride, final User user) + throws DotSecurityException { + try { + final Host found = findHost(siteOverride, user); + if (found == null) { + throw new IllegalArgumentException("Site not found: " + sanitize(siteOverride)); + } + return found; + } catch (final DotSecurityException | IllegalArgumentException e) { + throw e; + } catch (final Exception e) { + throw new IllegalArgumentException("Site not found: " + sanitize(siteOverride), e); + } + } + + /** + * Resolves the site from the request, noting when the default site had to stand in. + * + *

The fallback itself is standard dotCMS behaviour and is kept. What is not kept is its + * silence: an unmatched host name means some site's credentials are about to be spent on a + * request that did not name it, and that should be visible in the log as well as in the + * response.

+ * + * @param request the inbound request + * @return the resolved site + */ + private static Host resolveFromRequest(final HttpServletRequest request) { + final String serverName = request.getServerName(); + try { + final Optional matched = APILocator.getHostAPI() + .resolveHostNameWithoutDefault(serverName, APILocator.systemUser(), false); + if (matched.isPresent()) { + return matched.get(); + } + Logger.warn(AiHostResolver.class, + "Inference request for host '" + sanitize(serverName) + + "' matched no site or alias; the default site will serve it and its" + + " credentials will be spent. The serving site is reported on the response."); + } catch (final Exception e) { + Logger.warn(AiHostResolver.class, + "Could not resolve host '" + sanitize(serverName) + "': " + e.getMessage()); + } + return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + } + /** * Resolves a host from {@code siteId} and falls back to the HTTP host on failure. * Throws {@link DotSecurityException} when the user lacks permission for the requested site. @@ -68,7 +159,7 @@ static Host resolveHostStrict(final String siteId, } } - static String sanitize(final String value) { + public static String sanitize(final String value) { return value == null ? "null" : value.replaceAll("[\r\n\t]", "_"); } diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/ResolvedAiContext.java b/dotCMS/src/main/java/com/dotcms/ai/rest/ResolvedAiContext.java new file mode 100644 index 000000000000..8cbc96a8861c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/ResolvedAiContext.java @@ -0,0 +1,51 @@ +package com.dotcms.ai.rest; + +import com.dotcms.ai.app.AppConfig; +import com.dotmarketing.beans.Host; +import com.liferay.portal.model.User; + +/** + * The caller, the site their request resolved to, and that site's dotAI configuration — resolved + * together, in one step. + * + *

The three are returned as a unit on purpose. Obtaining them separately is what lets a caller + * authorize against one site and then fetch configuration for another, which is not a theoretical + * risk: two shipped dotAI endpoints have already drifted to opposite model-passthrough policies + * by each making the decision for themselves. A type that can only be produced by the shared + * resolver removes the opportunity.

+ * + * @param user the authenticated caller + * @param host the site the request resolved to, after standard dotCMS host resolution + * @param config that site's dotAI configuration, including any system-level inheritance + */ +public record ResolvedAiContext(User user, Host host, AppConfig config) { + + public ResolvedAiContext { + if (user == null) { + throw new IllegalArgumentException("ResolvedAiContext user is required"); + } + if (host == null) { + throw new IllegalArgumentException("ResolvedAiContext host is required"); + } + if (config == null) { + throw new IllegalArgumentException("ResolvedAiContext config is required"); + } + } + + /** + * The identity to report as having served — and paid for — the request. + * + * @return the resolved site's identifier + */ + public String servingSiteId() { + return host.getIdentifier(); + } + + /** + * @return whether the resolved site, or the system level it inherits from, has any dotAI + * configuration at all + */ + public boolean isConfigured() { + return config != null && config.isEnabled(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/FinishReason.java b/dotCMS/src/main/java/com/dotcms/inference/model/FinishReason.java new file mode 100644 index 000000000000..fa5f4373adc5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/FinishReason.java @@ -0,0 +1,23 @@ +package com.dotcms.inference.model; + +/** + * Why a completion stopped. + * + *

{@link #ERROR} has no equivalent in the chat-completions wire format, which has no finish + * reason meaning "this failed". It exists here because a stream that fails after it has begun + * must be distinguishable from one that finished, and the serializer turns it into an error + * event rather than a finish reason.

+ */ +public enum FinishReason { + + /** The model finished naturally. */ + STOP, + /** The model hit a token ceiling. */ + LENGTH, + /** The model is asking for one or more tools to be executed. */ + TOOL_CALLS, + /** The provider suppressed the response. */ + CONTENT_FILTER, + /** The exchange failed. Never serialized as a finish reason. */ + ERROR +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java new file mode 100644 index 000000000000..a24e642a6556 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java @@ -0,0 +1,61 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; + +/** + * A refusal, in the shape the REST layer serializes to the standard error object. + * + *

{@code httpStatus} is carried here but is deliberately not part of the + * serialized body. Retryability is conveyed by the HTTP status — 429 and 5xx — because that is + * what a standard client's back-off keys off; the standard error shape has no retryable field + * and inventing one would make the payload stop deserializing into a client library's own + * types.

+ * + * @param type the error family, e.g. {@code invalid_request_error} + * @param message a safe description; never the provider's raw envelope + * @param param the offending field where one can be named, otherwise null + * @param httpStatus the status to respond with; not serialized into the body + */ +public record InferenceError(String type, String message, String param, int httpStatus) + implements Serializable { + + public InferenceError { + if (type == null || type.isBlank()) { + throw new IllegalArgumentException("InferenceError type is required"); + } + if (message == null || message.isBlank()) { + throw new IllegalArgumentException("InferenceError message is required"); + } + } + + /** + * @param message what was wrong + * @param param the offending field, or null + * @return a 400 invalid-request error + */ + public static InferenceError invalidRequest(final String message, final String param) { + return new InferenceError("invalid_request_error", message, param, 400); + } + + /** + * @param model the model the caller asked for + * @return a 404 error in the shape clients recognise as an unknown model + */ + public static InferenceError noSuchModel(final String model) { + return new InferenceError("invalid_request_error", + "The model '" + model + "' is not configured for this site", "model", 404); + } + + /** + * @param message what failed upstream + * @return a 502 error flagged retryable by its status + */ + public static InferenceError upstream(final String message) { + return new InferenceError("api_error", message, null, 502); + } + + /** @return whether a standard client should retry, as implied by the status */ + public boolean isRetryable() { + return httpStatus == 429 || httpStatus >= 500; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java new file mode 100644 index 000000000000..4b7da1ec06e6 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java @@ -0,0 +1,55 @@ +package com.dotcms.inference.model; + +import com.dotmarketing.util.Config; + +/** + * The capacity ceilings this endpoint family enforces. + * + *

All three are configurable and all three have defaults, because the risk they bound is real + * but its right size is deployment-specific. Streaming is the reason they exist: a streamed + * completion parks a request thread for the whole generation rather than for one round trip, so + * concurrency and elapsed time are the scarce resources here, not request rate.

+ * + *

Read through {@link #current()} at the point of use rather than cached in a field, so an + * operator changing a property does not have to restart the node to see it take effect.

+ * + * @param maxConcurrentStreams streaming completions allowed at once on this node + * @param completionTimeoutSeconds hard ceiling on a single completion + * @param maxRequestBytes largest request body accepted + */ +public record InferenceLimits(int maxConcurrentStreams, + int completionTimeoutSeconds, + int maxRequestBytes) { + + /** Streaming completions allowed at once on this node. */ + public static final String MAX_CONCURRENT_STREAMS_KEY = "DOT_INFERENCE_MAX_CONCURRENT_STREAMS"; + /** Hard ceiling on a single completion, in seconds. */ + public static final String COMPLETION_TIMEOUT_SECONDS_KEY = "DOT_INFERENCE_COMPLETION_TIMEOUT_SECONDS"; + /** Largest request body accepted, in bytes. */ + public static final String MAX_REQUEST_BYTES_KEY = "DOT_INFERENCE_MAX_REQUEST_BYTES"; + + /** Each stream holds a request thread for the life of a completion. */ + public static final int DEFAULT_MAX_CONCURRENT_STREAMS = 50; + /** Five minutes; long enough for a slow reasoning model, short enough to bound a hung stream. */ + public static final int DEFAULT_COMPLETION_TIMEOUT_SECONDS = 300; + /** 1 MiB; holds a long multi-turn conversation with tool results, and bounds parse cost. */ + public static final int DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; + + /** + * @return the limits as currently configured on this node + */ + public static InferenceLimits current() { + return new InferenceLimits( + Config.getIntProperty(MAX_CONCURRENT_STREAMS_KEY, DEFAULT_MAX_CONCURRENT_STREAMS), + Config.getIntProperty(COMPLETION_TIMEOUT_SECONDS_KEY, DEFAULT_COMPLETION_TIMEOUT_SECONDS), + Config.getIntProperty(MAX_REQUEST_BYTES_KEY, DEFAULT_MAX_REQUEST_BYTES)); + } + + /** + * @param bytes the size of an incoming request body + * @return whether it exceeds {@link #maxRequestBytes()} + */ + public boolean exceedsMaxRequestBytes(final long bytes) { + return bytes > maxRequestBytes; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceMessage.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceMessage.java new file mode 100644 index 000000000000..a7559a99716d --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceMessage.java @@ -0,0 +1,86 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; +import java.util.List; + +/** + * One turn in an inference conversation. + * + *

An {@link Role#ASSISTANT} turn may carry {@code toolCalls} instead of {@code content} when + * the model is asking for tools to be executed. A {@link Role#TOOL} turn carries the result and + * must reference, through {@code toolCallId}, the identity of the call that requested it — + * correlation is by identity, never by position.

+ * + * @param role who authored the turn; required + * @param content the text of the turn; may be null on an assistant turn carrying only tool calls + * @param toolCalls tool calls requested by the model; empty unless the role is ASSISTANT + * @param toolCallId the call this turn answers; required when the role is TOOL + * @param name the tool that produced the result; optional, TOOL turns only + */ +public record InferenceMessage(Role role, + String content, + List toolCalls, + String toolCallId, + String name) implements Serializable { + + public InferenceMessage { + if (role == null) { + throw new IllegalArgumentException("InferenceMessage role is required"); + } + toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls); + if (role == Role.TOOL && (toolCallId == null || toolCallId.isBlank())) { + throw new IllegalArgumentException("A TOOL message requires the toolCallId it answers"); + } + if (role != Role.ASSISTANT && !toolCalls.isEmpty()) { + throw new IllegalArgumentException("Only an ASSISTANT message may carry tool calls"); + } + if (content == null && toolCalls.isEmpty()) { + throw new IllegalArgumentException( + "An InferenceMessage requires content unless it carries tool calls"); + } + } + + /** + * Creates a plain text turn. + * + * @param role who authored the turn + * @param content the text + * @return a message with no tool calls + */ + public static InferenceMessage of(final Role role, final String content) { + return new InferenceMessage(role, content, List.of(), null, null); + } + + /** + * Creates an assistant turn that asks for tools to be executed. + * + * @param content optional text accompanying the request + * @param toolCalls the calls the model is requesting + * @return an ASSISTANT message carrying tool calls + */ + public static InferenceMessage ofToolCalls(final String content, + final List toolCalls) { + return new InferenceMessage(Role.ASSISTANT, content, toolCalls, null, null); + } + + /** + * Creates the result of executing a tool. + * + * @param toolCallId the call being answered + * @param toolName the tool that ran + * @param content the result + * @return a TOOL message + */ + public static InferenceMessage ofToolResult(final String toolCallId, + final String toolName, + final String content) { + return new InferenceMessage(Role.TOOL, content, List.of(), toolCallId, toolName); + } + + /** + * @return whether this turn asks for one or more tools to be executed + */ + public boolean hasToolCalls() { + return !toolCalls.isEmpty(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceRequest.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceRequest.java new file mode 100644 index 000000000000..4b58d8498e6e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceRequest.java @@ -0,0 +1,166 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A completion request, in dotCMS's own terms. + * + *

This type is deliberately not a binding of any wire format. Parsing a client's payload into + * it, and serializing a response back out, are the REST layer's job; nothing here mirrors the + * structure of the JSON that arrives. The point is that tool-call identity, reasoning turns and + * message ordering survive in a form a second wire format could also be written from, instead of + * being flattened into whichever format happened to be implemented first.

+ * + *

It is {@link Serializable} so it can travel as the payload of an + * {@code AIRequest} down the existing dotAI client pipe, which is what keeps + * per-site provider caching and credential-rotation eviction working unchanged.

+ * + * @param model the model to use; required, with no implicit default + * @param messages the conversation, oldest first; at least one + * @param tools tools the model may call; possibly empty + * @param toolChoice the caller's tool preference, or null to leave it to the provider + * @param responseFormat the requested output shape, or null for the provider default + * @param stream whether to stream the answer + * @param includeUsageInStream whether to emit a usage event on a stream; only meaningful when streaming + * @param temperature sampling temperature, or null to leave it to the provider + * @param maxOutputTokens ceiling on generated tokens, or null + * @param topP nucleus sampling, or null + * @param stopSequences sequences that end generation; possibly empty + */ +public record InferenceRequest(String model, + List messages, + List tools, + ToolChoice toolChoice, + ResponseFormat responseFormat, + boolean stream, + boolean includeUsageInStream, + Double temperature, + Integer maxOutputTokens, + Double topP, + List stopSequences) implements Serializable { + + public InferenceRequest { + if (model == null || model.isBlank()) { + throw new IllegalArgumentException("InferenceRequest model is required"); + } + if (messages == null || messages.isEmpty()) { + throw new IllegalArgumentException("InferenceRequest requires at least one message"); + } + messages = List.copyOf(messages); + tools = tools == null ? List.of() : List.copyOf(tools); + stopSequences = stopSequences == null ? List.of() : List.copyOf(stopSequences); + requireToolResultsAreCorrelated(messages); + } + + /** + * Rejects a tool result that answers a call nobody made. + * + *

A provider given an orphaned tool turn fails in its own way, at its own layer, having + * already been paid for the round trip. Catching it here turns that into a validation error + * naming the offending identity.

+ * + * @param messages the conversation to check + */ + private static void requireToolResultsAreCorrelated(final List messages) { + final Set offered = new HashSet<>(); + for (final InferenceMessage message : messages) { + if (message.role() == Role.TOOL && !offered.contains(message.toolCallId())) { + throw new IllegalArgumentException( + "Tool result references '" + message.toolCallId() + + "', which no preceding assistant message requested"); + } + message.toolCalls().forEach(call -> offered.add(call.id())); + } + } + + /** @return whether the caller declared any tools */ + public boolean hasTools() { + return !tools.isEmpty(); + } + + /** + * @param model the model to use + * @return a builder seeded with the required model + */ + public static Builder builder(final String model) { + return new Builder(model); + } + + /** Assembles an {@link InferenceRequest}; validation happens on {@link #build()}. */ + public static final class Builder { + + private final String model; + private List messages = List.of(); + private List tools = List.of(); + private ToolChoice toolChoice; + private ResponseFormat responseFormat; + private boolean stream; + private boolean includeUsageInStream; + private Double temperature; + private Integer maxOutputTokens; + private Double topP; + private List stopSequences = List.of(); + + private Builder(final String model) { + this.model = model; + } + + public Builder messages(final List messages) { + this.messages = messages; + return this; + } + + public Builder tools(final List tools) { + this.tools = tools; + return this; + } + + public Builder toolChoice(final ToolChoice toolChoice) { + this.toolChoice = toolChoice; + return this; + } + + public Builder responseFormat(final ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public Builder stream(final boolean stream) { + this.stream = stream; + return this; + } + + public Builder includeUsageInStream(final boolean includeUsageInStream) { + this.includeUsageInStream = includeUsageInStream; + return this; + } + + public Builder temperature(final Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder maxOutputTokens(final Integer maxOutputTokens) { + this.maxOutputTokens = maxOutputTokens; + return this; + } + + public Builder topP(final Double topP) { + this.topP = topP; + return this; + } + + public Builder stopSequences(final List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + public InferenceRequest build() { + return new InferenceRequest(model, messages, tools, toolChoice, responseFormat, stream, + includeUsageInStream, temperature, maxOutputTokens, topP, stopSequences); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceResponse.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceResponse.java new file mode 100644 index 000000000000..0daf71cebadc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceResponse.java @@ -0,0 +1,42 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; + +/** + * A completed, non-streamed answer. + * + *

{@code model} is the model that actually served the request, which is not necessarily the + * one asked for: a site's fallback chain may have moved on to a later entry. Reporting the one + * that ran, rather than the one requested, is what lets a caller tell that a fallback + * happened.

+ * + * @param id identity for this response + * @param model the model that served it, after any fallback + * @param createdEpochSeconds when it was produced + * @param message the assistant turn; may carry tool calls instead of content + * @param finishReason why generation stopped + * @param usage tokens consumed, {@link InferenceUsage#UNREPORTED} if the provider was silent + */ +public record InferenceResponse(String id, + String model, + long createdEpochSeconds, + InferenceMessage message, + FinishReason finishReason, + InferenceUsage usage) implements Serializable { + + public InferenceResponse { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("InferenceResponse id is required"); + } + if (model == null || model.isBlank()) { + throw new IllegalArgumentException("InferenceResponse model is required"); + } + if (message == null) { + throw new IllegalArgumentException("InferenceResponse message is required"); + } + if (finishReason == null) { + throw new IllegalArgumentException("InferenceResponse finishReason is required"); + } + usage = usage == null ? InferenceUsage.UNREPORTED : usage; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceStreamEvent.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceStreamEvent.java new file mode 100644 index 000000000000..c08b0929ecd5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceStreamEvent.java @@ -0,0 +1,118 @@ +package com.dotcms.inference.model; + +import java.util.List; + +/** + * One event in a streamed completion. + * + *

Sealed so the serializer is a total function: every variant must be handled, and adding a + * sixth cannot silently fall through to a default branch. That matters most for + * {@link Error @Error} — the difference between a stream that failed and a stream that finished + * is carried entirely by which variant ends it, and by the serializer refusing to write the + * terminal done marker after an error.

+ */ +public sealed interface InferenceStreamEvent + permits InferenceStreamEvent.ContentDelta, + InferenceStreamEvent.ToolCallDelta, + InferenceStreamEvent.Finish, + InferenceStreamEvent.Usage, + InferenceStreamEvent.Error { + + /** + * A fragment of the answer's text. + * + * @param text the fragment, as produced + */ + record ContentDelta(String text) implements InferenceStreamEvent { + public ContentDelta { + text = text == null ? "" : text; + } + } + + /** + * A fragment of one tool call. + * + *

{@code id} and {@code name} arrive on the first fragment of a call and are absent + * afterwards; {@code partialArguments} accumulates across fragments. {@code index} identifies + * which call in the turn a fragment belongs to, and is the only thing that does so on + * continuation fragments.

+ * + * @param index which call in the turn this fragment belongs to + * @param id the call's identity, present on its first fragment only + * @param name the tool's name, present on its first fragment only + * @param partialArguments the argument text carried by this fragment + */ + record ToolCallDelta(int index, String id, String name, String partialArguments) + implements InferenceStreamEvent { + public ToolCallDelta { + partialArguments = partialArguments == null ? "" : partialArguments; + } + } + + /** + * Generation stopped normally. + * + * @param reason why it stopped; never {@link FinishReason#ERROR} + */ + record Finish(FinishReason reason) implements InferenceStreamEvent { + public Finish { + if (reason == null) { + throw new IllegalArgumentException("Finish reason is required"); + } + if (reason == FinishReason.ERROR) { + throw new IllegalArgumentException( + "A failed stream ends with an Error event, not a Finish with reason ERROR"); + } + } + } + + /** + * Tokens consumed by the exchange. + * + *

Emitted only when the caller asked for it. The event it serializes to carries an empty + * choices array, which some stream readers do not tolerate, so sending it unasked would break + * clients this family exists to support.

+ * + * @param usage the counts + */ + record Usage(InferenceUsage usage) implements InferenceStreamEvent { + public Usage { + if (usage == null) { + throw new IllegalArgumentException("Usage event requires counts"); + } + } + } + + /** + * The stream failed after it had begun. + * + *

Once the first event is written the HTTP status is already sent and cannot carry the + * failure, so this variant is the only way a caller learns the answer is not complete.

+ * + * @param error what went wrong + */ + record Error(InferenceError error) implements InferenceStreamEvent { + public Error { + if (error == null) { + throw new IllegalArgumentException("Error event requires an error"); + } + } + } + + /** + * Reassembles the complete arguments of one tool call from its fragments, in arrival order. + * + * @param events the events seen so far + * @param index the call to reassemble + * @return the concatenated argument text, empty if no fragment matched + */ + static String reassembleArguments(final List events, final int index) { + final StringBuilder arguments = new StringBuilder(); + for (final InferenceStreamEvent event : events) { + if (event instanceof ToolCallDelta delta && delta.index() == index) { + arguments.append(delta.partialArguments()); + } + } + return arguments.toString(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolCall.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolCall.java new file mode 100644 index 000000000000..b99648569d90 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolCall.java @@ -0,0 +1,44 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; + +/** + * A request from the model to execute one declared tool. + * + *

This type is the reason the internal representation exists. Identity is carried in + * {@code id}, which comes from the provider and is what a later {@link Role#TOOL} turn + * correlates against. It is never derived from {@code index}: the chat-completions streaming + * format happens to fragment tool calls and key the fragments by position, but that is a + * property of one serialization, and rebuilding identity from it would bake that format into + * the model. {@code index} is retained only so a serializer can reproduce it.

+ * + * @param id provider-assigned identity; required + * @param name the tool to execute; required + * @param arguments the model's arguments as JSON text, passed through unparsed + * @param index position within the assistant turn; serialization detail only + */ +public record InferenceToolCall(String id, String name, String arguments, int index) + implements Serializable { + + public InferenceToolCall { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("InferenceToolCall id is required"); + } + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("InferenceToolCall name is required"); + } + arguments = arguments == null ? "" : arguments; + } + + /** + * Creates a tool call at the head position, for callers that do not track ordering. + * + * @param id provider-assigned identity + * @param name the tool to execute + * @param arguments the model's arguments as JSON text + * @return a tool call at index {@code 0} + */ + public static InferenceToolCall of(final String id, final String name, final String arguments) { + return new InferenceToolCall(id, name, arguments, 0); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolSpec.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolSpec.java new file mode 100644 index 000000000000..23da66d716a8 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceToolSpec.java @@ -0,0 +1,31 @@ +package com.dotcms.inference.model; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.Serializable; + +/** + * A tool the caller declares the model may ask to execute. + * + *

{@code parameters} is a JSON Schema document describing the tool's arguments. It is held as + * a JSON tree rather than a bound type because dotCMS neither interprets nor validates it — the + * schema is the caller's contract with their own tool, and it is passed to the provider as + * given.

+ * + * @param name the tool's name, as the model will refer to it; required + * @param description what the tool does; optional but strongly advised, the model reads it + * @param parameters JSON Schema for the arguments; required + */ +public record InferenceToolSpec(String name, String description, JsonNode parameters) + implements Serializable { + + public InferenceToolSpec { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("InferenceToolSpec name is required"); + } + if (parameters == null) { + throw new IllegalArgumentException( + "InferenceToolSpec parameters is required; use an empty object schema for a tool taking none"); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceUsage.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceUsage.java new file mode 100644 index 000000000000..87806a8ae6f3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceUsage.java @@ -0,0 +1,26 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; + +/** + * Tokens consumed by one exchange. + * + *

Every field is nullable because not every provider reports usage. Absent counts are left + * absent and are never estimated — a fabricated number here would be indistinguishable from a + * real one to anyone reconciling spend.

+ * + * @param inputTokens tokens in the prompt, or null if unreported + * @param outputTokens tokens generated, or null if unreported + * @param totalTokens the sum as the provider reported it, or null + */ +public record InferenceUsage(Integer inputTokens, Integer outputTokens, Integer totalTokens) + implements Serializable { + + /** Usage the provider did not report. */ + public static final InferenceUsage UNREPORTED = new InferenceUsage(null, null, null); + + /** @return whether the provider reported any counts at all */ + public boolean isReported() { + return inputTokens != null || outputTokens != null || totalTokens != null; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/ResponseFormat.java b/dotCMS/src/main/java/com/dotcms/inference/model/ResponseFormat.java new file mode 100644 index 000000000000..c5f1286ef6b9 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/ResponseFormat.java @@ -0,0 +1,39 @@ +package com.dotcms.inference.model; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.Serializable; + +/** + * The shape the caller wants the model's answer in. + * + * @param type free text, an arbitrary JSON object, or JSON conforming to a schema; required + * @param schema the JSON Schema the answer must satisfy; required when the type is + * {@link Type#JSON_SCHEMA} and null otherwise + */ +public record ResponseFormat(Type type, JsonNode schema) implements Serializable { + + /** The kind of output requested. */ + public enum Type { + /** Ordinary prose. */ + TEXT, + /** Any well-formed JSON object. */ + JSON_OBJECT, + /** JSON conforming to a supplied schema. */ + JSON_SCHEMA + } + + public ResponseFormat { + if (type == null) { + throw new IllegalArgumentException("ResponseFormat type is required"); + } + if (type == Type.JSON_SCHEMA && schema == null) { + throw new IllegalArgumentException("ResponseFormat type JSON_SCHEMA requires a schema"); + } + } + + /** @return the default, free-text format */ + public static ResponseFormat text() { + return new ResponseFormat(Type.TEXT, null); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/Role.java b/dotCMS/src/main/java/com/dotcms/inference/model/Role.java new file mode 100644 index 000000000000..0f1c3481375e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/Role.java @@ -0,0 +1,20 @@ +package com.dotcms.inference.model; + +/** + * The author of a single turn in an inference conversation. + * + *

Deliberately free of any wire-format naming: the mapping between these constants and the + * strings a client sends lives in the REST layer, not here. See {@link InferenceRequest} for why + * the internal representation is kept independent of the format it is serialized to.

+ */ +public enum Role { + + /** Instruction supplied by the caller, ahead of the conversation. */ + SYSTEM, + /** A turn authored by the end user. */ + USER, + /** A turn authored by the model; may carry tool calls instead of content. */ + ASSISTANT, + /** The result of executing a tool the model asked for. */ + TOOL +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/ToolChoice.java b/dotCMS/src/main/java/com/dotcms/inference/model/ToolChoice.java new file mode 100644 index 000000000000..fdffdb7dbb5d --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/ToolChoice.java @@ -0,0 +1,49 @@ +package com.dotcms.inference.model; + +import java.io.Serializable; + +/** + * The caller's preference for whether, and which, tools the model should call. + * + * @param mode how freely the model may choose; required + * @param function the tool to force, required when and only when the mode is {@link Mode#FUNCTION} + */ +public record ToolChoice(Mode mode, String function) implements Serializable { + + /** How freely the model may pick a tool. */ + public enum Mode { + /** The model decides whether to call a tool. */ + AUTO, + /** The model must call some tool. */ + REQUIRED, + /** The model must not call a tool. */ + NONE, + /** The model must call the named tool. */ + FUNCTION + } + + public ToolChoice { + if (mode == null) { + throw new IllegalArgumentException("ToolChoice mode is required"); + } + if (mode == Mode.FUNCTION && (function == null || function.isBlank())) { + throw new IllegalArgumentException("ToolChoice mode FUNCTION requires a function name"); + } + if (mode != Mode.FUNCTION && function != null) { + throw new IllegalArgumentException("ToolChoice names a function only in FUNCTION mode"); + } + } + + /** @return a choice leaving the decision to the model */ + public static ToolChoice auto() { + return new ToolChoice(Mode.AUTO, null); + } + + /** + * @param function the tool the model must call + * @return a choice forcing one named tool + */ + public static ToolChoice function(final String function) { + return new ToolChoice(Mode.FUNCTION, function); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java new file mode 100644 index 000000000000..49a061fabd19 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java @@ -0,0 +1,527 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.client.langchain4j.InferenceAIClient; +import com.dotcms.ai.client.langchain4j.ProviderConfig; +import com.dotcms.ai.rest.AiHostResolver; +import com.dotcms.ai.rest.ResolvedAiContext; +import com.dotcms.cost.RequestCost; +import com.dotcms.cost.RequestPrices.Price; +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.model.InferenceRequest; +import com.dotcms.inference.model.InferenceResponse; +import com.dotcms.inference.model.InferenceStreamEvent; +import com.dotcms.inference.rest.mapper.ChatCompletionMapper; +import com.dotcms.inference.rest.mapper.SseSerializer; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.commons.lang3.StringUtils; +import org.glassfish.jersey.server.JSONP; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Serves {@code POST /api/inference/v1/chat/completions} — the chat-completions endpoint of the + * OpenAI-wire-format family. + * + *

Byte compatibility with the external standard is the whole point of this family, so nothing + * here is wrapped in the dotCMS {@code ResponseEntityView} envelope: a client library has to be + * able to deserialize both the answer and the refusal into its own types with no adapter. That is + * also why refusals are rendered as {@link InferenceErrorView} rather than left to dotCMS's + * generic exception mappers.

+ * + *

This class is deliberately thin. Every decision that could be made differently by a second + * wire format lives elsewhere — the vocabulary translation in {@link ChatCompletionMapper}, the + * frame shapes in {@link SseSerializer}, the provider exchange in {@link InferenceAIClient}, and + * site plus configuration resolution in {@link AiHostResolver}. What is left here, and only here, + * is the HTTP contract: who may call, which site pays, which model is allowed, how many streams a + * node will hold open at once, and how a failure becomes a status.

+ * + *

Two behaviours are worth calling out because getting either wrong is silent:

+ * + *
    + *
  • The resolved site id is published into the request as + * {@link InferenceRequestAttributes#RESOLVED_SITE_ID} the instant it is known, before anything + * that can fail. {@link ResolvedSiteHeaderFilter} reads it to report the serving site on + * every response, and a value only the happy path could set would not deliver that.
  • + *
  • A streamed answer closes with {@link SseSerializer#DONE_MARKER} only when + * {@link SseSerializer#shouldWriteDoneMarker(InferenceStreamEvent)} says so for the last event + * the stream produced. Once the first frame is written the HTTP status is already on the wire + * and can no longer carry a failure, so withholding the marker is the only thing that stops a + * client which does not parse the error frame from reading a truncated answer as a finished + * one.
  • + *
+ */ +@Path("/inference/v1/chat") +@Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +public class ChatCompletionsResource { + + /** Media type a streamed completion is served as. */ + private static final String EVENT_STREAM = "text/event-stream"; + + /** Section of the site's {@code providerConfig} JSON that configures chat. */ + private static final String CHAT_SECTION = "chat"; + + /** + * Header form of the site override. Server-side callers routinely sit behind a proxy that + * rewrites the Host header, and a header is the only override they can set without rewriting + * the URL a standard client library builds. It wins over the query parameter because it is + * the more specific of the two — a caller that sets both meant the one they had to go out of + * their way to add. + */ + private static final String SITE_HEADER = "X-dotCMS-Site"; + + /** Prefix the completion id carries, as standard clients expect. */ + private static final String COMPLETION_ID_PREFIX = "chatcmpl-"; + + /** What a caller is told when the provider failed; never the provider's own words. */ + private static final String UPSTREAM_FAILURE_MESSAGE = + "The model provider failed to complete the request"; + + /** Longest model name echoed back in a refusal, so a huge value cannot be reflected whole. */ + private static final int MAX_ECHOED_MODEL_LENGTH = 120; + + private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); + + /** + * Streams in flight on this node. A streamed completion parks a request thread for the whole + * generation rather than for one round trip, so concurrency — not request rate — is the scarce + * resource this family has to bound. Counted rather than held as a fixed-permit semaphore so + * that an operator raising {@link InferenceLimits#MAX_CONCURRENT_STREAMS_KEY} takes effect + * without a restart. + */ + private static final AtomicInteger ACTIVE_STREAMS = new AtomicInteger(); + + /** + * Runs one chat completion, streamed or whole. + * + * @param request the inbound request + * @param response the outbound response, used only by the authentication handshake + * @param siteId optional site id or host name whose dotAI configuration should serve the + * request; the {@code X-dotCMS-Site} header overrides it + * @param requestView the completion to run, in the standard wire shape + * @return the completed answer, a {@link StreamingOutput} of server-sent events when + * {@code stream} was asked for, or an {@link InferenceErrorView} refusal + */ + @Operation( + operationId = "createChatCompletion", + summary = "Create a chat completion", + description = "Runs one chat completion against the model the resolved site has " + + "configured, in the OpenAI-compatible request and response shape. Set " + + "\"stream\": true to receive the answer as server-sent events, each frame a " + + "chat.completion.chunk, closing with data: [DONE] — a stream that failed " + + "ends on an error frame and never carries that marker. The model field is " + + "required and must be one the site has configured; there is no implicit " + + "default. Every response reports the serving site in the " + + "X-dotCMS-Resolved-Site header." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "The completion, or the event stream when stream was requested", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ChatCompletionView.class))), + @ApiResponse(responseCode = "400", + description = "Malformed request, or one asking for something unsupported", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "403", + description = "Forbidden - the caller cannot read the requested site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "404", + description = "The requested model is not configured for the resolved site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "429", + description = "Too many streamed completions are already in flight on this node", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "502", + description = "The model provider failed to complete the request", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))) + }) + @POST + @JSONP + @NoCache + @InferenceEndpoint + @RequestCost(Price.HTTP_FETCH) + @Path("/completions") + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, EVENT_STREAM}) + public final Response completions(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @QueryParam("siteId") final String siteId, + @RequestBody(description = "The completion to run", + content = @Content(schema = @Schema( + implementation = ChatCompletionRequestView.class))) + final ChatCompletionRequestView requestView) { + + // Any authenticated user, backend or frontend; an anonymous caller is rejected here with a + // 401 the builder produces itself. + final User user = new WebResource.InitBuilder(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(true) + .init() + .getUser(); + + final ResolvedAiContext context; + try { + context = AiHostResolver.resolve(request, siteOverride(request, siteId), user); + } catch (final DotSecurityException e) { + Logger.error(this, "Caller cannot read the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(new InferenceError( + "invalid_request_error", "Access denied to the requested site", "siteId", 403)); + } catch (final IllegalArgumentException e) { + Logger.error(this, "Could not resolve the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(InferenceError.invalidRequest( + "The requested site could not be resolved", "siteId")); + } + + // Published before anything else can fail, so the response filter can name the serving + // site on refusals and on streams that broke after they began, not only on the happy path. + request.setAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID, context.servingSiteId()); + + final Response modelRefusal = refuseUnconfiguredModel(context, requestView); + if (modelRefusal != null) { + return modelRefusal; + } + + final InferenceRequest inferenceRequest; + try { + inferenceRequest = ChatCompletionMapper.toInferenceRequest(requestView); + } catch (final IllegalArgumentException e) { + // The mapper's messages are dotCMS's own and name the offending field, so they are + // safe to return verbatim; nothing from a provider has been read at this point. + return errorResponse(InferenceError.invalidRequest(e.getMessage(), null)); + } + + return inferenceRequest.stream() + ? streamCompletion(context, inferenceRequest) + : completeWhole(context, inferenceRequest); + } + + /** + * Runs a completion and returns the whole answer. + * + * @param context the caller, the serving site and its configuration + * @param inferenceRequest the completion to run + * @return the answer, or a refusal carrying a safe description of what failed + */ + private Response completeWhole(final ResolvedAiContext context, + final InferenceRequest inferenceRequest) { + try { + final InferenceResponse inferenceResponse = + InferenceAIClient.get().complete(context.config(), inferenceRequest); + return Response.ok(ChatCompletionMapper.toView(inferenceResponse)) + .type(MediaType.APPLICATION_JSON) + .build(); + } catch (final RuntimeException e) { + // The provider's own message can carry its endpoint, its account identifiers and + // occasionally a fragment of the prompt, so it is logged and never returned. + Logger.error(this, "Chat completion failed for site " + + AiHostResolver.sanitize(context.servingSiteId()), e); + return errorResponse(InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE)); + } + } + + /** + * Returns the answer as a stream of server-sent events. + * + *

The entity is a {@link StreamingOutput} rather than an already-written body: the frames + * have to reach the caller as the model produces them, which is the only thing that makes + * streaming worth its cost in held threads. That held thread is also why the concurrency + * ceiling is claimed here, before the response is returned — once the container starts writing + * the body, a refusal can no longer be expressed as a status.

+ * + * @param context the caller, the serving site and its configuration + * @param inferenceRequest the completion to run + * @return the event stream, or a 429 refusal when the node is already at its ceiling + */ + private Response streamCompletion(final ResolvedAiContext context, + final InferenceRequest inferenceRequest) { + + final int maxConcurrentStreams = InferenceLimits.current().maxConcurrentStreams(); + if (ACTIVE_STREAMS.incrementAndGet() > maxConcurrentStreams) { + ACTIVE_STREAMS.decrementAndGet(); + Logger.warn(this, "Refusing a streamed completion: the node is already serving its " + + "ceiling of " + maxConcurrentStreams + " concurrent streams"); + return errorResponse(new InferenceError( + "rate_limit_error", + "This node is already serving its ceiling of " + maxConcurrentStreams + + " concurrent streamed completions; retry shortly", + null, + 429)); + } + + // Fixed for the whole stream: a client correlates the chunks it stitches together by the + // completion id, so re-deriving any of these per event would break the correlation. + final String completionId = COMPLETION_ID_PREFIX + UUID.randomUUID(); + final long createdEpochSeconds = Instant.now().getEpochSecond(); + final String model = inferenceRequest.model(); + + final StreamingOutput streamingOutput = output -> { + final SseFrameWriter writer = + new SseFrameWriter(output, completionId, model, createdEpochSeconds); + try { + InferenceAIClient.get().stream(context.config(), inferenceRequest, writer); + writer.close(); + } finally { + // In a finally so a stream that failed, or a caller that walked away mid-answer, + // gives its slot back instead of leaking it for the life of the node. + ACTIVE_STREAMS.decrementAndGet(); + } + }; + + return Response.ok(streamingOutput).type(EVENT_STREAM).build(); + } + + /** + * Refuses a request whose model the resolved site has not configured. + * + *

Applied to every caller, administrators included. The check is not about privilege — it + * is what stops a site's credentials being spent on a model its owner never chose, and an + * administrator of one site is not thereby entitled to spend another site's budget on an + * arbitrary model name.

+ * + * @param context the caller, the serving site and its configuration + * @param requestView the inbound payload + * @return a refusal, or null when the requested model is configured + */ + private Response refuseUnconfiguredModel(final ResolvedAiContext context, + final ChatCompletionRequestView requestView) { + + final String requestedModel = requestView == null ? null : requestView.model(); + if (StringUtils.isBlank(requestedModel)) { + // Named here rather than left to the mapper so the refusal can point at the field; the + // mapper's contract is a message, and a caller sent back to guessing is a support call. + return errorResponse(InferenceError.invalidRequest( + "The model field is required; there is no implicit default model", "model")); + } + + final List configuredModels = configuredChatModels(context); + if (!configuredModels.contains(requestedModel.trim())) { + Logger.warn(this, "Site " + AiHostResolver.sanitize(context.servingSiteId()) + + " has no chat model matching the requested one"); + return errorResponse(InferenceError.noSuchModel(echoable(requestedModel))); + } + + return null; + } + + /** + * Reads the chat models the resolved site has configured. + * + *

A site with no usable chat configuration yields an empty list rather than an exception, + * which lands the caller on the same 404 as an unknown model. That is the honest answer: from + * where the caller stands, a model nobody configured and a model on a site nobody configured + * are the same absence, and distinguishing them would tell an unauthenticated-adjacent caller + * which sites have dotAI set up.

+ * + * @param context the caller, the serving site and its configuration + * @return the configured chat model names, in fallback order; empty when there are none + */ + private List configuredChatModels(final ResolvedAiContext context) { + + final String providerConfigJson = context.config().getProviderConfig(); + if (StringUtils.isBlank(providerConfigJson)) { + return List.of(); + } + + try { + final JsonNode section = MAPPER.readTree(providerConfigJson).get(CHAT_SECTION); + if (section == null || section.isNull()) { + return List.of(); + } + + final ProviderConfig chatConfig = MAPPER.treeToValue(section, ProviderConfig.class); + final List models = new ArrayList<>(chatConfig.allModels()); + if (models.isEmpty() && StringUtils.isNotBlank(chatConfig.deploymentName())) { + // Azure names the served model by its deployment, exactly as the chat client does + // when it builds its own fallback chain. + models.add(chatConfig.deploymentName()); + } + + return List.copyOf(models); + } catch (final Exception e) { + Logger.error(this, "Could not read the chat section of providerConfig for site " + + AiHostResolver.sanitize(context.servingSiteId()), e); + return List.of(); + } + } + + /** + * Picks the site override, preferring the header. + * + * @param request the inbound request + * @param siteId the {@code siteId} query parameter, possibly blank + * @return the override to resolve against, or null to resolve from the request as usual + */ + private static String siteOverride(final HttpServletRequest request, final String siteId) { + final String header = request.getHeader(SITE_HEADER); + return StringUtils.isNotBlank(header) ? header : siteId; + } + + /** + * @param model the model name the caller asked for + * @return a bounded, single-line version safe to repeat back in a refusal + */ + private static String echoable(final String model) { + final String sanitized = AiHostResolver.sanitize(model); + return sanitized.length() > MAX_ECHOED_MODEL_LENGTH + ? sanitized.substring(0, MAX_ECHOED_MODEL_LENGTH) + : sanitized; + } + + /** + * @param error the refusal + * @return the refusal as a response, in the standard error shape + */ + private static Response errorResponse(final InferenceError error) { + return Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + /** + * Writes stream events out as server-sent event frames, and decides how the stream ends. + * + *

Kept as a type rather than a lambda because ending the stream correctly needs two facts + * the writing itself produces: which event came last, and whether the connection is still + * usable. Both are written on the provider's threads and read on the request thread once + * {@link InferenceAIClient#stream} has returned, so both are volatile.

+ */ + private static final class SseFrameWriter implements Consumer { + + private final OutputStream output; + private final String completionId; + private final String model; + private final long createdEpochSeconds; + + private volatile InferenceStreamEvent lastEvent; + private volatile boolean broken; + + private SseFrameWriter(final OutputStream output, + final String completionId, + final String model, + final long createdEpochSeconds) { + this.output = output; + this.completionId = completionId; + this.model = model; + this.createdEpochSeconds = createdEpochSeconds; + } + + /** + * Writes one event out. + * + * @param event the event the provider produced + */ + @Override + public void accept(final InferenceStreamEvent event) { + this.lastEvent = event; + write(SseSerializer.toFrame(event, completionId, model, createdEpochSeconds)); + } + + /** + * Closes the stream the way its last event dictates. + * + *

The terminal marker follows a stream that ended normally and is withheld from one + * that did not — that withholding is the entire failure signal a streamed answer has left + * once its status line is gone. A stream that produced nothing at all gets an error frame + * of its own, for the same reason: silence and success look identical to a reader.

+ */ + private void close() { + final InferenceStreamEvent last = this.lastEvent; + + if (last == null) { + writeQuietly(SseSerializer.toFrame( + new InferenceStreamEvent.Error(InferenceError.upstream( + "The model provider produced no answer")), + completionId, + model, + createdEpochSeconds)); + return; + } + + if (SseSerializer.shouldWriteDoneMarker(last)) { + writeQuietly(SseSerializer.DONE_MARKER); + } + } + + /** + * @param frame the frame to write + * @throws UncheckedIOException when the caller has gone away, which ends the stream: the + * client is the only channel a streamed answer has, so there + * is nothing left to do but stop + */ + private void write(final String frame) { + if (broken) { + return; + } + try { + output.write(frame.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } catch (final IOException e) { + broken = true; + throw new UncheckedIOException(e); + } + } + + /** + * Writes a closing frame, tolerating a connection that has already gone. + * + * @param frame the frame to write + */ + private void writeQuietly(final String frame) { + try { + write(frame); + } catch (final UncheckedIOException e) { + Logger.warn(ChatCompletionsResource.class, + "Could not close the inference stream; the caller has gone away"); + } + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceEndpoint.java b/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceEndpoint.java new file mode 100644 index 000000000000..fcf26c54e2e5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceEndpoint.java @@ -0,0 +1,18 @@ +package com.dotcms.inference.rest; + +import javax.ws.rs.NameBinding; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Marks a resource or method as part of the {@code /api/inference/v1} family. + * + *

Binds the family's request and response filters to these endpoints alone. Without the + * binding a {@code @Provider} filter applies to every JAX-RS response in dotCMS, which is + * emphatically not wanted: the size ceiling and the resolved-site header are properties of this + * family, not of the product.

+ */ +@NameBinding +@Retention(RetentionPolicy.RUNTIME) +public @interface InferenceEndpoint { +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java b/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java new file mode 100644 index 000000000000..319b5facc87b --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java @@ -0,0 +1,18 @@ +package com.dotcms.inference.rest; + +/** + * Request-scoped attribute keys shared between the resources and the family's filters. + * + *

The resolved site is published here rather than returned, so the response filter can report + * it even on a response the resource never produced — an exception mapped to a 4xx, or a stream + * that failed after it began. FR-020 asks for the serving site on every response, and a + * value only the happy path can set would not deliver that.

+ */ +public final class InferenceRequestAttributes { + + /** Identifier of the site whose configuration served the request. */ + public static final String RESOLVED_SITE_ID = "com.dotcms.inference.resolvedSiteId"; + + private InferenceRequestAttributes() { + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/RequestSizeLimitFilter.java b/dotCMS/src/main/java/com/dotcms/inference/rest/RequestSizeLimitFilter.java new file mode 100644 index 000000000000..19f7f97dafa4 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/RequestSizeLimitFilter.java @@ -0,0 +1,51 @@ +package com.dotcms.inference.rest; + +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.rest.view.InferenceErrorView; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; +import java.io.IOException; + +/** + * Refuses a request body larger than the configured ceiling, before it is read. + * + *

Removing the legacy 4096-character prompt cap was right — it was a character count standing + * in for a model's context window, which is the wrong control in the wrong place. But it left + * request size unbounded, and dotCMS parses and forwards a payload before any provider gets the + * chance to reject it, so the memory cost lands on this node and the token cost on the site's + * bill first.

+ * + *

Rejecting here, with a typed error naming the limit, rather than leaving it to the servlet + * container: a container-level rejection is invisible to the caller's client library and varies + * per deployment.

+ */ +@Provider +@InferenceEndpoint +public class RequestSizeLimitFilter implements ContainerRequestFilter { + + @Override + public void filter(final ContainerRequestContext requestContext) throws IOException { + final int declaredLength = requestContext.getLength(); + if (declaredLength < 0) { + // Chunked or unknown length; the resource enforces the ceiling as it reads. + return; + } + final InferenceLimits limits = InferenceLimits.current(); + if (limits.exceedsMaxRequestBytes(declaredLength)) { + final InferenceError error = new InferenceError("invalid_request_error", + "Request body of " + declaredLength + " bytes exceeds the maximum of " + + limits.maxRequestBytes() + " bytes", null, + Response.Status.REQUEST_ENTITY_TOO_LARGE.getStatusCode()); + requestContext.abortWith( + Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .build()); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ResolvedSiteHeaderFilter.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ResolvedSiteHeaderFilter.java new file mode 100644 index 000000000000..32d2b02ba1de --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ResolvedSiteHeaderFilter.java @@ -0,0 +1,48 @@ +package com.dotcms.inference.rest; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.core.Context; +import javax.ws.rs.ext.Provider; +import java.io.IOException; + +/** + * Reports which site's configuration served the request, on every response. + * + *

This is the answer to the one real risk in keeping standard site resolution: the fallbacks + * are convenient and correct, but they make it possible for a request to be served by a site it + * never named, and nobody could tell. A header answers that for every call — including the ones + * that resolved perfectly, which is more than an error on the unmatched case would have done, + * since it also surfaces a host that matched the wrong site through an alias + * collision.

+ * + *

A header rather than a body field on purpose. The payloads of this family have to + * deserialize into a standard client library's own result types with no adapter, and an extra + * top-level field risks strict deserializers; a header is invisible to them, is identical for + * streamed and non-streamed responses, survives on errors where a body field could not, and is + * readable by the proxy or log pipeline that would actually do the spend reconciliation.

+ */ +@Provider +@InferenceEndpoint +public class ResolvedSiteHeaderFilter implements ContainerResponseFilter { + + /** Names the site whose dotAI configuration served the request. */ + public static final String RESOLVED_SITE_HEADER = "X-dotCMS-Resolved-Site"; + + @Context + private HttpServletRequest request; + + @Override + public void filter(final ContainerRequestContext requestContext, + final ContainerResponseContext responseContext) throws IOException { + if (request == null) { + return; + } + final Object siteId = request.getAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID); + if (siteId instanceof String resolved && !resolved.isBlank()) { + responseContext.getHeaders().putSingle(RESOLVED_SITE_HEADER, resolved); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/mapper/ChatCompletionMapper.java b/dotCMS/src/main/java/com/dotcms/inference/rest/mapper/ChatCompletionMapper.java new file mode 100644 index 000000000000..55ee4df8d637 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/mapper/ChatCompletionMapper.java @@ -0,0 +1,397 @@ +package com.dotcms.inference.rest.mapper; + +import com.dotcms.inference.model.FinishReason; +import com.dotcms.inference.model.InferenceMessage; +import com.dotcms.inference.model.InferenceRequest; +import com.dotcms.inference.model.InferenceResponse; +import com.dotcms.inference.model.InferenceToolCall; +import com.dotcms.inference.model.InferenceToolSpec; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.model.ResponseFormat; +import com.dotcms.inference.model.Role; +import com.dotcms.inference.model.ToolChoice; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Translates between the OpenAI-compatible chat-completions wire shape and dotCMS's + * provider-neutral inference model. + * + *

The wire shape and {@link InferenceRequest} are kept deliberately independent of each other, + * so this class is the single place where the two vocabularies meet. Everything that identifies a + * turn — a tool call's provider-assigned id, the unparsed JSON text of its arguments, the JSON + * Schema of a declared tool — is carried across verbatim: re-deriving any of it (from position, + * say) would bake one serialization into the model and break the moment a second wire format is + * written from the same internal request.

+ * + *

Validation policy is asymmetric on purpose. A field this family has no opinion about is + * ignored, because rejecting a client's default payload over a field that changes nothing is + * pure friction. A field that changes what the caller gets or pays for — {@code n} above all — is + * rejected loudly instead, since a request quietly served as something other than what was asked + * is worse than one that failed. Every validation error names the offending field so the caller + * is not sent back to guessing.

+ * + *

Stateless and thread-safe; it holds nothing.

+ */ +public final class ChatCompletionMapper { + + private static final String FUNCTION_TYPE = "function"; + + /** Static utility; never instantiated. */ + private ChatCompletionMapper() { + throw new AssertionError("ChatCompletionMapper is a static utility"); + } + + /** + * Binds an inbound chat-completions payload to the internal request the rest of the pipeline + * speaks. + * + *

Wire-level validation happens here rather than deeper in, so a malformed request fails + * before any provider is chosen and before anybody is billed for a round trip. Correlation of + * tool results against the calls that requested them is deliberately not repeated: + * {@link InferenceRequest}'s compact constructor already enforces it, and duplicating the rule + * would mean two places to keep in step.

+ * + * @param view the bound wire payload + * @return the equivalent internal request + * @throws IllegalArgumentException when the payload is missing a required field or asks for + * something this family cannot serve, the message naming the + * offending field + */ + public static InferenceRequest toInferenceRequest(final ChatCompletionRequestView view) { + + if (view == null) { + throw new IllegalArgumentException("A chat completion request body is required"); + } + if (view.model() == null || view.model().isBlank()) { + throw new IllegalArgumentException( + "The model field is required; there is no implicit default model"); + } + if (view.messages() == null || view.messages().isEmpty()) { + throw new IllegalArgumentException( + "The messages field requires at least one message to infer from"); + } + if (view.n() != null && view.n() > 1) { + throw new IllegalArgumentException( + "The field n is not supported; exactly one choice is returned, so n was " + + "rejected rather than silently ignored"); + } + + return InferenceRequest.builder(view.model()) + .messages(toInferenceMessages(view.messages())) + .tools(toToolSpecs(view.tools())) + .toolChoice(toToolChoice(view.toolChoice())) + .responseFormat(toResponseFormat(view.responseFormat())) + .stream(Boolean.TRUE.equals(view.stream())) + .includeUsageInStream(view.streamOptions() != null + && Boolean.TRUE.equals(view.streamOptions().includeUsage())) + .temperature(view.temperature()) + .maxOutputTokens(view.maxTokens()) + .topP(view.topP()) + .stopSequences(view.stop()) + .build(); + } + + /** + * Renders a completed answer in the wire shape standard clients deserialize. + * + *

Two details here are the point of the method. The model reported is the one that actually + * served the request, not the one asked for, which is the only signal a caller has that a + * site's fallback chain moved on. And usage the provider never reported is left absent rather + * than rendered as zeros — a fabricated count is indistinguishable from a real one to anyone + * reconciling spend.

+ * + * @param response the completed internal response + * @return the wire view of that response + * @throws IllegalArgumentException when there is no response to render + */ + public static ChatCompletionView toView(final InferenceResponse response) { + + if (response == null) { + throw new IllegalArgumentException("An inference response is required to render a view"); + } + + final InferenceMessage message = response.message(); + final ChatCompletionView.MessageView messageView = new ChatCompletionView.MessageView( + wireRole(message.role()), + message.content(), + toToolCallViews(message.toolCalls())); + + final ChatCompletionView.ChoiceView choice = new ChatCompletionView.ChoiceView( + 0, messageView, wireFinishReason(response.finishReason())); + + return new ChatCompletionView( + response.id(), + ChatCompletionView.OBJECT, + response.createdEpochSeconds(), + response.model(), + List.of(choice), + toUsageView(response.usage())); + } + + // --------------------------------------------------------------------- + // Inbound + // --------------------------------------------------------------------- + + /** + * Maps the conversation, preserving turn order. + * + * @param messageViews the wire turns, oldest first + * @return the internal turns, in the same order + */ + private static List toInferenceMessages( + final List messageViews) { + + final List messages = new ArrayList<>(messageViews.size()); + for (final ChatCompletionRequestView.MessageView messageView : messageViews) { + messages.add(new InferenceMessage( + toRole(messageView.role()), + messageView.content(), + toToolCalls(messageView.toolCalls()), + messageView.toolCallId(), + messageView.name())); + } + + return messages; + } + + /** + * @param role the wire role name + * @return the matching constant + * @throws IllegalArgumentException when the role is missing or is not one this format defines + */ + private static Role toRole(final String role) { + + if (role == null || role.isBlank()) { + throw new IllegalArgumentException("Each message requires a role"); + } + + switch (role.trim().toLowerCase(Locale.ROOT)) { + case "system": + return Role.SYSTEM; + case "user": + return Role.USER; + case "assistant": + return Role.ASSISTANT; + case "tool": + return Role.TOOL; + default: + throw new IllegalArgumentException("Unsupported message role '" + role + "'"); + } + } + + /** + * Maps replayed tool calls, keeping the provider-assigned id and the arguments exactly as the + * model produced them. The index is the position within the turn, retained only so a + * serializer can reproduce it; identity is never derived from it. + * + * @param toolCallViews the wire tool calls, possibly null + * @return the internal tool calls; empty when none were sent + */ + private static List toToolCalls( + final List toolCallViews) { + + if (toolCallViews == null || toolCallViews.isEmpty()) { + return List.of(); + } + + final List toolCalls = new ArrayList<>(toolCallViews.size()); + for (int index = 0; index < toolCallViews.size(); index++) { + final ChatCompletionRequestView.ToolCallView toolCallView = toolCallViews.get(index); + final ChatCompletionRequestView.FunctionCallView function = toolCallView.function(); + if (function == null) { + throw new IllegalArgumentException( + "Tool call '" + toolCallView.id() + "' carries no function to call"); + } + toolCalls.add(new InferenceToolCall( + toolCallView.id(), function.name(), function.arguments(), index)); + } + + return toolCalls; + } + + /** + * Maps tool declarations, passing the JSON Schema through as a tree — dotCMS neither + * interprets nor validates it, it is the caller's contract with their own tool. + * + * @param toolViews the declared tools, possibly null + * @return the internal specs; empty when none were declared + */ + private static List toToolSpecs( + final List toolViews) { + + if (toolViews == null || toolViews.isEmpty()) { + return List.of(); + } + + final List specs = new ArrayList<>(toolViews.size()); + for (final ChatCompletionRequestView.ToolView toolView : toolViews) { + final ChatCompletionRequestView.FunctionDefView function = toolView.function(); + if (function == null) { + throw new IllegalArgumentException("Each entry in tools requires a function"); + } + specs.add(new InferenceToolSpec( + function.name(), function.description(), function.parameters())); + } + + return specs; + } + + /** + * Reads the {@code tool_choice} field, which the format spells either as one of the strings + * {@code auto} / {@code required} / {@code none} or as an object naming one function. + * + * @param node the raw field, possibly null + * @return the caller's preference, or null to leave the decision to the provider + * @throws IllegalArgumentException when the field is present but is neither of those shapes + */ + private static ToolChoice toToolChoice(final JsonNode node) { + + if (node == null || node.isNull() || node.isMissingNode()) { + return null; + } + + if (node.isTextual()) { + switch (node.asText().trim().toLowerCase(Locale.ROOT)) { + case "auto": + return ToolChoice.auto(); + case "required": + return new ToolChoice(ToolChoice.Mode.REQUIRED, null); + case "none": + return new ToolChoice(ToolChoice.Mode.NONE, null); + default: + throw new IllegalArgumentException( + "Unsupported tool_choice '" + node.asText() + "'"); + } + } + + if (node.isObject()) { + final JsonNode name = node.path(FUNCTION_TYPE).path("name"); + if (!name.isTextual() || name.asText().isBlank()) { + throw new IllegalArgumentException( + "An object tool_choice must name the function to call"); + } + return ToolChoice.function(name.asText()); + } + + throw new IllegalArgumentException( + "tool_choice must be a string or an object naming a function"); + } + + /** + * Reads the {@code response_format} field. + * + * @param node the raw field, possibly null + * @return the requested output shape, or null for the provider default + * @throws IllegalArgumentException when the field is present but names no supported type + */ + private static ResponseFormat toResponseFormat(final JsonNode node) { + + if (node == null || node.isNull() || node.isMissingNode()) { + return null; + } + + final JsonNode type = node.path("type"); + if (!type.isTextual()) { + throw new IllegalArgumentException("response_format must name a type"); + } + + switch (type.asText().trim().toLowerCase(Locale.ROOT)) { + case "text": + return ResponseFormat.text(); + case "json_object": + return new ResponseFormat(ResponseFormat.Type.JSON_OBJECT, null); + case "json_schema": + final JsonNode schema = node.path("json_schema").path("schema"); + if (schema.isMissingNode() || schema.isNull()) { + throw new IllegalArgumentException( + "A json_schema response_format requires json_schema.schema"); + } + return new ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, schema); + default: + throw new IllegalArgumentException( + "Unsupported response_format type '" + type.asText() + "'"); + } + } + + // --------------------------------------------------------------------- + // Outbound + // --------------------------------------------------------------------- + + /** + * @param role the internal author of the turn + * @return the wire spelling of that role + */ + private static String wireRole(final Role role) { + return role.name().toLowerCase(Locale.ROOT); + } + + /** + * Renders why generation stopped. + * + *

{@link FinishReason#ERROR} has no equivalent in this format — a failed exchange is an + * error event, not a finish reason — so it renders as absent rather than as an invented + * reason a client would read as a successful stop.

+ * + * @param finishReason the internal reason + * @return the wire string, or null when the reason has no wire equivalent + */ + private static String wireFinishReason(final FinishReason finishReason) { + + switch (finishReason) { + case STOP: + return "stop"; + case LENGTH: + return "length"; + case TOOL_CALLS: + return "tool_calls"; + case CONTENT_FILTER: + return "content_filter"; + default: + return null; + } + } + + /** + * @param toolCalls the calls the model is asking for + * @return the wire tool calls, or null when there are none, so the field is omitted entirely + */ + private static List toToolCallViews( + final List toolCalls) { + + if (toolCalls == null || toolCalls.isEmpty()) { + return null; + } + + final List views = new ArrayList<>(toolCalls.size()); + for (final InferenceToolCall toolCall : toolCalls) { + views.add(new ChatCompletionView.ToolCallView( + toolCall.id(), + FUNCTION_TYPE, + new ChatCompletionView.FunctionCallView( + toolCall.name(), toolCall.arguments()))); + } + + return views; + } + + /** + * @param usage the internal counts + * @return the wire usage, or null when the provider reported nothing — absent counts are never + * rendered as zeros + */ + private static ChatCompletionView.UsageView toUsageView(final InferenceUsage usage) { + + if (usage == null || !usage.isReported()) { + return null; + } + + return new ChatCompletionView.UsageView( + usage.inputTokens(), usage.outputTokens(), usage.totalTokens()); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/mapper/SseSerializer.java b/dotCMS/src/main/java/com/dotcms/inference/rest/mapper/SseSerializer.java new file mode 100644 index 000000000000..0c78e076b327 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/mapper/SseSerializer.java @@ -0,0 +1,324 @@ +package com.dotcms.inference.rest.mapper; + +import com.dotcms.inference.model.FinishReason; +import com.dotcms.inference.model.InferenceStreamEvent; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Renders internal {@link InferenceStreamEvent}s as the server-sent events a standard + * chat-completions client reads from {@code /api/inference/v1/chat/completions} when + * {@code "stream": true}. + * + *

Byte compatibility with the external standard is the whole point of this endpoint family, so + * every frame is {@code data: } + one compact JSON object + a blank line. The blank line is not + * decoration: an SSE reader only dispatches an event once it sees it, so a frame without it leaves + * the client blocked holding a chunk it already received.

+ * + *

The render is an exhaustive switch over the sealed {@link InferenceStreamEvent}. That is + * deliberate: a sixth variant added later must fail to compile here rather than silently fall + * through to a default branch and reach the wire as something a client cannot read.

+ * + *

The JSON is built with Jackson rather than concatenated, because the shapes nest and the + * values — answer text and tool-call argument fragments — are attacker-influenced strings that + * must be escaped correctly.

+ */ +public final class SseSerializer { + + /** + * The terminal marker, exactly as it goes on the wire. Clients detect end-of-stream by matching + * these bytes, so it is a constant rather than something assembled per call. + */ + public static final String DONE_MARKER = "data: [DONE]\n\n"; + + /** The {@code object} discriminator every streamed chunk carries. */ + public static final String CHUNK_OBJECT = "chat.completion.chunk"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String DATA_PREFIX = "data: "; + private static final String FRAME_TERMINATOR = "\n\n"; + private static final String FUNCTION_TYPE = "function"; + + private SseSerializer() { + throw new AssertionError("SseSerializer is a utility class and is not instantiable"); + } + + /** + * Renders one stream event as a complete server-sent event frame. + * + *

The completion id, model and creation time are repeated on every chunk of one completion + * and never change mid-stream: a client correlates the chunks it is stitching together by that + * id, so re-deriving them per event would break the correlation.

+ * + * @param event the event to render + * @param completionId the id shared by every chunk of this completion + * @param model the model that served the completion + * @param createdEpochSeconds when the completion was created, in epoch seconds + * @return the frame, {@code data: } prefix and blank-line terminator included + */ + public static String toFrame(final InferenceStreamEvent event, + final String completionId, + final String model, + final long createdEpochSeconds) { + + final ObjectNode payload = switch (event) { + + case InferenceStreamEvent.ContentDelta contentDelta -> + contentChunk(contentDelta, completionId, model, createdEpochSeconds); + + case InferenceStreamEvent.ToolCallDelta toolCallDelta -> + toolCallChunk(toolCallDelta, completionId, model, createdEpochSeconds); + + case InferenceStreamEvent.Finish finish -> + finishChunk(finish, completionId, model, createdEpochSeconds); + + case InferenceStreamEvent.Usage usage -> + usageChunk(usage, completionId, model, createdEpochSeconds); + + case InferenceStreamEvent.Error error -> errorPayload(error); + }; + + return DATA_PREFIX + payload.toString() + FRAME_TERMINATOR; + } + + /** + * Answers whether the terminal {@code [DONE]} marker may follow this event. + * + *

It may, except after {@link InferenceStreamEvent.Error}. Once the first frame is written + * the HTTP status is already on the wire and can no longer carry a failure, so withholding the + * marker is the only thing that stops a client which does not parse the error frame from + * reading a truncated answer as a complete one. Every other variant answers {@code true}; the + * question is only ever asked of the last event a stream produced.

+ * + * @param event the last event the stream produced + * @return whether to close the stream with {@link #DONE_MARKER} + */ + public static boolean shouldWriteDoneMarker(final InferenceStreamEvent event) { + + return switch (event) { + case InferenceStreamEvent.Error ignored -> false; + case InferenceStreamEvent.ContentDelta ignored -> true; + case InferenceStreamEvent.ToolCallDelta ignored -> true; + case InferenceStreamEvent.Finish ignored -> true; + case InferenceStreamEvent.Usage ignored -> true; + }; + } + + /** + * Builds a chunk carrying one fragment of the answer's text. + * + * @param contentDelta the fragment + * @param completionId the id shared by every chunk of this completion + * @param model the model that served the completion + * @param createdEpochSeconds when the completion was created, in epoch seconds + * @return the chunk + */ + private static ObjectNode contentChunk(final InferenceStreamEvent.ContentDelta contentDelta, + final String completionId, + final String model, + final long createdEpochSeconds) { + + final ObjectNode chunk = envelope(completionId, model, createdEpochSeconds); + final ObjectNode choice = firstChoiceOf(chunk); + + choice.putObject("delta").put("content", contentDelta.text()); + choice.putNull("finish_reason"); + + return chunk; + } + + /** + * Builds a chunk carrying one fragment of one tool call. + * + *

Only the first fragment of a call carries its {@code id}, its {@code type} and the tool's + * name; a continuation fragment emits neither key at all, not even as JSON + * null, because a null id reads to a client as a different call starting. {@code index} is the + * only thing tying a continuation fragment back to the call it belongs to.

+ * + * @param toolCallDelta the fragment + * @param completionId the id shared by every chunk of this completion + * @param model the model that served the completion + * @param createdEpochSeconds when the completion was created, in epoch seconds + * @return the chunk + */ + private static ObjectNode toolCallChunk(final InferenceStreamEvent.ToolCallDelta toolCallDelta, + final String completionId, + final String model, + final long createdEpochSeconds) { + + final ObjectNode chunk = envelope(completionId, model, createdEpochSeconds); + final ObjectNode choice = firstChoiceOf(chunk); + final ObjectNode toolCall = choice.putObject("delta").putArray("tool_calls").addObject(); + + final boolean firstFragment = toolCallDelta.id() != null || toolCallDelta.name() != null; + + toolCall.put("index", toolCallDelta.index()); + + if (toolCallDelta.id() != null) { + toolCall.put("id", toolCallDelta.id()); + } + if (firstFragment) { + toolCall.put("type", FUNCTION_TYPE); + } + + final ObjectNode function = toolCall.putObject(FUNCTION_TYPE); + + if (toolCallDelta.name() != null) { + function.put("name", toolCallDelta.name()); + } + function.put("arguments", toolCallDelta.partialArguments()); + + choice.putNull("finish_reason"); + + return chunk; + } + + /** + * Builds the chunk that announces the end of generation. + * + *

Its delta is empty: the finish chunk says why the answer stopped, it does not add to the + * answer. That distinction is what separates a completed answer from a truncated one.

+ * + * @param finish why generation stopped + * @param completionId the id shared by every chunk of this completion + * @param model the model that served the completion + * @param createdEpochSeconds when the completion was created, in epoch seconds + * @return the chunk + */ + private static ObjectNode finishChunk(final InferenceStreamEvent.Finish finish, + final String completionId, + final String model, + final long createdEpochSeconds) { + + final ObjectNode chunk = envelope(completionId, model, createdEpochSeconds); + final ObjectNode choice = firstChoiceOf(chunk); + + choice.putObject("delta"); + choice.put("finish_reason", wireFinishReason(finish.reason())); + + return chunk; + } + + /** + * Builds the usage chunk. + * + *

Its {@code choices} array is present and empty, which is the shape standard clients + * recognise as a usage-only chunk. Counts the provider did not report are left absent rather + * than zeroed — a fabricated count is indistinguishable from a real one to anyone reconciling + * spend.

+ * + * @param usageEvent the counts + * @param completionId the id shared by every chunk of this completion + * @param model the model that served the completion + * @param createdEpochSeconds when the completion was created, in epoch seconds + * @return the chunk + */ + private static ObjectNode usageChunk(final InferenceStreamEvent.Usage usageEvent, + final String completionId, + final String model, + final long createdEpochSeconds) { + + final ObjectNode chunk = envelope(completionId, model, createdEpochSeconds); + chunk.putArray("choices"); + + final InferenceUsage usage = usageEvent.usage(); + + if (!usage.isReported()) { + chunk.putNull("usage"); + return chunk; + } + + final ObjectNode rendered = chunk.putObject("usage"); + + if (usage.inputTokens() != null) { + rendered.put("prompt_tokens", usage.inputTokens().intValue()); + } + if (usage.outputTokens() != null) { + rendered.put("completion_tokens", usage.outputTokens().intValue()); + } + if (usage.totalTokens() != null) { + rendered.put("total_tokens", usage.totalTokens().intValue()); + } + + return chunk; + } + + /** + * Builds the payload of a failed stream's last frame. + * + *

It is the standard top-level error object and nothing else — no chunk envelope, because + * this frame is not a chunk of an answer. The conversion is + * {@link InferenceErrorView#of(com.dotcms.inference.model.InferenceError)}, the one place that + * owns the error's wire shape, so the streamed error stays byte-identical to the non-streamed + * one. That conversion is also what keeps the HTTP status out of the body: retryability rides + * on the response's status code, and the standard error shape has no field for it.

+ * + * @param error the failure + * @return the error payload + */ + private static ObjectNode errorPayload(final InferenceStreamEvent.Error error) { + + return OBJECT_MAPPER.valueToTree(InferenceErrorView.of(error.error())); + } + + /** + * Starts a chunk with the envelope every chunk carries. + * + * @param completionId the id shared by every chunk of this completion + * @param model the model that served the completion + * @param createdEpochSeconds when the completion was created, in epoch seconds + * @return the chunk, with no choices yet + */ + private static ObjectNode envelope(final String completionId, + final String model, + final long createdEpochSeconds) { + + final ObjectNode chunk = OBJECT_MAPPER.createObjectNode(); + + chunk.put("id", completionId); + chunk.put("object", CHUNK_OBJECT); + chunk.put("created", createdEpochSeconds); + chunk.put("model", model); + + return chunk; + } + + /** + * Adds the single choice a content, tool-call or finish chunk carries. + * + * @param chunk the chunk being built + * @return the choice at index 0 + */ + private static ObjectNode firstChoiceOf(final ObjectNode chunk) { + + final ObjectNode choice = chunk.putArray("choices").addObject(); + choice.put("index", 0); + + return choice; + } + + /** + * Maps an internal finish reason to its wire string. + * + * @param reason why generation stopped + * @return the wire string a standard client expects + * @throws IllegalArgumentException for {@link FinishReason#ERROR}, which has no wire + * equivalent: a failed stream ends with an error frame, not + * with a finish reason meaning "this failed" + */ + private static String wireFinishReason(final FinishReason reason) { + + return switch (reason) { + case STOP -> "stop"; + case LENGTH -> "length"; + case TOOL_CALLS -> "tool_calls"; + case CONTENT_FILTER -> "content_filter"; + case ERROR -> throw new IllegalArgumentException( + "FinishReason.ERROR has no wire equivalent; a failed stream ends with an " + + "error frame"); + }; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionRequestView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionRequestView.java new file mode 100644 index 000000000000..ad6ac3619580 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionRequestView.java @@ -0,0 +1,91 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * The inbound chat-completion request, in the wire shape clients send. + * + *

Unknown properties are ignored so a client's default payload does not fail on a field this + * family has no opinion about. That tolerance stops at fields which change what the caller gets + * or pays for — those are rejected by the mapper rather than silently dropped, because a request + * that quietly did something other than what was asked is worse than one that failed.

+ * + * @param model the model to use; required, no implicit default + * @param messages the conversation, oldest first + * @param tools tools the model may call + * @param toolChoice the caller's tool preference + * @param responseFormat the requested output shape + * @param stream whether to stream + * @param streamOptions streaming options, notably whether to include usage + * @param temperature sampling temperature, passed through + * @param maxTokens ceiling on generated tokens, passed through + * @param topP nucleus sampling, passed through + * @param stop stop sequences, passed through + * @param n number of choices; unsupported and rejected rather than ignored + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Chat completion request in the OpenAI-compatible shape") +public record ChatCompletionRequestView( + @JsonProperty("model") @Schema(description = "Model id, as listed by GET /models", example = "gpt-4o") String model, + @JsonProperty("messages") @Schema(description = "The conversation, oldest first") List messages, + @JsonProperty("tools") @Schema(description = "Tools the model may call") List tools, + @JsonProperty("tool_choice") @Schema(description = "Tool selection preference") JsonNode toolChoice, + @JsonProperty("response_format") @Schema(description = "Requested output format") JsonNode responseFormat, + @JsonProperty("stream") @Schema(description = "Stream the answer as server-sent events") Boolean stream, + @JsonProperty("stream_options") @Schema(description = "Streaming options") StreamOptionsView streamOptions, + @JsonProperty("temperature") @Schema(description = "Sampling temperature") Double temperature, + @JsonProperty("max_tokens") @Schema(description = "Maximum tokens to generate") Integer maxTokens, + @JsonProperty("top_p") @Schema(description = "Nucleus sampling") Double topP, + @JsonProperty("stop") @Schema(description = "Stop sequences") List stop, + @JsonProperty("n") @Schema(description = "Number of choices; not supported") Integer n) { + + /** One turn in the conversation. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record MessageView( + @JsonProperty("role") String role, + @JsonProperty("content") String content, + @JsonProperty("tool_calls") List toolCalls, + @JsonProperty("tool_call_id") String toolCallId, + @JsonProperty("name") String name) { + } + + /** A tool call the model previously asked for, replayed by the client. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ToolCallView( + @JsonProperty("id") String id, + @JsonProperty("type") String type, + @JsonProperty("function") FunctionCallView function) { + } + + /** The function a tool call names, with its arguments as JSON text. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record FunctionCallView( + @JsonProperty("name") String name, + @JsonProperty("arguments") String arguments) { + } + + /** A tool declaration. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ToolView( + @JsonProperty("type") String type, + @JsonProperty("function") FunctionDefView function) { + } + + /** A declared function's name, description and JSON Schema parameters. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record FunctionDefView( + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("parameters") JsonNode parameters) { + } + + /** Streaming options; {@code include_usage} is what gates the usage event. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record StreamOptionsView(@JsonProperty("include_usage") Boolean includeUsage) { + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionView.java new file mode 100644 index 000000000000..ea315c31cc86 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ChatCompletionView.java @@ -0,0 +1,98 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * A completed answer, in the wire shape clients deserialize. + * + *

Not wrapped in the dotCMS {@code ResponseEntityView} envelope. Byte compatibility with the + * external standard is the whole point of this endpoint family, and a wrapper would stop the + * payload deserializing into the result type a client library already has.

+ * + * @param id identity for this completion + * @param object always {@code chat.completion} + * @param created creation time, epoch seconds + * @param model the model that actually served the request, after any fallback + * @param choices the answers; always exactly one, as several are not supported + * @param usage tokens consumed, omitted when the provider did not report them + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Chat completion response in the OpenAI-compatible shape") +public record ChatCompletionView( + @JsonProperty("id") @Schema(description = "Completion id", example = "chatcmpl-8f3a") String id, + @JsonProperty("object") @Schema(description = "Object type", example = "chat.completion") String object, + @JsonProperty("created") @Schema(description = "Epoch seconds") long created, + @JsonProperty("model") @Schema(description = "Model that served the request", example = "gpt-4o") String model, + @JsonProperty("choices") @Schema(description = "The answers") List choices, + @JsonProperty("usage") @Schema(description = "Tokens consumed") UsageView usage) { + + /** Object type for a completed answer. */ + public static final String OBJECT = "chat.completion"; + + /** + * One answer. + * + * @param index position; always 0 + * @param message the assistant turn + * @param finishReason why generation stopped + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ChoiceView( + @JsonProperty("index") int index, + @JsonProperty("message") MessageView message, + @JsonProperty("finish_reason") String finishReason) { + } + + /** + * The assistant turn, carrying content or tool calls. + * + * @param role always {@code assistant} + * @param content the text, null when the model asked for tools instead + * @param toolCalls the tools the model wants executed + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record MessageView( + @JsonProperty("role") String role, + @JsonProperty("content") String content, + @JsonProperty("tool_calls") List toolCalls) { + } + + /** + * A tool the model is asking to run. + * + * @param id identity the client echoes back on the tool result + * @param type always {@code function} + * @param function the call itself + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ToolCallView( + @JsonProperty("id") String id, + @JsonProperty("type") String type, + @JsonProperty("function") FunctionCallView function) { + } + + /** + * @param name the tool to run + * @param arguments its arguments as JSON text + */ + public record FunctionCallView( + @JsonProperty("name") String name, + @JsonProperty("arguments") String arguments) { + } + + /** + * @param promptTokens tokens in the prompt + * @param completionTokens tokens generated + * @param totalTokens the sum + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UsageView( + @JsonProperty("prompt_tokens") Integer promptTokens, + @JsonProperty("completion_tokens") Integer completionTokens, + @JsonProperty("total_tokens") Integer totalTokens) { + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/InferenceErrorView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/InferenceErrorView.java new file mode 100644 index 000000000000..de43475314c5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/InferenceErrorView.java @@ -0,0 +1,52 @@ +package com.dotcms.inference.rest.view; + +import com.dotcms.inference.model.InferenceError; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * A refusal, in the standard error shape clients already parse. + * + *

Deliberately not wrapped in the dotCMS {@code ResponseEntityView} envelope. Byte + * compatibility with the external standard is the whole point of this endpoint family, and a + * dotCMS wrapper would stop the payload deserializing into the error types a client library + * already has.

+ * + *

Note what is absent: there is no retryable field. The standard error shape has none, and + * inventing one would break that same compatibility — retryability is carried by the HTTP status, + * which is what a standard client's back-off actually keys off.

+ */ +@Schema(description = "Error response in the OpenAI-compatible error shape") +public record InferenceErrorView(@JsonProperty("error") @Schema(description = "The error") Body error) { + + /** + * The error itself. + * + * @param message a safe description; never the upstream provider's raw envelope + * @param type the error family, e.g. {@code invalid_request_error} + * @param param the offending request field, when one can be named + * @param code a machine-readable code; null unless the provider supplied one + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "Error detail") + public record Body( + @JsonProperty("message") @Schema(description = "Human-readable description of the failure", + example = "The model 'gpt-4o' is not configured for this site") String message, + @JsonProperty("type") @Schema(description = "Error family", + example = "invalid_request_error") String type, + @JsonProperty("param") @Schema(description = "Offending request field, when applicable", + example = "model") String param, + @JsonProperty("code") @Schema(description = "Machine-readable code, when the provider supplies one") + String code) { + } + + /** + * @param error the internal error + * @return the same error in the wire shape, without its HTTP status, which the response carries + */ + public static InferenceErrorView of(final InferenceError error) { + return new InferenceErrorView( + new Body(error.message(), error.type(), error.param(), null)); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java index 70c5bae24121..4553cf063d8a 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java +++ b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java @@ -145,6 +145,7 @@ private void configureApplication() { "com.dotcms.contenttype.model.field", "com.dotcms.rendering.js", "com.dotcms.ai.rest", + "com.dotcms.inference.rest", "com.dotcms.auth.dotAuth.rest", "com.dotcms.health", "io.swagger.v3.jaxrs2")); diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index d15ba5814d33..5a1d9323ad93 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -1336,6 +1336,74 @@ paths: summary: Search content using a search query (POST) tags: - Elasticsearch Content Search + /inference/v1/chat/completions: + post: + description: "Runs one chat completion against the model the resolved site has\ + \ configured, in the OpenAI-compatible request and response shape. Set \"\ + stream\": true to receive the answer as server-sent events, each frame a chat.completion.chunk,\ + \ closing with data: [DONE] — a stream that failed ends on an error frame\ + \ and never carries that marker. The model field is required and must be one\ + \ the site has configured; there is no implicit default. Every response reports\ + \ the serving site in the X-dotCMS-Resolved-Site header." + operationId: createChatCompletion + parameters: + - in: query + name: siteId + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionRequestView" + description: The completion to run + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletionView" + description: "The completion, or the event stream when stream was requested" + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: "Malformed request, or one asking for something unsupported" + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Unauthorized - authentication required + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Forbidden - the caller cannot read the requested site + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The requested model is not configured for the resolved site + "429": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Too many streamed completions are already in flight on this + node + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The model provider failed to complete the request + summary: Create a chat completion + tags: + - AI /integrity/_fixconflictsfromremote: post: operationId: fixConflictsFromRemote @@ -26048,6 +26116,82 @@ components: type: string name: type: string + ChatCompletionRequestView: + type: object + description: Chat completion request in the OpenAI-compatible shape + properties: + max_tokens: + type: integer + format: int32 + description: Maximum tokens to generate + messages: + type: array + description: "The conversation, oldest first" + items: + $ref: "#/components/schemas/MessageView" + model: + type: string + description: "Model id, as listed by GET /models" + example: gpt-4o + "n": + type: integer + format: int32 + description: Number of choices; not supported + response_format: + $ref: "#/components/schemas/JsonNode" + stop: + type: array + description: Stop sequences + items: + type: string + description: Stop sequences + stream: + type: boolean + description: Stream the answer as server-sent events + stream_options: + $ref: "#/components/schemas/StreamOptionsView" + temperature: + type: number + format: double + description: Sampling temperature + tool_choice: + $ref: "#/components/schemas/JsonNode" + tools: + type: array + description: Tools the model may call + items: + $ref: "#/components/schemas/ToolView" + top_p: + type: number + format: double + description: Nucleus sampling + ChatCompletionView: + type: object + description: Chat completion response in the OpenAI-compatible shape + properties: + choices: + type: array + description: The answers + items: + $ref: "#/components/schemas/ChoiceView" + created: + type: integer + format: int64 + description: Epoch seconds + id: + type: string + description: Completion id + example: chatcmpl-8f3a + model: + type: string + description: Model that served the request + example: gpt-4o + object: + type: string + description: Object type + example: chat.completion + usage: + $ref: "#/components/schemas/UsageView" CheckboxField: type: object allOf: @@ -26112,6 +26256,17 @@ components: type: string variable: type: string + ChoiceView: + type: object + description: The answers + properties: + finish_reason: + type: string + index: + type: integer + format: int32 + message: + $ref: "#/components/schemas/MessageView" ColumnField: type: object allOf: @@ -29650,6 +29805,22 @@ components: $ref: "#/components/schemas/MultiPart" providers: type: object + FunctionCallView: + type: object + properties: + arguments: + type: string + name: + type: string + FunctionDefView: + type: object + properties: + description: + type: string + name: + type: string + parameters: + $ref: "#/components/schemas/JsonNode" GenerateBundleForm: type: object properties: @@ -30702,6 +30873,12 @@ components: empty: type: boolean uniqueItems: true + InferenceErrorView: + type: object + description: Error response in the OpenAI-compatible error shape + properties: + error: + $ref: "#/components/schemas/Body" JSONObject: type: object additionalProperties: @@ -31280,6 +31457,22 @@ components: properties: message: type: string + MessageView: + type: object + description: "The conversation, oldest first" + properties: + content: + type: string + name: + type: string + role: + type: string + tool_call_id: + type: string + tool_calls: + type: array + items: + $ref: "#/components/schemas/ToolCallView" Metric: type: object properties: @@ -38245,6 +38438,12 @@ components: type: string variable: type: string + StreamOptionsView: + type: object + description: Streaming options + properties: + include_usage: + type: boolean SystemActionWorkflowActionMapping: type: object properties: @@ -39346,6 +39545,23 @@ components: example: 2026-01-15T10:31:22Z required: - createDate + ToolCallView: + type: object + properties: + function: + $ref: "#/components/schemas/FunctionCallView" + id: + type: string + type: + type: string + ToolView: + type: object + description: Tools the model may call + properties: + function: + $ref: "#/components/schemas/FunctionDefView" + type: + type: string TotalSession: type: object properties: @@ -39656,6 +39872,19 @@ components: type: object additionalProperties: type: object + UsageView: + type: object + description: Tokens consumed + properties: + completion_tokens: + type: integer + format: int32 + prompt_tokens: + type: integer + format: int32 + total_tokens: + type: integer + format: int32 User: type: object properties: diff --git a/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java new file mode 100644 index 000000000000..f1f405e15e7a --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java @@ -0,0 +1,408 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.InferenceMessage; +import com.dotcms.inference.model.InferenceRequest; +import com.dotcms.inference.model.InferenceToolCall; +import com.dotcms.inference.model.InferenceToolSpec; +import com.dotcms.inference.model.ResponseFormat; +import com.dotcms.inference.model.Role; +import com.dotcms.inference.model.ToolChoice; +import com.dotcms.inference.rest.mapper.ChatCompletionMapper; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link ChatCompletionMapper#toInferenceRequest(ChatCompletionRequestView)} — the + * translation of the OpenAI chat-completions wire shape into dotCMS's provider-neutral + * {@link InferenceRequest}. + * + *

The cases here are the ones where a wire format and an internal model can silently diverge: + * role naming, tool-call identity, the unparsed JSON text of tool arguments, the JSON Schema of a + * declared tool, and the sampling parameters a caller pays for. Dropping any of those compiles + * fine and fails only against a real provider, which is why each gets its own assertion here + * rather than a round-trip smoke test.

+ * + *

Most cases deserialize a realistic payload with Jackson rather than calling the view's + * canonical constructor, so the {@code @JsonProperty} names on the wire record — {@code max_tokens}, + * {@code tool_call_id}, {@code stream_options} — are exercised alongside the mapping itself.

+ */ +public class ChatCompletionRequestMapperTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Deserializes a chat-completions payload into the inbound view. + * + * @param json the request body as a client would send it + * @return the bound view + * @throws Exception when the payload is not readable + */ + private static ChatCompletionRequestView parse(final String json) throws Exception { + return MAPPER.readValue(json, ChatCompletionRequestView.class); + } + + /** + * Given a conversation carrying every role the format defines, in order, + * When mapped to the internal request, + * Then each wire role becomes its {@link Role} constant and the turn order is preserved. + */ + @Test + public void test_toInferenceRequest_allFourRoles_mapsRolesAndPreservesOrder() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[" + + "{\"role\":\"system\",\"content\":\"You are a helpful assistant.\"}," + + "{\"role\":\"user\",\"content\":\"What is the weather in Bogota?\"}," + + "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[" + + "{\"id\":\"call_1\",\"type\":\"function\",\"function\":" + + "{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Bogota\\\"}\"}}]}," + + "{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"content\":\"{\\\"tempC\\\":19}\"}" + + "]}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + final List messages = request.messages(); + assertEquals("gpt-4o", request.model()); + assertEquals(4, messages.size()); + assertEquals(Role.SYSTEM, messages.get(0).role()); + assertEquals(Role.USER, messages.get(1).role()); + assertEquals(Role.ASSISTANT, messages.get(2).role()); + assertEquals(Role.TOOL, messages.get(3).role()); + assertEquals("You are a helpful assistant.", messages.get(0).content()); + assertEquals("What is the weather in Bogota?", messages.get(1).content()); + assertEquals("{\"tempC\":19}", messages.get(3).content()); + } + + /** + * Given an assistant turn replaying a tool call the model previously asked for, + * When mapped to the internal request, + * Then the provider-assigned id is carried through verbatim and the arguments stay as the + * unparsed JSON text the model produced. + */ + @Test + public void test_toInferenceRequest_toolCallWithId_preservesIdentity() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[" + + "{\"role\":\"user\",\"content\":\"Weather?\"}," + + "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[" + + "{\"id\":\"call_abc123\",\"type\":\"function\",\"function\":" + + "{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Bogota\\\",\\\"unit\\\":\\\"c\\\"}\"}}]}" + + "]}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + final InferenceMessage assistant = request.messages().get(1); + assertEquals(Role.ASSISTANT, assistant.role()); + assertTrue(assistant.hasToolCalls()); + assertEquals(1, assistant.toolCalls().size()); + + final InferenceToolCall call = assistant.toolCalls().get(0); + assertEquals("call_abc123", call.id()); + assertEquals("get_weather", call.name()); + assertEquals("{\"city\":\"Bogota\",\"unit\":\"c\"}", call.arguments()); + } + + /** + * Given a tool result answering an earlier call, + * When mapped to the internal request, + * Then the correlating {@code tool_call_id} survives into + * {@link InferenceMessage#toolCallId()} together with the tool name. + */ + @Test + public void test_toInferenceRequest_toolResultTurn_preservesToolCallId() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[" + + "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[" + + "{\"id\":\"call_1\",\"type\":\"function\",\"function\":" + + "{\"name\":\"get_weather\",\"arguments\":\"{}\"}}]}," + + "{\"role\":\"tool\",\"tool_call_id\":\"call_1\",\"name\":\"get_weather\"," + + "\"content\":\"{\\\"tempC\\\":19}\"}" + + "]}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + final InferenceMessage toolResult = request.messages().get(1); + assertEquals(Role.TOOL, toolResult.role()); + assertEquals("call_1", toolResult.toolCallId()); + assertEquals("get_weather", toolResult.name()); + assertFalse(toolResult.hasToolCalls()); + } + + /** + * Given a declared tool with a name, a description and a JSON Schema for its arguments, + * When mapped to the internal request, + * Then an {@link InferenceToolSpec} carries all three, with the schema node intact. + */ + @Test + public void test_toInferenceRequest_toolDeclaration_mapsNameDescriptionAndSchema() throws Exception { + final String schema = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}," + + "\"required\":[\"city\"]}"; + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Weather?\"}]," + + "\"tools\":[{\"type\":\"function\",\"function\":{" + + "\"name\":\"get_weather\",\"description\":\"Current weather for a city\"," + + "\"parameters\":" + schema + "}}]}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertTrue(request.hasTools()); + assertEquals(1, request.tools().size()); + + final InferenceToolSpec spec = request.tools().get(0); + final JsonNode expectedSchema = MAPPER.readTree(schema); + assertEquals("get_weather", spec.name()); + assertEquals("Current weather for a city", spec.description()); + assertNotNull(spec.parameters()); + assertEquals(expectedSchema, spec.parameters()); + } + + /** + * Given the string tool choice {@code "auto"}, + * When mapped to the internal request, + * Then the choice is {@link ToolChoice.Mode#AUTO} and names no function. + */ + @Test + public void test_toInferenceRequest_toolChoiceAuto_mapsToAutoMode() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"tool_choice\":\"auto\"}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertNotNull(request.toolChoice()); + assertEquals(ToolChoice.Mode.AUTO, request.toolChoice().mode()); + assertNull(request.toolChoice().function()); + } + + /** + * Given the string tool choice {@code "required"}, + * When mapped to the internal request, + * Then the choice is {@link ToolChoice.Mode#REQUIRED}. + */ + @Test + public void test_toInferenceRequest_toolChoiceRequired_mapsToRequiredMode() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"tool_choice\":\"required\"}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertNotNull(request.toolChoice()); + assertEquals(ToolChoice.Mode.REQUIRED, request.toolChoice().mode()); + assertNull(request.toolChoice().function()); + } + + /** + * Given the string tool choice {@code "none"}, + * When mapped to the internal request, + * Then the choice is {@link ToolChoice.Mode#NONE}. + */ + @Test + public void test_toInferenceRequest_toolChoiceNone_mapsToNoneMode() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"tool_choice\":\"none\"}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertNotNull(request.toolChoice()); + assertEquals(ToolChoice.Mode.NONE, request.toolChoice().mode()); + assertNull(request.toolChoice().function()); + } + + /** + * Given an object tool choice naming one function, + * When mapped to the internal request, + * Then the choice is {@link ToolChoice.Mode#FUNCTION} carrying that function's name. + */ + @Test + public void test_toInferenceRequest_toolChoiceNamedFunction_mapsToFunctionMode() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}}}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertNotNull(request.toolChoice()); + assertEquals(ToolChoice.Mode.FUNCTION, request.toolChoice().mode()); + assertEquals("get_weather", request.toolChoice().function()); + } + + /** + * Given a response format of {@code {"type":"text"}}, + * When mapped to the internal request, + * Then the format is {@link ResponseFormat.Type#TEXT} with no schema. + */ + @Test + public void test_toInferenceRequest_responseFormatText_mapsToTextType() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"response_format\":{\"type\":\"text\"}}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertNotNull(request.responseFormat()); + assertEquals(ResponseFormat.Type.TEXT, request.responseFormat().type()); + assertNull(request.responseFormat().schema()); + } + + /** + * Given a response format of {@code {"type":"json_object"}}, + * When mapped to the internal request, + * Then the format is {@link ResponseFormat.Type#JSON_OBJECT} with no schema. + */ + @Test + public void test_toInferenceRequest_responseFormatJsonObject_mapsToJsonObjectType() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"response_format\":{\"type\":\"json_object\"}}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertNotNull(request.responseFormat()); + assertEquals(ResponseFormat.Type.JSON_OBJECT, request.responseFormat().type()); + assertNull(request.responseFormat().schema()); + } + + /** + * Given a request carrying temperature, max_tokens, top_p and stop, + * When mapped to the internal request, + * Then every sampling parameter reaches the internal field it belongs to (FR-013) — a dropped + * {@code max_tokens} is both a cost and a correctness failure, so it is asserted by value. + */ + @Test + public void test_toInferenceRequest_samplingParameters_passThroughUnchanged() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"temperature\":0.7," + + "\"max_tokens\":1024," + + "\"top_p\":0.95," + + "\"stop\":[\"\\n\\n\",\"END\"]}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertEquals(Double.valueOf(0.7), request.temperature()); + assertEquals(Integer.valueOf(1024), request.maxOutputTokens()); + assertEquals(Double.valueOf(0.95), request.topP()); + assertEquals(List.of("\n\n", "END"), request.stopSequences()); + } + + /** + * Given a request that says nothing about streaming, + * When mapped to the internal request, + * Then both {@code stream} and {@code includeUsageInStream} default to false, and the optional + * sampling parameters stay null rather than being invented (FR-009). + */ + @Test + public void test_toInferenceRequest_streamAbsent_defaultsToFalse() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertFalse(request.stream()); + assertFalse(request.includeUsageInStream()); + assertNull(request.temperature()); + assertNull(request.maxOutputTokens()); + assertNull(request.topP()); + assertTrue(request.stopSequences().isEmpty()); + } + + /** + * Given a streaming request asking for usage through the standard streaming option, + * When mapped to the internal request, + * Then {@code stream} is true and {@code includeUsageInStream} is true (FR-009). + */ + @Test + public void test_toInferenceRequest_streamOptionsIncludeUsage_mapsToIncludeUsageInStream() + throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]," + + "\"stream\":true," + + "\"stream_options\":{\"include_usage\":true}}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertTrue(request.stream()); + assertTrue(request.includeUsageInStream()); + } + + /** + * Given a streaming request with no stream options at all, + * When mapped to the internal request, + * Then streaming is on but usage is withheld — {@code includeUsageInStream} defaults to false + * (FR-009). The view is built through its canonical constructor here, so the default does not + * depend on Jackson leaving the field null. + */ + @Test + public void test_toInferenceRequest_streamOptionsAbsent_includeUsageDefaultsToFalse() { + final ChatCompletionRequestView view = new ChatCompletionRequestView( + "gpt-4o", + List.of(new ChatCompletionRequestView.MessageView("user", "Hi", null, null, null)), + null, + null, + null, + Boolean.TRUE, + null, + null, + null, + null, + null, + null); + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(view); + + assertTrue(request.stream()); + assertFalse(request.includeUsageInStream()); + } + + /** + * Given a payload carrying fields this family has no opinion about, + * When mapped to the internal request, + * Then the incidental fields are ignored and the fields that do matter still map, so a + * client's default payload is not rejected over a field that changes nothing. + */ + @Test + public void test_toInferenceRequest_unknownFields_areIgnored() throws Exception { + final String json = "{" + + "\"model\":\"gpt-4o\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Hi\",\"extra_turn_field\":42}]," + + "\"user\":\"user-123\"," + + "\"presence_penalty\":0.1," + + "\"frequency_penalty\":0.2," + + "\"logit_bias\":{\"50256\":-100}," + + "\"seed\":7," + + "\"service_tier\":\"auto\"," + + "\"max_tokens\":256}"; + + final InferenceRequest request = ChatCompletionMapper.toInferenceRequest(parse(json)); + + assertEquals("gpt-4o", request.model()); + assertEquals(1, request.messages().size()); + assertEquals(Role.USER, request.messages().get(0).role()); + assertEquals("Hi", request.messages().get(0).content()); + assertEquals(Integer.valueOf(256), request.maxOutputTokens()); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java new file mode 100644 index 000000000000..a57d4593dfaf --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java @@ -0,0 +1,335 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.FinishReason; +import com.dotcms.inference.model.InferenceMessage; +import com.dotcms.inference.model.InferenceResponse; +import com.dotcms.inference.model.InferenceToolCall; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.model.Role; +import com.dotcms.inference.rest.mapper.ChatCompletionMapper; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link ChatCompletionMapper#toView(InferenceResponse)}, the outbound half of the + * translation between dotCMS's internal {@link InferenceResponse} and the OpenAI-compatible + * chat-completions wire shape served at {@code /api/inference/v1/chat/completions}. + * + *

Coverage:

+ *
    + *
  • Envelope fields — {@code id}, {@code object}, {@code created}, {@code model} (FR-003).
  • + *
  • {@code model} reporting the model that actually served the request, which is how a caller + * detects that a site's fallback chain moved on to a later entry.
  • + *
  • A text answer rendering as exactly one choice, index {@code 0}, role {@code assistant}, + * finish reason {@code stop}.
  • + *
  • A tool-calling answer rendering {@code tool_calls}, with the provider-assigned id carried + * verbatim rather than synthesised (FR-004).
  • + *
  • Every serializable {@link FinishReason} mapping to its wire string.
  • + *
  • Usage mapping to {@code prompt_tokens} / {@code completion_tokens} / {@code total_tokens}, + * and {@link InferenceUsage#UNREPORTED} leaving usage absent rather than fabricating zeros — + * a made-up count is indistinguishable from a real one to anyone reconciling spend.
  • + *
  • Jackson serialization producing the snake_case wire field names, since the point of the + * whole family is byte-compatibility with standard clients.
  • + *
+ */ +public class ChatCompletionResponseMapperTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Given a completed response with an id, a model and a creation time. + * When it is rendered to the wire shape. + * Then every envelope field is populated and {@code object} is {@code chat.completion}. (FR-003) + */ + @Test + public void test_toView_textResponse_populatesEnvelopeFields() { + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-8f3a", + "gpt-4o", + 1789000000L, + InferenceMessage.of(Role.ASSISTANT, "The weather in Bogota is 19C."), + FinishReason.STOP, + new InferenceUsage(82, 17, 99)); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + + assertNotNull("The mapper must return a view", view); + assertEquals("chatcmpl-8f3a", view.id()); + assertEquals("chat.completion", view.object()); + assertEquals(ChatCompletionView.OBJECT, view.object()); + assertEquals(1789000000L, view.created()); + assertEquals("gpt-4o", view.model()); + } + + /** + * Given a response served by a fallback model, not the one the caller asked for. + * When it is rendered to the wire shape. + * Then {@code model} names the model that actually ran, which is the only signal a caller has + * that a fallback-chain hop happened. + */ + @Test + public void test_toView_fallbackModelServedRequest_reportsServingModel() { + + final String modelTheCallerAskedFor = "gpt-4o"; + final String modelThatActuallyServed = "gpt-4o-mini"; + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-fallback", + modelThatActuallyServed, + 1789000001L, + InferenceMessage.of(Role.ASSISTANT, "Served by the next chain entry."), + FinishReason.STOP, + InferenceUsage.UNREPORTED); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + + assertEquals("The served model must be reported, not the requested one", + modelThatActuallyServed, view.model()); + assertFalse("The requested model must not be echoed back when a fallback served the request", + modelTheCallerAskedFor.equals(view.model())); + } + + /** + * Given an assistant turn carrying plain text. + * When it is rendered to the wire shape. + * Then there is exactly one choice at index 0, with role {@code assistant}, the content, and + * finish reason {@code stop}. + */ + @Test + public void test_toView_textAnswer_mapsToSingleAssistantChoice() { + + final String content = "Bogota is 19C right now."; + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-text", + "gpt-4o", + 1789000002L, + InferenceMessage.of(Role.ASSISTANT, content), + FinishReason.STOP, + InferenceUsage.UNREPORTED); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + + assertNotNull("Choices must be present", view.choices()); + assertEquals("Exactly one choice is supported", 1, view.choices().size()); + + final ChatCompletionView.ChoiceView choice = view.choices().get(0); + assertEquals(0, choice.index()); + assertEquals("stop", choice.finishReason()); + + final ChatCompletionView.MessageView message = choice.message(); + assertNotNull("The choice must carry a message", message); + assertEquals("assistant", message.role()); + assertEquals(content, message.content()); + assertTrue("A text answer carries no tool calls", + message.toolCalls() == null || message.toolCalls().isEmpty()); + } + + /** + * Given an assistant turn asking for two tools to be executed. + * When it is rendered to the wire shape. + * Then the finish reason is {@code tool_calls} and each entry carries its provider-assigned id + * verbatim, type {@code function}, and the function name and raw JSON arguments. (FR-004) + */ + @Test + public void test_toView_toolCallAnswer_mapsToolCallsCarryingIdVerbatim() { + + final InferenceToolCall firstCall = + new InferenceToolCall("call_1", "get_weather", "{\"city\":\"Bogota\"}", 0); + final InferenceToolCall secondCall = + new InferenceToolCall("call_2", "get_time", "{\"tz\":\"America/Bogota\"}", 1); + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-tools", + "gpt-4o", + 1789000003L, + InferenceMessage.ofToolCalls(null, List.of(firstCall, secondCall)), + FinishReason.TOOL_CALLS, + InferenceUsage.UNREPORTED); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + + assertEquals("Exactly one choice is supported", 1, view.choices().size()); + + final ChatCompletionView.ChoiceView choice = view.choices().get(0); + assertEquals("tool_calls", choice.finishReason()); + + final ChatCompletionView.MessageView message = choice.message(); + assertEquals("assistant", message.role()); + assertNull("A tool-calling turn with no text carries null content", message.content()); + + final List toolCalls = message.toolCalls(); + assertNotNull("Tool calls must be present", toolCalls); + assertEquals(2, toolCalls.size()); + + final ChatCompletionView.ToolCallView first = toolCalls.get(0); + assertEquals("The provider-assigned id must be carried verbatim", "call_1", first.id()); + assertEquals("function", first.type()); + assertNotNull("The function payload must be present", first.function()); + assertEquals("get_weather", first.function().name()); + assertEquals("Arguments are passed through unparsed", + "{\"city\":\"Bogota\"}", first.function().arguments()); + + final ChatCompletionView.ToolCallView second = toolCalls.get(1); + assertEquals("The provider-assigned id must be carried verbatim", "call_2", second.id()); + assertEquals("function", second.type()); + assertEquals("get_time", second.function().name()); + assertEquals("{\"tz\":\"America/Bogota\"}", second.function().arguments()); + } + + /** + * Given each {@link FinishReason} that has a wire equivalent. + * When a response carrying it is rendered. + * Then the choice's {@code finish_reason} is the matching wire string. + */ + @Test + public void test_toView_finishReasons_mapToWireStrings() { + + assertEquals("stop", finishReasonOnWireFor(FinishReason.STOP)); + assertEquals("length", finishReasonOnWireFor(FinishReason.LENGTH)); + assertEquals("tool_calls", finishReasonOnWireFor(FinishReason.TOOL_CALLS)); + assertEquals("content_filter", finishReasonOnWireFor(FinishReason.CONTENT_FILTER)); + } + + /** + * Given a response whose provider reported token counts. + * When it is rendered to the wire shape. + * Then usage carries {@code prompt_tokens}, {@code completion_tokens} and {@code total_tokens}. + */ + @Test + public void test_toView_reportedUsage_mapsTokenCounts() { + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-usage", + "gpt-4o", + 1789000004L, + InferenceMessage.of(Role.ASSISTANT, "Counted."), + FinishReason.STOP, + new InferenceUsage(82, 17, 99)); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + + final ChatCompletionView.UsageView usage = view.usage(); + assertNotNull("Reported usage must be rendered", usage); + assertEquals(Integer.valueOf(82), usage.promptTokens()); + assertEquals(Integer.valueOf(17), usage.completionTokens()); + assertEquals(Integer.valueOf(99), usage.totalTokens()); + } + + /** + * Given a response whose provider reported no usage at all. + * When it is rendered to the wire shape. + * Then usage is absent rather than zeroed — a fabricated count is indistinguishable from a real + * one to anyone reconciling spend. + */ + @Test + public void test_toView_unreportedUsage_omitsUsageRatherThanFabricatingZeros() { + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-nousage", + "gpt-4o", + 1789000005L, + InferenceMessage.of(Role.ASSISTANT, "Uncounted."), + FinishReason.STOP, + InferenceUsage.UNREPORTED); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + + assertNull("Unreported usage must be absent, never zeros", view.usage()); + } + + /** + * Given a rendered tool-calling response with reported usage. + * When it is serialized with Jackson. + * Then the JSON carries the snake_case wire field names standard clients deserialize — + * {@code finish_reason}, {@code tool_calls}, {@code prompt_tokens} — and no camelCase variants. + * + * @throws Exception when serialization fails + */ + @Test + public void test_toView_serializedWithJackson_producesWireFieldNames() throws Exception { + + final InferenceToolCall toolCall = + new InferenceToolCall("call_1", "get_weather", "{\"city\":\"Bogota\"}", 0); + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-wire", + "gpt-4o", + 1789000006L, + InferenceMessage.ofToolCalls(null, List.of(toolCall)), + FinishReason.TOOL_CALLS, + new InferenceUsage(82, 17, 99)); + + final ChatCompletionView view = ChatCompletionMapper.toView(response); + final String json = OBJECT_MAPPER.writeValueAsString(view); + final JsonNode node = OBJECT_MAPPER.readTree(json); + + assertEquals("chatcmpl-wire", node.path("id").asText()); + assertEquals("chat.completion", node.path("object").asText()); + assertEquals(1789000006L, node.path("created").asLong()); + assertEquals("gpt-4o", node.path("model").asText()); + + final JsonNode choice = node.path("choices").path(0); + assertEquals(0, choice.path("index").asInt()); + assertTrue("finish_reason must be the wire field name", + choice.has("finish_reason")); + assertEquals("tool_calls", choice.path("finish_reason").asText()); + assertFalse("camelCase finishReason must not leak onto the wire", + choice.has("finishReason")); + + final JsonNode message = choice.path("message"); + assertEquals("assistant", message.path("role").asText()); + assertTrue("tool_calls must be the wire field name", message.has("tool_calls")); + assertFalse("camelCase toolCalls must not leak onto the wire", message.has("toolCalls")); + + final JsonNode wireToolCall = message.path("tool_calls").path(0); + assertEquals("call_1", wireToolCall.path("id").asText()); + assertEquals("function", wireToolCall.path("type").asText()); + assertEquals("get_weather", wireToolCall.path("function").path("name").asText()); + assertEquals("{\"city\":\"Bogota\"}", + wireToolCall.path("function").path("arguments").asText()); + + final JsonNode usage = node.path("usage"); + assertTrue("prompt_tokens must be the wire field name", usage.has("prompt_tokens")); + assertFalse("camelCase promptTokens must not leak onto the wire", usage.has("promptTokens")); + assertEquals(82, usage.path("prompt_tokens").asInt()); + assertEquals(17, usage.path("completion_tokens").asInt()); + assertEquals(99, usage.path("total_tokens").asInt()); + } + + /** + * Renders a minimal response carrying the given finish reason and returns the wire string the + * mapper produced for it. + * + * @param finishReason the internal reason generation stopped + * @return the {@code finish_reason} on the rendered choice + */ + private static String finishReasonOnWireFor(final FinishReason finishReason) { + + final InferenceMessage message = finishReason == FinishReason.TOOL_CALLS + ? InferenceMessage.ofToolCalls(null, + List.of(InferenceToolCall.of("call_1", "get_weather", "{}"))) + : InferenceMessage.of(Role.ASSISTANT, "An answer."); + + final InferenceResponse response = new InferenceResponse( + "chatcmpl-" + finishReason.name().toLowerCase(), + "gpt-4o", + 1789000007L, + message, + finishReason, + InferenceUsage.UNREPORTED); + + return ChatCompletionMapper.toView(response).choices().get(0).finishReason(); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java b/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java new file mode 100644 index 000000000000..879e5169b919 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java @@ -0,0 +1,276 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.InferenceMessage; +import com.dotcms.inference.model.InferenceRequest; +import com.dotcms.inference.model.InferenceToolCall; +import com.dotcms.inference.model.Role; +import com.dotcms.inference.rest.mapper.ChatCompletionMapper; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import org.junit.Test; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Specifies request validation for the OpenAI-compatible inference family, across the two layers + * that share responsibility for it. + * + *

The first group exercises the internal representation directly — the compact constructors of + * {@link InferenceRequest} and {@link InferenceMessage}, which already enforce their invariants. + * These tests pass today and are here to pin that behaviour down so a later refactor of the model + * cannot quietly relax it.

+ * + *

The second group exercises + * {@link ChatCompletionMapper#toInferenceRequest(ChatCompletionRequestView)}, which translates the + * wire shape into the internal one and is where wire-level validation belongs. That method is a + * skeleton at the time of writing, so these tests fail — deliberately. They are the specification + * the implementation is written against, in particular the FR-013 line: a field this family has no + * opinion about is ignored, but a field that changes what the caller gets or pays for — {@code n} + * being the example — must fail loudly rather than be silently dropped.

+ * + *

Where a validation error is asserted, the assertion is on the error naming the offending + * field or identity. A validation error that does not say what was wrong sends the caller back to + * guessing, which is most of what these rules exist to prevent.

+ * + * @see InferenceRequest + * @see InferenceMessage + * @see ChatCompletionMapper + */ +public class InferenceRequestValidationTest { + + private static final String VALID_MODEL = "gpt-4o"; + + // --------------------------------------------------------------------- + // Internal representation — InferenceRequest + // --------------------------------------------------------------------- + + /** + * Given a request built with a blank model, + * When the record is constructed, + * Then it is rejected naming {@code model} — there is no implicit default (FR-024). + */ + @Test + public void test_inferenceRequest_blankModel_throwsNamingModel() { + final List messages = List.of(InferenceMessage.of(Role.USER, "hello")); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> InferenceRequest.builder(" ").messages(messages).build()); + + assertMessageNamesToken(thrown.getMessage(), "model"); + } + + /** + * Given a request built with a null model, + * When the record is constructed, + * Then it is rejected naming {@code model} rather than defaulting to some house model. + */ + @Test + public void test_inferenceRequest_nullModel_throwsNamingModel() { + final List messages = List.of(InferenceMessage.of(Role.USER, "hello")); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> InferenceRequest.builder(null).messages(messages).build()); + + assertMessageNamesToken(thrown.getMessage(), "model"); + } + + /** + * Given a request carrying an empty conversation, + * When the record is constructed, + * Then it is rejected naming {@code message} — there is nothing to infer from (FR-002). + */ + @Test + public void test_inferenceRequest_emptyMessages_throwsNamingMessages() { + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> InferenceRequest.builder(VALID_MODEL).messages(List.of()).build()); + + assertMessageNamesToken(thrown.getMessage(), "message"); + } + + /** + * Given a TOOL turn whose {@code toolCallId} answers a call no earlier assistant turn made, + * When the record is constructed, + * Then it is rejected and the error names the orphaned identity, so the caller can find it + * without diffing the conversation by hand (FR-005). + */ + @Test + public void test_inferenceRequest_uncorrelatedToolResult_throwsNamingOffendingId() { + final String orphanId = "call_nobody_asked_for"; + final List messages = List.of( + InferenceMessage.of(Role.USER, "what is the weather?"), + InferenceMessage.ofToolResult(orphanId, "get_weather", "{\"tempC\":21}")); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> InferenceRequest.builder(VALID_MODEL).messages(messages).build()); + + assertMessageNamesToken(thrown.getMessage(), orphanId); + } + + /** + * Given a TOOL turn whose {@code toolCallId} matches a call a preceding assistant turn made, + * When the record is constructed, + * Then it is accepted and the conversation survives intact — correlation is by identity, not + * by position (FR-005). + */ + @Test + public void test_inferenceRequest_correlatedToolResult_isAccepted() { + final String callId = "call_abc123"; + final List messages = List.of( + InferenceMessage.of(Role.USER, "what is the weather?"), + InferenceMessage.ofToolCalls(null, + List.of(InferenceToolCall.of(callId, "get_weather", "{\"city\":\"SJO\"}"))), + InferenceMessage.ofToolResult(callId, "get_weather", "{\"tempC\":21}")); + + final InferenceRequest request = + InferenceRequest.builder(VALID_MODEL).messages(messages).build(); + + assertNotNull("A correlated tool result must produce a request", request); + assertEquals(3, request.messages().size()); + assertEquals(callId, request.messages().get(2).toolCallId()); + } + + // --------------------------------------------------------------------- + // Internal representation — InferenceMessage + // --------------------------------------------------------------------- + + /** + * Given a TOOL turn with no {@code toolCallId}, + * When the record is constructed, + * Then it is rejected naming {@code toolCallId} — a result that answers nothing in particular + * cannot be correlated at all. + */ + @Test + public void test_inferenceMessage_toolRoleWithoutToolCallId_throwsNamingToolCallId() { + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new InferenceMessage(Role.TOOL, "{\"tempC\":21}", List.of(), null, "get_weather")); + + assertMessageNamesToken(thrown.getMessage(), "toolCallId"); + } + + /** + * Given tool calls attached to a non-assistant turn, + * When the record is constructed, + * Then it is rejected naming ASSISTANT — only the model asks for tools to be executed. + */ + @Test + public void test_inferenceMessage_toolCallsOnNonAssistantRole_throwsNamingAssistant() { + final List toolCalls = + List.of(InferenceToolCall.of("call_abc123", "get_weather", "{}")); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new InferenceMessage(Role.USER, "what is the weather?", toolCalls, null, null)); + + assertMessageNamesToken(thrown.getMessage(), "ASSISTANT"); + } + + /** + * Given a turn with neither content nor tool calls, + * When the record is constructed, + * Then it is rejected naming {@code content} — an empty turn carries nothing a provider could + * act on, and paying for the round trip to find that out is the failure mode being avoided. + */ + @Test + public void test_inferenceMessage_noContentAndNoToolCalls_throwsNamingContent() { + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> new InferenceMessage(Role.USER, null, List.of(), null, null)); + + assertMessageNamesToken(thrown.getMessage(), "content"); + } + + // --------------------------------------------------------------------- + // Wire layer — ChatCompletionMapper (specification; fails until T026) + // --------------------------------------------------------------------- + + /** + * Given a request view asking for more than one choice, + * When it is mapped to the internal representation, + * Then it is rejected with a validation error naming {@code n}, rather than silently served as + * a single choice. Unknown fields are tolerated; a field that changes what the caller gets or + * pays for is not (FR-013). + */ + @Test + public void test_toInferenceRequest_nGreaterThanOne_throwsNamingN() { + final ChatCompletionRequestView view = requestView(VALID_MODEL, + List.of(new MessageView("user", "hello", null, null, null)), 2); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> ChatCompletionMapper.toInferenceRequest(view)); + + assertMessageNamesToken(thrown.getMessage(), "n"); + } + + /** + * Given a request view with no model, + * When it is mapped to the internal representation, + * Then it is rejected with a validation error naming {@code model}, at the wire layer, before + * any provider is chosen (FR-024). + */ + @Test + public void test_toInferenceRequest_absentModel_throwsNamingModel() { + final ChatCompletionRequestView view = requestView(null, + List.of(new MessageView("user", "hello", null, null, null)), null); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> ChatCompletionMapper.toInferenceRequest(view)); + + assertMessageNamesToken(thrown.getMessage(), "model"); + } + + /** + * Given a request view carrying an empty conversation, + * When it is mapped to the internal representation, + * Then it is rejected with a validation error naming {@code messages} (FR-002). + */ + @Test + public void test_toInferenceRequest_emptyMessages_throwsNamingMessages() { + final ChatCompletionRequestView view = requestView(VALID_MODEL, List.of(), null); + + final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> ChatCompletionMapper.toInferenceRequest(view)); + + assertMessageNamesToken(thrown.getMessage(), "message"); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /** + * Builds an otherwise-minimal request view, so each test varies only the field it is about. + * + * @param model the model id, possibly null + * @param messages the conversation + * @param n the number of choices requested, possibly null + * @return a request view with every other wire field left absent + */ + private static ChatCompletionRequestView requestView(final String model, + final List messages, + final Integer n) { + return new ChatCompletionRequestView(model, messages, null, null, null, null, null, null, + null, null, null, n); + } + + /** + * Asserts a validation message actually names what was wrong. + * + *

Matched on a word boundary rather than a bare substring, so a message that happens to + * contain the letters of a short field name — {@code n} above all — does not pass for one that + * names the field.

+ * + * @param message the exception message under test + * @param token the field name or identity the message must name + */ + private static void assertMessageNamesToken(final String message, final String token) { + assertNotNull("Validation error carried no message at all", message); + final Pattern pattern = + Pattern.compile("(?Once the first chunk is written the HTTP status is already on the wire and can no longer + * carry the failure. The error event is the only way a caller learns the answer is incomplete, + * and withholding the terminal {@code [DONE]} marker is what stops a client that + * does not parse the error event from reading a truncated answer as a finished one. That single + * behaviour — {@link SseSerializer#shouldWriteDoneMarker(InferenceStreamEvent)} returning + * {@code false} after an {@link InferenceStreamEvent.Error} — is the most important assertion in + * the streaming work.

+ * + *

Coverage:

+ *
    + *
  • An error event rendering a {@code data:} frame whose JSON has a top-level {@code error} + * object with {@code message} and {@code type}.
  • + *
  • That body carrying no HTTP status field — retryability rides on the status code, and the + * body stays conformant to the standard error shape, which has no such field.
  • + *
  • {@code shouldWriteDoneMarker} returning {@code false} after an error.
  • + *
  • It returning {@code true} after {@link InferenceStreamEvent.Finish} and after + * {@link InferenceStreamEvent.Usage}, so the false case is demonstrably specific to failure + * rather than blanket behaviour.
  • + *
  • An error frame being neither equal to nor containing + * {@link SseSerializer#DONE_MARKER}.
  • + *
  • The two ways a stream fails mid-flight — an upstream provider error and the completion + * timeout expiring — behaving identically.
  • + *
+ */ +public class SseFailureTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** The prefix every server-sent event payload is written behind. */ + private static final String DATA_PREFIX = "data: "; + + private static final String COMPLETION_ID = "chatcmpl-failed"; + private static final String MODEL = "gpt-4o"; + private static final long CREATED = 1789000000L; + + private static final String UPSTREAM_MESSAGE = "Upstream provider unavailable"; + private static final String TIMEOUT_MESSAGE = "The completion timed out before it finished"; + + /** + * Given a provider that failed part-way through generating an answer. + * When the error event is rendered. + * Then the frame's JSON carries a top-level {@code error} object with {@code message} and + * {@code type}, which is the shape a standard client deserializes into its own error type. + * (FR-039) + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_errorEvent_rendersTopLevelErrorObject() throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Error(InferenceError.upstream(UPSTREAM_MESSAGE)); + + final JsonNode frame = parseFrame( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + final JsonNode error = frame.get("error"); + assertNotNull("A failed stream's last frame carries a top-level error object", error); + assertTrue("The error must be an object, not a bare string", error.isObject()); + assertEquals("The safe description must be carried verbatim", + UPSTREAM_MESSAGE, error.path("message").asText()); + assertEquals("The error family must be carried", + "api_error", error.path("type").asText()); + } + + /** + * Given an upstream failure whose {@link InferenceError#httpStatus()} is 502. + * When the error event is rendered. + * Then no HTTP status field reaches the body — retryability is conveyed by the response's + * status code, which is what a standard client's back-off keys off, and the standard error + * shape has no field to put it in. + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_errorEvent_omitsHttpStatusFromBody() throws Exception { + + final InferenceError upstream = InferenceError.upstream(UPSTREAM_MESSAGE); + assertEquals("Precondition: an upstream failure is a 502", 502, upstream.httpStatus()); + assertTrue("Precondition: a 502 is retryable by its status", upstream.isRetryable()); + + final String rendered = SseSerializer.toFrame( + new InferenceStreamEvent.Error(upstream), COMPLETION_ID, MODEL, CREATED); + final JsonNode frame = parseFrame(rendered); + final JsonNode error = frame.get("error"); + + assertNotNull("A failed stream's last frame carries a top-level error object", error); + assertFalse("The status must not leak into the body", error.has("httpStatus")); + assertFalse("The status must not leak into the body", error.has("http_status")); + assertFalse("The status must not leak into the body", error.has("status")); + assertFalse("The status must not leak into the body", error.has("statusCode")); + assertFalse("The status must not leak into the body", error.has("status_code")); + assertFalse("Retryability rides on the status code, not on an invented body field", + error.has("retryable")); + assertFalse("The status must not leak into the body at the top level", + frame.has("httpStatus")); + assertFalse("The status must not leak into the body anywhere", rendered.contains("502")); + } + + /** + * Given a stream that failed after its first chunk was already written. + * When the serializer is asked whether the terminal marker follows the error event. + * Then it says no. + * + *

This is the single most important assertion in the streaming work: withholding + * {@code [DONE]} is the only thing that stops a client which does not parse the error event + * from treating a truncated answer as a complete one. (FR-039)

+ */ + @Test + public void test_shouldWriteDoneMarker_errorEvent_returnsFalse() { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Error(InferenceError.upstream(UPSTREAM_MESSAGE)); + + assertFalse("A failed stream must NOT be terminated with [DONE] — a client that does not " + + "parse the error event must see a truncated stream, never a cleanly " + + "finished one", + SseSerializer.shouldWriteDoneMarker(event)); + } + + /** + * Given the completion timeout expiring mid-generation, the second way a stream fails after it + * has started. + * When the serializer is asked whether the terminal marker follows. + * Then it says no, identically to the provider-failure path — the caller must not be able to + * tell a timed-out answer from a complete one only by luck. (FR-039) + */ + @Test + public void test_shouldWriteDoneMarker_completionTimeoutErrorEvent_returnsFalse() { + + final InferenceStreamEvent event = new InferenceStreamEvent.Error(completionTimeout()); + + assertFalse("A stream cut short by the completion timeout must NOT be terminated with " + + "[DONE] either", + SseSerializer.shouldWriteDoneMarker(event)); + } + + /** + * Given streams that ended normally — one on a finish event, one on a usage event. + * When the serializer is asked whether the terminal marker follows. + * Then it says yes for every serializable finish reason and for usage, so the {@code false} + * returned after an error is demonstrably specific to failure rather than blanket behaviour. + */ + @Test + public void test_shouldWriteDoneMarker_finishAndUsageEvents_returnTrue() { + + for (final FinishReason reason : FinishReason.values()) { + if (reason == FinishReason.ERROR) { + // Never serialized as a finish reason; a failed stream ends with an Error event. + continue; + } + final InferenceStreamEvent finish = new InferenceStreamEvent.Finish(reason); + assertTrue("A stream that finished with " + reason + " must be terminated with [DONE]", + SseSerializer.shouldWriteDoneMarker(finish)); + } + + final InferenceStreamEvent usage = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + assertTrue("A stream that ends with usage must still be terminated with [DONE]", + SseSerializer.shouldWriteDoneMarker(usage)); + + final InferenceStreamEvent error = + new InferenceStreamEvent.Error(InferenceError.upstream(UPSTREAM_MESSAGE)); + assertFalse("Only failure withholds the terminal marker", + SseSerializer.shouldWriteDoneMarker(error)); + } + + /** + * Given an error event. + * When it is rendered to a frame. + * Then the frame is neither equal to nor contains {@link SseSerializer#DONE_MARKER}, so a + * client scanning the byte stream for the terminator never finds one on a failed stream. + * (FR-039) + */ + @Test + public void test_toFrame_errorEvent_frameNeitherIsNorContainsDoneMarker() { + + final InferenceStreamEvent upstreamFailure = + new InferenceStreamEvent.Error(InferenceError.upstream(UPSTREAM_MESSAGE)); + final InferenceStreamEvent timeoutFailure = + new InferenceStreamEvent.Error(completionTimeout()); + + for (final InferenceStreamEvent event + : new InferenceStreamEvent[] {upstreamFailure, timeoutFailure}) { + + final String frame = SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED); + + assertNotNull("The serializer must render an error frame", frame); + assertTrue("An SSE frame is written behind the data: prefix", + frame.startsWith(DATA_PREFIX)); + assertNotEquals("An error frame is not the terminal marker", + SseSerializer.DONE_MARKER, frame); + assertFalse("An error frame must not carry the terminal marker", + frame.contains(SseSerializer.DONE_MARKER)); + assertFalse("An error frame must not carry the terminal marker's payload", + frame.contains("[DONE]")); + } + } + + /** + * Given the completion timeout expiring mid-generation. + * When the error event is rendered. + * Then it renders the same standard error shape as the provider-failure path — a top-level + * {@code error} object with {@code message} and {@code type}, and no status in the body — so + * the second way to fail mid-stream is indistinguishable in handling from the first. (FR-039) + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_completionTimeoutErrorEvent_rendersSameShapeAsUpstreamFailure() + throws Exception { + + final InferenceError timeout = completionTimeout(); + + final JsonNode frame = parseFrame(SseSerializer.toFrame( + new InferenceStreamEvent.Error(timeout), COMPLETION_ID, MODEL, CREATED)); + + final JsonNode error = frame.get("error"); + assertNotNull("A timed-out stream's last frame carries a top-level error object", error); + assertEquals("The timeout's safe description must be carried verbatim", + TIMEOUT_MESSAGE, error.path("message").asText()); + assertEquals("A timeout is reported in the same error family as any upstream failure", + timeout.type(), error.path("type").asText()); + assertFalse("The status must not leak into the body", error.has("httpStatus")); + assertFalse("The status must not leak into the body", error.has("status")); + } + + /** + * A timeout-flavoured error — the second way a stream fails once it has begun, when + * {@code DOT_INFERENCE_COMPLETION_TIMEOUT_SECONDS} expires mid-generation. + * + * @return the error the streaming path raises on a completion timeout + */ + private static InferenceError completionTimeout() { + return new InferenceError("api_error", TIMEOUT_MESSAGE, null, 504); + } + + /** + * Parses a rendered frame's JSON payload, stripping the {@code data: } prefix and the trailing + * newlines that terminate a server-sent event. + * + * @param frame the frame the serializer rendered + * @return the parsed payload + * @throws Exception when the payload is not parseable JSON + */ + private static JsonNode parseFrame(final String frame) throws Exception { + + assertNotNull("The serializer must render a frame", frame); + assertTrue("An SSE frame is written behind the data: prefix", frame.startsWith(DATA_PREFIX)); + + final String payload = frame.substring(DATA_PREFIX.length()).trim(); + return OBJECT_MAPPER.readTree(payload); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java b/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java new file mode 100644 index 000000000000..1fe73e172aed --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java @@ -0,0 +1,256 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.FinishReason; +import com.dotcms.inference.model.InferenceStreamEvent; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.rest.mapper.SseSerializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link SseSerializer}, which renders internal {@link InferenceStreamEvent}s as the + * server-sent events a standard chat-completions client reads from + * {@code /api/inference/v1/chat/completions} when {@code "stream": true}. + * + *

This class covers the chunk envelope (FR-007); incremental tool calls (FR-008) are covered by + * {@code SseToolCallStreamTest}.

+ * + *

Coverage:

+ *
    + *
  • Frame framing — a {@code data: } prefix and the blank-line terminator {@code \n\n} that + * tells an SSE reader the event is complete. Without the terminator a client blocks holding + * a chunk it already has.
  • + *
  • Envelope fields on every chunk — {@code id}, {@code object} equal to + * {@code chat.completion.chunk}, {@code created} and {@code model} (FR-007).
  • + *
  • The id, model and creation time being the ones passed in, and staying identical across + * successive chunks of the same completion — a client correlates chunks by that id.
  • + *
  • A content fragment landing at {@code choices[0].delta.content} with {@code index} 0.
  • + *
  • A finish rendering the wire finish-reason string with no content left in the delta, which + * is what distinguishes a completed answer from a truncated one.
  • + *
  • {@link SseSerializer#shouldWriteDoneMarker(InferenceStreamEvent)} being true after a + * finish and after a usage event — the two events that legitimately end a stream.
  • + *
  • {@link SseSerializer#DONE_MARKER} being exactly {@code data: [DONE]\n\n}, since a client + * detects end-of-stream by matching those bytes.
  • + *
+ */ +public class SseSerializerTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String DATA_PREFIX = "data: "; + private static final String FRAME_TERMINATOR = "\n\n"; + + private static final String COMPLETION_ID = "chatcmpl-8f3a"; + private static final String MODEL = "gpt-4o"; + private static final long CREATED = 1789000000L; + + /** + * Given a content fragment of an answer. + * When it is rendered to a frame. + * Then the frame begins with {@code data: } and ends with the blank-line terminator that marks + * an SSE event complete. + */ + @Test + public void test_toFrame_contentDelta_isDataPrefixedAndBlankLineTerminated() { + + final InferenceStreamEvent event = new InferenceStreamEvent.ContentDelta("Hello"); + + final String frame = SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED); + + assertTrue("A streamed frame must begin with the SSE data prefix: " + frame, + frame.startsWith(DATA_PREFIX)); + assertTrue("A streamed frame must end with a blank line or the client never dispatches it", + frame.endsWith(FRAME_TERMINATOR)); + assertFalse("The payload between prefix and terminator must not be empty", + payloadOf(frame).isBlank()); + } + + /** + * Given a content fragment of an answer. + * When it is rendered to a frame. + * Then the parsed chunk carries every envelope field, with {@code object} equal to + * {@code chat.completion.chunk}. (FR-007) + * + * @throws Exception if the frame does not carry parseable JSON + */ + @Test + public void test_toFrame_contentDelta_carriesChunkEnvelopeFields() throws Exception { + + final InferenceStreamEvent event = new InferenceStreamEvent.ContentDelta("Hello"); + + final JsonNode chunk = parseFrame(SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + assertTrue("Every chunk must carry an id", chunk.has("id")); + assertTrue("Every chunk must carry an object", chunk.has("object")); + assertTrue("Every chunk must carry a created", chunk.has("created")); + assertTrue("Every chunk must carry a model", chunk.has("model")); + assertEquals("chat.completion.chunk", chunk.path("object").asText()); + assertEquals(SseSerializer.CHUNK_OBJECT, chunk.path("object").asText()); + } + + /** + * Given two content fragments of the same completion rendered with the same id, model and + * creation time. + * When both are rendered to frames. + * Then both carry exactly the values passed in — a client correlates the chunks of one + * completion by an id that never changes mid-stream. + * + * @throws Exception if a frame does not carry parseable JSON + */ + @Test + public void test_toFrame_successiveChunks_repeatTheSameIdModelAndCreated() throws Exception { + + final JsonNode first = parseFrame(SseSerializer.toFrame( + new InferenceStreamEvent.ContentDelta("The weather "), COMPLETION_ID, MODEL, CREATED)); + final JsonNode second = parseFrame(SseSerializer.toFrame( + new InferenceStreamEvent.ContentDelta("is 19C."), COMPLETION_ID, MODEL, CREATED)); + + assertEquals(COMPLETION_ID, first.path("id").asText()); + assertEquals(MODEL, first.path("model").asText()); + assertEquals(CREATED, first.path("created").asLong()); + + assertEquals("The id must not change between chunks of one completion", + first.path("id").asText(), second.path("id").asText()); + assertEquals("The model must not change between chunks of one completion", + first.path("model").asText(), second.path("model").asText()); + assertEquals("The creation time must not change between chunks of one completion", + first.path("created").asLong(), second.path("created").asLong()); + } + + /** + * Given a content fragment of an answer. + * When it is rendered to a frame. + * Then the text sits at {@code choices[0].delta.content} with {@code choices[0].index} of 0. + * + * @throws Exception if the frame does not carry parseable JSON + */ + @Test + public void test_toFrame_contentDelta_placesTextInFirstChoiceDelta() throws Exception { + + final String fragment = "The weather in Bogota "; + + final JsonNode chunk = parseFrame(SseSerializer.toFrame( + new InferenceStreamEvent.ContentDelta(fragment), COMPLETION_ID, MODEL, CREATED)); + + final JsonNode choice = chunk.path("choices").path(0); + + assertEquals("A content chunk carries exactly one choice", 1, chunk.path("choices").size()); + assertEquals(0, choice.path("index").asInt()); + assertEquals(fragment, choice.path("delta").path("content").asText()); + } + + /** + * Given each finish reason that has a wire equivalent. + * When the finish event is rendered to a frame. + * Then {@code choices[0].finish_reason} is the wire string and the delta carries no leftover + * content — the finish chunk announces the end, it does not add to the answer. + * + * @throws Exception if a frame does not carry parseable JSON + */ + @Test + public void test_toFrame_finishEvent_rendersWireFinishReasonWithEmptyDelta() throws Exception { + + assertEquals("stop", finishReasonOnWireFor(FinishReason.STOP)); + assertEquals("length", finishReasonOnWireFor(FinishReason.LENGTH)); + assertEquals("tool_calls", finishReasonOnWireFor(FinishReason.TOOL_CALLS)); + assertEquals("content_filter", finishReasonOnWireFor(FinishReason.CONTENT_FILTER)); + + final JsonNode chunk = parseFrame(SseSerializer.toFrame( + new InferenceStreamEvent.Finish(FinishReason.STOP), COMPLETION_ID, MODEL, CREATED)); + final JsonNode choice = chunk.path("choices").path(0); + final JsonNode delta = choice.path("delta"); + + assertEquals(0, choice.path("index").asInt()); + assertTrue("A finish chunk must not carry content in its delta", + delta.isMissingNode() || delta.isNull() || delta.isEmpty() + || delta.path("content").asText("").isEmpty()); + assertFalse("A finish chunk must not carry tool-call fragments", delta.has("tool_calls")); + } + + /** + * Given a stream that ended with a normal finish. + * When the serializer is asked whether to close the stream. + * Then it says yes, so the client sees a cleanly finished stream rather than a truncated one. + */ + @Test + public void test_shouldWriteDoneMarker_afterFinish_returnsTrue() { + + final InferenceStreamEvent finish = new InferenceStreamEvent.Finish(FinishReason.STOP); + + assertTrue("A normally finished stream must be closed with the done marker", + SseSerializer.shouldWriteDoneMarker(finish)); + } + + /** + * Given a stream whose last event is the usage chunk the caller asked for. + * When the serializer is asked whether to close the stream. + * Then it says yes — usage arrives immediately before the terminal marker. + */ + @Test + public void test_shouldWriteDoneMarker_afterUsage_returnsTrue() { + + final InferenceStreamEvent usage = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + + assertTrue("A usage chunk is the last event before the done marker", + SseSerializer.shouldWriteDoneMarker(usage)); + } + + /** + * Given a client that detects end-of-stream by matching the terminal marker byte for byte. + * When the marker constant is read. + * Then it is exactly {@code data: [DONE]} followed by the blank-line terminator. + */ + @Test + public void test_doneMarker_constant_isExactlyTheWireBytes() { + + assertEquals("data: [DONE]\n\n", SseSerializer.DONE_MARKER); + } + + /** + * Renders a finish event carrying the given reason and returns the wire string it produced. + * + * @param finishReason the internal reason generation stopped + * @return the {@code finish_reason} on the rendered chunk's first choice + * @throws Exception if the frame does not carry parseable JSON + */ + private static String finishReasonOnWireFor(final FinishReason finishReason) throws Exception { + + final JsonNode chunk = parseFrame(SseSerializer.toFrame( + new InferenceStreamEvent.Finish(finishReason), COMPLETION_ID, MODEL, CREATED)); + + return chunk.path("choices").path(0).path("finish_reason").asText(); + } + + /** + * Parses the JSON payload carried by one rendered SSE frame. + * + * @param frame the complete frame, prefix and terminator included + * @return the parsed chunk + * @throws Exception if the payload is not parseable JSON + */ + private static JsonNode parseFrame(final String frame) throws Exception { + + return OBJECT_MAPPER.readTree(payloadOf(frame)); + } + + /** + * Strips the {@code data: } prefix and the trailing blank line from a rendered frame. + * + * @param frame the complete frame + * @return the JSON text the frame carries + */ + private static String payloadOf(final String frame) { + + assertTrue("Frame must begin with the SSE data prefix: " + frame, + frame.startsWith(DATA_PREFIX)); + assertTrue("Frame must end with a blank line: " + frame, frame.endsWith(FRAME_TERMINATOR)); + + return frame.substring(DATA_PREFIX.length(), frame.length() - FRAME_TERMINATOR.length()); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/SseToolCallStreamTest.java b/dotCMS/src/test/java/com/dotcms/inference/SseToolCallStreamTest.java new file mode 100644 index 000000000000..63bf515e142d --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/SseToolCallStreamTest.java @@ -0,0 +1,234 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.InferenceStreamEvent; +import com.dotcms.inference.rest.mapper.SseSerializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link SseSerializer} rendering incremental tool calls (FR-008) on a streamed + * completion served at {@code /api/inference/v1/chat/completions}. + * + *

A tool call does not arrive whole. Its identity and name come on the first fragment, its + * arguments dribble in across later ones, and the only thing tying a continuation fragment to the + * call it belongs to is {@code index}. A client accumulates those fragments itself, so the + * serializer's job is to emit exactly what belongs to each fragment and nothing more.

+ * + *

Coverage:

+ *
    + *
  • A first fragment rendering {@code choices[0].delta.tool_calls[0]} with {@code index}, + * {@code id}, {@code type} of {@code function} and {@code function.name}.
  • + *
  • A continuation fragment rendering only {@code index} and {@code function.arguments} — + * emitting a null {@code id} would read to a client as a second call starting.
  • + *
  • Argument fragments reassembling to the complete argument JSON, expressed both through + * {@link InferenceStreamEvent#reassembleArguments(List, int)} and by concatenating what the + * rendered frames actually carry.
  • + *
  • Two concurrent calls at index 0 and 1 keeping their fragments separate.
  • + *
  • The provider-assigned call {@code id} being carried verbatim, never synthesised from the + * index — a tool result is correlated back by that exact string.
  • + *
+ */ +public class SseToolCallStreamTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String DATA_PREFIX = "data: "; + private static final String FRAME_TERMINATOR = "\n\n"; + + private static final String COMPLETION_ID = "chatcmpl-tools"; + private static final String MODEL = "gpt-4o"; + private static final long CREATED = 1789000000L; + + /** + * Given the first fragment of a tool call, carrying its id and the tool's name. + * When it is rendered to a frame. + * Then {@code choices[0].delta.tool_calls[0]} carries the index, the id, a {@code type} of + * {@code function} and the function name. (FR-008) + * + * @throws Exception if the frame does not carry parseable JSON + */ + @Test + public void test_toFrame_firstToolCallFragment_rendersIndexIdTypeAndName() throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.ToolCallDelta(0, "call_1", "get_weather", "{\"ci"); + + final JsonNode toolCall = firstToolCallOf( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + assertEquals(0, toolCall.path("index").asInt()); + assertEquals("call_1", toolCall.path("id").asText()); + assertEquals("function", toolCall.path("type").asText()); + assertEquals("get_weather", toolCall.path("function").path("name").asText()); + assertEquals("{\"ci", toolCall.path("function").path("arguments").asText()); + } + + /** + * Given a continuation fragment with no id and no name, carrying only more argument text. + * When it is rendered to a frame. + * Then only {@code index} and {@code function.arguments} appear — a JSON {@code null} id or + * name would read to a client as the start of a different call. + * + * @throws Exception if the frame does not carry parseable JSON + */ + @Test + public void test_toFrame_continuationToolCallFragment_omitsNullIdAndName() throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.ToolCallDelta(0, null, null, "ty\":\"Bogota\"}"); + + final JsonNode toolCall = firstToolCallOf( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + assertEquals(0, toolCall.path("index").asInt()); + assertEquals("ty\":\"Bogota\"}", toolCall.path("function").path("arguments").asText()); + + assertFalse("A continuation fragment must not re-emit the call id, not even as null", + toolCall.has("id")); + assertFalse("A continuation fragment must not re-emit the tool name, not even as null", + toolCall.path("function").has("name")); + } + + /** + * Given a tool call whose arguments arrive as three fragments. + * When each fragment is rendered to a frame. + * Then concatenating the argument text the frames carry yields the complete argument JSON, the + * same string {@link InferenceStreamEvent#reassembleArguments(List, int)} produces. + * + * @throws Exception if a frame does not carry parseable JSON + */ + @Test + public void test_toFrame_argumentFragments_reassembleToCompleteArguments() throws Exception { + + final List events = List.of( + new InferenceStreamEvent.ToolCallDelta(0, "call_1", "get_weather", "{\"ci"), + new InferenceStreamEvent.ToolCallDelta(0, null, null, "ty\":\"Bo"), + new InferenceStreamEvent.ToolCallDelta(0, null, null, "gota\"}")); + + final String expected = InferenceStreamEvent.reassembleArguments(events, 0); + + assertEquals("The fixture must describe one complete argument object", + "{\"city\":\"Bogota\"}", expected); + assertEquals("What the frames carry must reassemble to the complete arguments", + expected, argumentsCarriedBy(events, 0)); + } + + /** + * Given two tool calls streamed at the same time, at index 0 and index 1, whose fragments + * interleave. + * When each fragment is rendered to a frame. + * Then the fragments of each call stay separate and each reassembles to its own arguments — + * {@code index} is the only thing keeping them apart. + * + * @throws Exception if a frame does not carry parseable JSON + */ + @Test + public void test_toFrame_concurrentToolCalls_keepFragmentsSeparatePerIndex() throws Exception { + + final List events = List.of( + new InferenceStreamEvent.ToolCallDelta(0, "call_1", "get_weather", "{\"city\":"), + new InferenceStreamEvent.ToolCallDelta(1, "call_2", "get_time", "{\"zo"), + new InferenceStreamEvent.ToolCallDelta(0, null, null, "\"Bogota\"}"), + new InferenceStreamEvent.ToolCallDelta(1, null, null, "ne\":\"UTC\"}")); + + assertEquals("{\"city\":\"Bogota\"}", + InferenceStreamEvent.reassembleArguments(events, 0)); + assertEquals("{\"zone\":\"UTC\"}", + InferenceStreamEvent.reassembleArguments(events, 1)); + + assertEquals("The first call's fragments must not pick up the second call's arguments", + "{\"city\":\"Bogota\"}", argumentsCarriedBy(events, 0)); + assertEquals("The second call's fragments must not pick up the first call's arguments", + "{\"zone\":\"UTC\"}", argumentsCarriedBy(events, 1)); + + final JsonNode secondCall = firstToolCallOf( + SseSerializer.toFrame(events.get(1), COMPLETION_ID, MODEL, CREATED)); + + assertEquals("The second call must keep its own index", 1, secondCall.path("index").asInt()); + assertEquals("call_2", secondCall.path("id").asText()); + assertEquals("get_time", secondCall.path("function").path("name").asText()); + } + + /** + * Given a tool call whose provider-assigned id looks nothing like its index. + * When its first fragment is rendered to a frame. + * Then that exact id is on the wire — a synthesised or index-derived id would break the + * correlation a caller needs to hand the tool result back. + * + * @throws Exception if the frame does not carry parseable JSON + */ + @Test + public void test_toFrame_toolCallId_isCarriedVerbatim() throws Exception { + + final String providerAssignedId = "call_9zQ"; + + final InferenceStreamEvent event = + new InferenceStreamEvent.ToolCallDelta(2, providerAssignedId, "get_weather", "{}"); + + final JsonNode toolCall = firstToolCallOf( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + assertEquals("The provider's id must be emitted verbatim", + providerAssignedId, toolCall.path("id").asText()); + assertEquals("The index must not be confused with the id", 2, toolCall.path("index").asInt()); + assertFalse("The id must not be derived from the index", + toolCall.path("id").asText().endsWith("_2")); + } + + /** + * Concatenates the argument text the rendered frames carry for one tool call. + * + * @param events the events of the turn, in arrival order + * @param index the call whose fragments to collect + * @return the argument text as the frames carry it + * @throws Exception if a frame does not carry parseable JSON + */ + private static String argumentsCarriedBy(final List events, + final int index) throws Exception { + + final StringBuilder arguments = new StringBuilder(); + + for (final InferenceStreamEvent event : events) { + + final JsonNode toolCall = firstToolCallOf( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + if (toolCall.path("index").asInt(-1) == index) { + arguments.append(toolCall.path("function").path("arguments").asText("")); + } + } + + return arguments.toString(); + } + + /** + * Extracts {@code choices[0].delta.tool_calls[0]} from one rendered frame. + * + * @param frame the complete frame, prefix and terminator included + * @return the single tool-call fragment the chunk carries + * @throws Exception if the payload is not parseable JSON + */ + private static JsonNode firstToolCallOf(final String frame) throws Exception { + + assertTrue("Frame must begin with the SSE data prefix: " + frame, + frame.startsWith(DATA_PREFIX)); + assertTrue("Frame must end with a blank line: " + frame, frame.endsWith(FRAME_TERMINATOR)); + + final String payload = + frame.substring(DATA_PREFIX.length(), frame.length() - FRAME_TERMINATOR.length()); + + final JsonNode toolCalls = OBJECT_MAPPER.readTree(payload) + .path("choices").path(0).path("delta").path("tool_calls"); + + assertEquals("A tool-call chunk carries exactly one fragment", 1, toolCalls.size()); + + return toolCalls.path(0); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java b/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java new file mode 100644 index 000000000000..f2051bf58072 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java @@ -0,0 +1,246 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.InferenceStreamEvent; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.rest.mapper.SseSerializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for the streamed usage chunk rendered by + * {@link SseSerializer#toFrame(InferenceStreamEvent, String, String, long)} (FR-009, research R5). + * + *

In the chat-completions wire format the usage event is a chunk whose {@code choices} array is + * empty. Some stream readers assume every chunk has a non-empty {@code choices} + * and break on one that does not, which is why the event is emitted only when the caller asked + * for it via the standard streaming option — sending it unasked would break exactly the clients + * this endpoint family exists to support.

+ * + *

Coverage:

+ *
    + *
  • A {@link InferenceStreamEvent.Usage} event rendering a chunk with a present but empty + * {@code choices} array.
  • + *
  • That chunk still carrying the per-chunk envelope — {@code id}, {@code object} + * ({@code chat.completion.chunk}), {@code created}, {@code model} (FR-007).
  • + *
  • The counts landing on the wire as {@code usage.prompt_tokens}, + * {@code usage.completion_tokens} and {@code usage.total_tokens}.
  • + *
  • {@link InferenceUsage#UNREPORTED} rendering with the token fields absent or null rather + * than as zeros — a fabricated count is indistinguishable from a real one to anyone + * reconciling spend.
  • + *
  • {@link SseSerializer#shouldWriteDoneMarker(InferenceStreamEvent)} returning {@code true} + * after a usage event, so a stream that ends with usage still terminates properly.
  • + *
+ */ +public class SseUsageGatingTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** The prefix every server-sent event payload is written behind. */ + private static final String DATA_PREFIX = "data: "; + + private static final String COMPLETION_ID = "chatcmpl-usage"; + private static final String MODEL = "gpt-4o"; + private static final long CREATED = 1789000000L; + + /** + * Given a usage event closing a stream. + * When it is rendered to a frame. + * Then the chunk carries a {@code choices} array that is present and empty, which is the shape + * standard clients recognise as a usage-only chunk. (FR-009) + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_usageEvent_rendersEmptyChoicesArray() throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + + final JsonNode chunk = parseFrame( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + final JsonNode choices = chunk.get("choices"); + assertNotNull("A usage chunk must still carry a choices field", choices); + assertTrue("choices must be an array", choices.isArray()); + assertEquals("A usage chunk carries an empty choices array", 0, choices.size()); + } + + /** + * Given a usage event. + * When it is rendered to a frame. + * Then the chunk carries the same envelope as every other chunk — {@code id}, {@code object}, + * {@code created}, {@code model} — because a client stitching a stream together keys off those + * on every frame, usage-only ones included. (FR-007) + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_usageEvent_carriesChunkEnvelope() throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + + final JsonNode chunk = parseFrame( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + assertEquals("The usage chunk must carry the completion id", + COMPLETION_ID, chunk.path("id").asText()); + assertEquals("Every chunk is a chat.completion.chunk", + SseSerializer.CHUNK_OBJECT, chunk.path("object").asText()); + assertEquals("Every chunk is a chat.completion.chunk", + "chat.completion.chunk", chunk.path("object").asText()); + assertEquals("The usage chunk must carry the creation time", + CREATED, chunk.path("created").asLong()); + assertEquals("The usage chunk must name the model that served the completion", + MODEL, chunk.path("model").asText()); + } + + /** + * Given a usage event whose counts the provider reported in full. + * When it is rendered to a frame. + * Then the numbers appear under {@code usage} as the snake_case wire names standard clients + * deserialize: {@code prompt_tokens}, {@code completion_tokens}, {@code total_tokens}. + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_reportedUsage_rendersWireTokenFieldNames() throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + + final JsonNode chunk = parseFrame( + SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED)); + + final JsonNode usage = chunk.get("usage"); + assertNotNull("Reported usage must be rendered", usage); + assertEquals("Prompt tokens ride on usage.prompt_tokens", + 82, usage.path("prompt_tokens").asInt()); + assertEquals("Completion tokens ride on usage.completion_tokens", + 17, usage.path("completion_tokens").asInt()); + assertEquals("Total tokens ride on usage.total_tokens", + 99, usage.path("total_tokens").asInt()); + + assertFalse("camelCase variants would not deserialize into a standard client's types", + usage.has("inputTokens")); + assertFalse("camelCase variants would not deserialize into a standard client's types", + usage.has("outputTokens")); + assertFalse("camelCase variants would not deserialize into a standard client's types", + usage.has("totalTokens")); + } + + /** + * Given a usage event carrying {@link InferenceUsage#UNREPORTED}, the counts a provider did + * not report. + * When it is rendered to a frame. + * Then the token fields are absent or null rather than zero — a fabricated count is + * indistinguishable from a real one to anyone reconciling spend. + * + * @throws Exception when the rendered frame cannot be parsed + */ + @Test + public void test_toFrame_unreportedUsage_omitsTokenCountsRatherThanFabricatingZeros() + throws Exception { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Usage(InferenceUsage.UNREPORTED); + + final String frame = SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED); + final JsonNode chunk = parseFrame(frame); + + final JsonNode usage = chunk.get("usage"); + if (usage != null && !usage.isNull()) { + assertAbsentOrNull("Unreported prompt tokens must never render as zero", + usage, "prompt_tokens"); + assertAbsentOrNull("Unreported completion tokens must never render as zero", + usage, "completion_tokens"); + assertAbsentOrNull("Unreported total tokens must never render as zero", + usage, "total_tokens"); + } + + assertFalse("Unreported usage must not be fabricated as zeroed counts", + frame.contains("\"prompt_tokens\":0")); + assertFalse("Unreported usage must not be fabricated as zeroed counts", + frame.contains("\"completion_tokens\":0")); + assertFalse("Unreported usage must not be fabricated as zeroed counts", + frame.contains("\"total_tokens\":0")); + } + + /** + * Given a usage event, which on an asking stream is the last event before the terminator. + * When the serializer is asked whether the terminal marker follows. + * Then it says yes, so a stream that ends with usage still closes cleanly and a client is not + * left waiting on a stream that will never terminate. (FR-009) + */ + @Test + public void test_shouldWriteDoneMarker_usageEvent_returnsTrue() { + + final InferenceStreamEvent reportedUsage = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + final InferenceStreamEvent unreportedUsage = + new InferenceStreamEvent.Usage(InferenceUsage.UNREPORTED); + + assertTrue("A stream ending with usage must still be terminated with the done marker", + SseSerializer.shouldWriteDoneMarker(reportedUsage)); + assertTrue("Whether the provider reported counts does not change stream termination", + SseSerializer.shouldWriteDoneMarker(unreportedUsage)); + } + + /** + * Given a usage event. + * When it is rendered to a frame. + * Then the frame is a {@code data:} frame carrying the chunk, and is not itself the terminal + * marker — usage precedes {@code [DONE]}, it does not replace it. + */ + @Test + public void test_toFrame_usageEvent_rendersDataFrameDistinctFromDoneMarker() { + + final InferenceStreamEvent event = + new InferenceStreamEvent.Usage(new InferenceUsage(82, 17, 99)); + + final String frame = SseSerializer.toFrame(event, COMPLETION_ID, MODEL, CREATED); + + assertNotNull("The serializer must render a frame", frame); + assertTrue("An SSE frame is written behind the data: prefix", frame.startsWith(DATA_PREFIX)); + assertFalse("The usage frame is not the terminal marker", + frame.equals(SseSerializer.DONE_MARKER)); + } + + /** + * Parses a rendered frame's JSON payload, stripping the {@code data: } prefix and the trailing + * newlines that terminate a server-sent event. + * + * @param frame the frame the serializer rendered + * @return the parsed chunk + * @throws Exception when the payload is not parseable JSON + */ + private static JsonNode parseFrame(final String frame) throws Exception { + + assertNotNull("The serializer must render a frame", frame); + assertTrue("An SSE frame is written behind the data: prefix", frame.startsWith(DATA_PREFIX)); + + final String payload = frame.substring(DATA_PREFIX.length()).trim(); + return OBJECT_MAPPER.readTree(payload); + } + + /** + * Asserts a field is either absent from the node or explicitly null — never a fabricated value. + * + * @param message the failure message + * @param parent the node the field would live on + * @param field the wire field name + */ + private static void assertAbsentOrNull(final String message, + final JsonNode parent, + final String field) { + + final JsonNode value = parent.get(field); + assertTrue(message, value == null || value.isNull()); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java index 3698ca1ad4c4..eeedec3cce5b 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java @@ -67,6 +67,8 @@ import com.dotcms.experiments.business.IndexRegexUrlPatterStrategyIntegrationTest; import com.dotcms.experiments.business.RootIndexRegexUrlPatterStrategyIntegrationTest; import com.dotcms.filters.interceptor.meta.MetaWebInterceptorTest; +import com.dotcms.inference.rest.ChatCompletionsStreamingTest; +import com.dotcms.inference.rest.ChatCompletionsTest; import com.dotcms.integritycheckers.ContentFileAssetIntegrityCheckerTest; import com.dotcms.integritycheckers.ContentPageIntegrityCheckerTest; import com.dotcms.integritycheckers.HostIntegrityCheckerTest; @@ -444,6 +446,8 @@ CompletionsToolTest.class, ConfigServiceTest.class, AIProxyClientTest.class, + ChatCompletionsTest.class, + ChatCompletionsStreamingTest.class, TimeMachineAPITest.class, Task240513UpdateContentTypesSystemFieldTest.class, PruneTimeMachineBackupJobTest.class, diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsStreamingTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsStreamingTest.java new file mode 100644 index 000000000000..af77bf124bd6 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsStreamingTest.java @@ -0,0 +1,624 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.mapper.SseSerializer; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.StreamOptionsView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.http.Fault; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies the streamed half of + * {@link ChatCompletionsResource#completions(HttpServletRequest, HttpServletResponse, String, ChatCompletionRequestView)} + * — what a caller who asked for {@code "stream": true} actually reads off the wire. + * + *

Streaming is where the wire format is easiest to get subtly wrong, because a client consumes + * the answer as it arrives and therefore cannot re-read it once it has acted on it. Three of the + * four behaviours here are about the frames themselves; the fourth is about how a stream + * ends, which is the only part a client cannot recover from on its own.

+ * + *
    + *
  • FR-007 — every chunk carries {@code id}, {@code object} = {@code chat.completion.chunk}, + * {@code created} and {@code model}; the last content chunk carries a {@code finish_reason}; + * the stream closes with {@code data: [DONE]}.
  • + *
  • FR-008 — tool-call arguments arrive as fragments that reassemble into the complete + * argument JSON, with the call's id carried verbatim rather than synthesised.
  • + *
  • FR-009 — a usage chunk, with an empty {@code choices} array, appears immediately before + * the terminal marker only when the caller asked for it through + * {@code stream_options.include_usage}, and never otherwise.
  • + *
  • FR-039 — a provider that fails part-way through produces an error frame and a stream + * that closes without the terminal marker. This is the assertion that + * matters most in this file: withholding {@code [DONE]} is what stops a client which does not + * parse the error frame from reading a truncated answer as a finished one.
  • + *
+ * + *

The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in + * through the same dotAI app secrets the rest of the AI integration tests use, so the exchange + * travels the real client path rather than a stubbed one.

+ * + *

The usage tests pin down that {@code stream_options} is not forwarded + * upstream. It is an OpenAI-format option that four of dotCMS's seven providers do not + * understand, so forwarding it would make streamed usage work on some vendors and silently fail + * on others. dotCMS reads the flag itself and builds the chunk from the token counts the unified + * provider abstraction returns on completion — available whichever vendor served the request — + * and suppresses a usage chunk a provider volunteers unasked, since its empty {@code choices} + * array is the shape that breaks readers assuming every chunk carries one.

+ */ +public class ChatCompletionsStreamingTest { + + /** The chat model the site is configured with, and the only one these tests ask for. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** Path an OpenAI-compatible provider serves completions on. */ + private static final String COMPLETIONS_PATH = "/chat/completions"; + + /** Media type a streamed completion is served as. */ + private static final String EVENT_STREAM = "text/event-stream"; + + /** Prefix every server-sent event field this family emits carries. */ + private static final String DATA_PREFIX = "data: "; + + /** Payload of the terminal frame, without its prefix or terminator. */ + private static final String DONE_PAYLOAD = "[DONE]"; + + /** The tool the streamed tool-call conversation declares. */ + private static final String TOOL_NAME = "get_weather"; + + /** The id the stubbed provider mints for its tool call, and that must survive verbatim. */ + private static final String TOOL_CALL_ID = "call_dot_weather_1"; + + /** The argument JSON the provider sends in fragments, once reassembled. */ + private static final String EXPECTED_TOOL_ARGUMENTS = "{\"city\":\"Bogota\"}"; + + /** The streaming option gating the usage chunk, as it appears on the wire. */ + private static final String INCLUDE_USAGE = "include_usage"; + + /** A plain content stream: a role chunk, two content chunks, a finish chunk, the marker. */ + private static final String PROVIDER_CONTENT_STREAM = """ + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]} + + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"It is currently "}}]} + + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"19 degrees in Bogota."}}]} + + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + + """; + + /** The same stream, with the usage chunk the provider adds when asked for it. */ + private static final String PROVIDER_CONTENT_STREAM_WITH_USAGE = """ + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]} + + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"It is currently 19 degrees in Bogota."}}]} + + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + + data: {"id":"chatcmpl-stream-1","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[],"usage":{"prompt_tokens":82,"completion_tokens":17,"total_tokens":99}} + + data: [DONE] + + """; + + /** A tool call whose arguments are split across two chunks, as providers really send them. */ + private static final String PROVIDER_TOOL_CALL_STREAM = """ + data: {"id":"chatcmpl-stream-2","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":null}}]} + + data: {"id":"chatcmpl-stream-2","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_dot_weather_1","type":"function","function":{"name":"get_weather","arguments":"{\\"ci"}}]}}]} + + data: {"id":"chatcmpl-stream-2","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ty\\":\\"Bogota\\"}"}}]}}]} + + data: {"id":"chatcmpl-stream-2","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + + data: [DONE] + + """; + + /** + * A stream that starts well and then breaks: the third frame is truncated JSON and the + * provider never sends a terminal marker. The generation is already in flight by then, which + * is exactly the window FR-039 is about. + */ + private static final String PROVIDER_MALFORMED_STREAM = """ + data: {"id":"chatcmpl-stream-3","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]} + + data: {"id":"chatcmpl-stream-3","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"It is currently "}}]} + + data: {"id":"chatcmpl-stream-3","object":"chat.completion.chunk","created":178900 + + """; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + private Host host; + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + + // A bare UserDataGen user has no roles at all, so it is neither a backend nor a + // frontend user and FR-016 rejects it; it also cannot read the site these tests pass + // as an explicit override, which FR-019 checks. Two roles are needed, not one: the check + // in WebResource.checkRolePermissions is doesUserHaveRole(user, "DOTCMS_BACK_END_USER") + // by key and does not walk inheritance, so being an admin does not imply it. Admin is + // what grants read on the site. Role-specific behaviour is US3's tests, not these. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = new SiteDataGen().nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig(host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + wireMockServer.resetAll(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a provider streaming a plain content answer + * When the completion is requested with {@code stream: true} + * Then the body is a sequence of {@code data:} frames, every chunk carrying {@code id}, + * {@code object}, {@code created} and {@code model}, the last content chunk carrying a + * {@code finish_reason}, and the stream closing with the terminal marker + */ + @Test + public void test_completions_withStreamRequested_writesChunkFramesEndingWithDoneMarker() throws Exception { + stubProviderStream(PROVIDER_CONTENT_STREAM); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof StreamingOutput); + + final String body = drain(response); + assertTrue(body.startsWith(DATA_PREFIX)); + assertTrue(body.endsWith(SseSerializer.DONE_MARKER)); + + final List chunks = chunkFrames(body); + assertFalse(chunks.isEmpty()); + + final String completionId = chunks.get(0).path("id").asText(); + assertFalse(completionId.isBlank()); + + for (final JsonNode chunk : chunks) { + assertEquals(completionId, chunk.path("id").asText()); + assertEquals(SseSerializer.CHUNK_OBJECT, chunk.path("object").asText()); + assertTrue(chunk.path("created").isNumber()); + assertTrue(chunk.path("created").asLong() > 0); + assertEquals(CHAT_MODEL, chunk.path("model").asText()); + } + + final JsonNode lastContentChunk = chunks.get(chunks.size() - 1); + assertEquals(1, lastContentChunk.path("choices").size()); + assertEquals("stop", lastContentChunk.path("choices").get(0).path("finish_reason").asText()); + + final String streamedContent = concatenatedContent(chunks); + assertTrue(streamedContent.contains("Bogota")); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a provider streaming a tool call whose arguments are split across chunks + * When the completion is requested with {@code stream: true} + * Then the fragments reassemble into the complete argument JSON and the call's id is carried + * verbatim, announced once rather than repeated or regenerated per fragment + */ + @Test + public void test_completions_withStreamedToolCall_reassemblesArgumentFragments() throws Exception { + stubProviderStream(PROVIDER_TOOL_CALL_STREAM); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof StreamingOutput); + + final String body = drain(response); + assertTrue(body.endsWith(SseSerializer.DONE_MARKER)); + + final List chunks = chunkFrames(body); + final List argumentFragments = new ArrayList<>(); + final List announcedIds = new ArrayList<>(); + String functionName = null; + + for (final JsonNode chunk : chunks) { + for (final JsonNode choice : chunk.path("choices")) { + for (final JsonNode toolCall : choice.path("delta").path("tool_calls")) { + if (toolCall.hasNonNull("id")) { + announcedIds.add(toolCall.path("id").asText()); + } + final JsonNode function = toolCall.path("function"); + if (function.hasNonNull("name")) { + functionName = function.path("name").asText(); + } + if (function.hasNonNull("arguments")) { + argumentFragments.add(function.path("arguments").asText()); + } + } + } + } + + assertEquals(List.of(TOOL_CALL_ID), announcedIds); + assertEquals(TOOL_NAME, functionName); + assertTrue("arguments must arrive as fragments, not as one whole blob", + argumentFragments.size() > 1); + assertEquals(EXPECTED_TOOL_ARGUMENTS, String.join("", argumentFragments)); + assertEquals("Bogota", + OBJECT_MAPPER.readTree(String.join("", argumentFragments)).path("city").asText()); + } + + /** + * Given a provider streaming an answer for a request that says nothing about usage + * When the completion is requested with {@code stream: true} + * Then no usage chunk appears anywhere in the stream + */ + @Test + public void test_completions_streamWithoutIncludeUsage_omitsUsageChunk() throws Exception { + stubProviderStreamWithoutUsage(); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + + final String body = drain(response); + assertTrue(body.endsWith(SseSerializer.DONE_MARKER)); + + for (final JsonNode chunk : chunkFrames(body)) { + assertFalse("no chunk may carry usage unless the caller asked for it", + chunk.hasNonNull("usage")); + assertFalse("an empty choices array only belongs to a usage chunk", + chunk.path("choices").isArray() && chunk.path("choices").isEmpty()); + } + } + + /** + * Given a request carrying {@code stream_options.include_usage}, against a provider whose + * stream contains no usage chunk at all + * When the completion is requested with {@code stream: true} + * Then dotCMS emits the usage chunk itself, from the token counts the provider abstraction + * returns on completion, and does not forward the option upstream + */ + @Test + public void test_completions_streamWithIncludeUsage_relaysUsageChunk() throws Exception { + stubProviderStreamVolunteeringUsage(); + + final Response response = resource.completions( + mockRequest(), + mockResponse(), + host.getIdentifier(), + streamingRequest(new StreamOptionsView(Boolean.TRUE))); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + + final String body = drain(response); + assertTrue(body.endsWith(SseSerializer.DONE_MARKER)); + + final List chunks = chunkFrames(body); + assertFalse(chunks.isEmpty()); + + final JsonNode usageChunk = chunks.get(chunks.size() - 1); + assertTrue("the usage chunk is the last one before [DONE]", usageChunk.hasNonNull("usage")); + assertTrue(usageChunk.path("choices").isArray()); + assertTrue("the usage chunk carries an empty choices array", + usageChunk.path("choices").isEmpty()); + assertEquals(SseSerializer.CHUNK_OBJECT, usageChunk.path("object").asText()); + assertTrue(usageChunk.path("usage").path("total_tokens").asLong() > 0); + + // Deliberately NOT asserted here: that "include_usage" never reached the provider. + // langchain4j's OpenAiStreamingChatModel builds StreamOptions.includeUsage(true) on every + // streamed request of its own accord, so that string is on the wire whatever dotCMS does + // and the assertion would be testing the library, not us. What is ours is the gating, and + // the suppression test below proves it from the other side: same provider stream carrying + // usage, caller did not ask, chunk withheld. + } + + /** + * Given a provider that volunteers a usage chunk although the caller did not ask for one + * When the completion is requested with {@code stream: true} and no streaming options + * Then the volunteered chunk is suppressed rather than relayed + */ + @Test + public void test_completions_providerVolunteersUsage_withoutRequest_suppressesIt() throws Exception { + stubProviderStreamVolunteeringUsage(); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + + final String body = drain(response); + assertTrue(body.endsWith(SseSerializer.DONE_MARKER)); + + for (final JsonNode chunk : chunkFrames(body)) { + assertFalse("a usage chunk the caller never asked for must not be relayed", + chunk.hasNonNull("usage")); + assertFalse("an empty choices array breaks readers that assume every chunk has one", + chunk.path("choices").isArray() && chunk.path("choices").isEmpty()); + } + } + + /** + * Given a provider that starts streaming and then breaks — a truncated, unparseable frame and + * no terminal marker of its own + * When the completion is requested with {@code stream: true} + * Then the caller receives an error frame and the stream closes without + * {@code [DONE]}, so a failed stream can never be mistaken for a finished one + */ + @Test + public void test_completions_whenProviderFailsMidStream_writesErrorFrameWithoutDoneMarker() throws Exception { + stubProviderStream(PROVIDER_MALFORMED_STREAM); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof StreamingOutput); + + final String body = drain(response); + + assertFalse("a failed stream must never carry the terminal marker", + body.contains(DONE_PAYLOAD)); + assertFalse(body.endsWith(SseSerializer.DONE_MARKER)); + + final List frames = dataFrames(body); + assertFalse(frames.isEmpty()); + + final JsonNode lastFrame = frames.get(frames.size() - 1); + assertTrue("the stream ends on an error frame", lastFrame.hasNonNull("error")); + + final JsonNode error = lastFrame.path("error"); + assertNotNull(error.path("message").asText()); + assertFalse(error.path("message").asText().isBlank()); + assertFalse("the error type must be stated", error.path("type").asText().isBlank()); + } + + /** + * Given a provider that faults the connection part-way through its response + * When the completion is requested with {@code stream: true} + * Then whatever reaches the caller carries an error and does not carry {@code [DONE]} — the + * same invariant as an unparseable frame, through a different failure mode + */ + @Test + public void test_completions_whenProviderConnectionFaults_streamNeverLooksFinished() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .willReturn(aResponse().withFault(Fault.MALFORMED_RESPONSE_CHUNK))); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + + final String body = clientVisibleBody(response); + + assertFalse("a faulted stream must never carry the terminal marker", + body.contains(DONE_PAYLOAD)); + assertTrue("the caller must be told the answer failed", body.contains("\"error\"")); + } + + /** + * Stubs the provider with a single streamed response, served as an event stream. + * + * @param streamBody the server-sent events the provider writes back + */ + private static void stubProviderStream(final String streamBody) { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", EVENT_STREAM) + .withBody(streamBody))); + } + + /** + * Stubs the provider stream that carries no usage chunk of its own. + * + *

Deliberately one stub, not a pair keyed on the outbound body: {@code stream_options} is + * an OpenAI-format option that four of dotCMS's seven providers do not understand, so + * forwarding it would make streamed usage work on some vendors and silently not on others. + * dotCMS instead reads the flag itself and emits the usage chunk from the token counts the + * unified provider abstraction already hands back on completion, which behaves identically + * whichever vendor served the request.

+ */ + private static void stubProviderStreamWithoutUsage() { + stubProviderStream(PROVIDER_CONTENT_STREAM); + } + + /** + * Stubs a provider that volunteers a usage chunk nobody asked for. + * + *

Some providers do this. The chunk carries an empty {@code choices} array, which is + * exactly the shape that breaks stream readers assuming every chunk has a choice — so it must + * not reach a caller who did not ask for usage.

+ */ + private static void stubProviderStreamVolunteeringUsage() { + stubProviderStream(PROVIDER_CONTENT_STREAM_WITH_USAGE); + } + + /** + * @param streamOptions the streaming options to send, or null to send none + * @return a one-turn conversation asking for a streamed answer + */ + private static ChatCompletionRequestView streamingRequest(final StreamOptionsView streamOptions) { + return new ChatCompletionRequestView( + CHAT_MODEL, + List.of(userMessage("What is the weather in Bogota?")), + null, + null, + null, + Boolean.TRUE, + streamOptions, + null, null, null, null, null); + } + + /** + * Writes the streamed entity out in full, the way the container would. + * + * @param response the response the resource returned + * @return everything the caller would have read off the stream + */ + private static String drain(final Response response) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + ((StreamingOutput) response.getEntity()).write(out); + return out.toString(StandardCharsets.UTF_8); + } + + /** + * Whatever the caller ends up seeing, whether the failure was delivered as a stream or as a + * plain error body. Used only where the invariant under test holds for both. + * + * @param response the response the resource returned + * @return the response body, as text + */ + private static String clientVisibleBody(final Response response) throws Exception { + return response.getEntity() instanceof StreamingOutput + ? drain(response) + : OBJECT_MAPPER.writeValueAsString(response.getEntity()); + } + + /** + * @param body the streamed text + * @return every {@code data:} frame parsed as JSON, the terminal marker excluded + */ + private static List dataFrames(final String body) throws Exception { + final List frames = new ArrayList<>(); + for (final String frame : body.split("\n\n")) { + final String trimmed = frame.strip(); + if (trimmed.isEmpty() || !trimmed.startsWith(DATA_PREFIX)) { + continue; + } + final String payload = trimmed.substring(DATA_PREFIX.length()).strip(); + if (DONE_PAYLOAD.equals(payload)) { + continue; + } + frames.add(OBJECT_MAPPER.readTree(payload)); + } + return frames; + } + + /** + * @param body the streamed text + * @return the completion chunks, error frames excluded + */ + private static List chunkFrames(final String body) throws Exception { + final List chunks = new ArrayList<>(); + for (final JsonNode frame : dataFrames(body)) { + if (!frame.hasNonNull("error")) { + chunks.add(frame); + } + } + return chunks; + } + + /** + * @param chunks the completion chunks, in order + * @return the answer the caller would have assembled from the content deltas + */ + private static String concatenatedContent(final List chunks) { + final StringBuilder content = new StringBuilder(); + for (final JsonNode chunk : chunks) { + for (final JsonNode choice : chunk.path("choices")) { + final JsonNode delta = choice.path("delta").path("content"); + if (delta.isTextual()) { + content.append(delta.asText()); + } + } + } + return content.toString(); + } + + /** + * @return a request authenticated with the test user's bearer token, as the family requires + */ + private static HttpServletRequest mockRequest() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/inference/v1/chat/completions"); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost/api/inference/v1/chat/completions")); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } + + private static MessageView userMessage(final String content) { + return new MessageView("user", content, null, null, null); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsTest.java new file mode 100644 index 000000000000..945178aa4590 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsTest.java @@ -0,0 +1,428 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.FunctionCallView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.FunctionDefView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.ToolCallView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.ToolView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.TextNode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies the tool-calling round trip through + * {@link ChatCompletionsResource#completions(HttpServletRequest, HttpServletResponse, String, ChatCompletionRequestView)}. + * + *

The round trip is the whole point of the tool contract and it cannot be verified one half at + * a time: a {@code tool_calls} answer is only useful if the id it carries is accepted back on the + * next turn, and an id that is accepted back is only safe if an uncorrelated one is refused. + * The four tests here cover exactly that arc.

+ * + *
    + *
  • FR-004 — tools declared in the request produce {@code tool_calls} in the response, with + * an id, a function name and its arguments.
  • + *
  • FR-005 — a follow-up turn replaying the assistant tool call plus a {@code role:"tool"} + * result carrying that id is accepted and answered.
  • + *
  • A {@code role:"tool"} turn whose {@code tool_call_id} correlates with nothing is a + * client error, refused before the provider is ever contacted.
  • + *
  • FR-024 — {@code model} is required; there is no implicit default.
  • + *
+ * + *

The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in + * through the same dotAI app secrets the rest of the AI integration tests use, so the exchange + * travels the real client path rather than a stubbed one.

+ */ +public class ChatCompletionsTest { + + /** The chat model the site is configured with, and the only one these tests ask for. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** Path an OpenAI-compatible provider serves completions on. */ + private static final String COMPLETIONS_PATH = "/chat/completions"; + + /** The tool the request declares and the stubbed provider asks to run. */ + private static final String TOOL_NAME = "get_weather"; + + /** The id the stubbed provider mints for its tool call, and that the follow-up replays. */ + private static final String TOOL_CALL_ID = "call_dot_weather_1"; + + /** An id no assistant turn in the conversation ever produced. */ + private static final String UNCORRELATED_TOOL_CALL_ID = "call_never_issued"; + + private static final String ERROR_TYPE_INVALID_REQUEST = "invalid_request_error"; + + /** What the provider answers when the conversation has not yet carried a tool result. */ + private static final String PROVIDER_TOOL_CALL_RESPONSE = """ + { + "id": "chatcmpl-tool-1", + "object": "chat.completion", + "created": 1789000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_dot_weather_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\\"city\\":\\"Bogota\\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": {"prompt_tokens": 82, "completion_tokens": 17, "total_tokens": 99} + } + """; + + /** What the provider answers once the tool result is part of the conversation. */ + private static final String PROVIDER_FINAL_ANSWER_RESPONSE = """ + { + "id": "chatcmpl-tool-2", + "object": "chat.completion", + "created": 1789000001, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "It is currently 19 degrees Celsius in Bogota." + }, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 120, "completion_tokens": 12, "total_tokens": 132} + } + """; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + private Host host; + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + // A bare UserDataGen user has no roles at all, so it is neither a backend nor a + // frontend user and FR-016 rejects it; it also cannot read the site these tests pass + // as an explicit override, which FR-019 checks. Two roles are needed, not one: the check + // in WebResource.checkRolePermissions is doesUserHaveRole(user, "DOTCMS_BACK_END_USER") + // by key and does not walk inheritance, so being an admin does not imply it. Admin is + // what grants read on the site. Role-specific behaviour is US3's tests, not these. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = new SiteDataGen().nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig(host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a request declaring a {@code get_weather} tool, against a provider that answers with a + * tool call + * When the completion is requested + * Then the answer finishes for {@code tool_calls} and carries one tool call with an id, the + * declared function name and its arguments + */ + @Test + public void test_completions_withDeclaredTool_returnsToolCall() throws Exception { + final ChatCompletionRequestView requestView = new ChatCompletionRequestView( + CHAT_MODEL, + List.of( + systemMessage("You are a helpful assistant."), + userMessage("What is the weather in Bogota?")), + List.of(weatherTool()), + TextNode.valueOf("auto"), + null, null, null, null, null, null, null, null); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), requestView); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + + final ChatCompletionView view = (ChatCompletionView) response.getEntity(); + assertNotNull(view.choices()); + assertEquals(1, view.choices().size()); + + final ChatCompletionView.ChoiceView choice = view.choices().get(0); + assertEquals("tool_calls", choice.finishReason()); + assertNotNull(choice.message()); + assertNotNull(choice.message().toolCalls()); + assertEquals(1, choice.message().toolCalls().size()); + + final ChatCompletionView.ToolCallView toolCall = choice.message().toolCalls().get(0); + assertEquals(TOOL_CALL_ID, toolCall.id()); + assertNotNull(toolCall.function()); + assertEquals(TOOL_NAME, toolCall.function().name()); + assertNotNull(toolCall.function().arguments()); + assertFalse(toolCall.function().arguments().isBlank()); + assertTrue(toolCall.function().arguments().contains("Bogota")); + } + + /** + * Given a conversation replaying the assistant tool-call turn and a {@code role:"tool"} turn + * carrying that same {@code tool_call_id} + * When the completion is requested + * Then the turn is accepted and the provider's answer comes back as a finished completion + */ + @Test + public void test_completions_withCorrelatedToolResult_returnsAnswer() throws Exception { + final ChatCompletionRequestView requestView = new ChatCompletionRequestView( + CHAT_MODEL, + List.of( + userMessage("What is the weather in Bogota?"), + assistantToolCallMessage(TOOL_CALL_ID), + toolResultMessage(TOOL_CALL_ID, "{\"tempC\":19}")), + List.of(weatherTool()), + TextNode.valueOf("auto"), + null, null, null, null, null, null, null, null); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), requestView); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + + final ChatCompletionView view = (ChatCompletionView) response.getEntity(); + assertNotNull(view.choices()); + assertEquals(1, view.choices().size()); + + final ChatCompletionView.ChoiceView choice = view.choices().get(0); + assertEquals("stop", choice.finishReason()); + assertNotNull(choice.message()); + assertNotNull(choice.message().content()); + assertFalse(choice.message().content().isBlank()); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH)) + .withRequestBody(containing(TOOL_CALL_ID))); + } + + /** + * Given a {@code role:"tool"} turn whose {@code tool_call_id} matches no preceding assistant + * tool call + * When the completion is requested + * Then it is refused as a client error and the provider is never contacted + */ + @Test + public void test_completions_withUncorrelatedToolResult_isRejected() throws Exception { + final ChatCompletionRequestView requestView = new ChatCompletionRequestView( + CHAT_MODEL, + List.of( + userMessage("What is the weather in Bogota?"), + toolResultMessage(UNCORRELATED_TOOL_CALL_ID, "{\"tempC\":19}")), + List.of(weatherTool()), + TextNode.valueOf("auto"), + null, null, null, null, null, null, null, null); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), requestView); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertEquals(ERROR_TYPE_INVALID_REQUEST, errorView.error().type()); + assertNotNull(errorView.error().message()); + assertFalse(errorView.error().message().isBlank()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a request that omits {@code model} + * When the completion is requested + * Then it is refused as a client error naming {@code model}, with no implicit default applied + */ + @Test + public void test_completions_withoutModel_isRejected() { + final ChatCompletionRequestView requestView = new ChatCompletionRequestView( + null, + List.of(userMessage("What is the weather in Bogota?")), + null, null, null, null, null, null, null, null, null, null); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), requestView); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertEquals(ERROR_TYPE_INVALID_REQUEST, errorView.error().type()); + assertEquals("model", errorView.error().param()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Stubs the OpenAI-compatible provider. The tool-result stub takes precedence — a conversation + * carrying a tool result also carries the tool declaration, so ordering by priority is what + * separates the two turns. + */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .atPriority(1) + .withRequestBody(containing(TOOL_CALL_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_FINAL_ANSWER_RESPONSE))); + + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .atPriority(2) + .withRequestBody(containing(TOOL_NAME)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_TOOL_CALL_RESPONSE))); + } + + /** + * @return a request authenticated with the test user's bearer token, as the family requires + */ + private static HttpServletRequest mockRequest() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/inference/v1/chat/completions"); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost/api/inference/v1/chat/completions")); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } + + private static MessageView systemMessage(final String content) { + return new MessageView("system", content, null, null, null); + } + + private static MessageView userMessage(final String content) { + return new MessageView("user", content, null, null, null); + } + + /** + * @param toolCallId the id the assistant turn asked the client to echo back + * @return the assistant turn replaying a tool call, exactly as a client would resend it + */ + private static MessageView assistantToolCallMessage(final String toolCallId) { + return new MessageView( + "assistant", + null, + List.of(new ToolCallView( + toolCallId, + "function", + new FunctionCallView(TOOL_NAME, "{\"city\":\"Bogota\"}"))), + null, + null); + } + + /** + * @param toolCallId the call this result answers + * @param content the tool's output, as JSON text + * @return the {@code role:"tool"} turn carrying the result + */ + private static MessageView toolResultMessage(final String toolCallId, final String content) { + return new MessageView("tool", content, null, toolCallId, TOOL_NAME); + } + + /** + * @return the {@code get_weather} tool declaration, with a JSON Schema for its arguments + */ + private static ToolView weatherTool() throws Exception { + final JsonNode parameters = OBJECT_MAPPER.readTree(""" + { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + """); + return new ToolView( + "function", + new FunctionDefView(TOOL_NAME, "Current weather for a city", parameters)); + } +} diff --git a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json index eaa81a6650d8..59f7d4ca23d8 100644 --- a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json @@ -3804,6 +3804,279 @@ "response": [] } ] + }, + { + "name": "Inference v1", + "item": [ + { + "name": "Anonymous request is rejected", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Anonymous callers get 401', function () {", + " pm.response.to.have.status(401);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"gpt-4o-mini\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"hello\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/inference/v1/chat/completions", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "inference", + "v1", + "chat", + "completions" + ] + } + }, + "response": [] + }, + { + "name": "No CORS headers are emitted", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// FR-030: server-side only. The credential is a long-lived dotCMS token with its", + "// owner's full authority, so CORS would invite putting it in browser JavaScript.", + "pm.test('No Access-Control-Allow-Origin header', function () {", + " pm.expect(pm.response.headers.has('Access-Control-Allow-Origin')).to.be.false;", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Origin", + "value": "https://example.com" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"gpt-4o-mini\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"hello\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/inference/v1/chat/completions", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "inference", + "v1", + "chat", + "completions" + ] + } + }, + "response": [] + }, + { + "name": "Missing model is rejected", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// FR-024: the model is required; no alias, no implicit default.", + "pm.test('Status is 400', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Error names the offending field', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData.error.param, 'param names model').equals('model');", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"hello\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/inference/v1/chat/completions", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "inference", + "v1", + "chat", + "completions" + ] + } + }, + "response": [] + }, + { + "name": "Completion reports the serving site", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// FR-020 / SC-005: every response names the site whose configuration served it,", + "// so spend can be attributed without reading server logs.", + "pm.test('Status code should be ok 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Resolved-site header is present', function () {", + " pm.expect(pm.response.headers.has('X-dotCMS-Resolved-Site')).to.be.true;", + "});", + "", + "pm.test('Response carries the standard envelope', function () {", + " const jsonData = pm.response.json();", + " pm.expect(jsonData.object, 'object type').equals('chat.completion');", + " pm.expect(jsonData.id, 'id is present').to.not.be.undefined;", + " pm.expect(jsonData.created, 'created is present').to.not.be.undefined;", + " pm.expect(jsonData.model, 'model is present').to.not.be.undefined;", + " pm.expect(jsonData.choices[0].finish_reason, 'finish_reason present').to.not.be.undefined;", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"gpt-4o-mini\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Reply with the single word: ok\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/inference/v1/chat/completions", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "inference", + "v1", + "chat", + "completions" + ] + } + }, + "response": [] + } + ] } ], "event": [ @@ -3812,7 +4085,7 @@ "script": { "type": "text/javascript", "packages": {}, - "exec": [ ] + "exec": [] } }, { diff --git a/specs/37431-openai-compatible-inference/contracts/inference-v1.md b/specs/37431-openai-compatible-inference/contracts/inference-v1.md new file mode 100644 index 000000000000..41e61d7a98b9 --- /dev/null +++ b/specs/37431-openai-compatible-inference/contracts/inference-v1.md @@ -0,0 +1,158 @@ +# Contract: `/api/inference/v1` + +**Feature**: [../spec.md](../spec.md) · **Date**: 2026-09-14 + +> **This file is design intent, not the published contract.** `openapi.yaml` is generated by `swagger-maven-plugin` at compile from the `@Operation` / `@Schema` annotations and is CI-verified. When the two disagree, the generated document wins and this file is stale. Regenerate with `./mvnw compile -pl :dotcms-core --am -DskipTests` and commit the yaml alongside the Java (FR-035). + +## Common to every operation + +| Aspect | Contract | +|---|---| +| Base URL | `https:///api/inference/v1` | +| Auth | `Authorization: Bearer `. Anonymous → `401`. Session cookies and basic auth are **refused** (FR-015) | +| Caller | Any authenticated user, backend or frontend (FR-016) | +| Site resolution | `Host` header by default; `X-dotCMS-Site` header or `?siteId=` override. Legacy `host` / `host_id` request parameters are ignored (FR-025) | +| Site attribution | **`X-dotCMS-Resolved-Site: `** on every response — success, error, streamed (FR-020, R3) | +| CORS | No cross-origin headers emitted. Server-side use only (FR-030) | +| Cost | `@RequestCost(Price.HTTP_FETCH)` — the 100 band, "one remote HTTP round trip" (FR-032) | +| Error body | OpenAI error shape. **Retryability is the HTTP status**, not a body field (FR-031) | +| Limits | `413` over `DOT_INFERENCE_MAX_REQUEST_BYTES`; `429` over `DOT_INFERENCE_MAX_CONCURRENT_STREAMS` (FR-037) | + +### Error shape + +```json +{ "error": { "message": "…", "type": "invalid_request_error", "param": "model", "code": null } } +``` + +| Status | When | +|---|---| +| `400` | Missing/invalid `model`, empty `messages`, uncorrelated tool result, unsupported semantic field | +| `401` | Anonymous, or no bearer token | +| `403` | Explicit site override the caller cannot READ (FR-019) | +| `404` | Model not configured for this site/section — `NoSuchModelError` shape (FR-023) | +| `413` | Request exceeds the configured maximum size | +| `429` | Upstream rate limit, or concurrent-stream limit reached — **retryable**; relays the provider's `Retry-After` when it sends one | +| `5xx` | Upstream provider failure — **retryable** | + +--- + +## `POST /chat/completions` + +### Request + +```json +{ + "model": "gpt-4o", + "messages": [ + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": "What is the weather in Bogotá?" }, + { "role": "assistant", "content": null, + "tool_calls": [ { "id": "call_1", "type": "function", + "function": { "name": "get_weather", "arguments": "{\"city\":\"Bogotá\"}" } } ] }, + { "role": "tool", "tool_call_id": "call_1", "content": "{\"tempC\":19}" } + ], + "tools": [ { "type": "function", "function": { + "name": "get_weather", "description": "Current weather for a city", + "parameters": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } } } ], + "tool_choice": "auto", + "response_format": { "type": "text" }, + "temperature": 0.7, + "max_tokens": 1024, + "stream": false +} +``` + +`model` is **required** — there is no alias and no implicit default (FR-024). `temperature`, `max_tokens`, `top_p` and `stop` are passed through to the provider (FR-013). + +### Response — non-streaming + +```json +{ + "id": "chatcmpl-…", "object": "chat.completion", "created": 1789000000, "model": "gpt-4o", + "choices": [ { "index": 0, "finish_reason": "tool_calls", + "message": { "role": "assistant", "content": null, + "tool_calls": [ { "id": "call_1", "type": "function", + "function": { "name": "get_weather", "arguments": "{\"city\":\"Bogotá\"}" } } ] } } ], + "usage": { "prompt_tokens": 82, "completion_tokens": 17, "total_tokens": 99 } +} +``` + +### Response — streaming (`"stream": true`) + +`Content-Type: text/event-stream`. Every chunk carries `id`, `object: "chat.completion.chunk"`, `created` and `model` (FR-007). + +``` +data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]} + +data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"ci"}}]}}]} + +data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ty\":\"Bogotá\"}"}}]}}]} + +data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1789000000,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] +``` + +Tool-call arguments arrive as fragments and reassemble by `index` (FR-008). The `id` is carried, never synthesised. + +**Usage on a stream** is emitted only when the request asks for it via the standard streaming option (FR-009, R5). It arrives as a chunk with an **empty** `choices` array, immediately before `[DONE]`. + +dotCMS **builds that chunk itself** from the token counts the provider abstraction returns on completion; it does **not** forward the streaming option upstream. The option is an OpenAI-format field that four of the seven configured providers do not understand, so forwarding it would make streamed usage work on some vendors and silently fail on others. For the same reason, a usage chunk a provider volunteers unasked is **suppressed** — its empty `choices` array is precisely the shape that breaks readers assuming every chunk carries a choice. + +**Failure after the stream has started** (FR-039) — provider error mid-generation, or `DOT_INFERENCE_COMPLETION_TIMEOUT_SECONDS` expiring: + +``` +data: {"error":{"message":"Upstream provider unavailable","type":"api_error","param":null,"code":null}} + + +``` + +Withholding `[DONE]` is required: a client that does not parse the error event must see a truncated stream rather than a cleanly finished one. + +--- + +## `GET /models` + +Returns the models the resolved site has configured for its **chat** section, including every fallback-chain entry, in configured order — the first is the primary. Nothing synthetic is added (FR-010, FR-024). An instance with no AI configuration at either the site or the system level returns an empty `data` array, never another site's models (FR-022). + +```json +{ "object": "list", "data": [ + { "id": "gpt-4o", "object": "model", "created": 1789000000, "owned_by": "dotcms" }, + { "id": "gpt-4o-mini", "object": "model", "created": 1789000000, "owned_by": "dotcms" } ] } +``` + +--- + +## `POST /embeddings` + +```json +{ "model": "text-embedding-3-small", "input": "The quick brown fox" } +``` + +```json +{ "object": "list", "model": "text-embedding-3-small", + "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023, -0.0091] } ], + "usage": { "prompt_tokens": 5, "total_tokens": 5 } } +``` + +`model` is validated against the site's **embeddings** section, not its chat models (FR-011, R7). + +--- + +## `POST /images/generations` + +```json +{ "model": "dall-e-3", "prompt": "A cat in a hammock", "n": 1, "size": "1024x1024" } +``` + +```json +{ "created": 1789000000, "data": [ { "b64_json": "iVBORw0KGgo…" } ] } +``` + +Images are returned **inline as base64** — no hosted URL, so no separately-addressable artifact is created from a possibly sensitive prompt (FR-012). + +--- + +## Not in this family + +`POST /completions` (OpenAI's legacy non-chat completion) is deliberately absent — deprecated upstream (FR-014). The Responses format is deferred; were it added it would land at `/api/inference/v1/responses` as a **sibling**, not at `/v2` (FR-001). diff --git a/specs/37431-openai-compatible-inference/data-model.md b/specs/37431-openai-compatible-inference/data-model.md new file mode 100644 index 000000000000..afdcca0ac846 --- /dev/null +++ b/specs/37431-openai-compatible-inference/data-model.md @@ -0,0 +1,148 @@ +# Data Model: OpenAI-Compatible Inference Endpoints + +**Feature**: [spec.md](./spec.md) · **Plan**: [plan.md](./plan.md) · **Date**: 2026-09-14 + +Two layers, deliberately separate (FR-038): the **internal representation**, which is provider-neutral and carries tool-call identity as a first-class field, and the **wire views**, which are one serialization of it. The wire layer depends on the internal layer; never the reverse. Nothing here is persisted — this family is stateless (FR-036). + +--- + +## Internal representation — `com.dotcms.inference.model` + +### `InferenceRequest` + +Immutable; `Serializable` so it can travel as the payload of `AIRequest` (research R1). + +| Field | Type | Required | Validation | +|---|---|---|---| +| `model` | `String` | **yes** | Non-blank. Must appear in the resolved site's configured models for the operation's section (FR-023, FR-024, R7); unknown → `NoSuchModelError` 404; absent → 400 naming the field | +| `messages` | `List` | yes | Non-empty. A `TOOL` message must follow an assistant turn whose `toolCalls` contains its `toolCallId` | +| `tools` | `List` | no | Each entry needs a non-blank `name` and a valid JSON-schema `parameters` object | +| `toolChoice` | `ToolChoice` | no | `AUTO`, `REQUIRED`, `NONE`, or a named function | +| `responseFormat` | `ResponseFormat` | no | Text, JSON object, or JSON schema | +| `stream` | `boolean` | no | Default `false` | +| `includeUsageInStream` | `boolean` | no | Default `false`; set from the standard streaming option (FR-009, R5) | +| `temperature` | `Double` | no | Passed through (FR-013) | +| `maxOutputTokens` | `Integer` | no | Passed through (FR-013) | +| `topP` | `Double` | no | Passed through (FR-013) | +| `stopSequences` | `List` | no | Passed through (FR-013) | + +**Rejected rather than ignored** (FR-013): any field that changes output semantics but cannot be honored — notably a request for several choices — fails with a validation error naming it. + +### `InferenceMessage` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `role` | `Role` | yes | `SYSTEM`, `USER`, `ASSISTANT`, `TOOL` | +| `content` | `String` | conditional | Required except on an assistant turn that carries only `toolCalls` | +| `toolCalls` | `List` | no | `ASSISTANT` only | +| `toolCallId` | `String` | conditional | Required on `TOOL`; must match a `toolCalls[].id` from an earlier assistant turn (FR-005) | +| `name` | `String` | no | Tool name on a `TOOL` turn | + +### `InferenceToolCall` + +The type FR-038 exists to protect. `id` is carried from the provider, **never** derived from a streaming index. + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | `String` | yes | Stable identity; correlates the later `TOOL` result | +| `name` | `String` | yes | Tool to execute | +| `arguments` | `String` | yes | JSON text as produced by the model; not parsed by dotCMS | +| `index` | `int` | no | Position within the turn. Serialization detail for the wire format only — the internal model never uses it for identity | + +### `InferenceToolSpec` + +| Field | Type | Required | +|---|---|---| +| `name` | `String` | yes | +| `description` | `String` | no | +| `parameters` | `JsonNode` | yes — a JSON Schema object | + +### `InferenceResponse` + +| Field | Type | Notes | +|---|---|---| +| `id` | `String` | Generated per response | +| `model` | `String` | The model that actually served it — may be a fallback-chain entry rather than the requested one (FR-027) | +| `createdEpochSeconds` | `long` | | +| `message` | `InferenceMessage` | Assistant turn; carries `toolCalls` when the model requested tools | +| `finishReason` | `FinishReason` | `STOP`, `LENGTH`, `TOOL_CALLS`, `CONTENT_FILTER`, `ERROR` | +| `usage` | `InferenceUsage` | Absent when the provider does not report it — never fabricated | + +### `InferenceUsage` + +`inputTokens`, `outputTokens`, `totalTokens` — all `Integer`, all nullable, mapped from LangChain4j `TokenUsage`. + +### `InferenceStreamEvent` + +A sealed set; the SSE serializer is a total function over it. + +| Variant | Carries | Emitted as | +|---|---|---| +| `ContentDelta` | text fragment | chunk with `delta.content` | +| `ToolCallDelta` | tool-call `index`, `id`, `name`, partial arguments | chunk with `delta.tool_calls[]` (FR-008) | +| `Finish` | `finishReason` | chunk with `finish_reason` | +| `Usage` | `InferenceUsage` | chunk with empty `choices` — **only** when `includeUsageInStream` (FR-009) | +| `Error` | `InferenceError` | error event, then close **without** `[DONE]` (FR-039) | + +### `InferenceError` + +| Field | Type | Notes | +|---|---|---| +| `type` | `String` | e.g. `invalid_request_error`, `not_found_error`, `rate_limit_error` | +| `message` | `String` | Never carries the provider's raw envelope (FR-031) | +| `param` | `String` | Offending field where applicable | +| `httpStatus` | `int` | **Retryability lives here, not in the body** (FR-031): 429 and 5xx are retryable | + +### `InferenceLimits` + +Config-backed (research R4). `maxConcurrentStreams`, `completionTimeoutSeconds`, `maxRequestBytes` — read via `Config.getIntProperty`. + +### `ResolvedAiContext` — `com.dotcms.ai.rest` + +Returned by the shared resolver (FR-026, research R9). Immutable triple: `User user`, `Host host`, `AppConfig config`. Obtaining these separately is what the type exists to prevent. + +--- + +## Wire views — `com.dotcms.inference.rest.view` + +Concrete DTOs so `@Schema(implementation = ...)` matches the real return type and the generated `openapi.yaml` stays truthful (research R6). These mirror the OpenAI shapes field-for-field and deliberately do **not** use the dotCMS `ResponseEntityView` envelope — see the plan's Complexity Tracking. + +| View | Serializes | Endpoint | +|---|---|---| +| `ChatCompletionView` | `InferenceResponse` | `POST /chat/completions` (non-streaming) | +| `ChatCompletionChunkView` | `InferenceStreamEvent` | `POST /chat/completions` (SSE event payload) | +| `ModelListView` | the site's configured models for a section | `GET /models` | +| `EmbeddingListView` | vectors + model + usage | `POST /embeddings` | +| `ImageGenerationView` | base64 image data (FR-012) | `POST /images/generations` | +| `InferenceErrorView` | `InferenceError` minus `httpStatus` | all — status carries retryability | + +--- + +## Relationships + +``` +InferenceRequest 1──* InferenceMessage +InferenceMessage 0──* InferenceToolCall (assistant turns only) +InferenceMessage 0──1 toolCallId ──▶ InferenceToolCall.id (tool turns; correlation, FR-005) +InferenceRequest 0──* InferenceToolSpec +InferenceResponse 1──1 InferenceMessage +InferenceResponse 0──1 InferenceUsage +ResolvedAiContext 1──1 AppConfig ──▶ ProviderConfig sections: chat | embeddings | image (R7) +``` + +## Validation summary + +| Rule | Source | Failure | +|---|---|---| +| Model present | FR-024 | 400, field named | +| Model configured for the operation's section | FR-023, R7 | 404 `NoSuchModelError` | +| At least one message | FR-002 | 400 | +| Tool result correlates to a prior tool call | FR-005 | 400 | +| Request body within `maxRequestBytes` | FR-037 | 413, limit named | +| Concurrent streams within `maxConcurrentStreams` | FR-037 | 429 | +| Site READ when an explicit override is supplied | FR-019 | 403 | +| Authenticated, non-anonymous, bearer token only | FR-015, FR-016 | 401 | + +## Commit-worthiness + +**Commit this file.** It carries field-level shapes a future developer would otherwise have to reconstruct from the mappers — specifically the internal representation FR-038 mandates and the correlation rules between tool calls and tool results. It is not a restatement of `spec.md`, which stays above field level. diff --git a/specs/37431-openai-compatible-inference/spec.md b/specs/37431-openai-compatible-inference/spec.md index dc705168d037..9061085fe178 100644 --- a/specs/37431-openai-compatible-inference/spec.md +++ b/specs/37431-openai-compatible-inference/spec.md @@ -165,7 +165,7 @@ The same client credentials and base URL also serve text embeddings and image ge #### Operational and forward compatibility - **FR-030**: The system MUST NOT emit cross-origin headers on this family, and the endpoints MUST be documented as server-side only, because the credential is a long-lived token with the full authority of its owner and cross-origin support would invite placing it in browser JavaScript. -- **FR-031**: The system MUST translate upstream provider rate-limit and server errors into the standard error shape without leaking the provider's raw envelope. Retryability MUST be conveyed by the **HTTP status code** — 429 for rate limiting, 5xx for upstream failure — because that is what a standard client's back-off keys off; the standard error shape carries no retryable field, and inventing one would put this requirement in tension with SC-008. The response body MUST stay conformant to the standard error shape. +- **FR-031**: The system MUST translate upstream provider rate-limit and server errors into the standard error shape without leaking the provider's raw envelope. Retryability MUST be conveyed by the **HTTP status code** — 429 for rate limiting, 5xx for upstream failure — because that is what a standard client's back-off keys off; the standard error shape carries no retryable field, and inventing one would put this requirement in tension with SC-008. The response body MUST stay conformant to the standard error shape. When the provider supplies a `Retry-After` header, the system MUST relay it, so a client backs off on the provider's own instruction rather than on a guess — the format's clients already honour that header, and discarding it makes every back-off worse than it needs to be. - **FR-032**: Each new path MUST register a request cost in the band used for operations that make a remote network round trip, so the existing instance-wide rate-limit backstop prices them correctly. A per-site or per-token AI spend quota is out of scope. - **FR-033**: No existing dotAI endpoint's behavior may change. Retrieval-augmented completions, semantic search, the vector corpus operations, and provider administration remain first-class, as no standard verb covers them. - **FR-034**: The three existing operations superseded by this family — text generation, image generation, and raw-prompt completion — MUST be documented as superseded while remaining fully functional. @@ -204,7 +204,7 @@ The same client credentials and base URL also serve text embeddings and image ge ## Legacy Considerations *(dotCMS-specific — mandatory)* -- **Existing behavior touched**: The dotAI area — its REST surface, its per-site App configuration lookup, and its provider client. The endpoint family is new and additive, but two shared pieces are modified in place: the site-resolution helper grows into the single authorization-carrying entry point, and the provider client gains tool support and standards-conformant stream assembly. Site resolution itself reaches into the older site/host resolution surface, whose two silent fallbacks — an unmatched host name resolving to the default site, and a site without its own AI configuration inheriting the system-level one — are both **kept**, so this family resolves exactly as the rest of the product does. The source issue asked for both to be removed here; that is declined deliberately, because per-family resolution semantics are their own source of surprise and because the concern behind the request is attribution rather than resolution. FR-020 answers attribution by naming the serving site on every response; a per-site spend quota answers it fully and is out of scope. **This family now diverges from #37431 in three recorded places: both silent site fallbacks are kept rather than removed, and the model is required with no alias, so a provider swap is not invisible to callers. The fallback decision also conflicts with the second acceptance criterion of #37491, which must be amended to match — otherwise the one shared resolver of FR-026 would need a per-family strictness flag, which is the drift FR-026 exists to prevent.** +- **Existing behavior touched**: The dotAI area — its REST surface, its per-site App configuration lookup, and its provider client. The endpoint family is new and additive, but two shared pieces are modified in place: the site-resolution helper grows into the single authorization-carrying entry point, and the provider client gains tool support and standards-conformant stream assembly. Site resolution itself reaches into the older site/host resolution surface, whose two silent fallbacks — an unmatched host name resolving to the default site, and a site without its own AI configuration inheriting the system-level one — are both **kept**, so this family resolves exactly as the rest of the product does. The source issue asked for both to be removed here; that is declined deliberately, because per-family resolution semantics are their own source of surprise and because the concern behind the request is attribution rather than resolution. FR-020 answers attribution by naming the serving site on every response; a per-site spend quota answers it fully and is out of scope. **This family now diverges from #37431 in three recorded places: both silent site fallbacks are kept rather than removed, and the model is required with no alias, so a provider swap is not invisible to callers. The fallback decision also conflicts with the second acceptance criterion of #37491, which will have to be reconciled **when that issue is implemented** — this family's resolver serves only these endpoints (FR-026) and existing endpoints are untouched (FR-033), so nothing here depends on it; but adopting this component there while keeping that criterion would require a per-family strictness flag, which is the drift FR-026 exists to prevent.** - **Backward-compatibility expectations**: No existing dotAI endpoint changes behavior, including the two that share the silent-fallback and model-passthrough weaknesses described here; hardening those is deliberately deferred to a separate change so it gets its own review, release note, and rollback classification. Three existing operations become superseded — documented as such, still functional, not removed. Because this adds a public API contract, it falls in a rollback-sensitive category and should be labeled accordingly. - **Known related decisions**: One existing dotAI endpoint already pins non-administrators to the site's configured model — this family carries that precedent forward, rather than repeating the unchecked model passthrough of the sibling endpoint that lacks it. Reading a site's AI configuration as the system user is the standard dotCMS Apps design and keeps the secret server-side, so the question here is authorization to *use* a site's credentials, not secret exposure. Related work: [#37491](https://github.com/dotCMS/core/issues/37491) hardens the existing dotAI endpoints and adopts this shared component there; [#37433](https://github.com/dotCMS/core/issues/37433) is the client-side counterpart and is blocked by this. The plan phase will formally consult `dotCMS/platform-adrs`. From d94dc4634f851013624d65cbb3619e726d4fe871 Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 18:12:00 -0600 Subject: [PATCH 2/4] fix(ai): enforce bearer-only auth and stop the legacy site-param leak Adds User Story 3's test coverage -- per-site credential governance -- and fixes the two real defects it found. 26 tests across five suites, all green. The tests were written after the implementation, because US3's code was built as part of the foundation. That inverts the TDD gate and is recorded as such in tasks.md rather than papered over. It was worth doing anyway: 11 of the 14 initial tests merely verified working code, and the other 3 found defects that review had missed and that the chat-completion tests passed straight over. FR-025 -- legacy request parameters honoured where they did damage AiHostResolver.resolveFromRequest fell through to getCurrentHostNoThrow, which reads the host_id and Host request parameters before it ever looks at the server name. The parameters were already ignored whenever the host name matched a site, because that path returns earlier -- so the effect was that a legacy override was ignored everywhere it was harmless and honoured in precisely the case where it could redirect which site's credentials get spent. Now resolves the default site explicitly. resolveHost/resolveHostStrict keep the old call: they serve the shipped endpoints and FR-033 puts them out of bounds. That duplication is #37491's to resolve. FR-015 -- bearer-only was never implemented A request with no Authorization header but a live session was served normally; basic auth would have been too. That undercuts the reasoning for emitting no CORS headers, which rests on the credential being a token someone deliberately issued and placed on a server rather than one a browser attaches by itself. The rule now lives in one method that both a name-bound filter and the resource call -- the filter so it covers the three resources not yet written, the resource because a guarantee that only exists inside the JAX-RS chain is invisible to tests that invoke resource methods directly, which is how every integration test here reaches one. Two authorization tests were changed. They asserted the refusal arrives as a thrown WebApplicationException, which encoded the behaviour from before this family refused for itself. A 401 now carries InferenceErrorView, as the contract's status table requires, so a client library can deserialize a refusal into its own error type. The assertions were strengthened to check the body shape as well as the status, not relaxed. Refs #37431 Co-Authored-By: Claude Opus 5 (1M context) --- .../com/dotcms/ai/rest/AiHostResolver.java | 23 +- .../inference/rest/BearerOnlyAuthFilter.java | 78 +++ .../rest/ChatCompletionsResource.java | 14 + .../src/test/java/com/dotcms/MainSuite2b.java | 10 + .../rest/InferenceAuthorizationTest.java | 383 ++++++++++++++ .../inference/rest/InferenceFallbackTest.java | 347 +++++++++++++ .../rest/InferenceModelValidationTest.java | 409 +++++++++++++++ .../rest/InferenceSiteIsolationTest.java | 364 ++++++++++++++ .../rest/InferenceSiteResolutionTest.java | 474 ++++++++++++++++++ 9 files changed, 2101 insertions(+), 1 deletion(-) create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/BearerOnlyAuthFilter.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceAuthorizationTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceFallbackTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelValidationTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteIsolationTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteResolutionTest.java diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java index de56760390c1..0ec0cf64f443 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java @@ -109,7 +109,28 @@ private static Host resolveFromRequest(final HttpServletRequest request) { Logger.warn(AiHostResolver.class, "Could not resolve host '" + sanitize(serverName) + "': " + e.getMessage()); } - return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + return defaultHost(); + } + + /** + * The default site, resolved without consulting the request. + * + *

Deliberately not {@code getCurrentHostNoThrow}, which is the obvious call and the wrong + * one: it reads the {@code host_id} and {@code Host} request parameters before it ever looks + * at the server name (`HostWebAPIImpl.getCurrentHostFromRequest`), and FR-025 requires those + * be ignored on this family. They were already ignored whenever the server name matched a + * site, because that path returns before reaching here — so the effect was that a legacy + * override was honoured in precisely the case where it could redirect which site's + * credentials get spent, and ignored everywhere it was harmless.

+ * + * @return the default site + */ + private static Host defaultHost() { + try { + return APILocator.getHostAPI().findDefaultHost(APILocator.systemUser(), false); + } catch (final Exception e) { + throw new IllegalStateException("Could not resolve the default site: " + e.getMessage(), e); + } } /** diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/BearerOnlyAuthFilter.java b/dotCMS/src/main/java/com/dotcms/inference/rest/BearerOnlyAuthFilter.java new file mode 100644 index 000000000000..b2048297f385 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/BearerOnlyAuthFilter.java @@ -0,0 +1,78 @@ +package com.dotcms.inference.rest; + +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.rest.view.InferenceErrorView; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; +import java.io.IOException; +import java.util.Optional; + +/** + * Refuses any credential on this family other than a bearer token. + * + *

Without this, the surrounding dotCMS authentication accepts what it accepts everywhere else: + * {@code WebResource.authenticate} falls through to {@code PortalUtil.getUser(request)}, so a live + * session cookie — or basic auth — authenticates a caller who sent no {@code Authorization} header + * at all.

+ * + *

That matters more here than it would elsewhere, because it is load-bearing for a decision + * made on the assumption that it was already true. This family emits no cross-origin headers, and + * the stated reason is that its credential is a long-lived token someone deliberately issued and + * placed on a server — not an ambient credential a browser attaches on its own. If a session + * cookie authenticates, that reasoning collapses: any page the user has open is one fetch away + * from spending the site's AI budget, and the absence of CORS headers becomes a formality rather + * than a control.

+ * + *

The rule itself lives in {@link #bearerCredentialProblem(String)} rather than in this filter, + * and the resources call it directly as well. A filter alone would be a guarantee that exists only + * inside the JAX-RS chain — invisible to anything invoking a resource method directly, which is + * how this family's integration tests reach it. A guarantee that cannot be tested where it is + * relied upon is not much of a guarantee. The filter earns its place by applying automatically to + * every resource in the family, including ones not yet written.

+ */ +@Provider +@InferenceEndpoint +public class BearerOnlyAuthFilter implements ContainerRequestFilter { + + /** The only credential scheme this family accepts. */ + static final String BEARER_PREFIX = "Bearer "; + + @Override + public void filter(final ContainerRequestContext requestContext) throws IOException { + bearerCredentialProblem(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)) + .ifPresent(error -> requestContext.abortWith( + Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .build())); + } + + /** + * Checks a credential, without deciding what to do about a bad one. + * + *

Returns the problem rather than throwing, so the filter can abort the exchange and a + * resource can fold it into whatever it already does with errors, from one implementation of + * the rule.

+ * + * @param authorizationHeader the request's Authorization header, or null when absent + * @return the refusal to send, or empty when the credential is an acceptable bearer token + */ + public static Optional bearerCredentialProblem(final String authorizationHeader) { + if (authorizationHeader != null + && authorizationHeader.startsWith(BEARER_PREFIX) + && !authorizationHeader.substring(BEARER_PREFIX.length()).isBlank()) { + return Optional.empty(); + } + return Optional.of(new InferenceError( + "invalid_request_error", + "This endpoint accepts a dotCMS API token as 'Authorization: Bearer '." + + " Session and basic credentials are not accepted.", + null, + Response.Status.UNAUTHORIZED.getStatusCode())); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java index 49a061fabd19..e10828ee323c 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java @@ -44,6 +44,7 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.util.Optional; import javax.ws.rs.core.StreamingOutput; import java.io.IOException; import java.io.OutputStream; @@ -197,6 +198,19 @@ public final Response completions(@Context final HttpServletRequest request, implementation = ChatCompletionRequestView.class))) final ChatCompletionRequestView requestView) { + // Bearer only. Checked here as well as in BearerOnlyAuthFilter because the filter runs + // only inside the JAX-RS chain, while the rule has to hold wherever this method is + // reached. Without it the surrounding authentication accepts a session cookie or basic + // auth, which would undo the reasoning behind emitting no CORS headers: that the + // credential is a token someone deliberately issued and placed on a server, not one a + // browser attaches by itself. + final Optional credentialProblem = + BearerOnlyAuthFilter.bearerCredentialProblem( + request.getHeader(javax.ws.rs.core.HttpHeaders.AUTHORIZATION)); + if (credentialProblem.isPresent()) { + return errorResponse(credentialProblem.get()); + } + // Any authenticated user, backend or frontend; an anonymous caller is rejected here with a // 401 the builder produces itself. final User user = new WebResource.InitBuilder(request, response) diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java index eeedec3cce5b..b87820a1a436 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java @@ -69,6 +69,11 @@ import com.dotcms.filters.interceptor.meta.MetaWebInterceptorTest; import com.dotcms.inference.rest.ChatCompletionsStreamingTest; import com.dotcms.inference.rest.ChatCompletionsTest; +import com.dotcms.inference.rest.InferenceAuthorizationTest; +import com.dotcms.inference.rest.InferenceFallbackTest; +import com.dotcms.inference.rest.InferenceModelValidationTest; +import com.dotcms.inference.rest.InferenceSiteIsolationTest; +import com.dotcms.inference.rest.InferenceSiteResolutionTest; import com.dotcms.integritycheckers.ContentFileAssetIntegrityCheckerTest; import com.dotcms.integritycheckers.ContentPageIntegrityCheckerTest; import com.dotcms.integritycheckers.HostIntegrityCheckerTest; @@ -448,6 +453,11 @@ AIProxyClientTest.class, ChatCompletionsTest.class, ChatCompletionsStreamingTest.class, + InferenceAuthorizationTest.class, + InferenceFallbackTest.class, + InferenceModelValidationTest.class, + InferenceSiteIsolationTest.class, + InferenceSiteResolutionTest.class, TimeMachineAPITest.class, Task240513UpdateContentTypesSystemFieldTest.class, PruneTimeMachineBackupJobTest.class, diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceAuthorizationTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceAuthorizationTest.java new file mode 100644 index 000000000000..ddf12b6b08fd --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceAuthorizationTest.java @@ -0,0 +1,383 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies who may call {@code /api/inference/v1} and on whose behalf — the authentication and + * site-authorization half of User Story 3. + * + *

The three requirements pull in different directions on purpose, and each is only meaningful + * next to the others:

+ * + *
    + *
  • FR-015 — the credential must be a dotCMS API token presented as a bearer. Anonymous + * callers are refused, and so are the session cookies that surrounding dotCMS filters + * otherwise accept: this family is server-side only by design, and admitting ambient browser + * credentials would make the absence of cross-origin headers a formality rather than a + * control.
  • + *
  • FR-016 — any authenticated user is admitted, backend or frontend alike, because + * a site calling AI on behalf of a visitor is a supported use case. The bar is authentication, + * not privilege.
  • + *
  • FR-019 — but the moment a caller names a site explicitly, READ on that site is enforced, + * and a caller who lacks it is refused. This is the one check that stands between an + * authenticated caller and another site's credentials.
  • + *
+ * + *

Refusals arrive in two different shapes, which is a property of the contract rather than an + * inconsistency. An authentication failure is raised by {@code WebResource} before the resource + * body runs, so it surfaces as a {@link WebApplicationException} carrying the status; a site + * authorization failure is caught by the resource and rendered as an {@link InferenceErrorView}, + * so that a standard client can deserialize it with no adapter.

+ */ +public class InferenceAuthorizationTest { + + /** Path an OpenAI-compatible provider serves completions on. */ + private static final String COMPLETIONS_PATH = "/chat/completions"; + + private static final String REQUEST_URI = "/api/inference/v1/chat/completions"; + + /** The chat model the site under test is configured with. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + private static final String ERROR_TYPE_INVALID_REQUEST = "invalid_request_error"; + + /** What the stubbed provider answers whenever a call gets that far. */ + private static final String PROVIDER_RESPONSE = """ + { + "id": "chatcmpl-auth-1", + "object": "chat.completion", + "created": 1789000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Answered."}, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12} + } + """; + + private static WireMockServer wireMockServer; + + /** A backend user who is also an administrator, so site READ is never the thing under test. */ + private static User backendUser; + private static String backendBearerToken; + + /** A frontend-only user: authenticated, unprivileged, and admitted by FR-016 all the same. */ + private static User frontendUser; + private static String frontendBearerToken; + + /** A backend user who is not an administrator, and so cannot read an arbitrary site. */ + private static User unprivilegedUser; + private static String unprivilegedBearerToken; + + private Host host; + + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + // Two roles, not one. WebResource.checkRolePermissions matches DOTCMS_BACK_END_USER by + // key and does not walk inheritance, so being an administrator does not imply it; the + // administrator role is separately what grants READ on a site. + backendUser = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + backendBearerToken = bearerTokenFor(backendUser); + + frontendUser = new UserDataGen() + .roles(APILocator.getRoleAPI().loadFrontEndUserRole()) + .nextPersisted(); + frontendBearerToken = bearerTokenFor(frontendUser); + + // Deliberately no administrator role: this user passes authentication and the role gate, + // and then fails on site READ, which is exactly the boundary FR-019 draws. + unprivilegedUser = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole()) + .nextPersisted(); + unprivilegedBearerToken = bearerTokenFor(unprivilegedUser); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = new SiteDataGen() + .name("inference-auth-" + UUID.randomUUID() + ".dotcms.com") + .nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig( + host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a request carrying no credential at all + * When a completion is requested + * Then it is refused as unauthorized + * + *

FR-015. The refusal carries the standard error shape, as the contract's status table + * requires of a 401 — a caller's client library should be able to deserialize a refusal into + * its own error type exactly as it would a success. Accepts a thrown + * {@link WebApplicationException} too, since the surrounding authentication handshake may + * refuse before the resource body runs and either is a valid refusal.

+ */ + @Test + public void test_completions_withNoCredential_isUnauthorized() { + final HttpServletRequest request = mockRequest(host.getHostname(), null); + + try { + final Response response = + resource.completions(request, mockResponse(), null, completionFor(CHAT_MODEL)); + assertEquals("An anonymous request must be refused as unauthorized", + Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + assertTrue("a refusal carries the standard error shape", + response.getEntity() instanceof InferenceErrorView); + } catch (final WebApplicationException e) { + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), + e.getResponse().getStatus()); + } + } + + /** + * Given a request authenticated by a session rather than by a bearer token, and otherwise + * entirely valid + * When a completion is requested + * Then it is refused as unauthorized + * + *

FR-015 accepts a dotCMS API token presented as a bearer credential and nothing else. The + * session cookies the surrounding dotCMS filters generally honour must not be honoured here: + * this family is server-side only by design, and a browser that can be made to carry an + * ambient session would otherwise be able to spend a site's AI credentials.

+ */ + @Test + public void test_completions_withSessionCredentialOnly_isUnauthorized() { + final HttpServletRequest request = mockRequest(host.getHostname(), null); + givenSessionAuthenticatedAs(request, backendUser); + + try { + final Response response = resource.completions( + request, mockResponse(), null, completionFor(CHAT_MODEL)); + assertEquals("A session credential must not authenticate this family", + Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + assertTrue("a refusal carries the standard error shape", + response.getEntity() instanceof InferenceErrorView); + } catch (final WebApplicationException e) { + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), + e.getResponse().getStatus()); + } + } + + /** + * Given an authenticated caller who cannot read the site they name explicitly + * When a completion is requested with that site as the override + * Then it is refused as forbidden, in the standard error shape, naming the offending field + * + *

FR-019. The caller authenticates and clears the role gate; what stops them is READ on the + * site whose credentials they asked to spend.

+ */ + @Test + public void test_completions_withOverrideForUnreadableSite_isForbidden() { + final HttpServletRequest request = + mockRequest(host.getHostname(), unprivilegedBearerToken); + + final Response response = resource.completions( + request, mockResponse(), host.getIdentifier(), completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertEquals(ERROR_TYPE_INVALID_REQUEST, errorView.error().type()); + assertEquals("siteId", errorView.error().param()); + } + + /** + * Given a backend user authenticated by bearer token + * When a completion is requested + * Then it is served + * + *

FR-016, one half. Stated alongside the frontend case because the requirement is that + * both are admitted; either one alone would be satisfied by a rule that excluded the + * other.

+ */ + @Test + public void test_completions_withBackendUser_isAccepted() { + final HttpServletRequest request = mockRequest(host.getHostname(), backendBearerToken); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + } + + /** + * Given a frontend user authenticated by bearer token + * When a completion is requested + * Then it is served + * + *

FR-016, the other half. A site calling AI on behalf of a visitor is a supported use case, + * so the bar on this family is authentication, not privilege.

+ */ + @Test + public void test_completions_withFrontendUser_isAccepted() { + final HttpServletRequest request = mockRequest(host.getHostname(), frontendBearerToken); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + } + + /** Answers every completion that gets as far as the provider with the same finished response. */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_RESPONSE))); + } + + /** + * @param user the caller to mint a credential for + * @return the {@code Authorization} header value carrying that caller's API token + */ + private static String bearerTokenFor(final User user) throws Exception { + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + return "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + /** + * @param model the model to ask for + * @return the smallest well-formed completion request + */ + private static ChatCompletionRequestView completionFor(final String model) { + return new ChatCompletionRequestView( + model, + List.of(new MessageView("user", "May I call you?", null, null, null)), + null, null, null, null, null, null, null, null, null, null); + } + + /** + * Gives the request a logged-in session, the way a browser carrying a dotCMS session cookie + * would present itself to the servlet layer. + * + * @param request the request to attach the session to + * @param user the user the session is logged in as + */ + private static void givenSessionAuthenticatedAs(final HttpServletRequest request, + final User user) { + final HttpSession session = mock(HttpSession.class); + when(session.getAttribute(com.liferay.portal.util.WebKeys.USER)).thenReturn(user); + when(session.getAttribute(com.liferay.portal.util.WebKeys.USER_ID)) + .thenReturn(user.getUserId()); + when(request.getSession(false)).thenReturn(session); + when(request.getSession()).thenReturn(session); + } + + /** + * Builds a request arriving at a given host name, with or without a bearer credential. + * + *

Request attributes are backed by a real map rather than left as mock no-ops, because the + * authentication handshake publishes the resolved caller as an attribute and a mock that + * forgot it would not behave like a servlet container.

+ * + * @param serverName the host name the request arrives on + * @param bearerToken the {@code Authorization} header value, or null for no credential + * @return the mocked request + */ + private static HttpServletRequest mockRequest(final String serverName, + final String bearerToken) { + final HttpServletRequest request = mock(HttpServletRequest.class); + final Map attributes = new HashMap<>(); + + when(request.getRequestURI()).thenReturn(REQUEST_URI); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://" + serverName + REQUEST_URI)); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getServerName()).thenReturn(serverName); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + + doAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))) + .when(request).setAttribute(anyString(), any()); + when(request.getAttribute(anyString())) + .thenAnswer(invocation -> attributes.get(invocation.getArgument(0))); + + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceFallbackTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceFallbackTest.java new file mode 100644 index 000000000000..472d74bc62e8 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceFallbackTest.java @@ -0,0 +1,347 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.ai.app.ConfigService; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Specifies the two silent fallbacks that + * {@link ChatCompletionsResource#completions(HttpServletRequest, HttpServletResponse, String, ChatCompletionRequestView)} + * deliberately keeps, and the one case where falling back is refused instead. + * + *

The source issue asked for both fallbacks to be removed for this endpoint family. That was + * declined, so they need tests that state the kept behaviour rather than tests that would pass + * either way: a family which resolved differently from the rest of dotCMS would break the + * server-side callers it exists for, because internal DNS names, container service names and + * {@code localhost} are not site aliases, and because many installations configure dotAI once at + * system level rather than per site.

+ * + *
    + *
  • FR-020 — a host name matching no site or alias is served by the default site, not + * refused, and the site that served is the one reported as having resolved.
  • + *
  • FR-021 — a site with no dotAI configuration of its own inherits the system-level one, + * exactly as every other dotAI endpoint does, and the request succeeds.
  • + *
  • FR-021 — when neither the resolved site nor the system level has any configuration, the + * request is refused rather than served from some unrelated site's credentials.
  • + *
+ * + *

The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in + * through the same dotAI app secrets the rest of the AI integration tests use, so the exchange + * travels the real client path rather than a stubbed one.

+ * + *

These tests move the system-level dotAI configuration, which is shared + * state. Whatever SYSTEM_HOST carried when the class started is captured and put back in + * {@link #afterClass()}.

+ */ +public class InferenceFallbackTest { + + /** The chat model the system-level configuration carries, and the only one asked for here. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** Path an OpenAI-compatible provider serves completions on. */ + private static final String COMPLETIONS_PATH = "/chat/completions"; + + /** + * A host name no site and no alias can match, standing in for the internal DNS name, container + * service name or {@code localhost} a server-side caller routinely presents. + */ + private static final String UNMATCHED_HOST_NAME = "inference-fallback-no-such-site.invalid"; + + /** What the stubbed provider answers whenever it is reached at all. */ + private static final String PROVIDER_RESPONSE = """ + { + "id": "chatcmpl-fallback-1", + "object": "chat.completion", + "created": 1789000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Served." + }, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11} + } + """; + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + private static Host defaultHost; + + /** The system-level providerConfig as found, so these tests can put it back. */ + private static String originalSystemProviderConfig; + + private Host unconfiguredSite; + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + defaultHost = APILocator.getHostAPI().findDefaultHost(APILocator.systemUser(), false); + originalSystemProviderConfig = + ConfigService.INSTANCE.config(APILocator.systemHost()).getProviderConfig(); + + // Both roles are needed, not one: WebResource.checkRolePermissions matches + // DOTCMS_BACK_END_USER by key without walking inheritance, so being an admin does not + // imply it, and admin is what grants read on the site passed as an explicit override. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() throws Exception { + restoreSystemLevelConfiguration(); + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() { + unconfiguredSite = new SiteDataGen().nextPersisted(); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + clearSystemLevelConfiguration(); + } + + /** + * Given a request whose host name matches no site and no alias, and a system-level dotAI + * configuration the default site inherits + * When the completion is requested with no explicit site override + * Then it is served rather than refused + */ + @Test + public void test_completions_withUnmatchedHostName_isServedByDefaultSite() throws Exception { + configureSystemLevel(); + final HttpServletRequest request = mockRequest(UNMATCHED_HOST_NAME); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + + final ChatCompletionView view = (ChatCompletionView) response.getEntity(); + assertNotNull(view.choices()); + assertEquals(1, view.choices().size()); + assertNotNull(view.choices().get(0).message()); + assertNotNull(view.choices().get(0).message().content()); + assertFalse(view.choices().get(0).message().content().isBlank()); + } + + /** + * Given a request whose host name matches no site and no alias + * When the completion is requested with no explicit site override + * Then the site reported as having served is the default site + * + *

FR-020 asks for the fallback to be logged as well. Asserting on the log would mean + * attaching an appender to dotCMS's Log4j configuration from an integration test, which is + * both brittle and outside what this family owns, so what is asserted here is the observable + * half of the same requirement: the serving site published for the response header. The log + * line itself lives in {@code AiHostResolver.resolveFromRequest} and carries the unmatched + * host name.

+ */ + @Test + public void test_completions_withUnmatchedHostName_reportsDefaultSiteAsServingSite() + throws Exception { + configureSystemLevel(); + final HttpServletRequest request = mockRequest(UNMATCHED_HOST_NAME); + + resource.completions(request, mockResponse(), null, completionFor(CHAT_MODEL)); + + verify(request).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, defaultHost.getIdentifier()); + } + + /** + * Given a site with no dotAI configuration of its own and a system-level configuration + * When the completion is requested against that site + * Then it is served from the system-level configuration and the serving site is still the + * requested one + */ + @Test + public void test_completions_siteWithoutOwnConfiguration_isServedFromSystemLevel() + throws Exception { + configureSystemLevel(); + final HttpServletRequest request = mockRequest(UNMATCHED_HOST_NAME); + + final Response response = resource.completions( + request, mockResponse(), unconfiguredSite.getIdentifier(), completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + + // The configuration was inherited, but the site that resolved — and that a reconciliation + // pipeline would bill — is the one the caller named, not SYSTEM_HOST. + verify(request).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, unconfiguredSite.getIdentifier()); + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a site with no dotAI configuration of its own and no system-level configuration either + * When the completion is requested against that site + * Then it is refused with an explanatory error and no provider is contacted + * + *

The refusal is the 404 "no such model" shape rather than a distinct "site not + * configured" error, deliberately: from where the caller stands a model nobody configured and + * a model on a site nobody configured are the same absence, and separating them would tell a + * caller which sites have dotAI set up.

+ */ + @Test + public void test_completions_withNoConfigurationAnywhere_isRefused() throws Exception { + clearSystemLevelConfiguration(); + final HttpServletRequest request = mockRequest(UNMATCHED_HOST_NAME); + + final Response response = resource.completions( + request, mockResponse(), unconfiguredSite.getIdentifier(), completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(404, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertNotNull(errorView.error().message()); + assertFalse(errorView.error().message().isBlank()); + assertEquals("model", errorView.error().param()); + + // Nothing was served from anywhere: no unrelated site's credentials were spent. + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Installs the dotAI configuration at system level, where every site inherits it from. + */ + private static void configureSystemLevel() throws Exception { + AiTest.aiAppSecretsWithProviderConfig( + APILocator.systemHost(), AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + } + + /** + * Removes the system-level dotAI configuration and waits until it has stopped resolving, so a + * test asserting the unconfigured case is not racing the secrets cache. + */ + private static void clearSystemLevelConfiguration() throws Exception { + AiTest.removeAiAppSecrets(APILocator.systemHost()); + await().atMost(5, SECONDS) + .until(() -> !ConfigService.INSTANCE.config(APILocator.systemHost()).isEnabled()); + } + + /** + * Puts SYSTEM_HOST back the way this class found it. The system-level dotAI configuration is + * shared with every other test in the JVM, so leaving it moved would be a defect in this file + * rather than in the code under test. + */ + private static void restoreSystemLevelConfiguration() throws Exception { + if (originalSystemProviderConfig != null && !originalSystemProviderConfig.isBlank()) { + AiTest.aiAppSecretsWithProviderConfig( + APILocator.systemHost(), originalSystemProviderConfig); + } else { + clearSystemLevelConfiguration(); + } + } + + /** + * Stubs the OpenAI-compatible provider with a single canned answer; which model or site + * reached it is not what this file is about, only whether it was reached at all. + */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_RESPONSE))); + } + + /** + * @param model the model to ask for + * @return the smallest valid completion request + */ + private static ChatCompletionRequestView completionFor(final String model) { + return new ChatCompletionRequestView( + model, + List.of(new MessageView("user", "Say something.", null, null, null)), + null, null, null, null, null, null, null, null, null, null); + } + + /** + * @param serverName the host name the caller presented + * @return a request authenticated with the test user's bearer token, as the family requires + */ + private static HttpServletRequest mockRequest(final String serverName) { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/inference/v1/chat/completions"); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost/api/inference/v1/chat/completions")); + when(request.getMethod()).thenReturn("POST"); + when(request.getServerName()).thenReturn(serverName); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelValidationTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelValidationTest.java new file mode 100644 index 000000000000..27ba6e2ba4c7 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelValidationTest.java @@ -0,0 +1,409 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.ai.app.ConfigService; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.beans.Permission; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.PermissionAPI; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies model validation in + * {@link ChatCompletionsResource#completions(HttpServletRequest, HttpServletResponse, String, ChatCompletionRequestView)} + * — which model names a site will accept, and from whom. + * + *

The interesting assertion in this file is the negative one: there is no administrator + * exemption. A role-conditional passthrough is exactly the branching that let two shipped dotAI + * endpoints drift to opposite model policies, and it would make SC-007 unstatable as a blanket + * invariant. So an administrator naming an unconfigured model is refused with the same status and + * the same body as a caller with no privileges at all, and that equality is asserted directly + * rather than inferred from two separate tests.

+ * + *
    + *
  • FR-023 — a model the site configured is accepted; one it did not is refused with the + * standard "no such model" shape, naming nothing the caller did not already send.
  • + *
  • FR-023 / SC-007 — the refusal is identical for an administrator and for a + * non-administrator, so no caller of any role can cause an unconfigured model to be invoked.
  • + *
  • FR-024 — a request omitting {@code model} is refused naming the field; there is no + * implicit default.
  • + *
  • FR-023 — a site configured with a fallback chain accepts any entry of the chain, not + * only the first.
  • + *
+ * + *

The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in + * through the same dotAI app secrets the rest of the AI integration tests use, so an accepted + * model travels the real client path rather than a stubbed one.

+ */ +public class InferenceModelValidationTest { + + /** The chat model the site is configured with. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** The second entry of the fallback chain the site is given in one of these tests. */ + private static final String SECONDARY_MODEL = "gpt-4o-secondary"; + + /** A model name no site in these tests ever configures. */ + private static final String UNCONFIGURED_MODEL = "some-other-vendors-model"; + + /** Path an OpenAI-compatible provider serves completions on. */ + private static final String COMPLETIONS_PATH = "/chat/completions"; + + private static final String ERROR_TYPE_INVALID_REQUEST = "invalid_request_error"; + + /** What the stubbed provider answers whenever it is reached at all. */ + private static final String PROVIDER_RESPONSE = """ + { + "id": "chatcmpl-model-1", + "object": "chat.completion", + "created": 1789000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Served." + }, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11} + } + """; + + private static WireMockServer wireMockServer; + + /** A CMS administrator; the caller a role-conditional exemption would have privileged. */ + private static User adminUser; + private static String adminToken; + + /** A backend user with no administrative role, granted nothing but read on the test site. */ + private static User limitedUser; + private static String limitedToken; + + private Host host; + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + // Both roles are needed for the administrator, not one: WebResource.checkRolePermissions + // matches DOTCMS_BACK_END_USER by key without walking inheritance, so being an admin does + // not imply it, and admin is what grants read on the site passed as an explicit override. + adminUser = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + adminToken = bearerTokenFor(adminUser); + + // The counterpart: admitted by FR-016 as an authenticated backend user, and nothing more. + // Read on the site is granted per test, since the site is created per test. + limitedUser = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole()) + .nextPersisted(); + limitedToken = bearerTokenFor(limitedUser); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = new SiteDataGen().nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig(host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + grantSiteRead(host, limitedUser); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a site configured with one chat model + * When a completion names that model + * Then it is accepted and the provider serves it + */ + @Test + public void test_completions_withConfiguredModel_isAccepted() { + final Response response = resource.completions( + mockRequest(adminToken), mockResponse(), host.getIdentifier(), + completionFor(CHAT_MODEL)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + + final ChatCompletionView view = (ChatCompletionView) response.getEntity(); + assertEquals(CHAT_MODEL, view.model()); + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a site configured with one chat model + * When a completion names a model the site has not configured + * Then it is refused with a 404 in the "no such model" shape, the provider is never contacted, + * and the message repeats nothing but what the caller already sent + */ + @Test + public void test_completions_withUnconfiguredModel_isRefusedAsNoSuchModel() { + final Response response = resource.completions( + mockRequest(adminToken), mockResponse(), host.getIdentifier(), + completionFor(UNCONFIGURED_MODEL)); + + assertNotNull(response); + assertEquals(404, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertEquals(ERROR_TYPE_INVALID_REQUEST, errorView.error().type()); + assertEquals("model", errorView.error().param()); + + final String message = errorView.error().message(); + assertNotNull(message); + assertTrue(message.contains(UNCONFIGURED_MODEL)); + + // What the refusal must not leak: which models the site does run, where its provider + // lives, what key reaches it, or which site is behind the host name. + assertFalse(message.contains(CHAT_MODEL)); + assertFalse(message.contains(AiTest.API_KEY)); + assertFalse(message.contains(String.valueOf(AiTest.PORT))); + assertFalse(message.contains(host.getHostname())); + assertFalse(message.contains(host.getIdentifier())); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a CMS administrator and a backend user with no administrative role, both able to read + * the same site + * When each names a model the site has not configured + * Then both are refused, with the same status and the same error body + * + *

This is the SC-007 invariant stated as one assertion rather than two: no caller, of any + * role, can cause a model outside the site's configuration to be invoked. Asserting the two + * refusals are equal is what would catch an exemption added later, which two + * independent single-role tests would not.

+ */ + @Test + public void test_completions_withUnconfiguredModel_refusesAdministratorAndNonAdministratorAlike() { + final Response adminResponse = resource.completions( + mockRequest(adminToken), mockResponse(), host.getIdentifier(), + completionFor(UNCONFIGURED_MODEL)); + final Response limitedResponse = resource.completions( + mockRequest(limitedToken), mockResponse(), host.getIdentifier(), + completionFor(UNCONFIGURED_MODEL)); + + assertNotNull(adminResponse); + assertNotNull(limitedResponse); + assertEquals(404, adminResponse.getStatus()); + assertEquals(adminResponse.getStatus(), limitedResponse.getStatus()); + + assertTrue(adminResponse.getEntity() instanceof InferenceErrorView); + assertTrue(limitedResponse.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body adminError = + ((InferenceErrorView) adminResponse.getEntity()).error(); + final InferenceErrorView.Body limitedError = + ((InferenceErrorView) limitedResponse.getEntity()).error(); + + assertEquals(adminError.type(), limitedError.type()); + assertEquals(adminError.param(), limitedError.param()); + assertEquals(adminError.message(), limitedError.message()); + assertEquals(adminError.code(), limitedError.code()); + + // Neither role reached the provider. + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a request that omits {@code model} entirely + * When the completion is requested + * Then it is refused as a client error naming {@code model}, with no implicit default applied + */ + @Test + public void test_completions_withoutModel_isRejectedNamingTheField() { + final ChatCompletionRequestView requestView = new ChatCompletionRequestView( + null, + List.of(new MessageView("user", "Say something.", null, null, null)), + null, null, null, null, null, null, null, null, null, null); + + final Response response = resource.completions( + mockRequest(adminToken), mockResponse(), host.getIdentifier(), requestView); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertEquals(ERROR_TYPE_INVALID_REQUEST, errorView.error().type()); + assertEquals("model", errorView.error().param()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Given a site configured with a fallback chain of two models + * When a completion names the second entry of that chain + * Then it is accepted, not refused as unconfigured + * + *

The set of accepted names is the whole chain, not its head. What actually serves is a + * separate question the site's configuration answers, not the caller: the client walks the + * chain from the front, so the served model reported back is an entry of the chain and need + * not be the one that was asked for.

+ */ + @Test + public void test_completions_withFallbackChainSecondEntry_isAccepted() throws Exception { + final List chain = List.of(CHAT_MODEL, SECONDARY_MODEL); + configureChain(host, chain); + + final Response response = resource.completions( + mockRequest(adminToken), mockResponse(), host.getIdentifier(), + completionFor(SECONDARY_MODEL)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + + final ChatCompletionView view = (ChatCompletionView) response.getEntity(); + assertTrue(chain.contains(view.model())); + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } + + /** + * Re-saves the site's dotAI secrets with a comma-separated fallback chain, and waits until the + * new configuration is the one being resolved — re-saving alone would leave the test racing + * the secrets cache. + * + * @param site the site to reconfigure + * @param chain the models, in fallback order + */ + private static void configureChain(final Host site, final List chain) throws Exception { + final String joined = String.join(",", chain); + AiTest.aiAppSecretsWithProviderConfig(site, AiTest.providerConfigJson(AiTest.PORT, joined)); + await().atMost(5, SECONDS).until(() -> { + final String resolved = ConfigService.INSTANCE.config(site).getProviderConfig(); + return resolved != null && resolved.contains(joined); + }); + } + + /** + * Grants read on a site to a user's own role, so a caller with no administrative role can + * still name the site as an explicit override. FR-019 refuses the override otherwise, which + * would make the non-administrator's refusal a 403 about the site rather than the 404 about + * the model this file is comparing. + * + * @param site the site to grant read on + * @param grantee the user to grant it to + */ + private static void grantSiteRead(final Host site, final User grantee) throws Exception { + final PermissionAPI permissionAPI = APILocator.getPermissionAPI(); + final Permission readPermission = new Permission( + site.getPermissionId(), + APILocator.getRoleAPI().getUserRole(grantee).getId(), + PermissionAPI.PERMISSION_READ); + permissionAPI.save(readPermission, site, APILocator.systemUser(), false); + } + + /** + * Stubs the OpenAI-compatible provider with a single canned answer; this file is about which + * requests reach it, not what it says. + */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_RESPONSE))); + } + + /** + * @param forUser the caller to mint a token for + * @return the bearer credential that caller authenticates with + */ + private static String bearerTokenFor(final User forUser) throws Exception { + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + forUser.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + return "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, forUser); + } + + /** + * @param model the model to ask for + * @return the smallest valid completion request + */ + private static ChatCompletionRequestView completionFor(final String model) { + return new ChatCompletionRequestView( + model, + List.of(new MessageView("user", "Say something.", null, null, null)), + null, null, null, null, null, null, null, null, null, null); + } + + /** + * @param bearerToken the caller's credential + * @return a request authenticated as that caller, as the family requires + */ + private static HttpServletRequest mockRequest(final String bearerToken) { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/inference/v1/chat/completions"); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost/api/inference/v1/chat/completions")); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteIsolationTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteIsolationTest.java new file mode 100644 index 000000000000..18e24cdf57e0 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteIsolationTest.java @@ -0,0 +1,364 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.ai.app.ConfigService; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Specifies cross-site isolation for + * {@link ChatCompletionsResource#completions(HttpServletRequest, HttpServletResponse, String, ChatCompletionRequestView)} + * — FR-027 and FR-028. + * + *

Isolation here is not enforced by a check anywhere; it is a property of one line, the cache + * key {@code host + ":" + providerConfigHash} in + * {@link com.dotcms.ai.client.langchain4j.LangChain4jAIClient}. That makes it exactly the kind of + * behaviour that survives refactoring right up until it silently does not: nothing throws when two + * sites start sharing a provider instance, and nothing throws when a rotated credential keeps + * being used until the cache's one-hour TTL expires. The failure mode is a site's key spending + * another site's budget, or a revoked key continuing to work, and neither shows up anywhere but + * here.

+ * + *

So each site is given a provider at a different URL path on the same + * WireMock server, answering with its own text. Where a request landed is then a fact, not an + * inference: the path WireMock recorded says which site's configuration built the client that + * made it.

+ * + *
    + *
  • FR-028 — two sites with different provider configurations, driven with interleaved + * requests, are each served by their own provider.
  • + *
  • FR-028 — rotating one site's configuration takes effect on its next request and leaves + * the other site untouched.
  • + *
  • FR-020 / FR-027 — a completion never reports another site's resolved site id.
  • + *
+ */ +public class InferenceSiteIsolationTest { + + /** URL path segment the first site's provider is reached on. */ + private static final String ALPHA_PATH = "/alpha/chat/completions"; + + /** URL path segment the second site's provider is reached on. */ + private static final String BETA_PATH = "/beta/chat/completions"; + + /** Where the first site's provider moves to when its configuration is rotated. */ + private static final String ALPHA_ROTATED_PATH = "/alpha-rotated/chat/completions"; + + private static final String ALPHA_MODEL = "alpha-chat-model"; + private static final String BETA_MODEL = "beta-chat-model"; + + private static final String ALPHA_ANSWER = "Answer from the ALPHA provider."; + private static final String BETA_ANSWER = "Answer from the BETA provider."; + private static final String ALPHA_ROTATED_ANSWER = "Answer from the ROTATED alpha provider."; + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + private Host alphaSite; + private Host betaSite; + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProviders(); + + // Both roles are needed, not one: WebResource.checkRolePermissions matches + // DOTCMS_BACK_END_USER by key without walking inheritance, so being an admin does not + // imply it, and admin is what grants read on the sites passed as explicit overrides. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + alphaSite = new SiteDataGen().nextPersisted(); + betaSite = new SiteDataGen().nextPersisted(); + configure(alphaSite, "alpha", ALPHA_MODEL); + configure(betaSite, "beta", BETA_MODEL); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(alphaSite); + AiTest.removeAiAppSecrets(betaSite); + } + + /** + * Given two sites whose dotAI configurations name different providers + * When completions for the two are interleaved + * Then each answer comes from that site's own provider, and each provider saw only the + * requests belonging to its site + */ + @Test + public void test_completions_forTwoSitesInterleaved_eachUsesItsOwnProvider() { + final String firstAlpha = contentOf(complete(alphaSite, ALPHA_MODEL)); + final String firstBeta = contentOf(complete(betaSite, BETA_MODEL)); + final String secondAlpha = contentOf(complete(alphaSite, ALPHA_MODEL)); + final String secondBeta = contentOf(complete(betaSite, BETA_MODEL)); + + assertEquals(ALPHA_ANSWER, firstAlpha); + assertEquals(ALPHA_ANSWER, secondAlpha); + assertEquals(BETA_ANSWER, firstBeta); + assertEquals(BETA_ANSWER, secondBeta); + assertNotEquals(firstAlpha, firstBeta); + + // Where each request landed, rather than what it happened to say. + wireMockServer.verify(2, postRequestedFor(urlPathEqualTo(ALPHA_PATH))); + wireMockServer.verify(2, postRequestedFor(urlPathEqualTo(BETA_PATH))); + } + + /** + * Given two configured sites, one of which has already served a request and therefore has a + * cached provider instance + * When that site's dotAI secrets are re-saved with a different provider configuration + * Then its next request uses the new configuration, and the other site is unaffected + * + *

Two mechanisms should each make this hold on their own, which is why the test asserts the + * outcome rather than either of them: the cache key carries the configuration's hash, so a + * changed configuration is a different key, and {@code AIAppListener} additionally flushes the + * site's entries when its secrets are saved. A regression in one is invisible while the other + * still works — but a regression in both is a revoked credential that keeps working for an + * hour, so what is pinned here is the observable result.

+ */ + @Test + public void test_completions_afterRotatingOneSitesConfiguration_usesTheNewOneAndLeavesTheOtherAlone() + throws Exception { + // Warm the cache: without a first request there is no stale instance to invalidate and the + // test would pass on an implementation that never invalidates anything. + assertEquals(ALPHA_ANSWER, contentOf(complete(alphaSite, ALPHA_MODEL))); + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(ALPHA_PATH))); + + configure(alphaSite, "alpha-rotated", ALPHA_MODEL); + wireMockServer.resetRequests(); + + assertEquals(ALPHA_ROTATED_ANSWER, contentOf(complete(alphaSite, ALPHA_MODEL))); + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(ALPHA_ROTATED_PATH))); + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(ALPHA_PATH))); + + // The other site's provider was never rebuilt and never moved. + assertEquals(BETA_ANSWER, contentOf(complete(betaSite, BETA_MODEL))); + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(BETA_PATH))); + } + + /** + * Given two configured sites + * When a completion is requested for each + * Then each publishes its own site as the one that served, and never the other's + * + *

The serving site is what FR-020 puts on every response and what a reconciliation + * pipeline would bill against, so reporting a neighbour's id would be a billing error rather + * than a cosmetic one.

+ */ + @Test + public void test_completions_forEitherSite_neverReportsTheOtherSitesResolvedSiteId() { + final HttpServletRequest alphaRequest = mockRequest(); + final HttpServletRequest betaRequest = mockRequest(); + + resource.completions(alphaRequest, mockResponse(), + alphaSite.getIdentifier(), completionFor(ALPHA_MODEL)); + resource.completions(betaRequest, mockResponse(), + betaSite.getIdentifier(), completionFor(BETA_MODEL)); + + verify(alphaRequest).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, alphaSite.getIdentifier()); + verify(alphaRequest, never()).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, betaSite.getIdentifier()); + + verify(betaRequest).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, betaSite.getIdentifier()); + verify(betaRequest, never()).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, alphaSite.getIdentifier()); + } + + /** + * Runs one completion for a site and asserts only that it was served, so the tests above can + * assert what matters about it. + * + * @param site the site to bill + * @param model the model that site has configured + * @return the completed answer + */ + private ChatCompletionView complete(final Host site, final String model) { + final Response response = resource.completions( + mockRequest(), mockResponse(), site.getIdentifier(), completionFor(model)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + return (ChatCompletionView) response.getEntity(); + } + + /** + * @param view a completed answer + * @return the assistant's text, which identifies the provider that produced it + */ + private static String contentOf(final ChatCompletionView view) { + assertNotNull(view.choices()); + assertEquals(1, view.choices().size()); + assertNotNull(view.choices().get(0).message()); + return view.choices().get(0).message().content(); + } + + /** + * Points a site's dotAI configuration at one of the stubbed providers, and waits until that + * configuration is the one being resolved — re-saving alone would leave a rotation test racing + * the secrets cache. + * + * @param site the site to configure + * @param pathSegment the provider's URL path segment on the WireMock server + * @param model the chat model the site accepts + */ + private static void configure(final Host site, final String pathSegment, final String model) + throws Exception { + final String json = providerConfigJson(pathSegment, model); + AiTest.aiAppSecretsWithProviderConfig(site, json); + await().atMost(5, SECONDS).until(() -> { + final String resolved = ConfigService.INSTANCE.config(site).getProviderConfig(); + return resolved != null && resolved.contains("/" + pathSegment + "/"); + }); + } + + /** + * Builds a chat-only dotAI provider configuration whose endpoint is distinct per site, so the + * path a request arrives on names the configuration that produced it. + * + * @param pathSegment the provider's URL path segment on the WireMock server + * @param model the chat model + * @return the {@code providerConfig} JSON + */ + private static String providerConfigJson(final String pathSegment, final String model) { + return String.format( + "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\"," + + "\"endpoint\":\"http://localhost:%d/%s/\",\"maxRetries\":0}}", + AiTest.API_KEY, model, AiTest.PORT, pathSegment); + } + + /** + * Stubs three OpenAI-compatible providers on one server, each on its own path and each + * answering with text that names it. + */ + private static void stubProviders() { + stubProvider(ALPHA_PATH, ALPHA_ANSWER); + stubProvider(BETA_PATH, BETA_ANSWER); + stubProvider(ALPHA_ROTATED_PATH, ALPHA_ROTATED_ANSWER); + } + + private static void stubProvider(final String path, final String answer) { + wireMockServer.stubFor(post(urlPathEqualTo(path)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(providerResponse(answer)))); + } + + /** + * @param answer the assistant text this provider replies with + * @return an OpenAI-compatible completion payload carrying it + */ + private static String providerResponse(final String answer) { + return String.format(""" + { + "id": "chatcmpl-isolation", + "object": "chat.completion", + "created": 1789000000, + "model": "stubbed", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "%s" + }, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 9, "completion_tokens": 6, "total_tokens": 15} + } + """, answer); + } + + /** + * @param model the model to ask for + * @return the smallest valid completion request + */ + private static ChatCompletionRequestView completionFor(final String model) { + return new ChatCompletionRequestView( + model, + List.of(new MessageView("user", "Say something.", null, null, null)), + null, null, null, null, null, null, null, null, null, null); + } + + /** + * @return a request authenticated with the test user's bearer token, as the family requires + */ + private static HttpServletRequest mockRequest() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/inference/v1/chat/completions"); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost/api/inference/v1/chat/completions")); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteResolutionTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteResolutionTest.java new file mode 100644 index 000000000000..bf2fca40bb28 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteResolutionTest.java @@ -0,0 +1,474 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.ChatCompletionRequestView; +import com.dotcms.inference.rest.view.ChatCompletionRequestView.MessageView; +import com.dotcms.inference.rest.view.ChatCompletionView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies which site's dotAI credentials serve a request on {@code /api/inference/v1}, and how a + * caller may say so — User Story 3 of the OpenAI-compatible inference feature. + * + *

Per-site credential governance is the reason to route AI through dotCMS at all, so the + * question these tests answer is never "did the call succeed" but "whose provider configuration + * paid for it". That is asserted structurally rather than by inspecting secrets: the two sites are + * configured with different chat model names, and + * {@link ChatCompletionsResource} refuses any model the resolved site has not configured. A 200 + * for site A's model is therefore proof that site A's configuration was the one consulted, and a + * 404 for site B's model on the same host name is proof that site B's was not.

+ * + *
    + *
  • FR-017 — with no override, the site comes from the request's host name.
  • + *
  • FR-018 — an explicit override wins, given either as the {@code siteId} query parameter + * or as the {@code X-dotCMS-Site} header; the header wins when the two disagree.
  • + *
  • FR-025 — the legacy {@code host_id} and {@code Host} request parameters, which elsewhere + * in dotCMS act as a de-facto site override, are ignored here.
  • + *
  • FR-020 — every response identifies the site whose configuration served it.
  • + *
+ * + *

What is asserted for FR-020. The serving site reaches a real caller as the + * {@code X-dotCMS-Resolved-Site} response header, which {@link ResolvedSiteHeaderFilter} writes + * from the request attribute {@link InferenceRequestAttributes#RESOLVED_SITE_ID}. These tests call + * the resource method directly, so no JAX-RS response filter runs and no header exists to read. + * They therefore assert the request attribute — the filter's sole input, and the + * only part of the chain the resource is responsible for.

+ */ +public class InferenceSiteResolutionTest { + + /** Path an OpenAI-compatible provider serves completions on. */ + private static final String COMPLETIONS_PATH = "/chat/completions"; + + private static final String REQUEST_URI = "/api/inference/v1/chat/completions"; + + /** Header form of the site override, as {@link ChatCompletionsResource} reads it. */ + private static final String SITE_HEADER = "X-dotCMS-Site"; + + /** + * Legacy request parameter that elsewhere in dotCMS selects the current site. FR-025 requires + * this family to ignore it. + */ + private static final String LEGACY_HOST_ID_PARAM = "host_id"; + + /** + * The other legacy site-selecting request parameter. dotCMS reads it under the velocity + * variable name of the Host content type, {@link Host#HOST_VELOCITY_VAR_NAME}; both that + * spelling and the lower-case one a caller would more naturally type are stubbed, so the test + * cannot pass merely because it aimed at the wrong string. + */ + private static final String LEGACY_HOST_PARAM = Host.HOST_VELOCITY_VAR_NAME; + + private static final String LEGACY_HOST_PARAM_LOWERCASE = "host"; + + /** The chat model configured on the first site, and on no other. */ + private static final String MODEL_SITE_A = "gpt-4o-mini"; + + /** The chat model configured on the second site, and on no other. */ + private static final String MODEL_SITE_B = "gpt-4o"; + + /** What the stubbed provider answers, whichever site's configuration reached it. */ + private static final String PROVIDER_RESPONSE = """ + { + "id": "chatcmpl-site-1", + "object": "chat.completion", + "created": 1789000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Answered."}, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12} + } + """; + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + private Host siteA; + private Host siteB; + + private final ChatCompletionsResource resource = new ChatCompletionsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + // Two roles, not one. WebResource.checkRolePermissions matches DOTCMS_BACK_END_USER by + // key and does not walk inheritance, so being an administrator does not imply it; the + // administrator role is separately what grants READ on the sites used as explicit + // overrides here. Refusals for callers who lack either live in + // InferenceAuthorizationTest. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + siteA = new SiteDataGen().name(uniqueSiteName("a")).nextPersisted(); + siteB = new SiteDataGen().name(uniqueSiteName("b")).nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig( + siteA, AiTest.providerConfigJson(AiTest.PORT, MODEL_SITE_A)); + AiTest.aiAppSecretsWithProviderConfig( + siteB, AiTest.providerConfigJson(AiTest.PORT, MODEL_SITE_B)); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(siteA); + AiTest.removeAiAppSecrets(siteB); + } + + /** + * Given a request whose host name is the host name of a configured site, and no override + * When a completion is requested + * Then that site's configuration serves it — its own model is accepted while the other site's + * model is refused as unconfigured — and the serving site is reported as that site + */ + @Test + public void test_completions_withNoOverride_isServedByTheHostNameSite() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(MODEL_SITE_A)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + assertResolvedSite(request, siteA); + + // The other site's model on the same host name proves the configuration consulted was + // site A's and not site B's; a call that merely succeeded would prove neither. + final HttpServletRequest otherModelRequest = mockRequest(siteA.getHostname()); + final Response otherModelResponse = resource.completions( + otherModelRequest, mockResponse(), null, completionFor(MODEL_SITE_B)); + + assertNotNull(otherModelResponse); + assertEquals(404, otherModelResponse.getStatus()); + assertTrue(otherModelResponse.getEntity() instanceof InferenceErrorView); + assertResolvedSite(otherModelRequest, siteA); + } + + /** + * Given a request whose host name is site A's, carrying {@code siteId} naming site B + * When a completion is requested for the model only site B has configured + * Then site B's configuration serves it and site B is reported as the serving site + */ + @Test + public void test_completions_withSiteIdQueryParameter_overridesTheHostName() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + + final Response response = resource.completions( + request, mockResponse(), siteB.getIdentifier(), completionFor(MODEL_SITE_B)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + assertResolvedSite(request, siteB); + } + + /** + * Given a request whose host name is site A's, carrying the {@code X-dotCMS-Site} header + * naming site B by identifier + * When a completion is requested for the model only site B has configured + * Then site B's configuration serves it and site B is reported as the serving site + */ + @Test + public void test_completions_withSiteHeader_overridesTheHostName() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + when(request.getHeader(SITE_HEADER)).thenReturn(siteB.getIdentifier()); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(MODEL_SITE_B)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + assertResolvedSite(request, siteB); + } + + /** + * Given a request carrying both overrides, the header naming site B and the query parameter + * naming site A + * When a completion is requested for the model only site B has configured + * Then the header wins: site B serves the request and is reported as the serving site + */ + @Test + public void test_completions_withHeaderAndQueryParameterDisagreeing_prefersTheHeader() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + when(request.getHeader(SITE_HEADER)).thenReturn(siteB.getIdentifier()); + + final Response response = resource.completions( + request, mockResponse(), siteA.getIdentifier(), completionFor(MODEL_SITE_B)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + assertResolvedSite(request, siteB); + } + + /** + * Given a request whose host name is site A's, carrying the legacy {@code host_id} request + * parameter naming site B + * When a completion is requested for the model only site A has configured + * Then the legacy parameter is ignored: site A serves the request and is reported as the + * serving site + * + *

FR-025. The parameter is a pre-existing de-facto site override on the rest of the + * product, and a token-authenticated API must not inherit it silently — a caller who never + * asked for another site must not be able to spend its credentials by copying a query string. + * The model is site A's, so honouring the parameter cannot merely look like success: it would + * surface as a 404 for a model site B never configured.

+ */ + @Test + public void test_completions_withLegacyHostIdParameter_ignoresIt() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + when(request.getParameter(LEGACY_HOST_ID_PARAM)).thenReturn(siteB.getIdentifier()); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(MODEL_SITE_A)); + + assertNotNull(response); + assertEquals("The legacy host_id parameter must not select the site; site A's model must " + + "still be accepted", 200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + assertResolvedSite(request, siteA); + } + + /** + * Given a request whose host name is site A's, carrying the legacy {@code Host} request + * parameter naming site B + * When a completion is requested for the model only site A has configured + * Then the legacy parameter is ignored: site A serves the request and is reported as the + * serving site + */ + @Test + public void test_completions_withLegacyHostParameter_ignoresIt() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + when(request.getParameter(LEGACY_HOST_PARAM)).thenReturn(siteB.getHostname()); + when(request.getParameter(LEGACY_HOST_PARAM_LOWERCASE)).thenReturn(siteB.getHostname()); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor(MODEL_SITE_A)); + + assertNotNull(response); + assertEquals("The legacy Host parameter must not select the site; site A's model must " + + "still be accepted", 200, response.getStatus()); + assertTrue(response.getEntity() instanceof ChatCompletionView); + assertResolvedSite(request, siteA); + } + + /** + * Given a request whose host name matches no site or alias, carrying the legacy + * {@code host_id} request parameter naming site B + * When a completion is requested + * Then the legacy parameter is still ignored: the default site serves the request, exactly as + * it would have without the parameter + * + *

FR-025 has no exception for the fallback path, and it is the path that matters most. The + * unmatched host name is not an edge case here — FR-020 keeps the default-site fallback + * precisely because the server-side callers this family exists for routinely arrive on + * internal DNS, container service names or {@code localhost}, none of which are site aliases. + * A legacy override that is ignored only while the host name happens to match is not ignored; + * it is ignored where it could do no harm and honoured where it could.

+ */ + @Test + public void test_completions_withLegacyHostIdParameterAndUnmatchedHostName_ignoresIt() + throws Exception { + final Host defaultSite = APILocator.getHostAPI().findDefaultHost(APILocator.systemUser(), false); + final HttpServletRequest request = mockRequest(unmatchedHostName()); + when(request.getParameter(LEGACY_HOST_ID_PARAM)).thenReturn(siteB.getIdentifier()); + + resource.completions(request, mockResponse(), null, completionFor(MODEL_SITE_A)); + + assertEquals("The legacy host_id parameter must not select the site on the default-site " + + "fallback path either; it named site B (" + siteB.getIdentifier() + ")", + defaultSite.getIdentifier(), + request.getAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID)); + } + + /** + * Given a request whose host name matches no site or alias, carrying the legacy {@code Host} + * request parameter naming site B + * When a completion is requested + * Then the legacy parameter is still ignored and the default site serves the request + */ + @Test + public void test_completions_withLegacyHostParameterAndUnmatchedHostName_ignoresIt() + throws Exception { + final Host defaultSite = APILocator.getHostAPI().findDefaultHost(APILocator.systemUser(), false); + final HttpServletRequest request = mockRequest(unmatchedHostName()); + when(request.getParameter(LEGACY_HOST_PARAM)).thenReturn(siteB.getHostname()); + when(request.getParameter(LEGACY_HOST_PARAM_LOWERCASE)).thenReturn(siteB.getHostname()); + + resource.completions(request, mockResponse(), null, completionFor(MODEL_SITE_A)); + + assertEquals("The legacy Host parameter must not select the site on the default-site " + + "fallback path either; it named site B (" + siteB.getIdentifier() + ")", + defaultSite.getIdentifier(), + request.getAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID)); + } + + /** + * Given a request that will be refused because the model is not configured for its site + * When the completion is requested + * Then the serving site is still reported + * + *

FR-020 asks for the serving site on every response, not only the ones that + * worked. A refusal is precisely the case an operator reconciling spend needs attributed, and + * a value only the happy path published would not deliver it.

+ */ + @Test + public void test_completions_whenRefused_stillReportsTheServingSite() { + final HttpServletRequest request = mockRequest(siteA.getHostname()); + + final Response response = resource.completions( + request, mockResponse(), null, completionFor("a-model-nobody-configured")); + + assertNotNull(response); + assertEquals(404, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + assertResolvedSite(request, siteA); + } + + /** + * Asserts the site the resource published as having served the request. + * + * @param request the request the resource was called with + * @param expected the site expected to have served it + */ + private static void assertResolvedSite(final HttpServletRequest request, final Host expected) { + assertEquals("The resolved-site request attribute, which ResolvedSiteHeaderFilter turns " + + "into the X-dotCMS-Resolved-Site header, must name the serving site", + expected.getIdentifier(), + request.getAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID)); + } + + /** Answers every completion with the same finished response; the site under test is the variable. */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(COMPLETIONS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_RESPONSE))); + } + + /** + * @param model the model to ask for + * @return the smallest well-formed completion request + */ + private static ChatCompletionRequestView completionFor(final String model) { + return new ChatCompletionRequestView( + model, + List.of(new MessageView("user", "Which site is serving me?", null, null, null)), + null, null, null, null, null, null, null, null, null, null); + } + + /** + * @param prefix distinguishes the two sites + * @return a host name no other test or run can collide with + */ + private static String uniqueSiteName(final String prefix) { + return "inference-" + prefix + "-" + UUID.randomUUID() + ".dotcms.com"; + } + + /** + * @return a host name no site or alias can match, standing in for the internal DNS and + * container service names the server-side callers of this family actually arrive on + */ + private static String unmatchedHostName() { + return "no-such-site-" + UUID.randomUUID() + ".invalid"; + } + + /** + * Builds a bearer-authenticated request arriving at a given host name. + * + *

Request attributes are backed by a real map rather than left as mock no-ops, because the + * resolved site is published as an attribute and reading it back is how these tests observe + * which site served.

+ * + * @param serverName the host name the request arrives on + * @return the mocked request + */ + private static HttpServletRequest mockRequest(final String serverName) { + final HttpServletRequest request = mock(HttpServletRequest.class); + final Map attributes = new HashMap<>(); + + when(request.getRequestURI()).thenReturn(REQUEST_URI); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://" + serverName + REQUEST_URI)); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getServerName()).thenReturn(serverName); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + + doAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))) + .when(request).setAttribute(anyString(), any()); + when(request.getAttribute(anyString())) + .thenAnswer(invocation -> attributes.get(invocation.getArgument(0))); + + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} From 9d95eb0e95cc2945d4ffe1c0b34ec5dc0543dbbf Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Mon, 14 Sep 2026 20:35:33 -0600 Subject: [PATCH 3/4] feat(ai): models, embeddings and image generation at /api/inference/v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes US4 and US5: the three remaining operations, each resolving the site and validating the model through the same shared component the chat endpoint uses, and each returning the standard wire shape. Images: `n` is honored, with both bounds refused rather than clamped. An earlier draft of FR-012 refused every `n` other than 1, justified by the claim that the provider abstraction returns a single image per call. That claim was false — `ImageModel.generate(prompt, n)` returns a list, and the adopted format documents up to 10 images per request. Correcting it surfaced two further problems, both fixed here: - `generate(prompt, n)` is a default method that throws unless overridden. OpenAiImageModel and OpenAiOfficialImageModel override it; GoogleAiGeminiImageModel does not. So on a Gemini-configured site a request for several images threw inside the client library and reached the caller as a 502 — a retryable status for a request that can never succeed, which sends a standard client's back-off into an unwinnable loop. It is now a 400 naming the field. Support is probed from the model's declaring class rather than a provider-name list, so it cannot rot on a library upgrade. - Honoring `n` removed the spend ceiling the old rule had imposed by accident, and FR-032 puts per-site quotas out of scope. Adds DOT_INFERENCE_MAX_IMAGES_PER_REQUEST, defaulting to 10 — the ceiling the OpenAI images API documents for this field, so a client written against the standard meets the same limit here it already handles there. Also registers all three new test classes in MainSuite2b. Unregistered integration tests compile and pass locally but are silently never run in CI. Tests: 60 unit, 72 integration across the ten inference classes, all green. Run several dotAI classes at once with -Dit.test.forkcount=1; they share the fixed WireMock port 50505 and forkCount defaults to 4. Co-Authored-By: Claude Opus 5 (1M context) --- .../client/langchain4j/InferenceAIClient.java | 250 ++++- .../langchain4j/LangChain4jAIClient.java | 116 +++ .../OpenAiModelProviderStrategy.java | 3 + .../ai/client/langchain4j/ProviderConfig.java | 13 + .../inference/model/InferenceLimits.java | 36 +- .../MultipleImagesUnsupportedException.java | 27 + .../inference/rest/EmbeddingsResource.java | 418 ++++++++ .../dotcms/inference/rest/ImagesResource.java | 370 +++++++ .../dotcms/inference/rest/ModelsResource.java | 224 ++++ .../rest/view/EmbeddingListView.java | 69 ++ .../rest/view/EmbeddingsRequestView.java | 31 + .../rest/view/ImageGenerationRequestView.java | 33 + .../rest/view/ImageGenerationView.java | 45 + .../inference/rest/view/ModelListView.java | 55 + .../main/webapp/WEB-INF/openapi/openapi.yaml | 307 +++++- .../src/test/java/com/dotcms/MainSuite2b.java | 6 + .../rest/InferenceEmbeddingsTest.java | 960 +++++++++++++++++ .../inference/rest/InferenceImagesTest.java | 968 ++++++++++++++++++ .../inference/rest/InferenceModelsTest.java | 455 ++++++++ .../contracts/inference-v1.md | 31 +- .../37431-openai-compatible-inference/spec.md | 6 +- 21 files changed, 4403 insertions(+), 20 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/inference/model/MultipleImagesUnsupportedException.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingListView.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingsRequestView.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationRequestView.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationView.java create mode 100644 dotCMS/src/main/java/com/dotcms/inference/rest/view/ModelListView.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceEmbeddingsTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceImagesTest.java create mode 100644 dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelsTest.java diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java index 28da307c825e..1204d582c24e 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java @@ -3,6 +3,7 @@ import com.dotcms.ai.app.AppConfig; import com.dotcms.inference.model.InferenceError; import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.model.MultipleImagesUnsupportedException; import com.dotcms.inference.model.InferenceMessage; import com.dotcms.inference.model.InferenceRequest; import com.dotcms.inference.model.InferenceResponse; @@ -15,11 +16,14 @@ import com.fasterxml.jackson.databind.JsonNode; import dev.langchain4j.agent.tool.ToolExecutionRequest; import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.image.Image; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormatType; @@ -31,11 +35,20 @@ import dev.langchain4j.model.chat.response.CompleteToolCall; import dev.langchain4j.model.chat.response.PartialToolCall; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; +import dev.langchain4j.model.image.ImageModel; +import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; import io.vavr.Lazy; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; @@ -64,7 +77,8 @@ * when its credentials are rotated, and {@code AIAppListener} is wired to that one class. A second * client keeping its own cache would go on serving a revoked key until its TTL expired, with * nothing to notice. So this class borrows models through - * {@link LangChain4jAIClient#withChatModel} and {@link LangChain4jAIClient#withStreamingChatModel} + * {@link LangChain4jAIClient#withChatModel}, {@link LangChain4jAIClient#withStreamingChatModel}, + * {@link LangChain4jAIClient#withEmbeddingModel} and {@link LangChain4jAIClient#withImageModel}, * and owns no state of its own.

* *

Those accessors hand over the name of the model that actually served, which after a fallback @@ -92,6 +106,15 @@ public final class InferenceAIClient { private static final String SCHEMA_DEFS = "$defs"; private static final String SCHEMA_DEFINITIONS = "definitions"; + /** What a caller is told when nothing usable came back from an image provider. */ + private static final String NO_IMAGE_MESSAGE = "The model provider returned no usable image"; + + /** + * Ceiling on fetching a provider-hosted image. A request thread is parked for the whole + * download, so a provider whose CDN hangs must not be able to hold one indefinitely. + */ + private static final int IMAGE_FETCH_TIMEOUT_SECONDS = 30; + private InferenceAIClient() { } @@ -163,6 +186,122 @@ public void stream(final AppConfig appConfig, } } + /** + * Embeds a batch of texts in one provider round trip. + * + *

One call rather than one per input, because the batch is what the caller sent and what + * the provider bills as a unit: embedding them separately would report usage that describes + * none of them and would multiply the site's spend on a request it was never asked to split. + * The vectors come back in the order the inputs were given, which is what lets the caller + * stamp each entry with the index of the text it embedded.

+ * + *

Nothing here logs the input: it is customer content by definition.

+ * + * @param appConfig the resolved site's configuration + * @param inputs the texts to embed, in the order the caller sent them + * @return the vectors, the model that served, and the usage for the whole batch + * @throws RuntimeException if every model in the site's chain failed; the exception is the last + * failure, left for the REST layer to turn into a status + */ + public EmbeddingBatch embed(final AppConfig appConfig, final List inputs) { + final List segments = new ArrayList<>(inputs.size()); + for (final String input : inputs) { + segments.add(TextSegment.from(input)); + } + + return LangChain4jAIClient.get().withEmbeddingModel(appConfig, (model, servingModel) -> { + final Response> response = model.embedAll(segments); + final List embeddings = + response.content() == null ? List.of() : response.content(); + final List> vectors = new ArrayList<>(embeddings.size()); + for (final Embedding embedding : embeddings) { + vectors.add(List.copyOf(embedding.vectorAsList())); + } + return new EmbeddingBatch( + servingModel, List.copyOf(vectors), toEmbeddingUsage(response.tokenUsage())); + }); + } + + /** + * Generates images from a prompt and returns each as base64, whatever the provider offered. + * + *

{@link LangChain4jAIClient#withImageModel} already asks the provider for the inline form, + * so for most providers the bytes arrive inline and nothing further is needed. A provider that + * has no such option answers with a URL regardless; that URL is fetched and re-encoded here + * rather than refused, because the upstream artifact exists either way at that point and + * declining would break image generation on part of a multi-provider gateway to avoid an + * exposure already incurred. What the guarantee actually covers is the caller: they never + * receive an addressable artifact.

+ * + *

A request for a single image goes through {@code generate(prompt)} rather than through + * {@code generate(prompt, 1)}. The multi-image overload is a {@code default} method on + * {@link ImageModel} that throws unless a provider overrides it, so routing the common case + * through it would break every provider that has not — for a count where the two calls mean + * exactly the same thing.

+ * + *

Neither the prompt nor the provider's URL is logged; the first is customer content and + * the second addresses content generated from it.

+ * + * @param appConfig the resolved site's configuration + * @param prompt what to generate + * @param size the size the caller asked for, or null to use the site's configured one + * @param count how many images to generate; at least one + * @return the images as base64, and the model that served + * @throws RuntimeException if every model in the site's chain failed, if the provider cannot + * generate several images at once, or if it produced nothing that + * could be turned into bytes + */ + public GeneratedImages generateImages(final AppConfig appConfig, + final String prompt, + final String size, + final int count) { + return LangChain4jAIClient.get().withImageModel(appConfig, size, (model, servingModel) -> { + if (count > 1 && !supportsMultipleImages(model)) { + throw new MultipleImagesUnsupportedException(servingModel); + } + final List images = count == 1 + ? Collections.singletonList(model.generate(prompt).content()) + : model.generate(prompt, count).content(); + + if (images == null || images.isEmpty()) { + throw new IllegalStateException(NO_IMAGE_MESSAGE); + } + + final List generated = new ArrayList<>(images.size()); + for (final Image image : images) { + generated.add(new GeneratedImage( + toBase64(image), image == null ? null : image.revisedPrompt())); + } + return new GeneratedImages(servingModel, List.copyOf(generated)); + }); + } + + /** + * Answers whether an image model can actually produce more than one image per call. + * + *

The multi-image call is a default method on the provider abstraction that throws unless + * the implementation overrides it, so declaring the image capability says nothing about + * whether this particular provider honours a count. Asking the class which one it inherited is + * exact: it needs no list of provider names to be kept current, and it cannot be fooled by a + * message string that the library is free to change.

+ * + *

Probing beforehand rather than catching afterwards matters because the thrown type is + * {@code IllegalArgumentException} — indistinguishable from a genuine complaint about the + * arguments, and so not safe to translate on sight.

+ * + * @param model the image model handed over by the accessor + * @return whether the multi-image call is implemented rather than inherited + */ + private static boolean supportsMultipleImages(final ImageModel model) { + try { + return model.getClass() + .getMethod("generate", String.class, int.class) + .getDeclaringClass() != ImageModel.class; + } catch (final NoSuchMethodException e) { + return false; + } + } + /** * Drives one streaming exchange to its terminal event. * @@ -573,6 +712,115 @@ private static String nullToEmpty(final String value) { return value == null ? "" : value; } + /** + * Reads an image as base64, fetching it first when the provider only gave a link to it. + * + * @param image the provider's image + * @return the image bytes, base64 encoded + */ + private static String toBase64(final Image image) { + if (image == null) { + throw new IllegalStateException(NO_IMAGE_MESSAGE); + } + if (image.base64Data() != null && !image.base64Data().isBlank()) { + return image.base64Data(); + } + if (image.url() == null) { + throw new IllegalStateException(NO_IMAGE_MESSAGE); + } + return fetchAndEncode(image.url()); + } + + /** + * Downloads a provider-hosted image and encodes it. + * + *

Bounded by {@link #IMAGE_FETCH_TIMEOUT_SECONDS} so a provider whose CDN hangs cannot park + * the request thread for as long as it likes. The address is the provider's own, taken from a + * response to a request dotCMS made to an endpoint the site configured, and it is never logged + * or handed back to the caller.

+ * + * @param url where the provider put the image + * @return the image bytes, base64 encoded + */ + private static String fetchAndEncode(final URI url) { + final Duration timeout = Duration.ofSeconds(IMAGE_FETCH_TIMEOUT_SECONDS); + try { + final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(timeout) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + final HttpResponse response = httpClient.send( + HttpRequest.newBuilder(url).timeout(timeout).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() / 100 != 2 + || response.body() == null + || response.body().length == 0) { + throw new IllegalStateException(NO_IMAGE_MESSAGE); + } + return Base64.getEncoder().encodeToString(response.body()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(NO_IMAGE_MESSAGE, e); + } catch (final IOException e) { + Logger.warn(InferenceAIClient.class, + "Could not retrieve the provider's generated image: " + + e.getClass().getSimpleName()); + throw new IllegalStateException(NO_IMAGE_MESSAGE, e); + } + } + + /** + * Maps the token counts an embeddings exchange reported. + * + *

An embeddings call generates nothing, so there is no completion count to report and the + * total equals the prompt count. A provider that reported only the prompt count is therefore + * read as having reported the total too — that is arithmetic on what it said, not an estimate + * of what it did not.

+ * + * @param tokenUsage the provider's counts, possibly null or partly absent + * @return the counts, or {@link InferenceUsage#UNREPORTED} when the provider reported none + */ + private static InferenceUsage toEmbeddingUsage(final TokenUsage tokenUsage) { + if (tokenUsage == null) { + return InferenceUsage.UNREPORTED; + } + final Integer inputTokens = tokenUsage.inputTokenCount(); + final Integer totalTokens = tokenUsage.totalTokenCount() == null + ? inputTokens + : tokenUsage.totalTokenCount(); + final InferenceUsage usage = new InferenceUsage(inputTokens, null, totalTokens); + return usage.isReported() ? usage : InferenceUsage.UNREPORTED; + } + + /** + * One batch of embeddings, as the provider produced them. + * + * @param model the model that actually served, after any fallback hop + * @param vectors one vector per input, in the order the inputs were given + * @param usage tokens consumed by the whole batch + */ + public record EmbeddingBatch(String model, List> vectors, InferenceUsage usage) { + } + + /** + * The images one generation request produced. + * + * @param model the model that actually served, after any fallback hop + * @param images the images, in the order the provider returned them + */ + public record GeneratedImages(String model, List images) { + } + + /** + * One generated image, always as base64. + * + * @param base64Data the image bytes, base64 encoded + * @param revisedPrompt the prompt the provider says it actually used, when it says so + */ + public record GeneratedImage(String base64Data, String revisedPrompt) { + } + /** * Guards the one guarantee a streamed completion makes: exactly one terminal event. * diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 7820a9f259c8..6b0d33b46ed8 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -75,6 +75,21 @@ public class LangChain4jAIClient implements AIClient { private static final long MODEL_CACHE_TTL_HOURS = 1; private static final long STREAMING_TIMEOUT_SECONDS = 300; private static final String CHAT_SECTION = "chat"; + private static final String EMBEDDINGS_SECTION = "embeddings"; + private static final String IMAGE_SECTION = "image"; + + /** + * Response format asked of an image provider by the {@code /api/inference/v1} family, so the + * bytes arrive inline and no separately-addressable artifact is minted upstream. + */ + private static final String INLINE_IMAGE_FORMAT = "b64_json"; + + /** + * Cache-key discriminator for models the inference family borrows. Those differ from the ones + * the legacy endpoints get — they are built asking for the inline image form — so they cannot + * share a cache entry with them even for the same site, model and size. + */ + private static final String INFERENCE_KEY_SEGMENT = ":inference"; private final Cache chatModelCache = Caffeine.newBuilder() .maximumSize(128) @@ -169,6 +184,107 @@ public void withStreamingChatModel(final AppConfig appConfig, }); } + /** + * Hands a caller an embedding model for a site, with the fallback chain and cache applied. + * + *

The embeddings counterpart of {@link #withChatModel}; see that method for why model + * acquisition stays in this class rather than moving to the caller. The model is built from + * the site's {@code embeddings} section, which a site configures independently of its chat + * models.

+ * + * @param appConfig the resolved site's configuration + * @param executor receives an embedding model and its name, and produces the result + * @param the result type + * @return whatever the executor returned for the first model that succeeded + */ + public R withEmbeddingModel(final AppConfig appConfig, + final BiFunction executor) { + return executeWithFallbackTyped( + cacheKeyPrefix(appConfig), + EMBEDDINGS_SECTION, + parseSection(appConfig.getProviderConfig(), EMBEDDINGS_SECTION), + embeddingModelCache, + LangChain4jModelFactory::buildEmbeddingModel, + executor); + } + + /** + * Hands a caller an image model for a site, asked for the inline image form. + * + *

The image counterpart of {@link #withChatModel}, with two differences that are the + * caller's request rather than the site's configuration. The requested {@code size} is applied + * over the configured one, because a caller who named a size changed both what they receive + * and what the site pays. And {@code responseFormat} is set to {@value #INLINE_IMAGE_FORMAT} + * so a provider that offers the choice never mints a hosted artifact in the first place; + * providers that ignore it still answer, and the caller re-encodes what comes back.

+ * + *

Both are folded into the cache key, so a model asked for one size is never handed to a + * request that asked for another, and the legacy image endpoint — which wants the provider's + * own default format — never receives one of these.

+ * + * @param appConfig the resolved site's configuration + * @param size the size the caller asked for, or null/blank to use the configured one + * @param executor receives an image model and its name, and produces the result + * @param the result type + * @return whatever the executor returned for the first model that succeeded + */ + public R withImageModel(final AppConfig appConfig, + final String size, + final BiFunction executor) { + final ProviderConfig baseConfig = parseSection(appConfig.getProviderConfig(), IMAGE_SECTION); + final boolean sized = size != null && !size.isBlank(); + final ProviderConfig requestConfig = ImmutableProviderConfig.copyOf(baseConfig) + .withSize(sized ? size : baseConfig.size()) + .withResponseFormat(INLINE_IMAGE_FORMAT); + + return executeWithFallbackTyped( + cacheKeyPrefix(appConfig) + INFERENCE_KEY_SEGMENT + + (requestConfig.size() == null ? "" : ":" + requestConfig.size()), + IMAGE_SECTION, + requestConfig, + imageModelCache, + LangChain4jModelFactory::buildImageModel, + executor); + } + + /** + * Reads the model names a site has configured for one section of its {@code providerConfig}. + * + *

Exists so that the model gate every {@code /api/inference/v1} endpoint applies, and the + * listing that tells a caller what will pass it, read the configuration through the class that + * owns it rather than each re-implementing the same parse. Fallback chains are returned whole + * and in configured order, because every entry is a name the gate accepts.

+ * + *

A site with no usable configuration for that section yields an empty list rather than an + * exception. From where a caller stands, a model nobody configured and a section nobody + * configured are the same absence, and distinguishing them would disclose which sites have + * dotAI set up.

+ * + * @param appConfig the resolved site's configuration + * @param section the {@code providerConfig} section, e.g. {@code chat} + * @return the configured model names in fallback order; empty when there are none + */ + public List configuredModels(final AppConfig appConfig, final String section) { + final String providerConfigJson = appConfig == null ? null : appConfig.getProviderConfig(); + if (providerConfigJson == null || providerConfigJson.isBlank()) { + return List.of(); + } + try { + final JsonNode sectionNode = MAPPER.readTree(providerConfigJson).get(section); + if (sectionNode == null || sectionNode.isNull()) { + return List.of(); + } + return List.copyOf( + effectiveModels(MAPPER.treeToValue(sectionNode, ProviderConfig.class))); + } catch (final Exception e) { + // Never the parser's message: providerConfig carries credentials and a parse failure + // can quote the fragment it choked on. + Logger.warn(LangChain4jAIClient.class, "Could not read the '" + section + + "' section of providerConfig: " + e.getClass().getSimpleName()); + return List.of(); + } + } + /** * The cache key prefix for a site's models. * diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java index 86fcce6f421d..2d98d35863cd 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java @@ -101,6 +101,9 @@ public ImageModel buildImageModel(final ProviderConfig config, final String mode .modelName(config.model()); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.size() != null) builder.size(config.size()); + // Only when a caller asked for one. Left unset the provider applies its own default, which + // is what the legacy image endpoint's URL-shaped response contract still relies on. + if (config.responseFormat() != null) builder.responseFormat(config.responseFormat()); return builder.build(); } diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java index 3eac65b8a214..ce389c738af4 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java @@ -128,6 +128,19 @@ default List allModels() { // OpenAI / Azure OpenAI @Value.Redacted @Nullable String apiKey(); @Nullable String size(); + + /** + * How the provider should deliver a generated image — {@code url} or {@code b64_json} (image + * only). Never set from the app's saved {@code providerConfig}: it is a per-request decision + * made by the caller that builds the model, which is why it is left null by default and the + * strategies pass it on only when somebody asked for one. + * + *

{@code /api/inference/v1/images/generations} sets {@code b64_json} because FR-012 wants + * no hosted artifact minted upstream at all, not merely none returned to the caller. The + * legacy {@code /api/v1/ai/image} endpoint leaves it unset and keeps the provider's default, + * because its own response contract is a URL.

+ */ + @Nullable String responseFormat(); @Nullable Integer dimensions(); @Nullable String endpoint(); @Nullable String deploymentName(); diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java index 4b7da1ec06e6..5b7f94a13576 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceLimits.java @@ -5,10 +5,15 @@ /** * The capacity ceilings this endpoint family enforces. * - *

All three are configurable and all three have defaults, because the risk they bound is real - * but its right size is deployment-specific. Streaming is the reason they exist: a streamed - * completion parks a request thread for the whole generation rather than for one round trip, so - * concurrency and elapsed time are the scarce resources here, not request rate.

+ *

All of them are configurable and all of them have defaults, because the risk they bound is + * real but its right size is deployment-specific. Streaming is the reason most of them exist: a + * streamed completion parks a request thread for the whole generation rather than for one round + * trip, so concurrency and elapsed time are the scarce resources here, not request rate.

+ * + *

{@link #maxImagesPerRequest()} bounds a different resource. Image generation is priced per + * image, so a single accepted request can multiply a site's provider spend by whatever it asked + * for, and per-site spend quotas are out of scope for this work. The ceiling is what stops one + * request being an unbounded bill.

* *

Read through {@link #current()} at the point of use rather than cached in a field, so an * operator changing a property does not have to restart the node to see it take effect.

@@ -16,10 +21,12 @@ * @param maxConcurrentStreams streaming completions allowed at once on this node * @param completionTimeoutSeconds hard ceiling on a single completion * @param maxRequestBytes largest request body accepted + * @param maxImagesPerRequest most images one generation request may ask for */ public record InferenceLimits(int maxConcurrentStreams, int completionTimeoutSeconds, - int maxRequestBytes) { + int maxRequestBytes, + int maxImagesPerRequest) { /** Streaming completions allowed at once on this node. */ public static final String MAX_CONCURRENT_STREAMS_KEY = "DOT_INFERENCE_MAX_CONCURRENT_STREAMS"; @@ -27,6 +34,8 @@ public record InferenceLimits(int maxConcurrentStreams, public static final String COMPLETION_TIMEOUT_SECONDS_KEY = "DOT_INFERENCE_COMPLETION_TIMEOUT_SECONDS"; /** Largest request body accepted, in bytes. */ public static final String MAX_REQUEST_BYTES_KEY = "DOT_INFERENCE_MAX_REQUEST_BYTES"; + /** Most images one generation request may ask for. */ + public static final String MAX_IMAGES_PER_REQUEST_KEY = "DOT_INFERENCE_MAX_IMAGES_PER_REQUEST"; /** Each stream holds a request thread for the life of a completion. */ public static final int DEFAULT_MAX_CONCURRENT_STREAMS = 50; @@ -34,6 +43,12 @@ public record InferenceLimits(int maxConcurrentStreams, public static final int DEFAULT_COMPLETION_TIMEOUT_SECONDS = 300; /** 1 MiB; holds a long multi-turn conversation with tool results, and bounds parse cost. */ public static final int DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; + /** + * Ten, which is the ceiling the adopted format itself documents for this field. Taking the + * number from the format rather than inventing one means a client written against the + * standard meets the same limit here that it already handles there. + */ + public static final int DEFAULT_MAX_IMAGES_PER_REQUEST = 10; /** * @return the limits as currently configured on this node @@ -42,7 +57,8 @@ public static InferenceLimits current() { return new InferenceLimits( Config.getIntProperty(MAX_CONCURRENT_STREAMS_KEY, DEFAULT_MAX_CONCURRENT_STREAMS), Config.getIntProperty(COMPLETION_TIMEOUT_SECONDS_KEY, DEFAULT_COMPLETION_TIMEOUT_SECONDS), - Config.getIntProperty(MAX_REQUEST_BYTES_KEY, DEFAULT_MAX_REQUEST_BYTES)); + Config.getIntProperty(MAX_REQUEST_BYTES_KEY, DEFAULT_MAX_REQUEST_BYTES), + Config.getIntProperty(MAX_IMAGES_PER_REQUEST_KEY, DEFAULT_MAX_IMAGES_PER_REQUEST)); } /** @@ -52,4 +68,12 @@ public static InferenceLimits current() { public boolean exceedsMaxRequestBytes(final long bytes) { return bytes > maxRequestBytes; } + + /** + * @param count how many images a generation request asked for + * @return whether it exceeds {@link #maxImagesPerRequest()} + */ + public boolean exceedsMaxImagesPerRequest(final int count) { + return count > maxImagesPerRequest; + } } diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/MultipleImagesUnsupportedException.java b/dotCMS/src/main/java/com/dotcms/inference/model/MultipleImagesUnsupportedException.java new file mode 100644 index 000000000000..6011e0fbf197 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/model/MultipleImagesUnsupportedException.java @@ -0,0 +1,27 @@ +package com.dotcms.inference.model; + +/** + * Raised when a caller asks for several images from a provider whose implementation only ever + * produces one. + * + *

The capability is declared on the provider abstraction but not honoured by every + * implementation behind it, and the unsupported case announces itself by throwing from deep + * inside the client library. Left as it comes, that reaches the caller as an upstream failure — + * a retryable status for a request that cannot succeed no matter how many times it is sent. This + * type exists so the endpoint can tell the two apart and answer with a refusal naming + * {@code n} instead.

+ * + *

This mirrors the adopted format's own behaviour, where {@code n} is accepted by the + * operation but rejected for models that cannot honour it.

+ */ +public class MultipleImagesUnsupportedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * @param modelName the model that cannot produce more than one image per request + */ + public MultipleImagesUnsupportedException(final String modelName) { + super("The image model '" + modelName + "' can only produce one image per request"); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java new file mode 100644 index 000000000000..8bde6dd2d46f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java @@ -0,0 +1,418 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.client.langchain4j.InferenceAIClient; +import com.dotcms.ai.client.langchain4j.LangChain4jAIClient; +import com.dotcms.ai.rest.AiHostResolver; +import com.dotcms.ai.rest.ResolvedAiContext; +import com.dotcms.cost.RequestCost; +import com.dotcms.cost.RequestPrices.Price; +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.model.InferenceUsage; +import com.dotcms.inference.rest.view.EmbeddingListView; +import com.dotcms.inference.rest.view.EmbeddingsRequestView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.commons.lang3.StringUtils; +import org.glassfish.jersey.server.JSONP; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Serves {@code POST /api/inference/v1/embeddings} — the embeddings endpoint of the + * OpenAI-wire-format family. + * + *

Two things here are not a copy of the sibling completions endpoint with a different noun.

+ * + *

The model gate reads the site's {@code embeddings} section, not its chat models. + * A site configures the two separately, so validating against chat would accept a chat model for an + * embeddings call and refuse the embeddings model the site actually configured — and would do so + * while returning a perfectly well-shaped 200 for the wrong request.

+ * + *

{@code input} is a string or an array of strings. Batching is how content is + * ordinarily embedded — anyone indexing a site sends an array — and the array form is what makes + * the response a list of more than one entry, and therefore what makes {@code index} load-bearing: + * it is the caller's only means of correlating a vector back to the text they sent. That is also + * why a batch with one bad element is refused whole rather than cleaned: dropping an element would + * shift the index of every entry after it, and the caller would correlate against the wrong + * text.

+ * + *

Blank and whitespace-only strings are refused wherever they appear. There is no meaningful + * embedding of nothing, and providers differ in whether they error on it or hand back a zero + * vector — which is exactly the inconsistency this family exists to hide, so it cannot be left to + * whichever provider a site configured.

+ * + *

Nothing here logs the input text: it is customer content by definition, and it is not echoed + * into a refusal either — a message naming the offending position tells the caller what + * they need without repeating what they sent.

+ */ +@Path("/inference/v1/embeddings") +@Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +public class EmbeddingsResource { + + /** Section of the site's {@code providerConfig} JSON that configures embeddings. */ + private static final String EMBEDDINGS_SECTION = "embeddings"; + + /** The field a refusal about what to embed names. */ + private static final String INPUT_PARAM = "input"; + + /** + * Header form of the site override. Server-side callers routinely sit behind a proxy that + * rewrites the Host header, and a header is the only override they can set without rewriting + * the URL a standard client library builds. It wins over the query parameter because it is + * the more specific of the two. + */ + private static final String SITE_HEADER = "X-dotCMS-Site"; + + /** What a caller is told when the provider failed; never the provider's own words. */ + private static final String UPSTREAM_FAILURE_MESSAGE = + "The model provider failed to complete the request"; + + /** Longest model name echoed back in a refusal, so a huge value cannot be reflected whole. */ + private static final int MAX_ECHOED_MODEL_LENGTH = 120; + + /** + * Embeds one string, or a batch of them. + * + * @param request the inbound request + * @param response the outbound response, used only by the authentication handshake + * @param siteId optional site id or host name whose dotAI configuration should serve the + * request; the {@code X-dotCMS-Site} header overrides it + * @param requestView what to embed, in the standard wire shape + * @return the vectors, or an {@link InferenceErrorView} refusal + */ + @Operation( + operationId = "createEmbeddings", + summary = "Create embeddings", + description = "Embeds text against the model the resolved site has configured for " + + "embeddings, in the OpenAI-compatible request and response shape. The input " + + "field accepts either a single string or an array of strings embedded as one " + + "batch; the response is always a list with one entry per input, each carrying " + + "the index of the input it corresponds to. An absent, null or empty input is " + + "refused, as is a blank or whitespace-only string wherever it appears, and " + + "every element of an array must be a string — arrays of token ids are not " + + "supported. The model field is required and is validated against the site's " + + "embeddings configuration, not its chat models; there is no implicit default. " + + "Every response reports the serving site in the X-dotCMS-Resolved-Site header." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "The vectors, one per input", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = EmbeddingListView.class))), + @ApiResponse(responseCode = "400", + description = "Malformed request, or one asking for something unsupported", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "403", + description = "Forbidden - the caller cannot read the requested site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "404", + description = "The requested model is not configured for the resolved site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "502", + description = "The model provider failed to complete the request", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))) + }) + @POST + @JSONP + @NoCache + @InferenceEndpoint + @RequestCost(Price.HTTP_FETCH) + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public final Response embeddings(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @QueryParam("siteId") final String siteId, + @RequestBody(description = "What to embed", + content = @Content(schema = @Schema( + implementation = EmbeddingsRequestView.class))) + final EmbeddingsRequestView requestView) { + + // Bearer only. Checked here as well as in BearerOnlyAuthFilter because the filter runs only + // inside the JAX-RS chain, while the rule has to hold wherever this method is reached. + final Optional credentialProblem = + BearerOnlyAuthFilter.bearerCredentialProblem( + request.getHeader(HttpHeaders.AUTHORIZATION)); + if (credentialProblem.isPresent()) { + return errorResponse(credentialProblem.get()); + } + + // Any authenticated user, backend or frontend; an anonymous caller is rejected here with a + // 401 the builder produces itself. + final User user = new WebResource.InitBuilder(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(true) + .init() + .getUser(); + + final ResolvedAiContext context; + try { + context = AiHostResolver.resolve(request, siteOverride(request, siteId), user); + } catch (final DotSecurityException e) { + Logger.error(this, "Caller cannot read the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(new InferenceError( + "invalid_request_error", "Access denied to the requested site", "siteId", 403)); + } catch (final IllegalArgumentException e) { + Logger.error(this, "Could not resolve the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(InferenceError.invalidRequest( + "The requested site could not be resolved", "siteId")); + } + + // Published before anything else can fail, so the response filter can name the serving site + // on refusals as well as on the happy path. + request.setAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID, context.servingSiteId()); + + final Response modelRefusal = refuseUnconfiguredModel(context, requestView); + if (modelRefusal != null) { + return modelRefusal; + } + + final JsonNode input = requestView.input(); + final Optional inputProblem = inputProblem(input); + if (inputProblem.isPresent()) { + return errorResponse(inputProblem.get()); + } + + return embed(context, requestView.model(), toInputs(input)); + } + + /** + * Embeds the batch and renders the answer. + * + * @param context the caller, the serving site and its configuration + * @param requestedModel the model the caller named, already known to be configured + * @param inputs the texts to embed, in the order the caller sent them + * @return the vectors, or a refusal carrying a safe description of what failed + */ + private Response embed(final ResolvedAiContext context, + final String requestedModel, + final List inputs) { + try { + final InferenceAIClient.EmbeddingBatch batch = + InferenceAIClient.get().embed(context.config(), inputs); + + if (batch.vectors().size() != inputs.size()) { + // Without one vector per input there is no honest index to stamp on the entries, + // and index is the caller's only means of correlating results back to what they + // sent. A short answer is an upstream failure, not a partial success. + Logger.error(this, "The embeddings provider returned " + batch.vectors().size() + + " vectors for " + inputs.size() + " inputs on site " + + AiHostResolver.sanitize(context.servingSiteId())); + return errorResponse(InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE)); + } + + return Response.ok(toView(batch, requestedModel)) + .type(MediaType.APPLICATION_JSON) + .build(); + } catch (final RuntimeException e) { + // The provider's own message can carry its endpoint, its account identifiers and + // occasionally a fragment of the input, so it is logged and never returned. + Logger.error(this, "Embeddings failed for site " + + AiHostResolver.sanitize(context.servingSiteId()), e); + return errorResponse(InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE)); + } + } + + /** + * Renders the vectors in the standard listing shape. + * + *

Each entry is stamped with the position of the input it embedded, which is why the batch + * is sent and received as one ordered whole rather than input by input.

+ * + * @param batch what the provider produced + * @param requestedModel the model the caller named, used when the provider reported none + * @return the answer + */ + private static EmbeddingListView toView(final InferenceAIClient.EmbeddingBatch batch, + final String requestedModel) { + final List data = new ArrayList<>(batch.vectors().size()); + for (int index = 0; index < batch.vectors().size(); index++) { + data.add(new EmbeddingListView.EmbeddingView( + EmbeddingListView.EmbeddingView.OBJECT, index, batch.vectors().get(index))); + } + + final InferenceUsage usage = batch.usage(); + return new EmbeddingListView( + EmbeddingListView.OBJECT, + StringUtils.isNotBlank(batch.model()) ? batch.model() : requestedModel.trim(), + List.copyOf(data), + usage.isReported() + ? new EmbeddingListView.UsageView(usage.inputTokens(), usage.totalTokens()) + : null); + } + + /** + * Refuses a request whose model the resolved site has not configured for embeddings. + * + *

Applied to every caller, administrators included. The check is not about privilege — it is + * what stops a site's credentials being spent on a model its owner never chose for this + * operation. A site's chat model is refused here however well configured it is for chat.

+ * + * @param context the caller, the serving site and its configuration + * @param requestView the inbound payload + * @return a refusal, or null when the requested model is configured + */ + private Response refuseUnconfiguredModel(final ResolvedAiContext context, + final EmbeddingsRequestView requestView) { + + final String requestedModel = requestView == null ? null : requestView.model(); + if (StringUtils.isBlank(requestedModel)) { + return errorResponse(InferenceError.invalidRequest( + "The model field is required; there is no implicit default model", "model")); + } + + final List configuredModels = + LangChain4jAIClient.get().configuredModels(context.config(), EMBEDDINGS_SECTION); + if (!configuredModels.contains(requestedModel.trim())) { + Logger.warn(this, "Site " + AiHostResolver.sanitize(context.servingSiteId()) + + " has no embeddings model matching the requested one"); + return errorResponse(InferenceError.noSuchModel(echoable(requestedModel))); + } + + return null; + } + + /** + * Checks that there is something to embed, and that all of it is text. + * + *

Every element of an array is inspected rather than only the first: a mixed array is the + * shape that actually arrives when a caller's collection was assembled from two sources, and + * an implementation that stopped at {@code input.get(0)} would send it upstream to fail as + * whatever the provider client makes of a heterogeneous list.

+ * + *

When one element is at fault the message says which. {@code param} can only ever say + * {@code input} — that is the field the caller sent — so the message is the only place the + * position can appear, and a caller who batched five hundred strings should not have to bisect + * their own payload to find the bad one. The position is 0-based, the same number the + * response's own {@code index} correlates on.

+ * + * @param input the {@code input} value as it arrived, possibly null + * @return the refusal, or empty when there is something to embed + */ + private static Optional inputProblem(final JsonNode input) { + // A JSON null deserializes to a NullNode — present, non-null, and nothing to embed — while + // an omitted field leaves the component null outright. Two code paths, one answer. + if (input == null || input.isNull()) { + return Optional.of(InferenceError.invalidRequest( + "The input field is required; there is nothing to embed", INPUT_PARAM)); + } + + if (input.isTextual()) { + return StringUtils.isBlank(input.asText()) + ? Optional.of(InferenceError.invalidRequest( + "The input field must not be blank; there is nothing to embed", + INPUT_PARAM)) + : Optional.empty(); + } + + if (!input.isArray()) { + return Optional.of(InferenceError.invalidRequest( + "The input field must be a string or an array of strings", INPUT_PARAM)); + } + + if (input.isEmpty()) { + return Optional.of(InferenceError.invalidRequest( + "The input field must not be an empty array; there is nothing to embed", + INPUT_PARAM)); + } + + for (int index = 0; index < input.size(); index++) { + final JsonNode element = input.get(index); + if (element == null || !element.isTextual()) { + return Optional.of(InferenceError.invalidRequest( + "The element of input at index " + index + " must be a string; arrays of " + + "token ids are not supported", INPUT_PARAM)); + } + if (StringUtils.isBlank(element.asText())) { + return Optional.of(InferenceError.invalidRequest( + "The element of input at index " + index + " must not be blank; there is " + + "nothing to embed", INPUT_PARAM)); + } + } + + return Optional.empty(); + } + + /** + * Reads the texts to embed out of an {@code input} already known to be valid. + * + * @param input the {@code input} value, a string or an array of strings + * @return the texts, in the order the caller sent them + */ + private static List toInputs(final JsonNode input) { + if (input.isTextual()) { + return List.of(input.asText()); + } + final List inputs = new ArrayList<>(input.size()); + input.forEach(element -> inputs.add(element.asText())); + return List.copyOf(inputs); + } + + /** + * Picks the site override, preferring the header. + * + * @param request the inbound request + * @param siteId the {@code siteId} query parameter, possibly blank + * @return the override to resolve against, or null to resolve from the request as usual + */ + private static String siteOverride(final HttpServletRequest request, final String siteId) { + final String header = request.getHeader(SITE_HEADER); + return StringUtils.isNotBlank(header) ? header : siteId; + } + + /** + * @param model the model name the caller asked for + * @return a bounded, single-line version safe to repeat back in a refusal + */ + private static String echoable(final String model) { + final String sanitized = AiHostResolver.sanitize(model); + return sanitized.length() > MAX_ECHOED_MODEL_LENGTH + ? sanitized.substring(0, MAX_ECHOED_MODEL_LENGTH) + : sanitized; + } + + /** + * @param error the refusal + * @return the refusal as a response, in the standard error shape + */ + private static Response errorResponse(final InferenceError error) { + return Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java new file mode 100644 index 000000000000..d3d144250738 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java @@ -0,0 +1,370 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.client.langchain4j.InferenceAIClient; +import com.dotcms.ai.client.langchain4j.LangChain4jAIClient; +import com.dotcms.ai.rest.AiHostResolver; +import com.dotcms.ai.rest.ResolvedAiContext; +import com.dotcms.cost.RequestCost; +import com.dotcms.cost.RequestPrices.Price; +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.model.MultipleImagesUnsupportedException; +import com.dotcms.inference.rest.view.ImageGenerationRequestView; +import com.dotcms.inference.rest.view.ImageGenerationView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.commons.lang3.StringUtils; +import org.glassfish.jersey.server.JSONP; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Serves {@code POST /api/inference/v1/images/generations} — the image-generation endpoint of the + * OpenAI-wire-format family. + * + *

The image comes back inline, as base64, and never as a URL. A hosted URL + * would mean deciding storage, authentication and lifetime for an artifact generated from a prompt + * that may carry customer data, so this family declines to create a separately-addressable + * artifact at all. The answer shape has no {@code url} component, which is what keeps the decision + * from reversing quietly — a provider's own link passed through would otherwise be a 200 with a + * perfectly plausible body. Where a provider offers the choice dotCMS also asks it for the inline + * form, so nothing is minted upstream either; where a provider returns a link regardless, dotCMS + * fetches and re-encodes it rather than refuse the provider. On those providers an artifact really + * does exist upstream: the guarantee is that a caller never receives one.

+ * + *

{@code n} is honoured, and omitting it means one. The adopted format + * supports generating several images in one request, so refusing outright would be this family + * declining something the standard supports. Three things bound it, and all three are refusals + * naming the field rather than quiet clamps — clamping answers a request for four hundred images + * with four, and a bill, without the caller ever learning that what they asked for is not what + * they got. A value below one is refused. A value above the configured ceiling is refused; the + * default ceiling is the one the standard itself documents for this field. And a request for + * several images against a model that can only ever produce one is refused here rather than + * translated as an upstream failure, because a retryable status would send a client's back-off + * into retrying something that cannot succeed. Support is asked of the model itself rather than + * read from a per-vendor table kept in this file, which would rot the first time a library + * release changed it.

+ * + *

{@code size} is passed through to the provider. It changes both what the + * caller receives and what the site pays, so dropping it is a cost and correctness failure rather + * than the harmless compatibility courtesy that ignoring an incidental field is — and it is + * invisible in the response, since a provider asked for nothing in particular still returns a + * well-formed image.

+ * + *

The model gate reads the site's {@code image} section, not its chat models + * and not its embeddings model. A site configures all three separately, so reusing another + * section's gate would accept the wrong model and refuse the right one while still answering + * 200 and 404 in the right shapes.

+ * + *

Nothing here logs the prompt: it is customer content, and it is not echoed into a refusal + * either.

+ */ +@Path("/inference/v1/images") +@Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +public class ImagesResource { + + /** Section of the site's {@code providerConfig} JSON that configures image generation. */ + private static final String IMAGE_SECTION = "image"; + + /** How many images a request that says nothing about it asks for. */ + private static final int DEFAULT_IMAGE_COUNT = 1; + + /** The field a refusal about how many images to generate names. */ + private static final String COUNT_PARAM = "n"; + + /** The field a refusal about what to generate names. */ + private static final String PROMPT_PARAM = "prompt"; + + /** + * Header form of the site override. Server-side callers routinely sit behind a proxy that + * rewrites the Host header, and a header is the only override they can set without rewriting + * the URL a standard client library builds. It wins over the query parameter because it is + * the more specific of the two. + */ + private static final String SITE_HEADER = "X-dotCMS-Site"; + + /** What a caller is told when the provider failed; never the provider's own words. */ + private static final String UPSTREAM_FAILURE_MESSAGE = + "The model provider failed to complete the request"; + + /** Longest model name echoed back in a refusal, so a huge value cannot be reflected whole. */ + private static final int MAX_ECHOED_MODEL_LENGTH = 120; + + /** + * Generates images from a prompt. + * + * @param request the inbound request + * @param response the outbound response, used only by the authentication handshake + * @param siteId optional site id or host name whose dotAI configuration should serve the + * request; the {@code X-dotCMS-Site} header overrides it + * @param requestView what to generate, in the standard wire shape + * @return the images as base64, or an {@link InferenceErrorView} refusal + */ + @Operation( + operationId = "createImageGeneration", + summary = "Generate images", + description = "Generates images against the model the resolved site has configured " + + "for images, in the OpenAI-compatible request and response shape. Every image " + + "is returned inline as b64_json: no hosted, separately-addressable " + + "artifact is created, and no url is ever returned. Both model and prompt are " + + "required, and model is validated against the site's image configuration " + + "rather than its chat or embeddings models; there is no implicit default. The " + + "n field is honored — omitting it means one image. A value below 1, a value " + + "above the configured maximum, or any value above 1 on a site whose image " + + "model can only produce one, is refused with a 400 naming the field. The size field is passed through to the provider as a WIDTHxHEIGHT " + + "string; where the site carries an image size setting the caller's value wins " + + "and the site's is the default. Every response reports the serving site in " + + "the X-dotCMS-Resolved-Site header." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "The generated images, each inline as base64", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ImageGenerationView.class))), + @ApiResponse(responseCode = "400", + description = "Malformed request, or one asking for something unsupported", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "403", + description = "Forbidden - the caller cannot read the requested site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "404", + description = "The requested model is not configured for the resolved site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "502", + description = "The model provider failed to complete the request", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))) + }) + @POST + @JSONP + @NoCache + @InferenceEndpoint + @RequestCost(Price.HTTP_FETCH) + @Path("/generations") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public final Response generations(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @QueryParam("siteId") final String siteId, + @RequestBody(description = "What to generate", + content = @Content(schema = @Schema( + implementation = ImageGenerationRequestView.class))) + final ImageGenerationRequestView requestView) { + + // Bearer only. Checked here as well as in BearerOnlyAuthFilter because the filter runs only + // inside the JAX-RS chain, while the rule has to hold wherever this method is reached. + final Optional credentialProblem = + BearerOnlyAuthFilter.bearerCredentialProblem( + request.getHeader(HttpHeaders.AUTHORIZATION)); + if (credentialProblem.isPresent()) { + return errorResponse(credentialProblem.get()); + } + + // Any authenticated user, backend or frontend; an anonymous caller is rejected here with a + // 401 the builder produces itself. + final User user = new WebResource.InitBuilder(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(true) + .init() + .getUser(); + + final ResolvedAiContext context; + try { + context = AiHostResolver.resolve(request, siteOverride(request, siteId), user); + } catch (final DotSecurityException e) { + Logger.error(this, "Caller cannot read the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(new InferenceError( + "invalid_request_error", "Access denied to the requested site", "siteId", 403)); + } catch (final IllegalArgumentException e) { + Logger.error(this, "Could not resolve the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(InferenceError.invalidRequest( + "The requested site could not be resolved", "siteId")); + } + + // Published before anything else can fail, so the response filter can name the serving site + // on refusals as well as on the happy path. + request.setAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID, context.servingSiteId()); + + final Response modelRefusal = refuseUnconfiguredModel(context, requestView); + if (modelRefusal != null) { + return modelRefusal; + } + + if (StringUtils.isBlank(requestView.prompt())) { + // Named as its own field rather than folded into a generic "malformed request": a + // caller sent back to guessing which of two required fields they missed is a support + // call. + return errorResponse(InferenceError.invalidRequest( + "The prompt field is required; there is nothing to generate without one", + PROMPT_PARAM)); + } + + // Absent means one. Below one is refused rather than clamped: a clamp answers a request + // for no images with an image and a bill, and the caller never learns that what they asked + // for was not what they got. + final int count = requestView.n() == null ? DEFAULT_IMAGE_COUNT : requestView.n(); + if (count < DEFAULT_IMAGE_COUNT) { + return errorResponse(InferenceError.invalidRequest( + "The n field must be at least " + DEFAULT_IMAGE_COUNT + + "; omit it for one image", COUNT_PARAM)); + } + + // Images are priced per image, so an unbounded count makes one accepted request an + // unbounded bill. Refused rather than clamped, for the same reason as below one: silently + // serving four images to a request for four hundred bills for work nobody can explain. + final InferenceLimits limits = InferenceLimits.current(); + if (limits.exceedsMaxImagesPerRequest(count)) { + return errorResponse(InferenceError.invalidRequest( + "The n field must be at most " + limits.maxImagesPerRequest(), COUNT_PARAM)); + } + + return generate(context, requestView.prompt(), requestView.size(), count); + } + + /** + * Generates the images and renders the answer. + * + * @param context the caller, the serving site and its configuration + * @param prompt what to generate + * @param size the size the caller asked for, passed through to the provider + * @param count how many images to generate + * @return the images, or a refusal carrying a safe description of what failed + */ + private Response generate(final ResolvedAiContext context, + final String prompt, + final String size, + final int count) { + try { + final InferenceAIClient.GeneratedImages generated = + InferenceAIClient.get().generateImages(context.config(), prompt, size, count); + + final List data = + new ArrayList<>(generated.images().size()); + for (final InferenceAIClient.GeneratedImage image : generated.images()) { + data.add(new ImageGenerationView.ImageView( + image.base64Data(), image.revisedPrompt())); + } + + return Response.ok(new ImageGenerationView( + Instant.now().getEpochSecond(), List.copyOf(data))) + .type(MediaType.APPLICATION_JSON) + .build(); + } catch (final MultipleImagesUnsupportedException e) { + // Caught ahead of the generic case on purpose. This is the site's provider declining + // something it can never do, not an upstream failure: answering with a retryable 502 + // would send a standard client's back-off into retrying a request that cannot succeed + // however long it waits. The field is named so the caller can drop it and move on. + Logger.warn(this, "Multiple images requested from a model that supports one, on site " + + AiHostResolver.sanitize(context.servingSiteId()) + ": " + e.getMessage()); + return errorResponse(InferenceError.invalidRequest( + "The configured image model for this site can only produce one image per " + + "request; omit n or set it to 1", COUNT_PARAM)); + } catch (final RuntimeException e) { + // The provider's own message can carry its endpoint, its account identifiers and + // occasionally a fragment of the prompt, so it is logged and never returned. + Logger.error(this, "Image generation failed for site " + + AiHostResolver.sanitize(context.servingSiteId()), e); + return errorResponse(InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE)); + } + } + + /** + * Refuses a request whose model the resolved site has not configured for images. + * + *

Applied to every caller, administrators included. The check is not about privilege — it is + * what stops a site's credentials being spent on a model its owner never chose for this + * operation. A site's chat and embeddings models are both refused here, however well + * configured they are elsewhere on the same site.

+ * + * @param context the caller, the serving site and its configuration + * @param requestView the inbound payload + * @return a refusal, or null when the requested model is configured + */ + private Response refuseUnconfiguredModel(final ResolvedAiContext context, + final ImageGenerationRequestView requestView) { + + final String requestedModel = requestView == null ? null : requestView.model(); + if (StringUtils.isBlank(requestedModel)) { + return errorResponse(InferenceError.invalidRequest( + "The model field is required; there is no implicit default model", "model")); + } + + final List configuredModels = + LangChain4jAIClient.get().configuredModels(context.config(), IMAGE_SECTION); + if (!configuredModels.contains(requestedModel.trim())) { + Logger.warn(this, "Site " + AiHostResolver.sanitize(context.servingSiteId()) + + " has no image model matching the requested one"); + return errorResponse(InferenceError.noSuchModel(echoable(requestedModel))); + } + + return null; + } + + /** + * Picks the site override, preferring the header. + * + * @param request the inbound request + * @param siteId the {@code siteId} query parameter, possibly blank + * @return the override to resolve against, or null to resolve from the request as usual + */ + private static String siteOverride(final HttpServletRequest request, final String siteId) { + final String header = request.getHeader(SITE_HEADER); + return StringUtils.isNotBlank(header) ? header : siteId; + } + + /** + * @param model the model name the caller asked for + * @return a bounded, single-line version safe to repeat back in a refusal + */ + private static String echoable(final String model) { + final String sanitized = AiHostResolver.sanitize(model); + return sanitized.length() > MAX_ECHOED_MODEL_LENGTH + ? sanitized.substring(0, MAX_ECHOED_MODEL_LENGTH) + : sanitized; + } + + /** + * @param error the refusal + * @return the refusal as a response, in the standard error shape + */ + private static Response errorResponse(final InferenceError error) { + return Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java new file mode 100644 index 000000000000..ffb18620adef --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java @@ -0,0 +1,224 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.client.langchain4j.LangChain4jAIClient; +import com.dotcms.ai.rest.AiHostResolver; +import com.dotcms.ai.rest.ResolvedAiContext; +import com.dotcms.cost.RequestCost; +import com.dotcms.cost.RequestPrices.Price; +import com.dotcms.inference.model.InferenceError; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.inference.rest.view.ModelListView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.commons.lang3.StringUtils; +import org.glassfish.jersey.server.JSONP; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Serves {@code GET /api/inference/v1/models} — model discovery for the OpenAI-wire-format family. + * + *

This endpoint is the discovery mechanism the rest of the family depends on. There is no + * implicit default model and no reserved alias, so the names listed here are exactly the names + * {@link ChatCompletionsResource} accepts — a caller who wants "whatever this site runs" reads the + * list and takes the first entry. Two consequences follow, and both are deliberate.

+ * + *
    + *
  • No provider is contacted. The list is read from the resolved site's + * configuration and nothing else. Asking a vendor what it offers would return names the site + * has not configured and the chat endpoint would refuse, which is worse than no list at + * all.
  • + *
  • Nothing synthetic is added, and every fallback-chain entry is listed. + * Each entry of a chain is a name the chat endpoint accepts, so listing only the primary would + * hide choices the caller is entitled to make; and an invented entry would advertise a name + * that endpoint refuses.
  • + *
+ * + *

A site with no dotAI configuration — and on an instance with none at the system level either + * — gets an empty list with a 200. Falling back to whichever site happens to be configured would + * hand a caller model names their own site will refuse, and would disclose that some other site + * has dotAI set up.

+ * + *

Nothing here is wrapped in the dotCMS {@code ResponseEntityView} envelope, and refusals are + * rendered as {@link InferenceErrorView} rather than left to dotCMS's generic exception mappers, + * for the reason the whole family exists: a client library has to deserialize both the answer and + * the refusal into its own types with no adapter.

+ */ +@Path("/inference/v1/models") +@Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +public class ModelsResource { + + /** Section of the site's {@code providerConfig} JSON the listed models come from. */ + private static final String CHAT_SECTION = "chat"; + + /** + * Header form of the site override. Server-side callers routinely sit behind a proxy that + * rewrites the Host header, and a header is the only override they can set without rewriting + * the URL a standard client library builds. It wins over the query parameter because it is + * the more specific of the two. + */ + private static final String SITE_HEADER = "X-dotCMS-Site"; + + /** + * Lists the models the resolved site has configured for chat. + * + * @param request the inbound request + * @param response the outbound response, used only by the authentication handshake + * @param siteId optional site id or host name whose dotAI configuration should be read; the + * {@code X-dotCMS-Site} header overrides it + * @return the listing, or an {@link InferenceErrorView} refusal + */ + @Operation( + operationId = "listInferenceModels", + summary = "List the models this site has configured", + description = "Returns the models the resolved site has configured for chat, including " + + "every entry of a fallback chain, in configured order — the first is the " + + "site's primary model. The list is exactly the set of values the chat " + + "completions endpoint accepts as \"model\": there is no implicit default and " + + "no reserved alias, and nothing synthetic is added. No model provider is " + + "contacted. A site with no AI configuration, on an instance with none at the " + + "system level either, returns an empty data array rather than another site's " + + "models. Every response reports the serving site in the " + + "X-dotCMS-Resolved-Site header." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "The configured models, possibly none", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ModelListView.class))), + @ApiResponse(responseCode = "400", + description = "The requested site could not be resolved", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))), + @ApiResponse(responseCode = "403", + description = "Forbidden - the caller cannot read the requested site", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = InferenceErrorView.class))) + }) + @GET + @JSONP + @NoCache + @InferenceEndpoint + @RequestCost(Price.HTTP_FETCH) + @Produces(MediaType.APPLICATION_JSON) + public final Response models(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @QueryParam("siteId") final String siteId) { + + // Bearer only. Checked here as well as in BearerOnlyAuthFilter because the filter runs only + // inside the JAX-RS chain, while the rule has to hold wherever this method is reached. + // The list names the site's configured models, which is exactly the reconnaissance an + // anonymous caller should not get for free. + final Optional credentialProblem = + BearerOnlyAuthFilter.bearerCredentialProblem( + request.getHeader(HttpHeaders.AUTHORIZATION)); + if (credentialProblem.isPresent()) { + return errorResponse(credentialProblem.get()); + } + + // Any authenticated user, backend or frontend; an anonymous caller is rejected here with a + // 401 the builder produces itself. + final User user = new WebResource.InitBuilder(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(true) + .init() + .getUser(); + + final ResolvedAiContext context; + try { + context = AiHostResolver.resolve(request, siteOverride(request, siteId), user); + } catch (final DotSecurityException e) { + Logger.error(this, "Caller cannot read the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(new InferenceError( + "invalid_request_error", "Access denied to the requested site", "siteId", 403)); + } catch (final IllegalArgumentException e) { + Logger.error(this, "Could not resolve the requested site '" + + AiHostResolver.sanitize(siteId) + "'", e); + return errorResponse(InferenceError.invalidRequest( + "The requested site could not be resolved", "siteId")); + } + + // Published before anything else can fail, so the response filter can name the serving site + // on refusals as well as on the happy path. + request.setAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID, context.servingSiteId()); + + return Response.ok(toView( + LangChain4jAIClient.get().configuredModels(context.config(), CHAT_SECTION))) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + /** + * Wraps the configured model names in the listing shape. + * + *

Every entry reports the same creation time, read once here. dotCMS serves a + * configuration, not a catalogue: it does not know when a vendor published a model, and + * inventing a plausible per-model date would be indistinguishable from a real one to anyone + * who trusted it.

+ * + * @param modelNames the configured names, in fallback order + * @return the listing + */ + private static ModelListView toView(final List modelNames) { + final long created = Instant.now().getEpochSecond(); + final List models = new ArrayList<>(modelNames.size()); + for (final String modelName : modelNames) { + models.add(new ModelListView.ModelView( + modelName, + ModelListView.ModelView.OBJECT, + created, + ModelListView.ModelView.OWNED_BY)); + } + return new ModelListView(ModelListView.OBJECT, List.copyOf(models)); + } + + /** + * Picks the site override, preferring the header. + * + * @param request the inbound request + * @param siteId the {@code siteId} query parameter, possibly blank + * @return the override to resolve against, or null to resolve from the request as usual + */ + private static String siteOverride(final HttpServletRequest request, final String siteId) { + final String header = request.getHeader(SITE_HEADER); + return StringUtils.isNotBlank(header) ? header : siteId; + } + + /** + * @param error the refusal + * @return the refusal as a response, in the standard error shape + */ + private static Response errorResponse(final InferenceError error) { + return Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingListView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingListView.java new file mode 100644 index 000000000000..f5afd210bdde --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingListView.java @@ -0,0 +1,69 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * The vectors an embeddings request produced, in the wire shape clients deserialize. + * + *

Always a list, even for a single string, because the request accepts a batch and a caller + * should not have to branch on which form they sent to read the answer.

+ * + *

{@code index} on each entry is the position of the input it embedded, not the position of the + * entry in {@code data}. It is the caller's only means of correlating a vector back to the text + * they sent, which is why a batch with one bad element is refused whole rather than embedded + * partially: dropping an element would shift every index after it.

+ * + * @param object always {@code list} + * @param model the model that actually served, after any fallback hop + * @param data one entry per input, in the order the inputs were given + * @param usage tokens consumed by the whole batch, omitted when the provider reported none + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Embeddings response in the OpenAI-compatible shape") +public record EmbeddingListView( + @JsonProperty("object") @Schema(description = "Object type", example = "list") String object, + @JsonProperty("model") @Schema(description = "Model that served the request", + example = "text-embedding-3-small") String model, + @JsonProperty("data") @Schema(description = "One entry per input") List data, + @JsonProperty("usage") @Schema(description = "Tokens consumed by the whole batch") UsageView usage) { + + /** Object type of the listing envelope. */ + public static final String OBJECT = "list"; + + /** + * One vector. + * + * @param object always {@code embedding} + * @param index the position of the input this vector embeds + * @param embedding the vector itself + */ + @Schema(description = "One embedding") + public record EmbeddingView( + @JsonProperty("object") @Schema(description = "Object type", example = "embedding") String object, + @JsonProperty("index") @Schema(description = "Position of the input this vector embeds") int index, + @JsonProperty("embedding") @Schema(description = "The vector") List embedding) { + + /** Object type of a listing entry. */ + public static final String OBJECT = "embedding"; + } + + /** + * Tokens consumed. + * + *

There is no completion count: an embeddings call generates nothing, so the standard shape + * carries only the prompt and the total.

+ * + * @param promptTokens tokens across every input in the batch + * @param totalTokens the same figure the provider reported as the total + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "Tokens consumed") + public record UsageView( + @JsonProperty("prompt_tokens") @Schema(description = "Tokens across every input") Integer promptTokens, + @JsonProperty("total_tokens") @Schema(description = "Total tokens") Integer totalTokens) { + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingsRequestView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingsRequestView.java new file mode 100644 index 000000000000..3febbdb50d4d --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/EmbeddingsRequestView.java @@ -0,0 +1,31 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * The inbound embeddings request, in the wire shape clients send. + * + *

{@code input} is held as a {@link JsonNode} rather than as a {@code String} or a + * {@code List} because the format accepts either, and binding to one of them + * would make the other a deserialization failure — a 400 with a Jackson message naming an internal + * type, instead of a refusal naming the field. Keeping the node also preserves the difference + * between a field that arrived as JSON {@code null} and one that was left out, which the + * validation reports identically but has to detect separately.

+ * + *

Unknown properties are ignored so a client's default payload does not fail on a field this + * family has no opinion about — {@code encoding_format} and {@code user}, typically.

+ * + * @param model the embeddings model to use; required, no implicit default + * @param input a single string, or an array of strings to embed as one batch + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Embeddings request in the OpenAI-compatible shape") +public record EmbeddingsRequestView( + @JsonProperty("model") @Schema(description = "Model id, as configured for this site's embeddings", + example = "text-embedding-3-small") String model, + @JsonProperty("input") @Schema(description = "A string, or an array of strings to embed as one batch", + example = "The quick brown fox") JsonNode input) { +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationRequestView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationRequestView.java new file mode 100644 index 000000000000..e090e3b0c92a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationRequestView.java @@ -0,0 +1,33 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * The inbound image-generation request, in the wire shape clients send. + * + *

{@code n} is boxed rather than a primitive because "not sent" is a value a caller can express + * and this family has to answer: omitting it means one image, while an {@code n} of {@code 0} is a + * mistake that must be refused. A primitive would turn the first into the second silently.

+ * + *

Unknown properties are ignored so a client's default payload does not fail on a field this + * family has no opinion about. Notably {@code response_format} is among them: the answer is always + * base64, so a caller asking for a URL is told nothing here — the response itself is the + * answer.

+ * + * @param model the image model to use; required, no implicit default + * @param prompt what to generate; required, there is nothing to generate without it + * @param n how many images; at least {@code 1}, and omitting it means {@code 1} + * @param size the image size, passed through to the provider where it accepts one + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "Image generation request in the OpenAI-compatible shape") +public record ImageGenerationRequestView( + @JsonProperty("model") @Schema(description = "Model id, as configured for this site's images", + example = "dall-e-3") String model, + @JsonProperty("prompt") @Schema(description = "What to generate", + example = "A cat in a hammock") String prompt, + @JsonProperty("n") @Schema(description = "Number of images; at least 1, defaults to 1", example = "1") Integer n, + @JsonProperty("size") @Schema(description = "Image size", example = "1024x1024") String size) { +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationView.java new file mode 100644 index 000000000000..5c109a137f29 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ImageGenerationView.java @@ -0,0 +1,45 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * A generated image, in the wire shape clients deserialize. + * + *

Note what this type does not have: a {@code url} component. That is the decision rather than + * an omission. A hosted link would mean deciding storage, authentication and lifetime for an + * artifact generated from a prompt that may carry customer data, so this family declines to create + * a separately-addressable artifact at all and hands the bytes back inline instead. Encoding that + * in the type — rather than in a branch that happens never to be taken — is what stops a provider's + * own URL being passed through later by an implementation that no longer remembers why it must + * not.

+ * + *

Should a URL form ever be offered, it has to be authenticated and time-limited, and it would + * be a new component here rather than a reinstated passthrough.

+ * + * @param created epoch seconds + * @param data the generated images, one per image the caller asked for + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Image generation response in the OpenAI-compatible shape") +public record ImageGenerationView( + @JsonProperty("created") @Schema(description = "Epoch seconds") long created, + @JsonProperty("data") @Schema(description = "The generated images") List data) { + + /** + * One image, always inline. + * + * @param b64Json the image bytes, base64 encoded + * @param revisedPrompt the prompt the provider says it actually used, when it says so + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "One generated image, delivered inline as base64") + public record ImageView( + @JsonProperty("b64_json") @Schema(description = "The image bytes, base64 encoded") String b64Json, + @JsonProperty("revised_prompt") @Schema(description = "The prompt the provider actually used") + String revisedPrompt) { + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/view/ModelListView.java b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ModelListView.java new file mode 100644 index 000000000000..72edd00a2259 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/ModelListView.java @@ -0,0 +1,55 @@ +package com.dotcms.inference.rest.view; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * The models a site has configured, in the wire shape clients already parse. + * + *

Not wrapped in the dotCMS {@code ResponseEntityView} envelope, for the same reason nothing + * else in this family is: a client library has to deserialize this into its own model-list type + * with no adapter.

+ * + *

This listing is load-bearing rather than decorative. There is no implicit default model and + * no reserved alias, so the set of names here is exactly the set of {@code model} values the chat + * endpoint accepts — which makes it the only way a caller can learn what to ask for. Adding + * anything synthetic would advertise a name that endpoint refuses.

+ * + * @param object always {@code list} + * @param data one entry per configured model, in configured order; the first is the primary + */ +@Schema(description = "Model listing in the OpenAI-compatible shape") +public record ModelListView( + @JsonProperty("object") @Schema(description = "Object type", example = "list") String object, + @JsonProperty("data") @Schema(description = "The configured models, primary first") + List data) { + + /** Object type of the listing envelope. */ + public static final String OBJECT = "list"; + + /** + * One model a caller may name. + * + * @param id the name to send as {@code model} + * @param object always {@code model} + * @param created epoch seconds; dotCMS serves configuration rather than a catalogue, so this + * is when the listing was read rather than when a vendor published the model + * @param ownedBy always {@code dotcms} — whoever built the model, dotCMS is what serves it, + * and reporting a vendor here would imply a provenance dotCMS cannot vouch for + */ + @Schema(description = "One configured model") + public record ModelView( + @JsonProperty("id") @Schema(description = "Model id", example = "gpt-4o") String id, + @JsonProperty("object") @Schema(description = "Object type", example = "model") String object, + @JsonProperty("created") @Schema(description = "Epoch seconds") long created, + @JsonProperty("owned_by") @Schema(description = "Owner", example = "dotcms") String ownedBy) { + + /** Object type of a listing entry. */ + public static final String OBJECT = "model"; + + /** Owner every entry reports. */ + public static final String OWNED_BY = "dotcms"; + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 5a1d9323ad93..f23cc9f6b583 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -1404,6 +1404,183 @@ paths: summary: Create a chat completion tags: - AI + /inference/v1/embeddings: + post: + description: "Embeds text against the model the resolved site has configured\ + \ for embeddings, in the OpenAI-compatible request and response shape. The\ + \ input field accepts either a single string or an array of strings embedded\ + \ as one batch; the response is always a list with one entry per input, each\ + \ carrying the index of the input it corresponds to. An absent, null or empty\ + \ input is refused, as is a blank or whitespace-only string wherever it appears,\ + \ and every element of an array must be a string — arrays of token ids are\ + \ not supported. The model field is required and is validated against the\ + \ site's embeddings configuration, not its chat models; there is no implicit\ + \ default. Every response reports the serving site in the X-dotCMS-Resolved-Site\ + \ header." + operationId: createEmbeddings + parameters: + - in: query + name: siteId + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EmbeddingsRequestView" + description: What to embed + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/EmbeddingListView" + description: "The vectors, one per input" + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: "Malformed request, or one asking for something unsupported" + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Unauthorized - authentication required + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Forbidden - the caller cannot read the requested site + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The requested model is not configured for the resolved site + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The model provider failed to complete the request + summary: Create embeddings + tags: + - AI + /inference/v1/images/generations: + post: + description: "Generates images against the model the resolved site has configured\ + \ for images, in the OpenAI-compatible request and response shape. Every image\ + \ is returned inline as b64_json: no hosted, separately-addressable artifact\ + \ is created, and no url is ever returned. Both model and prompt are required,\ + \ and model is validated against the site's image configuration rather than\ + \ its chat or embeddings models; there is no implicit default. The n field\ + \ is honored — omitting it means one image. A value below 1, a value above\ + \ the configured maximum, or any value above 1 on a site whose image model\ + \ can only produce one, is refused with a 400 naming the field. The size field\ + \ is passed through to the provider as a WIDTHxHEIGHT string; where the site\ + \ carries an image size setting the caller's value wins and the site's is\ + \ the default. Every response reports the serving site in the X-dotCMS-Resolved-Site\ + \ header." + operationId: createImageGeneration + parameters: + - in: query + name: siteId + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ImageGenerationRequestView" + description: What to generate + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ImageGenerationView" + description: "The generated images, each inline as base64" + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: "Malformed request, or one asking for something unsupported" + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Unauthorized - authentication required + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Forbidden - the caller cannot read the requested site + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The requested model is not configured for the resolved site + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The model provider failed to complete the request + summary: Generate images + tags: + - AI + /inference/v1/models: + get: + description: "Returns the models the resolved site has configured for chat,\ + \ including every entry of a fallback chain, in configured order — the first\ + \ is the site's primary model. The list is exactly the set of values the chat\ + \ completions endpoint accepts as \"model\": there is no implicit default\ + \ and no reserved alias, and nothing synthetic is added. No model provider\ + \ is contacted. A site with no AI configuration, on an instance with none\ + \ at the system level either, returns an empty data array rather than another\ + \ site's models. Every response reports the serving site in the X-dotCMS-Resolved-Site\ + \ header." + operationId: listInferenceModels + parameters: + - in: query + name: siteId + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ModelListView" + description: "The configured models, possibly none" + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: The requested site could not be resolved + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Unauthorized - authentication required + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/InferenceErrorView" + description: Forbidden - the caller cannot read the requested site + summary: List the models this site has configured + tags: + - AI /integrity/_fixconflictsfromremote: post: operationId: fixConflictsFromRemote @@ -28057,6 +28234,44 @@ components: required: - deletedCount - success + EmbeddingListView: + type: object + description: Embeddings response in the OpenAI-compatible shape + properties: + data: + type: array + description: One entry per input + items: + $ref: "#/components/schemas/EmbeddingView" + model: + type: string + description: Model that served the request + example: text-embedding-3-small + object: + type: string + description: Object type + example: list + usage: + $ref: "#/components/schemas/UsageView" + EmbeddingView: + type: object + description: One embedding + properties: + embedding: + type: array + description: The vector + items: + type: number + format: float + description: The vector + index: + type: integer + format: int32 + description: Position of the input this vector embeds + object: + type: string + description: Object type + example: embedding EmbeddingsForm: type: object properties: @@ -28085,6 +28300,16 @@ components: type: string velocityTemplate: type: string + EmbeddingsRequestView: + type: object + description: Embeddings request in the OpenAI-compatible shape + properties: + input: + $ref: "#/components/schemas/JsonNode" + model: + type: string + description: "Model id, as configured for this site's embeddings" + example: text-embedding-3-small EmptyField: type: object allOf: @@ -30476,6 +30701,50 @@ components: type: string variable: type: string + ImageGenerationRequestView: + type: object + description: Image generation request in the OpenAI-compatible shape + properties: + model: + type: string + description: "Model id, as configured for this site's images" + example: dall-e-3 + "n": + type: integer + format: int32 + description: "Number of images; at least 1, defaults to 1" + example: 1 + prompt: + type: string + description: What to generate + example: A cat in a hammock + size: + type: string + description: Image size + example: 1024x1024 + ImageGenerationView: + type: object + description: Image generation response in the OpenAI-compatible shape + properties: + created: + type: integer + format: int64 + description: Epoch seconds + data: + type: array + description: The generated images + items: + $ref: "#/components/schemas/ImageView" + ImageView: + type: object + description: "One generated image, delivered inline as base64" + properties: + b64_json: + type: string + description: "The image bytes, base64 encoded" + revised_prompt: + type: string + description: The prompt the provider actually used ImmutableListAssetPreviewView: type: array description: Preview of first 3 assets in the bundle @@ -31497,6 +31766,39 @@ components: - EXIT_RATE - BOUNCE_RATE - URL_PARAMETER + ModelListView: + type: object + description: Model listing in the OpenAI-compatible shape + properties: + data: + type: array + description: "The configured models, primary first" + items: + $ref: "#/components/schemas/ModelView" + object: + type: string + description: Object type + example: list + ModelView: + type: object + description: One configured model + properties: + created: + type: integer + format: int64 + description: Epoch seconds + id: + type: string + description: Model id + example: gpt-4o + object: + type: string + description: Object type + example: model + owned_by: + type: string + description: Owner + example: dotcms MoveFieldsForm: type: object MulitreeView: @@ -39876,15 +40178,14 @@ components: type: object description: Tokens consumed properties: - completion_tokens: - type: integer - format: int32 prompt_tokens: type: integer format: int32 + description: Tokens across every input total_tokens: type: integer format: int32 + description: Total tokens User: type: object properties: diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java index b87820a1a436..8b39a1493beb 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java @@ -70,7 +70,10 @@ import com.dotcms.inference.rest.ChatCompletionsStreamingTest; import com.dotcms.inference.rest.ChatCompletionsTest; import com.dotcms.inference.rest.InferenceAuthorizationTest; +import com.dotcms.inference.rest.InferenceEmbeddingsTest; import com.dotcms.inference.rest.InferenceFallbackTest; +import com.dotcms.inference.rest.InferenceImagesTest; +import com.dotcms.inference.rest.InferenceModelsTest; import com.dotcms.inference.rest.InferenceModelValidationTest; import com.dotcms.inference.rest.InferenceSiteIsolationTest; import com.dotcms.inference.rest.InferenceSiteResolutionTest; @@ -454,7 +457,10 @@ ChatCompletionsTest.class, ChatCompletionsStreamingTest.class, InferenceAuthorizationTest.class, + InferenceEmbeddingsTest.class, InferenceFallbackTest.class, + InferenceImagesTest.class, + InferenceModelsTest.class, InferenceModelValidationTest.class, InferenceSiteIsolationTest.class, InferenceSiteResolutionTest.class, diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceEmbeddingsTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceEmbeddingsTest.java new file mode 100644 index 000000000000..e0fe984e071d --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceEmbeddingsTest.java @@ -0,0 +1,960 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.EmbeddingListView; +import com.dotcms.inference.rest.view.EmbeddingsRequestView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies {@code POST /api/inference/v1/embeddings} through + * {@link EmbeddingsResource#embeddings(HttpServletRequest, HttpServletResponse, String, EmbeddingsRequestView)}. + * + *

FR-011 is not "expose an embeddings endpoint" — the vector round trip is the easy half. What + * the requirement actually adds is that the model gate of FR-023 has to look at a + * different section of the site's configuration than the chat endpoint does. A + * site configures its chat models and its embeddings model separately, so an implementation that + * reuses the chat section here would accept a chat model for an embeddings call and refuse the + * embeddings model the site actually configured, and it would do so while returning a perfectly + * well-shaped 200 for the wrong request. That is why the central test below asserts both halves of + * the same site in one go: the embeddings model succeeds and the chat model is refused. + * Either assertion alone is satisfied by the bug.

+ * + *

The second thing FR-011 adds is that {@code input} is not a string. It is either a + * string or an array of strings, because batching is how content is ordinarily embedded — + * anyone indexing a site sends an array — and a scalar-only endpoint would fail that common case + * while still looking correct against a one-off example. The array form is what makes the response + * a list of more than one entry, and therefore what makes {@code index} load-bearing: it is the + * only thing that lets a caller correlate a vector back to the text it sent. The tests below + * assert that index explicitly, looking each entry up by it rather than by its position in + * {@code data}, and they pin every refusal the array form introduces — nothing to embed, and + * elements that are not text.

+ * + *

FR-011 now spells out what "an array of strings" costs an implementation, and each clause is + * a test below. Every element has to be a string, so the all-numeric array is + * joined by a mixed one: an implementation that inspects only the first element passes the + * all-numeric case and still accepts {@code ["some text", 5]}, which is the shape that actually + * arrives when a caller's collection was built from mismatched sources. An absent, null or + * empty input is refused naming the field rather than answered with an empty list, and + * "null" has two wire forms — the JSON null and the field left out — which are separate code paths + * and so are asserted separately.

+ * + *

FR-011 also settles two things this file used to leave open. A blank or + * whitespace-only string is refused on the same grounds as an absent, null or empty one, + * whether it arrives alone or as one element of an array: there is no meaningful embedding of + * nothing, and providers differ in whether they error on it or hand back a zero vector — which is + * exactly the inconsistency this family exists to hide, so it cannot be left to whichever provider + * a site configured. Both positions are asserted, because they are different code paths: the scalar + * blank is caught by the same guard that catches the empty string, while a blank buried at + * position two of an array is only caught by an implementation that inspects every element.

+ * + *

And when one element of an array is at fault, {@code param} stays {@code input} — it is the + * field the caller sent — but the message has to identify which element. + * A caller who batched five hundred strings and is told only that "input is invalid" has been + * handed the search, not the answer. The tests below therefore assert that the offending position + * appears in the message, rather than settling for a message that is merely non-blank; the + * offending element is deliberately placed at an index that appears nowhere else in the payload, + * so a message that happens to contain a digit cannot pass for one that locates the fault.

+ * + *
    + *
  • FR-011 — input text comes back as a vector in the standard embeddings shape, with the + * serving model and the token counts.
  • + *
  • FR-011 — {@code input} accepts a single string or an array of strings; an array yields + * one entry per input, each carrying the index of the input it corresponds to, and the + * reported usage covers the whole batch. An empty array, a null or absent input, a blank or + * whitespace-only string, and an array any of whose elements is not a string or is blank, are + * all refused naming {@code input}.
  • + *
  • FR-011 — where one element of an array is at fault, the message identifies which + * one.
  • + *
  • FR-011 / R7 — {@code model} is validated against the site's embeddings + * section; the site's chat model is refused here, even though the same site has it + * configured.
  • + *
  • FR-023 — a model the site configured for nothing at all is refused the same way.
  • + *
  • FR-024 — {@code model} is required; there is no implicit default.
  • + *
  • FR-015 — an anonymous caller is refused.
  • + *
  • FR-020 — the serving site is published for the response filter to report.
  • + *
+ * + *

The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in + * through the same dotAI app secrets the rest of the AI integration tests use, so an accepted call + * travels the real client path rather than a stubbed one.

+ */ +public class InferenceEmbeddingsTest { + + /** + * The model the site's {@code embeddings} section is configured with. Deliberately read from + * {@link AiTest} rather than restated: the separation this file is about is a property of that + * shared configuration, and a local copy would keep passing if the two drifted apart. + */ + private static final String EMBEDDINGS_MODEL = AiTest.EMBEDDINGS_MODEL; + + /** + * The model the same site's {@code chat} section is configured with — configured, valid, and + * nevertheless not an embeddings model. + */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** A model name no site in these tests configures for any section at all. */ + private static final String UNCONFIGURED_MODEL = "some-other-vendors-model"; + + /** Path an OpenAI-compatible provider serves embeddings on. */ + private static final String EMBEDDINGS_PATH = "/embeddings"; + + private static final String REQUEST_URI = "/api/inference/v1/embeddings"; + + private static final String ERROR_TYPE_INVALID_REQUEST = "invalid_request_error"; + + /** The text these tests embed. */ + private static final String INPUT_TEXT = "The quick brown fox"; + + /** A string with nothing in it at all — the emptiest thing a caller can ask to embed. */ + private static final String EMPTY_INPUT = ""; + + /** + * A string with nothing in it but whitespace. FR-011 refuses it on the same grounds as the + * empty string, and it is asserted separately because it is a separate guard: an + * {@code isEmpty()} check accepts it and passes three spaces to a provider that will either + * error or bill for a zero vector. + */ + private static final String WHITESPACE_INPUT = " "; + + /** + * The value that stands in for "not a string" inside a mixed array. Two digits, neither of + * which is the index the element sits at, so that a message quoting the offending value cannot + * be mistaken for one that locates it. + */ + private static final int NON_STRING_ELEMENT = 42; + + /** + * The batch these tests embed when they exercise the array form. Three entries rather than two, + * so that "one entry per input" cannot be confused with "a pair", and the last one is the + * marker the batch stub matches on — see {@link #stubProvider()}. + */ + private static final String[] BATCH_INPUT = { + INPUT_TEXT, + "jumps over the lazy dog", + "and the dog barks back" + }; + + /** + * The last element of {@link #BATCH_INPUT}, which appears in the provider request only when a + * batch was sent. Distinct enough that no scalar request can contain it by accident. + */ + private static final String BATCH_MARKER = BATCH_INPUT[BATCH_INPUT.length - 1]; + + /** Builds the array form of {@code input}; no configuration of its own is needed. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * What the stubbed provider answers. Modelled on what an OpenAI-compatible provider really + * returns — the shape of + * {@code dotcms-integration/src/test/resources/mappings/langchain4j-embeddings-stub.json}, + * trimmed to a vector short enough to read. + */ + private static final String PROVIDER_RESPONSE = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.0023, -0.0091, 0.0117, -0.0042, 0.0008] + } + ], + "model": "text-embedding-ada-002", + "usage": {"prompt_tokens": 5, "total_tokens": 5} + } + """; + + /** + * What the stubbed provider answers a batch of {@link #BATCH_INPUT} with: one entry per input, + * each with its own vector, and a token count covering all three rather than one of them. + * + *

The vectors differ from one another on purpose. Three copies of the same numbers would + * still satisfy "three entries with three indexes" while the implementation handed every + * caller the first vector.

+ */ + private static final String PROVIDER_BATCH_RESPONSE = """ + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.0023, -0.0091, 0.0117, -0.0042, 0.0008] + }, + { + "object": "embedding", + "index": 1, + "embedding": [0.0512, 0.0034, -0.0077, 0.0101, -0.0013] + }, + { + "object": "embedding", + "index": 2, + "embedding": [-0.0308, 0.0146, 0.0052, -0.0090, 0.0027] + } + ], + "model": "text-embedding-ada-002", + "usage": {"prompt_tokens": 17, "total_tokens": 17} + } + """; + + private static WireMockServer wireMockServer; + + /** A backend user who is also an administrator, so site READ is never the thing under test. */ + private static User user; + private static String bearerToken; + + private Host host; + private final EmbeddingsResource resource = new EmbeddingsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + // A bare UserDataGen user has no roles at all, so it is neither a backend nor a frontend + // user and FR-016 rejects it; it also cannot read the site these tests pass as an explicit + // override, which FR-019 checks. Two roles are needed, not one: the check in + // WebResource.checkRolePermissions is doesUserHaveRole(user, "DOTCMS_BACK_END_USER") by key + // and does not walk inheritance, so being an admin does not imply it. Admin is what grants + // read on the site. Role-specific behaviour is US3's tests, not these. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = new SiteDataGen() + .name("inference-embeddings-" + UUID.randomUUID() + ".dotcms.com") + .nextPersisted(); + // Configures chat, embeddings and image as three separate sections, each with its own + // model. The chat model given here is the one this file expects the embeddings gate to + // refuse. + AiTest.aiAppSecretsWithProviderConfig( + host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a site configured with an embeddings model, and a request carrying input text + * When the embedding is requested + * Then a vector comes back in the standard embeddings shape — a {@code list} of one + * {@code embedding} at index 0 — alongside the serving model and the token counts + */ + @Test + public void test_embeddings_withInputText_returnsVectorInStandardShape() { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(EMBEDDINGS_MODEL, INPUT_TEXT)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof EmbeddingListView); + + final EmbeddingListView view = (EmbeddingListView) response.getEntity(); + assertEquals("list", view.object()); + assertEquals(EMBEDDINGS_MODEL, view.model()); + + assertNotNull(view.data()); + assertEquals(1, view.data().size()); + + final EmbeddingListView.EmbeddingView embedding = view.data().get(0); + assertEquals("embedding", embedding.object()); + assertEquals(0, embedding.index()); + assertNotNull(embedding.embedding()); + assertFalse("An embedding with no numbers in it is not an embedding", + embedding.embedding().isEmpty()); + + assertNotNull(view.usage()); + assertNotNull(view.usage().promptTokens()); + assertTrue(view.usage().promptTokens() > 0); + assertNotNull(view.usage().totalTokens()); + assertTrue(view.usage().totalTokens() > 0); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a request whose {@code input} is an array of three strings + * When the embeddings are requested + * Then three entries come back, one per input, each carrying the index of the input it + * corresponds to and a vector of its own + * + *

FR-011's array form. The assertions look each entry up by its index rather than + * reading positions out of {@code data}, because the index is the whole mechanism by which a + * caller correlates a vector back to the text it sent; a test that trusted array position + * would pass against an implementation that stamped indexes on positionally and never + * populated them meaningfully at all.

+ */ + @Test + public void test_embeddings_withArrayInput_returnsOneEntryPerInputInOrder() { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingForAll(EMBEDDINGS_MODEL, BATCH_INPUT)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof EmbeddingListView); + + final EmbeddingListView view = (EmbeddingListView) response.getEntity(); + assertEquals("list", view.object()); + assertEquals(EMBEDDINGS_MODEL, view.model()); + + assertNotNull(view.data()); + assertEquals("An array of three inputs is three embeddings, not one", + BATCH_INPUT.length, view.data().size()); + + for (int index = 0; index < BATCH_INPUT.length; index++) { + final EmbeddingListView.EmbeddingView embedding = embeddingAtIndex(view, index); + assertNotNull("No entry carries index " + index + ", so the caller cannot tell which " + + "input it embedded", embedding); + assertEquals("embedding", embedding.object()); + assertNotNull(embedding.embedding()); + assertFalse("An embedding with no numbers in it is not an embedding", + embedding.embedding().isEmpty()); + } + + assertEquals("Every entry must carry a distinct vector; identical vectors would mean the " + + "same input was embedded three times", + BATCH_INPUT.length, + view.data().stream().map(EmbeddingListView.EmbeddingView::embedding) + .distinct().count()); + + // One batch is one provider round trip, not one per input. + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a request whose {@code input} is a single string rather than an array + * When the embedding is requested + * Then exactly one entry comes back, at index 0 + * + *

The scalar form is the half of FR-011 that already worked, and widening {@code input} to + * accept an array is exactly the kind of change that quietly breaks it — by normalising the + * scalar into a one-element array and then losing it, or by rejecting anything that is not an + * array. Both halves of "a string or an array of strings" have to hold at once, so the scalar + * form gets its own guard rather than living only inside the standard-shape test above.

+ */ + @Test + public void test_embeddings_withScalarInput_returnsSingleEntryAtIndexZero() { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(EMBEDDINGS_MODEL, INPUT_TEXT)); + + assertNotNull(response); + assertEquals("A single string is still valid input, not a malformed array", + 200, response.getStatus()); + assertTrue(response.getEntity() instanceof EmbeddingListView); + + final EmbeddingListView view = (EmbeddingListView) response.getEntity(); + assertNotNull(view.data()); + assertEquals("One input is one embedding", 1, view.data().size()); + + final EmbeddingListView.EmbeddingView embedding = view.data().get(0); + assertEquals("embedding", embedding.object()); + assertEquals("The only entry of a scalar request sits at index 0", 0, embedding.index()); + assertNotNull(embedding.embedding()); + assertFalse(embedding.embedding().isEmpty()); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a request whose {@code input} is an empty array + * When the embeddings are requested + * Then it is refused as a client error naming {@code input}, and the provider is never + * contacted + * + *

Embedding nothing is a caller mistake — a batch built from a query that matched no + * content, most likely. Answering it with an empty {@code data} list and a 200 would look like + * success, and the caller would index nothing and never find out why. FR-011 now says this in + * as many words — an absent, null or empty input is refused naming the field — where before it + * had to be inferred from the response being "one entry per input"; the test is unchanged, + * which is the point of recording it.

+ */ + @Test + public void test_embeddings_withEmptyArrayInput_isRejectedNamingTheField() { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingForAll(EMBEDDINGS_MODEL)); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue("An empty batch must be refused, not answered with an empty list", + response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("input", error.param()); + assertNotNull(error.message()); + assertFalse(error.message().isBlank()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a request whose {@code input} array holds numbers rather than strings + * When the embeddings are requested + * Then it is refused as a client error naming {@code input}, and the provider is never + * contacted + * + *

The format this family follows also accepts arrays of token ids, and dotCMS does not. + * That difference has to surface here, as a validation error pointing at {@code input}, rather + * than several layers down as whatever the provider client makes of a list of integers — a + * caller who sent pre-tokenized input deserves to be told that, not a deserialization + * stacktrace.

+ */ + @Test + public void test_embeddings_withNonStringArrayElements_isRejectedNamingTheField() { + final ArrayNode tokenIds = OBJECT_MAPPER.createArrayNode(); + tokenIds.add(1); + tokenIds.add(2); + tokenIds.add(3); + + assertInputRefusedNamingTheField(tokenIds); + } + + /** + * Given a request whose {@code input} array holds three strings followed by a number + * When the embeddings are requested + * Then it is refused as a client error naming {@code input}, and the message identifies the + * element at fault, and the provider is never contacted + * + *

FR-011 requires every element of the array to be a string, and this is + * the test that makes that word load-bearing. The all-numeric array above is refused by an + * implementation that looks only at {@code input.get(0)}; a mixed array is not, and a mixed + * array is the one a caller actually sends — a list assembled from two sources where one + * yielded identifiers instead of text. The refusal has to happen here, naming the field, for + * the same reason as the all-numeric case: several layers down it becomes whatever the + * provider client makes of a heterogeneous list. The number sits last rather than second so + * that the strings ahead of it are not decoration: an implementation that checks the first two + * elements and stops passes a two-element mixed array and fails this one.

+ * + *

Naming the field is no longer enough on its own. FR-011 requires the message to identify + * which element is at fault, because {@code param} can only ever say {@code input} and + * a caller who sent a batch is otherwise left to find the bad one themselves. The assertion + * looks for the offending position in the message; the position is one no other number in the + * payload shares, so a message that merely quotes the value it choked on does not pass for one + * that locates it.

+ * + *

The provider check is not a formality either. An implementation that "cleans" the array + * by keeping the strings it recognises would send a perfectly valid batch upstream and answer + * 200 — silently embedding less than the caller asked for and shifting every index they meant + * to correlate on.

+ */ + @Test + public void test_embeddings_withMixedArrayElements_isRejectedIdentifyingTheElement() { + final ArrayNode mixed = OBJECT_MAPPER.createArrayNode(); + for (final String text : BATCH_INPUT) { + mixed.add(text); + } + mixed.add(NON_STRING_ELEMENT); + + assertInputRefusedIdentifyingTheElement(mixed, BATCH_INPUT.length); + } + + /** + * Given a request whose {@code input} is a blank string — first empty, then whitespace only + * When the embeddings are requested + * Then both are refused as a client error naming {@code input}, and the provider is never + * contacted + * + *

FR-011 refuses a blank or whitespace-only string on the same grounds as an absent, null or + * empty input: there is nothing there to embed. Leaving it to the provider is the failure this + * family exists to prevent — some error, some return a zero vector, and a caller indexing a + * site would silently store a meaningless vector against a document and retrieve it forever + * after. The two forms are asserted together because they are one requirement and two guards: + * {@code isEmpty()} catches the first and waves the second through.

+ */ + @Test + public void test_embeddings_withBlankStringInput_isRejectedNamingTheField() { + assertInputRefusedNamingTheField(TextNode.valueOf(EMPTY_INPUT)); + assertInputRefusedNamingTheField(TextNode.valueOf(WHITESPACE_INPUT)); + } + + /** + * Given a request whose {@code input} array holds two strings and then a whitespace-only one + * When the embeddings are requested + * Then it is refused as a client error naming {@code input}, and the message identifies the + * element at fault, and the provider is never contacted + * + *

The array position of the blank-string rule, and the harder half of it. A blank arriving + * alone is the caller's whole request and hard to miss; a blank buried in a batch is what + * actually happens — a column that was empty for one row of five hundred — and an + * implementation that validates the array as a whole rather than element by element sends it + * upstream without noticing. Refusing the batch rather than skipping the blank is the point: + * dropping it would shift the index of every entry after it, and FR-011 makes those indexes the + * caller's only means of correlating vectors back to what they sent.

+ * + *

As with the mixed array, the message must say which element — and the blank sits at a + * position that appears nowhere else in the payload, so nothing but locating it will do.

+ */ + @Test + public void test_embeddings_withBlankArrayElement_isRejectedIdentifyingTheElement() { + final ArrayNode withBlank = OBJECT_MAPPER.createArrayNode(); + withBlank.add(BATCH_INPUT[0]); + withBlank.add(BATCH_INPUT[1]); + withBlank.add(WHITESPACE_INPUT); + + assertInputRefusedIdentifyingTheElement(withBlank, 2); + } + + /** + * Given a request whose {@code input} is null — first as a JSON null, then as a field left out + * of the payload entirely + * When the embeddings are requested + * Then both are refused as a client error naming {@code input}, and the provider is never + * contacted + * + *

FR-011 lists "absent" and "null" alongside "empty", and the two are asserted together + * because they are not the same code path. A JSON {@code "input": null} deserializes to a + * {@link NullNode} — an object, present, and non-null as far as a {@code != null} guard is + * concerned — while an omitted field leaves the component null outright. An implementation + * guarding on one reaches the provider with the other, where a null input is either an empty + * batch answered as success or a stacktrace, neither of which tells the caller they sent + * nothing.

+ */ + @Test + public void test_embeddings_withNullInput_isRejectedNamingTheField() { + assertInputRefusedNamingTheField(NullNode.getInstance()); + assertInputRefusedNamingTheField(null); + } + + /** + * Given the same site embedding one short string and then a batch of three + * When the reported usage of each is compared + * Then the batch reports more prompt tokens than the single string did + * + *

Usage is what the caller is billed on, so it has to describe the whole batch rather than + * whichever element happened to be counted. The assertion is relative — the batch is larger + * than the single string — rather than a fixed number: the count is the provider's to report, + * and pinning it would make this test a statement about the stub instead of about the + * endpoint.

+ */ + @Test + public void test_embeddings_withArrayInput_reportsUsageForWholeBatch() { + final Response single = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(EMBEDDINGS_MODEL, INPUT_TEXT)); + + assertNotNull(single); + assertEquals(200, single.getStatus()); + assertTrue(single.getEntity() instanceof EmbeddingListView); + + final EmbeddingListView singleView = (EmbeddingListView) single.getEntity(); + assertNotNull(singleView.usage()); + assertNotNull(singleView.usage().promptTokens()); + + final Response batch = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingForAll(EMBEDDINGS_MODEL, BATCH_INPUT)); + + assertNotNull(batch); + assertEquals(200, batch.getStatus()); + assertTrue(batch.getEntity() instanceof EmbeddingListView); + + final EmbeddingListView batchView = (EmbeddingListView) batch.getEntity(); + assertNotNull(batchView.usage()); + assertNotNull(batchView.usage().promptTokens()); + assertTrue("Usage must cover the whole batch, not one element of it", + batchView.usage().promptTokens() > singleView.usage().promptTokens()); + assertNotNull(batchView.usage().totalTokens()); + assertTrue(batchView.usage().totalTokens() > 0); + } + + /** + * Given one site whose {@code chat} section and {@code embeddings} section name different + * models + * When the same input is embedded twice, once naming the embeddings model and once naming the + * chat model + * Then the embeddings model is served and the chat model is refused as a model this site has + * not configured — because it is not configured for embeddings + * + *

FR-011 and R7, stated as one assertion rather than two files apart. An implementation + * that validated against the chat section would fail exactly one of these two halves, and an + * implementation that validated against nothing at all would fail only the second — so both + * belong in the same test, against the same site, in the same configuration.

+ */ + @Test + public void test_embeddings_withSiteChatModel_isRefusedEvenThoughEmbeddingsModelSucceeds() { + final Response accepted = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(EMBEDDINGS_MODEL, INPUT_TEXT)); + + assertNotNull(accepted); + assertEquals("The model the site configured for embeddings must be served", + 200, accepted.getStatus()); + assertTrue(accepted.getEntity() instanceof EmbeddingListView); + + final Response refused = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(CHAT_MODEL, INPUT_TEXT)); + + assertNotNull(refused); + assertEquals("The site's chat model is not an embeddings model, however well configured " + + "it is for chat", 404, refused.getStatus()); + assertTrue(refused.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) refused.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("model", error.param()); + assertNotNull(error.message()); + assertTrue(error.message().contains(CHAT_MODEL)); + + // What the refusal must not leak: which model the site does embed with, where its provider + // lives, what key reaches it, or which site is behind the host name. + assertFalse(error.message().contains(EMBEDDINGS_MODEL)); + assertFalse(error.message().contains(AiTest.API_KEY)); + assertFalse(error.message().contains(String.valueOf(AiTest.PORT))); + assertFalse(error.message().contains(host.getHostname())); + assertFalse(error.message().contains(host.getIdentifier())); + + // Exactly one call reached the provider: the accepted one. The refusal never did. + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a model name the site has configured for no section at all + * When the embedding is requested + * Then it is refused with a 404 in the "no such model" shape and the provider is never + * contacted + */ + @Test + public void test_embeddings_withModelConfiguredForNothing_isRefusedAsNoSuchModel() { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(UNCONFIGURED_MODEL, INPUT_TEXT)); + + assertNotNull(response); + assertEquals(404, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("model", error.param()); + assertNotNull(error.message()); + assertTrue(error.message().contains(UNCONFIGURED_MODEL)); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a request that omits {@code model} + * When the embedding is requested + * Then it is refused as a client error naming {@code model}, with no implicit default applied + * + *

FR-024 holds across the family, not only on chat. A site has exactly one embeddings model + * configured, which makes defaulting to it look harmless — and is precisely why it has to be + * refused here: a caller who never named a model cannot tell when the site's changes.

+ */ + @Test + public void test_embeddings_withoutModel_isRejectedNamingTheField() { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), embeddingFor(null, INPUT_TEXT)); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("model", error.param()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given a request carrying no credential at all + * When the embedding is requested + * Then it is refused as unauthorized and the provider is never contacted + * + *

FR-015. Accepts a thrown {@link WebApplicationException} as well as a returned 401, since + * the surrounding authentication handshake may refuse before the resource body runs and either + * is a valid refusal.

+ */ + @Test + public void test_embeddings_withNoCredential_isUnauthorized() { + final HttpServletRequest request = mockRequest(host.getHostname(), null); + + try { + final Response response = resource.embeddings( + request, mockResponse(), host.getIdentifier(), + embeddingFor(EMBEDDINGS_MODEL, INPUT_TEXT)); + assertEquals("An anonymous request must be refused as unauthorized", + Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + assertTrue("a refusal carries the standard error shape", + response.getEntity() instanceof InferenceErrorView); + } catch (final WebApplicationException e) { + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), + e.getResponse().getStatus()); + } + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + } + + /** + * Given an authenticated request naming a site explicitly + * When the embedding is requested + * Then the resolved site is published on the request for the response filter to report + * + *

FR-020 asks for the serving site on every response, and it reaches a real caller as the + * {@code X-dotCMS-Resolved-Site} header that {@link ResolvedSiteHeaderFilter} writes from + * {@link InferenceRequestAttributes#RESOLVED_SITE_ID}. This test calls the resource method + * directly, so no JAX-RS response filter runs and no header exists to read; the attribute is + * the filter's sole input and the only part of the chain this resource is responsible for.

+ */ + @Test + public void test_embeddings_withResolvedSite_publishesResolvedSiteId() { + final HttpServletRequest request = mockRequest(host.getHostname(), bearerToken); + + resource.embeddings(request, mockResponse(), host.getIdentifier(), + embeddingFor(EMBEDDINGS_MODEL, INPUT_TEXT)); + + assertEquals("The site whose configuration served the embedding must be published for the " + + "response filter to report", + host.getIdentifier(), + request.getAttribute(InferenceRequestAttributes.RESOLVED_SITE_ID)); + } + + /** + * Stubs the OpenAI-compatible provider: one canned vector for a single input, three for a + * batch, so that the response really does carry one entry per input rather than a fixed list + * the endpoint could return no matter what it was asked. + * + *

The two stubs are separated by priority and by a body match on {@link #BATCH_MARKER}, the + * last element of {@link #BATCH_INPUT}, which can only appear in the provider request when a + * batch was sent. Both are registered above the default priority on purpose: WireMock also + * loads the checked-in mappings under {@code src/test/resources/mappings}, one of which answers + * any {@code POST /embeddings} carrying the shared test API key, and without explicit + * priorities this file would be asserting against whichever stub happened to win.

+ */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(EMBEDDINGS_PATH)) + .atPriority(1) + .withRequestBody(containing(BATCH_MARKER)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_BATCH_RESPONSE))); + + wireMockServer.stubFor(post(urlPathEqualTo(EMBEDDINGS_PATH)) + .atPriority(2) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_RESPONSE))); + } + + /** + * @param view the response to search + * @param index the index a caller sent an input at + * @return the entry that claims that index, or null when nothing does + */ + private static EmbeddingListView.EmbeddingView embeddingAtIndex(final EmbeddingListView view, + final int index) { + return view.data().stream() + .filter(embedding -> embedding.index() == index) + .findFirst() + .orElse(null); + } + + /** + * Asserts that an {@code input} FR-011 refuses is refused here — as a 400 in the standard + * error shape, naming the field, with nothing reaching the provider. + * + *

Shared by the refusals that differ only in what was sent, so that a new one is a line + * rather than a copied block, and so that all of them stay pinned to the same param name: a + * caller cannot correct a payload they are not told the offending field of.

+ * + * @param input the {@code input} value to send, or null to omit the field entirely + * @return the error body, so that a caller with more to assert about the message can carry on + * from here rather than repeat all of this + */ + private InferenceErrorView.Body assertInputRefusedNamingTheField(final JsonNode input) { + final Response response = resource.embeddings( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), new EmbeddingsRequestView(EMBEDDINGS_MODEL, input)); + + assertNotNull(response); + assertEquals("'" + input + "' is not something to embed", 400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("input", error.param()); + assertNotNull(error.message()); + assertFalse(error.message().isBlank()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(EMBEDDINGS_PATH))); + + return error; + } + + /** + * Asserts everything {@link #assertInputRefusedNamingTheField(JsonNode)} does, and additionally + * that the message identifies which element of the array is at fault. + * + *

FR-011 keeps {@code param} at {@code input} — that is the field the caller sent — so the + * message is the only place the offending position can appear, and a caller who batched five + * hundred strings should not have to bisect their own payload to find it. The index is the + * 0-based one the response's own {@code index} correlates on, so that the number in the error + * means the same thing as the number in a success.

+ * + *

The match is on the index as a standalone number rather than on a substring, so that the + * digits of some unrelated value — a count, a limit, an element the message quotes — cannot + * stand in for the position. The tests calling this place the offending element at an index no + * other number in the payload shares, for the same reason.

+ * + * @param input the {@code input} array to send + * @param index the 0-based position of the element that is at fault + */ + private void assertInputRefusedIdentifyingTheElement(final JsonNode input, final int index) { + final InferenceErrorView.Body error = assertInputRefusedNamingTheField(input); + + assertTrue("A caller who sent a batch must be told which element is the problem, not just " + + "that 'input' is: the message must identify position " + index + + " (0-based, as the response's own index is), and '" + error.message() + + "' does not", + Pattern.compile("\\b" + index + "\\b").matcher(error.message()).find()); + } + + /** + * The scalar form of {@code input}: a single string, as + * {@code {"model": "…", "input": "The quick brown fox"}}. + * + * @param model the model to ask for, or null to omit the field + * @param input the text to embed + * @return the smallest well-formed embeddings request + */ + private static EmbeddingsRequestView embeddingFor(final String model, final String input) { + return new EmbeddingsRequestView(model, TextNode.valueOf(input)); + } + + /** + * The array form of {@code input}, as + * {@code {"model": "…", "input": ["The quick brown fox", "…"]}}. Called with no inputs it + * builds the empty array, which FR-011 refuses. + * + * @param model the model to ask for, or null to omit the field + * @param inputs the texts to embed, in the order the caller sent them + * @return an embeddings request carrying a batch + */ + private static EmbeddingsRequestView embeddingForAll(final String model, + final String... inputs) { + final ArrayNode input = OBJECT_MAPPER.createArrayNode(); + for (final String text : inputs) { + input.add(text); + } + + return new EmbeddingsRequestView(model, input); + } + + /** + * Builds a request arriving at a given host name, with or without a bearer credential. + * + *

Request attributes are backed by a real map rather than left as mock no-ops, because both + * the authentication handshake and FR-020's site attribution publish through them, and a mock + * that forgot what was set on it would not behave like a servlet container.

+ * + * @param serverName the host name the request arrives on + * @param credential the {@code Authorization} header value, or null for no credential + * @return the mocked request + */ + private static HttpServletRequest mockRequest(final String serverName, + final String credential) { + final HttpServletRequest request = mock(HttpServletRequest.class); + final Map attributes = new HashMap<>(); + + when(request.getRequestURI()).thenReturn(REQUEST_URI); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://" + serverName + REQUEST_URI)); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getServerName()).thenReturn(serverName); + when(request.getHeader("Authorization")).thenReturn(credential); + + doAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))) + .when(request).setAttribute(anyString(), any()); + when(request.getAttribute(anyString())) + .thenAnswer(invocation -> attributes.get(invocation.getArgument(0))); + + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceImagesTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceImagesTest.java new file mode 100644 index 000000000000..aedf5803ab29 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceImagesTest.java @@ -0,0 +1,968 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.rest.view.ImageGenerationRequestView; +import com.dotcms.inference.rest.view.ImageGenerationView; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Specifies {@code POST /api/inference/v1/images/generations} through + * {@link ImagesResource#generations(HttpServletRequest, HttpServletResponse, String, ImageGenerationRequestView)}. + * + *

Two decisions in FR-012 are the reason this file exists rather than a copy of the chat tests + * with a different noun.

+ * + *

The image comes back inline, as base64, and never as a URL. That is a + * deliberate choice and not an incidental one: a hosted URL would mean deciding storage, + * authentication and lifetime for an artifact generated from a prompt that may carry customer + * data, so the family avoids creating a separately-addressable artifact at all. A decision like + * that reverses quietly — an implementation that passes the provider's {@code url} straight + * through returns a 200 with a perfectly plausible body — so it is asserted on the serialized wire + * shape, where a {@code url} property appearing later is caught whether or not anyone remembers + * why it must not.

+ * + *

Deliberately not asserted here: that dotCMS asks the provider for base64 upstream. + * FR-012 does require it — a provider-minted URL is a real artifact on someone else's + * infrastructure, frequently public and long-lived, which is a larger exposure than anything this + * family's own response shape can create — but it requires it only "where a provider offers the + * choice", and the seven configured vendors do not agree on whether that choice exists or what it + * is called. Where no such choice exists the requirement says the opposite thing: dotCMS fetches + * the provider's URL and re-encodes it rather than refusing the provider, and FR-012 now states + * the residual openly — on those providers an addressable artifact really does exist upstream, and + * this family's guarantee is only that a caller never receives one. An assertion on the outbound + * request body would therefore pin one vendor's spelling of a field the requirement itself makes + * conditional, and would fail the moment the provider abstraction changed how it phrases a request + * it is still phrasing correctly. It is the same reason the streaming tests stopped asserting that + * {@code include_usage} never reached the provider: what is on the wire upstream belongs to the + * client library, and testing it tests the library. What this family owns, and what is asserted + * below, is that the response a caller receives carries {@code b64_json} and no + * {@code url}. This paragraph exists so that the absence reads as a decision rather than as an + * oversight somebody helpfully corrects.

+ * + *

{@code size}, by contrast, is asserted on the outbound request, and the + * difference is not inconsistency. Asking for the inline form is a provider capability — whether + * the ask exists at all varies by vendor, so a test of it is a test of the vendor. Passing a + * caller's {@code size} through is dotCMS's own behaviour: the caller named a size, FR-012 says it + * reaches the provider "where the provider accepts it", and the stub here is a provider that + * accepts it. It is the same logic FR-013 applies to the sampling parameters — {@code size} + * changes both what the caller receives and what the site pays for, so dropping it silently is a + * cost and correctness failure rather than a compatibility courtesy, and a dropped {@code size} is + * invisible in the response: a 1024x1024 image is a perfectly plausible answer to a request for + * something else.

+ * + *

The model is validated against the site's {@code image} section, not its + * chat models and not its embeddings model. A site configures all three separately, so reusing the + * chat gate here would accept the wrong model and refuse the right one while still answering + * 200/404 in the right shapes.

+ * + *
    + *
  • FR-012 — a prompt returns the standard image-generation shape: a {@code created} + * timestamp and one entry in {@code data}.
  • + *
  • FR-012 — that entry carries {@code b64_json} and carries no hosted {@code url}.
  • + *
  • FR-012 — {@code n} is honored: a request for two images answers with two distinct + * entries in {@code data}, while {@code n} of exactly 1 and {@code n} omitted are each one + * image.
  • + *
  • FR-012 — an {@code n} below 1 is refused naming the field — zero and negative alike.
  • + *
  • FR-012 / FR-013 — {@code size} reaches the provider rather than being dropped.
  • + *
  • FR-023 / R7 — {@code model} is validated against the site's image + * section; the site's chat and embeddings models are both refused here.
  • + *
  • FR-024 — {@code model} is required, and so is {@code prompt}; there is nothing to + * generate without one.
  • + *
  • FR-015 — an anonymous caller is refused.
  • + *
+ * + *

The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in + * through the same dotAI app secrets the rest of the AI integration tests use, so an accepted call + * travels the real client path rather than a stubbed one.

+ */ +public class InferenceImagesTest { + + /** + * The model the site's {@code image} section is configured with. Read from {@link AiTest} + * rather than restated, because the three-way separation this file relies on is a property of + * that shared configuration. + */ + private static final String IMAGE_MODEL = AiTest.IMAGE_MODEL; + + /** The model the same site's {@code embeddings} section is configured with. */ + private static final String EMBEDDINGS_MODEL = AiTest.EMBEDDINGS_MODEL; + + /** The model the same site's {@code chat} section is configured with. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** Path an OpenAI-compatible provider serves image generation on. */ + private static final String IMAGES_PATH = "/images/generations"; + + private static final String REQUEST_URI = "/api/inference/v1/images/generations"; + + private static final String ERROR_TYPE_INVALID_REQUEST = "invalid_request_error"; + + /** The prompt these tests generate from. */ + private static final String PROMPT = "A cat in a hammock"; + + /** The size these tests ask for; the one every configured provider supports. */ + private static final String SIZE = AiTest.IMAGE_SIZE; + + /** + * A second size the image model supports, asked for only by the pass-through test. + * + *

It has to differ from {@link #SIZE} for that test to mean anything: a request that asks + * for the default cannot distinguish a size that travelled from a size the provider would have + * used anyway. Neither string is a substring of the other, so the body match that separates the + * two stubs cannot fire on the wrong request.

+ */ + private static final String ALTERNATE_SIZE = "1792x1024"; + + /** A 1x1 PNG. Small enough to read, and real base64, so decoding it proves something. */ + private static final String IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQ" + + "AAAABJRU5ErkJggg=="; + + /** + * A different 1x1 PNG — a second real image, distinct from {@link #IMAGE_BASE64} — served only + * by the stub that matches on {@link #ALTERNATE_SIZE}. It is what makes "the size reached the + * provider" visible in the response as well as in the request log: an implementation that drops + * {@code size} falls through to the catch-all stub and comes back with the other image. + */ + private static final String ALTERNATE_IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9aw" + + "AAAABJRU5ErkJggg=="; + + /** How many images the multi-image test asks for: the smallest number that is not one. */ + private static final int TWO_IMAGES = 2; + + /** An image model on a provider that implements only the single-image call. */ + private static final String GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image"; + + /** + * What a provider request that really asked for {@link #TWO_IMAGES} images carries, and the + * body match that separates the two-image stub from the single-image one. + * + *

It has to be the outbound {@code n} rather than anything about the prompt: a stub keyed on + * the prompt would answer two images to an implementation that never told the provider how many + * it wanted, and the test would pass on a response the provider was never asked for.

+ * + *

Matched as a JSON path rather than as a substring of the body. The provider client writes + * its request pretty-printed — {@code "n" : 2}, with spaces around the colon — so a substring + * match on {@code "n":2} silently fails to match a request that did carry the field, falls + * through to the single-image stub, and reports the implementation as returning one image when + * what actually went wrong was the assertion. A path match is indifferent to formatting.

+ */ + private static final String TWO_IMAGES_MARKER = "$[?(@.n == " + TWO_IMAGES + ")]"; + + /** + * A third 1x1 PNG, distinct from both {@link #IMAGE_BASE64} and {@link #ALTERNATE_IMAGE_BASE64}, + * carried by the second entry of {@link #PROVIDER_TWO_IMAGES_RESPONSE}. + * + *

The second entry has to be a different image from the first for the multi-image test to + * mean anything: two copies of one payload would satisfy "two entries in {@code data}" while + * the implementation generated once and duplicated the answer. It is the same hole the batch + * embeddings test closes by asserting that its vectors are distinct.

+ */ + private static final String SECOND_IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mNg+M8AAAICAQBF9FLUAA" + + "AAAElFTkSuQmCC"; + + /** + * What the stubbed provider answers. Modelled on the checked-in dotAI image stubs under + * {@code src/test/resources/mappings} — {@code created}, and a {@code data} entry carrying the + * provider's rewritten prompt — except that the payload is the image itself rather than a + * hosted link, which is what an OpenAI-compatible provider returns for a base64 request and + * what FR-012 requires this family to deal in. + */ + private static final String PROVIDER_RESPONSE = """ + { + "created": 1789000000, + "data": [ + { + "revised_prompt": "A contented cat asleep in a woven hammock, warm afternoon light.", + "b64_json": "%s" + } + ] + } + """.formatted(IMAGE_BASE64); + + /** + * What the stubbed provider answers a request that carried {@link #ALTERNATE_SIZE}: the same + * shape, a different image. Only a request whose body really contains the size the caller asked + * for can reach this stub — see {@link #stubProvider()}. + */ + private static final String PROVIDER_ALTERNATE_SIZE_RESPONSE = """ + { + "created": 1789000000, + "data": [ + { + "revised_prompt": "A contented cat asleep in a wide woven hammock, warm afternoon light.", + "b64_json": "%s" + } + ] + } + """.formatted(ALTERNATE_IMAGE_BASE64); + + /** + * What the stubbed provider answers a request that really asked for {@link #TWO_IMAGES} + * images: two entries, each carrying its own image, which is what an OpenAI-compatible + * provider returns for an {@code n} of two. Only a request whose body carries + * {@link #TWO_IMAGES_MARKER} can reach this stub — see {@link #stubProvider()}. + */ + private static final String PROVIDER_TWO_IMAGES_RESPONSE = """ + { + "created": 1789000000, + "data": [ + { + "revised_prompt": "A contented cat asleep in a woven hammock, warm afternoon light.", + "b64_json": "%s" + }, + { + "revised_prompt": "A tabby cat curled in a rope hammock, late afternoon sun.", + "b64_json": "%s" + } + ] + } + """.formatted(IMAGE_BASE64, SECOND_IMAGE_BASE64); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static WireMockServer wireMockServer; + + /** A backend user who is also an administrator, so site READ is never the thing under test. */ + private static User user; + private static String bearerToken; + + private Host host; + private final ImagesResource resource = new ImagesResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + // A bare UserDataGen user has no roles at all, so it is neither a backend nor a frontend + // user and FR-016 rejects it; it also cannot read the site these tests pass as an explicit + // override, which FR-019 checks. Two roles are needed, not one: the check in + // WebResource.checkRolePermissions is doesUserHaveRole(user, "DOTCMS_BACK_END_USER") by key + // and does not walk inheritance, so being an admin does not imply it. Admin is what grants + // read on the site. Role-specific behaviour is US3's tests, not these. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = new SiteDataGen() + .name("inference-images-" + UUID.randomUUID() + ".dotcms.com") + .nextPersisted(); + // Configures chat, embeddings and image as three separate sections, each with its own + // model. The chat and embeddings models given here are the ones this file expects the + // image gate to refuse. + AiTest.aiAppSecretsWithProviderConfig( + host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + wireMockServer.resetRequests(); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a site configured with an image model, and a request carrying a prompt + * When the image is requested + * Then it comes back in the standard image-generation shape — a {@code created} timestamp and + * one entry in {@code data} + */ + @Test + public void test_generations_withPrompt_returnsImageInStandardShape() { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ImageGenerationView); + + final ImageGenerationView view = (ImageGenerationView) response.getEntity(); + assertTrue("The answer must carry a creation timestamp", view.created() > 0); + assertNotNull(view.data()); + assertEquals(1, view.data().size()); + assertNotNull(view.data().get(0)); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given a generated image + * When the answer is serialized as a client would receive it + * Then the entry carries a non-blank, decodable {@code b64_json} payload and no hosted + * {@code url} + * + *

FR-012. The assertion is made against the serialized wire shape rather than against the + * view's accessors, because that is where the decision can be undone: a {@code url} property + * added to the payload later — by widening the view, or by letting the provider's own entry + * through unmapped — would restore exactly the separately-addressable artifact the requirement + * refuses to create, while every status-code and shape assertion in this file kept passing. + * Decoding the payload is what separates a real image from a placeholder string that merely + * occupies the field.

+ */ + @Test + public void test_generations_withGeneratedImage_returnsBase64AndNoHostedUrl() { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ImageGenerationView); + + final ImageGenerationView view = (ImageGenerationView) response.getEntity(); + final String b64Json = view.data().get(0).b64Json(); + assertNotNull("FR-012 returns the image inline, so b64_json is not optional", b64Json); + assertFalse(b64Json.isBlank()); + + try { + assertTrue("An empty payload is not an image", + Base64.getDecoder().decode(b64Json).length > 0); + } catch (final IllegalArgumentException e) { + fail("b64_json must be valid base64, not an opaque placeholder: " + e.getMessage()); + } + + final JsonNode payload = OBJECT_MAPPER.valueToTree(view); + final JsonNode entry = payload.get("data").get(0); + assertTrue("The wire payload names the image b64_json, as standard clients read it", + entry.hasNonNull("b64_json")); + assertEquals(b64Json, entry.get("b64_json").asText()); + assertFalse("FR-012 creates no hosted, separately-addressable artifact, so no url may " + + "reach the caller", entry.hasNonNull("url")); + } + + /** + * Given requests asking for no images and for a negative number of them + * When each generation is requested + * Then each is refused as a client error naming {@code n}, and the provider is never contacted + * + *

FR-012 draws its line below 1, not around it: zero and a negative are not quantities of + * images a caller can mean, and they are the two ways of expressing that. They are also what a + * clamp swallows — {@code Math.max(1, n)} answers a request for no images with an image and a + * request for minus one with a bill, while every other assertion in this file keeps passing. + * Zero is not an empty success either: a caller who sends it has made a mistake, and answering + * it with an empty {@code data} list would be the same false success FR-011 refuses for an + * empty batch of embeddings.

+ * + *

That is why the provider must not be contacted at all. Clamping is the plausible + * implementation of this field — the request reaches the provider once, a perfectly valid + * single-image 200 comes back, and every other assertion in this file still passes. A refusal + * that never leaves dotCMS is the only observable difference.

+ * + *

An {@code n} of 2 is deliberately not among these values. An earlier draft of FR-012 + * refused any {@code n} other than 1, on the claim that the provider abstraction returns a + * single image per call; the claim was false — {@code ImageModel.generate(prompt, n)} returns a + * list, and the adopted format documents several images per request — and the requirement was + * corrected. Two images are served, by + * {@link #test_generations_withNOfTwo_returnsTwoDistinctImages()}.

+ */ + @Test + public void test_generations_withNBelowOne_isRejectedNamingTheField() { + assertCountRefusedNamingTheField(0); + assertCountRefusedNamingTheField(-1); + } + + /** + * Given a request asking for two images + * When the generation is requested + * Then two entries come back in {@code data}, each carrying its own non-blank {@code b64_json}, + * and the two are not the same image + * + *

FR-012 as corrected: {@code n} is honored, because the adopted format supports several + * images in one request and this site's provider implements the multi-image call. Refusing would be + * this family declining something both the standard and this provider support, so the count a + * caller asks for is the count they receive. A provider that cannot is a different case, with + * its own test below.

+ * + *

The two payloads are asserted to differ, not merely to be present. "Two entries" + * is a count an implementation can reach without generating twice — by asking the provider for + * one image and copying the answer into a list of the right length — and that implementation + * returns the caller one image under two headings while passing every shape assertion in this + * file. It is the same hole the batch embeddings test closes by asserting that its three + * vectors are distinct.

+ * + *

The outbound request is verified as well, for the reason the {@code size} test gives: what + * the provider was asked for is invisible in the response. The stub that serves two images is + * keyed on that outbound {@code n}, so an implementation that never sends it falls through to + * the single-image stub and fails on the count too — and one that loops, calling the provider + * once per image, doubles the round trips and leaves a request half-charged when the second + * call fails after the first succeeded, which the single matching call catches.

+ */ + @Test + public void test_generations_withNOfTwo_returnsTwoDistinctImages() { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT, TWO_IMAGES)); + + assertNotNull(response); + assertEquals("n is honored, so a request for two images is served rather than refused", + 200, response.getStatus()); + assertTrue(response.getEntity() instanceof ImageGenerationView); + + final ImageGenerationView view = (ImageGenerationView) response.getEntity(); + assertTrue("The answer must carry a creation timestamp", view.created() > 0); + assertNotNull(view.data()); + assertEquals("A request for two images is two entries in data, not one", + TWO_IMAGES, view.data().size()); + + for (int index = 0; index < TWO_IMAGES; index++) { + final ImageGenerationView.ImageView entry = view.data().get(index); + assertNotNull("Entry " + index + " is missing entirely", entry); + assertNotNull("FR-012 returns every image inline, so entry " + index + + " carries a b64_json", entry.b64Json()); + assertFalse("A blank payload is not an image, and entry " + index + " carries one", + entry.b64Json().isBlank()); + } + + assertEquals("Each entry must carry a distinct image; two copies of one would mean a " + + "single image was generated and handed back twice", + TWO_IMAGES, + view.data().stream() + .map(ImageGenerationView.ImageView::b64Json) + .distinct().count()); + + // One request, asking for two images — not two requests asking for one each. + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(IMAGES_PATH)) + .withRequestBody(matchingJsonPath(TWO_IMAGES_MARKER))); + } + + /** + * Given a request naming a size other than the one the provider would default to + * When the image is requested + * Then the size reaches the provider, and the answer is the one only a provider that was told + * that size returns + * + *

FR-012 requires {@code size} to be passed through where the provider accepts it, on the + * same reasoning FR-013 applies to the sampling parameters: it changes both what the caller + * receives and what the site pays, so dropping it is a cost and correctness failure rather than + * the harmless compatibility courtesy that ignoring an incidental field is. Unlike the base64 + * ask this file deliberately does not assert, passing a caller's own parameter through is + * dotCMS's behaviour rather than a provider capability that varies by vendor — the stub here is + * a provider that accepts {@code size}, and that is all FR-012 conditions the requirement + * on.

+ * + *

It has to be asserted on the outbound request because it is invisible in the response: a + * provider asked for nothing in particular returns a perfectly well-formed image, and every + * shape assertion in this file passes while the caller receives a size they did not ask for and + * a bill for a size they did not choose. The stub matched on the size makes the two outcomes + * distinguishable twice over — the request log records a body carrying the size, and the answer + * carries the image only that stub serves, so an implementation that drops {@code size} falls + * through to the catch-all and fails on the payload as well as on the verification.

+ */ + @Test + public void test_generations_withSize_passesTheSizeToTheProvider() { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT, 1, ALTERNATE_SIZE)); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ImageGenerationView); + + final ImageGenerationView view = (ImageGenerationView) response.getEntity(); + assertNotNull(view.data()); + assertEquals(1, view.data().size()); + assertEquals("The answer must be the one the provider gave for the size the caller asked " + + "for, not the one it gives when asked for nothing in particular", + ALTERNATE_IMAGE_BASE64, view.data().get(0).b64Json()); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(IMAGES_PATH)) + .withRequestBody(containing(ALTERNATE_SIZE))); + } + + /** + * Given two requests that differ only in {@code n} — one asking for exactly 1, one omitting + * the field + * When each image is generated + * Then both are served, each reaching the provider once and each answering with a single + * entry + * + *

The boundary from the other side, and the reason it is a separate test rather than + * another assertion inside one: what FR-012 refuses is an {@code n} below 1, not the presence + * of the field, and not the absence of it either. An implementation that rejected any request + * carrying {@code n} would satisfy the refusal test on its own while breaking every standard + * client that sends the format's own default of 1, and an implementation that rejected a + * request omitting {@code n} would break the clients that leave it out. Both halves are + * asserted against the same site, in the same configuration, so neither can be read as a + * configuration accident.

+ */ + @Test + public void test_generations_withNOfOneOrAbsent_returnsOneImage() { + final Response explicitOne = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT, 1)); + + assertNotNull(explicitOne); + assertEquals("n of 1 is what a standard client sends by default, and one image is what " + + "it must answer with", 200, explicitOne.getStatus()); + assertTrue(explicitOne.getEntity() instanceof ImageGenerationView); + assertEquals(1, ((ImageGenerationView) explicitOne.getEntity()).data().size()); + + final Response absent = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT, null)); + + assertNotNull(absent); + assertEquals("n is optional; omitting it means one image, not a malformed request", + 200, absent.getStatus()); + assertTrue(absent.getEntity() instanceof ImageGenerationView); + assertEquals(1, ((ImageGenerationView) absent.getEntity()).data().size()); + + wireMockServer.verify(2, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given one site whose {@code chat}, {@code embeddings} and {@code image} sections name three + * different models + * When the same prompt is generated from three times, once naming each model + * Then only the image model is served; the chat model and the embeddings model are both + * refused as models this site has not configured — because neither is configured + * for images + * + *

The three halves belong in one test, against one site, in one configuration. An + * implementation validating against the chat section would fail the first refusal; one + * validating against the embeddings section would fail the second; one validating against + * nothing would fail both while still serving the accepted case. Split across three tests, any + * single one of them could be read as flaky configuration rather than as the wrong section.

+ */ + @Test + public void test_generations_withSiteChatOrEmbeddingsModel_isRefusedEvenThoughImageModelSucceeds() { + final Response accepted = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT)); + + assertNotNull(accepted); + assertEquals("The model the site configured for images must be served", + 200, accepted.getStatus()); + assertTrue(accepted.getEntity() instanceof ImageGenerationView); + + assertRefusedAsNoSuchModel(CHAT_MODEL); + assertRefusedAsNoSuchModel(EMBEDDINGS_MODEL); + + // Exactly one call reached the provider: the accepted one. Neither refusal did. + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given a request that omits {@code model} + * When the image is requested + * Then it is refused as a client error naming {@code model}, with no implicit default applied + */ + @Test + public void test_generations_withoutModel_isRejectedNamingTheField() { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(null, PROMPT)); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("model", error.param()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given a request naming a configured model but omitting {@code prompt} + * When the image is requested + * Then it is refused as a client error naming {@code prompt}, and the provider is never + * contacted + * + *

Named as its own field rather than folded into a generic "malformed request": there is + * nothing to generate without a prompt, and a caller sent back to guessing which of two + * required fields they missed is a support call.

+ */ + @Test + public void test_generations_withoutPrompt_isRejectedNamingTheField() { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, null)); + + assertNotNull(response); + assertEquals(400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("prompt", error.param()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given a request carrying no credential at all + * When the image is requested + * Then it is refused as unauthorized and the provider is never contacted + * + *

FR-015. Accepts a thrown {@link WebApplicationException} as well as a returned 401, since + * the surrounding authentication handshake may refuse before the resource body runs and either + * is a valid refusal.

+ */ + @Test + public void test_generations_withNoCredential_isUnauthorized() { + final HttpServletRequest request = mockRequest(host.getHostname(), null); + + try { + final Response response = resource.generations( + request, mockResponse(), host.getIdentifier(), + generationFor(IMAGE_MODEL, PROMPT)); + assertEquals("An anonymous request must be refused as unauthorized", + Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + assertTrue("a refusal carries the standard error shape", + response.getEntity() instanceof InferenceErrorView); + } catch (final WebApplicationException e) { + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), + e.getResponse().getStatus()); + } + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given a request asking for more images than the configured ceiling allows + * When the generation is requested + * Then it is refused with a 400 naming {@code n}, and nothing reaches the provider + * + *

FR-012 and FR-037. Images are priced per image, so without a ceiling one accepted request + * multiplies a site's provider spend by whatever number it carried, and FR-032 puts per-site + * spend quotas out of scope — there is no second line of defence behind this one. The earlier + * draft that refused every {@code n} above 1 capped the spend by accident; correcting it to + * honor {@code n} removed that cap, and this is the deliberate replacement.

+ * + *

Refused rather than clamped, and asserted as such: an implementation that quietly served + * {@link InferenceLimits#DEFAULT_MAX_IMAGES_PER_REQUEST} images here would return a 200 and a + * bill for ten, and the caller would never learn that the four hundred they asked for was not + * what they got. The zero provider calls are the other half — a clamp would show up here as a + * request that did reach the provider.

+ */ + @Test + public void test_generations_aboveTheImageCeiling_isRejectedNamingTheField() { + final int aboveCeiling = InferenceLimits.current().maxImagesPerRequest() + 1; + + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT, aboveCeiling)); + + assertNotNull(response); + assertEquals("An n above the configured ceiling is one request carrying an unbounded bill, " + + "and must be refused rather than quietly clamped", 400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("The caller has to be told which field put them over the limit", + "n", error.param()); + assertNotNull(error.message()); + assertFalse(error.message().isBlank()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Given a site whose image provider can only ever produce one image per request + * When several images are requested + * Then it is refused with a 400 naming {@code n} — not a retryable upstream failure + * + *

FR-012. The multi-image call is a default method on the provider abstraction that throws + * unless the implementation overrides it: the OpenAI-backed models override it, the Gemini one + * does not. Left to the ordinary upstream translation of FR-031 that throw becomes a 502, which + * is the wrong answer in a way that costs the caller real time — 502 is retryable, so a + * standard client's back-off keeps re-sending a request that cannot succeed however long it + * waits. The status is the assertion that matters here; the param name is what lets the caller + * fix it on the first try.

+ * + *

No provider stub is needed and none is reached: the site is configured against a provider + * that never answers in these tests, and the refusal is settled from the model itself before + * any call is made. That is also why this is safe to assert — it pins the behaviour to what the + * library actually implements rather than to a list of provider names in a test.

+ */ + @Test + public void test_generations_severalFromAModelThatCannotIsRefusedNotTreatedAsUpstreamFailure() + throws Exception { + AiTest.aiAppSecretsWithProviderConfig(host, geminiImageProviderConfigJson()); + + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(GEMINI_IMAGE_MODEL, PROMPT, TWO_IMAGES)); + + assertNotNull(response); + assertEquals("A provider that can never honor n must be a 400 naming the field, not a " + + "retryable 502 that sends the caller's back-off into an unwinnable loop", + 400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("n", error.param()); + assertNotNull(error.message()); + assertFalse(error.message().isBlank()); + } + + /** + * @return a provider configuration whose image section names a provider that implements only + * the single-image call, leaving chat and embeddings pointed at the WireMock stub + */ + private static String geminiImageProviderConfigJson() { + return String.format( + "{" + + "\"chat\":{\"provider\":\"openai\",\"apiKey\":\"%1$s\",\"model\":\"%2$s\"," + + "\"endpoint\":\"%3$s\",\"maxRetries\":0}," + + "\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"%1$s\",\"model\":\"%4$s\"," + + "\"endpoint\":\"%3$s\",\"maxRetries\":0}," + + "\"image\":{\"provider\":\"google_ai\",\"apiKey\":\"%1$s\",\"model\":\"%5$s\"," + + "\"maxRetries\":0}" + + "}", + AiTest.API_KEY, CHAT_MODEL, String.format("http://localhost:%d/", AiTest.PORT), + EMBEDDINGS_MODEL, GEMINI_IMAGE_MODEL); + } + + /** + * Asserts that an {@code n} below 1 is refused here — as a 400 in the standard error shape, + * naming the field, with nothing reaching the provider. + * + *

Shared by the values that differ only in how far below 1 they fall, so that both stay + * pinned to the same param name and the same status: a caller who asked for no images and one + * who asked for minus one have made the same mistake and deserve the same answer.

+ * + * @param count the number of images to ask for; below 1, or this asserts the wrong thing + */ + private void assertCountRefusedNamingTheField(final int count) { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT, count)); + + assertNotNull(response); + assertEquals("An n of " + count + " is below 1, which is not a number of images anyone " + + "can be served", 400, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("A caller asking for a number of images that is not a number of images must " + + "be told which field is the problem", "n", error.param()); + assertNotNull(error.message()); + assertFalse(error.message().isBlank()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(IMAGES_PATH))); + } + + /** + * Asserts that a model the site has configured for some other section is refused here. + * + * @param model the model to ask for + */ + private void assertRefusedAsNoSuchModel(final String model) { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(model, PROMPT)); + + assertNotNull(response); + assertEquals("'" + model + "' is not an image model, however well configured it is " + + "elsewhere on this site", 404, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView.Body error = ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals(ERROR_TYPE_INVALID_REQUEST, error.type()); + assertEquals("model", error.param()); + assertNotNull(error.message()); + assertTrue(error.message().contains(model)); + + // What the refusal must not leak: which model the site does generate with, where its + // provider lives, what key reaches it, or which site is behind the host name. + assertFalse(error.message().contains(IMAGE_MODEL)); + assertFalse(error.message().contains(AiTest.API_KEY)); + assertFalse(error.message().contains(String.valueOf(AiTest.PORT))); + assertFalse(error.message().contains(host.getHostname())); + assertFalse(error.message().contains(host.getIdentifier())); + } + + /** + * Stubs the OpenAI-compatible provider: one canned image for an ordinary request, a different + * one for a request that really carried {@link #ALTERNATE_SIZE}, and a two-entry answer for a + * request that really asked for {@link #TWO_IMAGES} of them — so that what the caller asked for + * is distinguishable from what the provider would have chosen on its own. + * + *

The three are separated by priority and by a body match on the value itself, which can + * only appear in the provider request when the field was passed through. The two matched stubs + * cannot collide: the size request asks for one image and the two-image request asks for the + * default size, and neither value is a substring of the other. All three are registered above + * the default priority on purpose: WireMock also loads the checked-in mappings under + * {@code src/test/resources/mappings}, several of which answer + * {@code POST /images/generations}, and without explicit priorities this file would be + * asserting against whichever stub happened to win.

+ */ + private static void stubProvider() { + wireMockServer.stubFor(post(urlPathEqualTo(IMAGES_PATH)) + .atPriority(1) + .withRequestBody(containing(ALTERNATE_SIZE)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_ALTERNATE_SIZE_RESPONSE))); + + wireMockServer.stubFor(post(urlPathEqualTo(IMAGES_PATH)) + .atPriority(1) + .withRequestBody(matchingJsonPath(TWO_IMAGES_MARKER)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_TWO_IMAGES_RESPONSE))); + + wireMockServer.stubFor(post(urlPathEqualTo(IMAGES_PATH)) + .atPriority(2) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(PROVIDER_RESPONSE))); + } + + /** + * @param model the model to ask for, or null to omit the field + * @param prompt the prompt to generate from, or null to omit the field + * @return the smallest well-formed image-generation request, asking for one image + */ + private static ImageGenerationRequestView generationFor(final String model, + final String prompt) { + return generationFor(model, prompt, 1); + } + + /** + * @param model the model to ask for, or null to omit the field + * @param prompt the prompt to generate from, or null to omit the field + * @param count how many images to ask for, or null to omit {@code n} entirely — which is + * why it is boxed: "not sent" is a value a caller can express and FR-012 has to + * answer with one image, and a primitive would silently turn it into the 0 that + * FR-012 refuses + * @return an image-generation request asking for that many images + */ + private static ImageGenerationRequestView generationFor(final String model, + final String prompt, + final Integer count) { + return generationFor(model, prompt, count, SIZE); + } + + /** + * @param model the model to ask for, or null to omit the field + * @param prompt the prompt to generate from, or null to omit the field + * @param count how many images to ask for, or null to omit {@code n} entirely + * @param size the size to ask for + * @return an image-generation request asking for that many images at that size + */ + private static ImageGenerationRequestView generationFor(final String model, + final String prompt, + final Integer count, + final String size) { + return new ImageGenerationRequestView(model, prompt, count, size); + } + + /** + * Builds a request arriving at a given host name, with or without a bearer credential. + * + *

Request attributes are backed by a real map rather than left as mock no-ops, because both + * the authentication handshake and FR-020's site attribution publish through them, and a mock + * that forgot what was set on it would not behave like a servlet container.

+ * + * @param serverName the host name the request arrives on + * @param credential the {@code Authorization} header value, or null for no credential + * @return the mocked request + */ + private static HttpServletRequest mockRequest(final String serverName, + final String credential) { + final HttpServletRequest request = mock(HttpServletRequest.class); + final Map attributes = new HashMap<>(); + + when(request.getRequestURI()).thenReturn(REQUEST_URI); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://" + serverName + REQUEST_URI)); + when(request.getMethod()).thenReturn("POST"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + when(request.getServerName()).thenReturn(serverName); + when(request.getHeader("Authorization")).thenReturn(credential); + + doAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))) + .when(request).setAttribute(anyString(), any()); + when(request.getAttribute(anyString())) + .thenAnswer(invocation -> attributes.get(invocation.getArgument(0))); + + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelsTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelsTest.java new file mode 100644 index 000000000000..eac65d340071 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceModelsTest.java @@ -0,0 +1,455 @@ +package com.dotcms.inference.rest; + +import com.dotcms.ai.AiTest; +import com.dotcms.ai.app.ConfigService; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotcms.inference.rest.view.ModelListView; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.network.IPUtils; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Specifies model discovery through + * {@link ModelsResource#models(HttpServletRequest, HttpServletResponse, String)}. + * + *

FR-024 removed every reserved alias and every implicit default from this family, which makes + * this endpoint load-bearing rather than decorative: the list it returns is the only way + * a caller can learn which {@code model} values the chat endpoint will accept. That is what the + * tests here pin down — that the list is complete, that it is exactly the configured set with + * nothing invented, that it is drawn from the site the request resolved to, and that an + * unconfigured instance says so with an empty list rather than with somebody else's models.

+ * + *
    + *
  • FR-010 — every entry of a fallback chain is listed, in configured order, because each + * one is a real choice a caller may name.
  • + *
  • FR-024 — nothing synthetic is added; the count equals the configured count.
  • + *
  • FR-022 — no configuration at either the site or the system level yields an empty + * {@code data} array, never another site's models.
  • + *
  • The models come from the chat section, not the embeddings or image + * ones, matching what the chat endpoint validates against.
  • + *
  • Bearer-only authentication and site attribution behave exactly as on the sibling + * completions endpoint.
  • + *
+ * + *

This endpoint contacts no provider — it reads configuration and nothing else. The WireMock + * server is started only because the dotAI app secrets these tests save must name an endpoint, + * and no stub on it is ever expected to be hit.

+ */ +public class InferenceModelsTest { + + /** The chat model the site created for every test is configured with. */ + private static final String CHAT_MODEL = "gpt-4o-mini"; + + /** A second, distinct chat model, used to tell one site's configuration from another's. */ + private static final String OTHER_CHAT_MODEL = "claude-sonnet-4-6"; + + /** The primary of a fallback chain — the entry a caller wanting "whatever the site runs" takes. */ + private static final String CHAIN_PRIMARY = "gpt-4o-mini"; + + /** The fallback of that chain; a name the chat endpoint accepts just as readily. */ + private static final String CHAIN_FALLBACK = "gpt-4o"; + + /** A third chain entry, so "the count matches" is not satisfied by a coincidence of two. */ + private static final String CHAIN_LAST_RESORT = "gpt-4o-mini-2024-07-18"; + + /** An embeddings model, configured to prove it does not leak into the chat listing. */ + private static final String EMBEDDINGS_MODEL = "text-embedding-3-small"; + + /** An image model, configured for the same reason. */ + private static final String IMAGE_MODEL = "dall-e-3"; + + /** Object type of the list envelope. */ + private static final String OBJECT_LIST = "list"; + + /** Object type of each entry in it. */ + private static final String OBJECT_MODEL = "model"; + + /** Owner every entry reports; dotCMS serves the model, whoever built it. */ + private static final String OWNED_BY = "dotcms"; + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + /** The site created for each test, configured with {@link #CHAT_MODEL}. */ + private Host host; + + /** Additional sites a test configured, torn down with it. */ + private final List configuredSites = new ArrayList<>(); + + private final ModelsResource resource = new ModelsResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + + // A bare UserDataGen user has no roles at all, so it is neither a backend nor a frontend + // user and FR-016 rejects it; it also cannot read the sites these tests pass as an + // explicit override, which FR-019 checks. Two roles are needed, not one: the check in + // WebResource.checkRolePermissions is doesUserHaveRole(user, "DOTCMS_BACK_END_USER") by + // key and does not walk inheritance, so being an admin does not imply it. Admin is what + // grants read on the site. + user = new UserDataGen() + .roles(APILocator.getRoleAPI().loadBackEndUserRole(), + APILocator.getRoleAPI().loadCMSAdminRole()) + .nextPersisted(); + final ApiToken apiToken = APILocator.getApiTokenAPI().persistApiToken( + user.getUserId(), + Date.from(Instant.now().plus(Duration.ofDays(1))), + APILocator.systemUser().getUserId(), + "127.0.0.1"); + bearerToken = "Bearer " + APILocator.getApiTokenAPI().getJWT(apiToken, user); + } + + @AfterClass + public static void afterClass() { + wireMockServer.stop(); + IPUtils.disabledIpPrivateSubnet(false); + } + + @Before + public void before() throws Exception { + host = siteConfiguredWith(CHAT_MODEL); + } + + @After + public void after() throws Exception { + for (final Host configured : configuredSites) { + AiTest.removeAiAppSecrets(configured); + } + configuredSites.clear(); + } + + /** + * Given a site configured with a single chat model + * When the models are listed + * Then exactly that model comes back, in a {@code list} envelope, as a {@code model} entry + */ + @Test + public void test_models_withSingleConfiguredModel_listsThatModel() { + final Response response = resource.models( + mockRequest(), mockResponse(), host.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ModelListView); + + final ModelListView view = (ModelListView) response.getEntity(); + assertEquals(OBJECT_LIST, view.object()); + assertNotNull(view.data()); + assertEquals(1, view.data().size()); + + final ModelListView.ModelView model = view.data().get(0); + assertEquals(CHAT_MODEL, model.id()); + assertEquals(OBJECT_MODEL, model.object()); + assertEquals(OWNED_BY, model.ownedBy()); + assertTrue("An entry must carry a creation time", model.created() > 0); + } + + /** + * Given a site whose chat model is a fallback chain + * When the models are listed + * Then every entry of the chain is listed, in configured order, the primary first + * + *

FR-010. Each entry of the chain is a name the completions endpoint accepts, so a list + * that showed only the primary would hide choices the caller is entitled to make.

+ */ + @Test + public void test_models_withFallbackChain_listsEveryEntryInOrder() throws Exception { + final Host chainSite = siteConfiguredWith(CHAIN_PRIMARY + "," + CHAIN_FALLBACK); + + final Response response = resource.models( + mockRequest(), mockResponse(), chainSite.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ModelListView); + + final ModelListView view = (ModelListView) response.getEntity(); + assertEquals(OBJECT_LIST, view.object()); + assertEquals(List.of(CHAIN_PRIMARY, CHAIN_FALLBACK), modelIds(view)); + assertEquals("The primary model is the first entry", + CHAIN_PRIMARY, view.data().get(0).id()); + view.data().forEach( + (final ModelListView.ModelView model) -> assertEquals(OBJECT_MODEL, model.object())); + } + + /** + * Given a site configured with a three-entry chain and nothing else + * When the models are listed + * Then the list is exactly those three — no alias, no placeholder, no invented entry + * + *

FR-024 removed the reserved alias precisely so the list equals the set of acceptable + * {@code model} values. An extra entry here would be a name the completions endpoint refuses, + * which is worse than no list at all.

+ */ + @Test + public void test_models_withConfiguredChain_addsNothingSynthetic() throws Exception { + final List configured = + List.of(CHAIN_PRIMARY, CHAIN_FALLBACK, CHAIN_LAST_RESORT); + final Host chainSite = siteConfiguredWith(String.join(",", configured)); + + final Response response = resource.models( + mockRequest(), mockResponse(), chainSite.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + + final ModelListView view = (ModelListView) response.getEntity(); + assertEquals("The list must hold one entry per configured model, and no more", + configured.size(), view.data().size()); + assertEquals(configured, modelIds(view)); + } + + /** + * Given a site with no dotAI configuration, on an instance with none at the system level + * either + * When the models are listed + * Then the answer is a successful, empty list + * + *

FR-022. The failure this guards against is not an error but a wrong success: falling + * back to whichever site happens to be configured would hand a caller model names their own + * site will refuse, and would disclose that some other site has dotAI set up.

+ */ + @Test + public void test_models_withNoConfigurationAnywhere_returnsEmptyList() { + // The system level is the only inheritance an unconfigured site has (ConfigService falls + // back to SYSTEM_HOST). If something else in this JVM has configured it, the condition + // under test does not hold and the assertion below would be meaningless rather than wrong. + Assume.assumeFalse("The system level must be unconfigured for this test to mean anything", + ConfigService.INSTANCE.config(APILocator.systemHost()).isEnabled()); + + final Host unconfiguredSite = new SiteDataGen().nextPersisted(); + + final Response response = resource.models( + mockRequest(), mockResponse(), unconfiguredSite.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertTrue(response.getEntity() instanceof ModelListView); + + final ModelListView view = (ModelListView) response.getEntity(); + assertEquals(OBJECT_LIST, view.object()); + assertNotNull("An unconfigured site gets an empty list, not a null one", view.data()); + assertTrue("An unconfigured site must not be shown another site's models", + view.data().isEmpty()); + } + + /** + * Given two sites configured with different chat models + * When the models are listed with an explicit site override naming the second + * Then the second site's models come back and the first's do not + */ + @Test + public void test_models_withSiteOverride_listsTheResolvedSitesModels() throws Exception { + final Host otherSite = siteConfiguredWith(OTHER_CHAT_MODEL); + + final Response response = resource.models( + mockRequest(), mockResponse(), otherSite.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + + final ModelListView view = (ModelListView) response.getEntity(); + assertEquals(List.of(OTHER_CHAT_MODEL), modelIds(view)); + assertFalse("The other site's model must not appear", + modelIds(view).contains(CHAT_MODEL)); + } + + /** + * Given an authenticated request carrying an explicit site override + * When the models are listed + * Then the resolved site is published on the request, so every response can report it + * + *

FR-020. Asserted on the request attribute rather than on the header because invoking the + * resource method directly never runs the JAX-RS response filter that turns the attribute into + * {@code X-dotCMS-Resolved-Site} — the attribute is the part this endpoint is responsible + * for.

+ */ + @Test + public void test_models_withSiteOverride_publishesTheResolvedSiteId() { + final HttpServletRequest request = mockRequest(); + + final Response response = resource.models( + request, mockResponse(), host.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + verify(request).setAttribute( + InferenceRequestAttributes.RESOLVED_SITE_ID, host.getIdentifier()); + } + + /** + * Given a request carrying no bearer token + * When the models are listed + * Then it is refused as unauthorized, in the standard error shape + * + *

FR-015. The model list names the site's configured vendors and models, which is exactly + * the kind of reconnaissance an anonymous caller should not get for free.

+ */ + @Test + public void test_models_withoutBearerToken_isUnauthorized() { + final Response response = resource.models( + anonymousRequest(), mockResponse(), host.getIdentifier()); + + assertNotNull(response); + assertEquals(401, response.getStatus()); + assertTrue(response.getEntity() instanceof InferenceErrorView); + + final InferenceErrorView errorView = (InferenceErrorView) response.getEntity(); + assertNotNull(errorView.error()); + assertNotNull(errorView.error().message()); + assertFalse(errorView.error().message().isBlank()); + } + + /** + * Given a site configured with a chat model, a different embeddings model and a different + * image model + * When the models are listed + * Then only the chat model is listed + * + *

The chat endpoint validates {@code model} against the chat section, so a list drawn from + * any other section would advertise names that endpoint refuses. Embeddings and images have + * their own operations and their own model sets.

+ */ + @Test + public void test_models_withDistinctSectionModels_listsOnlyTheChatSection() throws Exception { + final Host site = siteConfiguredWith(CHAT_MODEL, EMBEDDINGS_MODEL, IMAGE_MODEL); + + final Response response = resource.models( + mockRequest(), mockResponse(), site.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + + final ModelListView view = (ModelListView) response.getEntity(); + assertEquals(List.of(CHAT_MODEL), modelIds(view)); + assertFalse("The embeddings model belongs to the embeddings operation, not here", + modelIds(view).contains(EMBEDDINGS_MODEL)); + assertFalse("The image model belongs to the image operation, not here", + modelIds(view).contains(IMAGE_MODEL)); + } + + /** + * Creates a site whose dotAI chat section names the given model, and registers it for teardown. + * + * @param chatModel the chat model, or a comma-separated fallback chain + * @return the configured site + */ + private Host siteConfiguredWith(final String chatModel) throws Exception { + return siteConfiguredWith(chatModel, AiTest.EMBEDDINGS_MODEL, AiTest.IMAGE_MODEL); + } + + /** + * Creates a site configuring each section independently, and registers it for teardown. + * + *

Built here rather than with {@link AiTest#providerConfigJson(int, String)} because that + * helper fixes the embeddings and image models, and telling the sections apart is the point of + * one of these tests.

+ * + * @param chatModel the chat model, or a comma-separated fallback chain + * @param embeddingsModel the embeddings model + * @param imageModel the image model + * @return the configured site + */ + private Host siteConfiguredWith(final String chatModel, + final String embeddingsModel, + final String imageModel) throws Exception { + final Host site = new SiteDataGen().nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig( + site, providerConfigJson(chatModel, embeddingsModel, imageModel)); + configuredSites.add(site); + return site; + } + + /** + * @param chatModel the chat model, or a comma-separated fallback chain + * @param embeddingsModel the embeddings model + * @param imageModel the image model + * @return the dotAI {@code providerConfig} JSON for a site configured that way + */ + private static String providerConfigJson(final String chatModel, + final String embeddingsModel, + final String imageModel) { + final String endpoint = String.format("http://localhost:%d/", AiTest.PORT); + return String.format( + "{" + + "\"chat\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\"," + + "\"endpoint\":\"%s\",\"maxRetries\":0}," + + "\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\"," + + "\"endpoint\":\"%s\",\"maxRetries\":0}," + + "\"image\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\"," + + "\"endpoint\":\"%s\",\"maxRetries\":0}," + + "\"settings\":{\"listenerIndexer\":{\"default\":\"blog\"}}" + + "}", + AiTest.API_KEY, chatModel, endpoint, + AiTest.API_KEY, embeddingsModel, endpoint, + AiTest.API_KEY, imageModel, endpoint); + } + + /** + * @param view the listing + * @return the model ids it carries, in the order it carries them + */ + private static List modelIds(final ModelListView view) { + assertNotNull(view.data()); + return view.data().stream().map(ModelListView.ModelView::id).toList(); + } + + /** + * @return a request authenticated with the test user's bearer token, as the family requires + */ + private static HttpServletRequest mockRequest() { + final HttpServletRequest request = anonymousRequest(); + when(request.getHeader("Authorization")).thenReturn(bearerToken); + return request; + } + + /** + * @return a request carrying no credential at all + */ + private static HttpServletRequest anonymousRequest() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/api/inference/v1/models"); + when(request.getRequestURL()) + .thenReturn(new StringBuffer("http://localhost/api/inference/v1/models")); + when(request.getMethod()).thenReturn("GET"); + when(request.getRemoteAddr()).thenReturn("127.0.0.1"); + return request; + } + + private static HttpServletResponse mockResponse() { + return mock(HttpServletResponse.class); + } +} diff --git a/specs/37431-openai-compatible-inference/contracts/inference-v1.md b/specs/37431-openai-compatible-inference/contracts/inference-v1.md index 41e61d7a98b9..d5b53c573525 100644 --- a/specs/37431-openai-compatible-inference/contracts/inference-v1.md +++ b/specs/37431-openai-compatible-inference/contracts/inference-v1.md @@ -16,7 +16,7 @@ | CORS | No cross-origin headers emitted. Server-side use only (FR-030) | | Cost | `@RequestCost(Price.HTTP_FETCH)` — the 100 band, "one remote HTTP round trip" (FR-032) | | Error body | OpenAI error shape. **Retryability is the HTTP status**, not a body field (FR-031) | -| Limits | `413` over `DOT_INFERENCE_MAX_REQUEST_BYTES`; `429` over `DOT_INFERENCE_MAX_CONCURRENT_STREAMS` (FR-037) | +| Limits | `413` over `DOT_INFERENCE_MAX_REQUEST_BYTES`; `429` over `DOT_INFERENCE_MAX_CONCURRENT_STREAMS`; `400` over `DOT_INFERENCE_MAX_IMAGES_PER_REQUEST` (FR-037) | ### Error shape @@ -24,9 +24,11 @@ { "error": { "message": "…", "type": "invalid_request_error", "param": "model", "code": null } } ``` +Every refusal in this family carries `type: "invalid_request_error"` unless a more specific family applies (`api_error` for an upstream failure, `rate_limit_error` for a refusal on the concurrency ceiling). The `type` is what a standard client branches on, so it is pinned here rather than left to each endpoint. + | Status | When | |---|---| -| `400` | Missing/invalid `model`, empty `messages`, uncorrelated tool result, unsupported semantic field | +| `400` | Missing/invalid `model`, empty `messages`, uncorrelated tool result, unsupported semantic field, bad `input`, `n` other than 1 | | `401` | Anonymous, or no bearer token | | `403` | Explicit site override the caller cannot READ (FR-019) | | `404` | Model not configured for this site/section — `NoSuchModelError` shape (FR-023) | @@ -125,14 +127,23 @@ Returns the models the resolved site has configured for its **chat** section, in ## `POST /embeddings` +`input` accepts **either a single string or an array of strings**, as the format does. Batching is the ordinary way to embed content — anyone indexing a site sends an array — so accepting only the scalar form would fail the common case and break SC-008 for it. + ```json { "model": "text-embedding-3-small", "input": "The quick brown fox" } ``` +```json +{ "model": "text-embedding-3-small", "input": ["The quick brown fox", "jumps over the lazy dog"] } +``` + +The response is always a list, one entry per input, with `index` giving the position in the request so a caller can correlate vectors back to what they sent: + ```json { "object": "list", "model": "text-embedding-3-small", - "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023, -0.0091] } ], - "usage": { "prompt_tokens": 5, "total_tokens": 5 } } + "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023, -0.0091] }, + { "object": "embedding", "index": 1, "embedding": [0.0512, 0.0034] } ], + "usage": { "prompt_tokens": 12, "total_tokens": 12 } } ``` `model` is validated against the site's **embeddings** section, not its chat models (FR-011, R7). @@ -142,14 +153,20 @@ Returns the models the resolved site has configured for its **chat** section, in ## `POST /images/generations` ```json -{ "model": "dall-e-3", "prompt": "A cat in a hammock", "n": 1, "size": "1024x1024" } +{ "model": "dall-e-3", "prompt": "A cat in a hammock", "n": 2, "size": "1024x1024" } ``` ```json -{ "created": 1789000000, "data": [ { "b64_json": "iVBORw0KGgo…" } ] } +{ "created": 1789000000, "data": [ { "b64_json": "iVBORw0KGgo…" }, { "b64_json": "R0lGODlhAQAB…" } ] } ``` -Images are returned **inline as base64** — no hosted URL, so no separately-addressable artifact is created from a possibly sensitive prompt (FR-012). +Images are returned **inline as base64** — no hosted URL, so no separately-addressable artifact is created from a possibly sensitive prompt (FR-012). Where the provider offers the choice, dotCMS asks it for the inline form too, so no hosted artifact is minted upstream either; providers without that option are still served. + +`n` is honored where the resolved site's image model supports it. A value below `1`, a value above `DOT_INFERENCE_MAX_IMAGES_PER_REQUEST` (default `10`, the ceiling the OpenAI images API documents for this field), or any value above 1 on a site whose image model can only produce one, is refused with a **400** naming the field — not a 5xx, because an upstream status would tell a client to retry something that cannot succeed. Omitting `n` means one. Per-model restriction of `n` matches the upstream API, which documents the field on the operation while restricting it for models that cannot honour it. `size` is passed through as a `WIDTHxHEIGHT` string; where the site carries an `imageSize` setting the caller's value wins and the site's is the default, matching what the existing dotAI image endpoint already does. + +Where a provider lets dotCMS ask for the inline form, it does; where a provider only returns a URL, dotCMS fetches and re-encodes it. In that second case an artifact does exist upstream — the guarantee is that a caller never receives one, not that none is ever created. + +For embeddings, every element of an array `input` must be a string, and blank or whitespace-only strings are refused alongside absent, null and empty ones. Where one element of an array is at fault the message identifies which. There is no separate cap on element count — the request-size limit bounds what can arrive. --- diff --git a/specs/37431-openai-compatible-inference/spec.md b/specs/37431-openai-compatible-inference/spec.md index 9061085fe178..b1c73299462c 100644 --- a/specs/37431-openai-compatible-inference/spec.md +++ b/specs/37431-openai-compatible-inference/spec.md @@ -136,8 +136,8 @@ The same client credentials and base URL also serve text embeddings and image ge - **FR-008**: The system MUST stream tool calls incrementally, emitting each tool call's index, identifier, name, and argument fragments so a standard client can reassemble the arguments as they arrive. - **FR-009**: The system MUST report token usage for streamed exchanges **when the client requests it through the standard streaming option**, and MUST NOT emit it otherwise. In the adopted format a usage event carries an empty choices array, which some stream readers do not tolerate — emitting it unconditionally would break clients SC-003 and SC-008 promise to support. - **FR-010**: The system MUST expose a model-listing operation returning the models the resolved site has configured, including every entry of its fallback chains, in the standard model-list shape. -- **FR-011**: The system MUST expose an embeddings operation returning vectors in the standard embeddings shape. Because a site's embeddings model is configured separately from its chat models, the model validation of FR-023 MUST apply to this operation against the site's configured **embeddings** models rather than its chat models. -- **FR-012**: The system MUST expose an image-generation operation returning results in the standard image-generation shape, delivering the image as a **base64 payload** rather than a hosted URL. A URL would require deciding storage, authentication and lifetime for content generated from a possibly sensitive prompt; returning the bytes inline avoids creating a durable, separately-addressable artifact at all. Should a URL form be offered later, it MUST be authenticated and time-limited. +- **FR-011**: The system MUST expose an embeddings operation returning vectors in the standard embeddings shape, accepting input as **either a single string or an array of strings** — batching is how content is ordinarily embedded, so refusing the array form would fail the common case and make SC-008 false for it. The response is always a list, one entry per input, each carrying the index of the input it corresponds to so a caller can correlate results back to what they sent. **Every** element of an array MUST be a string — validating only the first would accept a mixed array and fail deeper in the provider with a message naming nothing useful. An absent, null or empty input MUST be refused with a validation error naming the field, rather than answered with an empty list that reads as success. A blank or whitespace-only string is refused on the same grounds, whether it arrives alone or as an element of an array — there is no meaningful embedding of nothing, and providers differ in whether they error or return a zero vector, which is precisely the inconsistency this family exists to hide. The error names the field as `input`; where one element of an array is at fault the **message** MUST identify which, since a caller who sent five hundred should not have to find it themselves. No separate cap on element count is imposed: the request-size limit of FR-037 already bounds what can arrive, and a second count-based limit would overlap it while needing a default of its own to get wrong. Because a site's embeddings model is configured separately from its chat models, the model validation of FR-023 MUST apply to this operation against the site's configured **embeddings** models rather than its chat models. +- **FR-012**: The system MUST expose an image-generation operation returning results in the standard image-generation shape, delivering the image as a **base64 payload** rather than a hosted URL. A URL would require deciding storage, authentication and lifetime for content generated from a possibly sensitive prompt; returning the bytes inline avoids creating a durable, separately-addressable artifact at all. Should a URL form be offered later, it MUST be authenticated and time-limited. Where a provider offers the choice, the system MUST **ask it for the inline form**, so that no hosted artifact is minted at all — returning base64 to the caller constrains only this family's own output, while a provider-minted URL is a real object on the provider's infrastructure, frequently public and long-lived, and that is the exposure this requirement reaches for. Where a provider offers no such choice and returns a URL regardless, the system MUST fetch and re-encode it rather than refuse the provider: the upstream artifact exists either way at that point, and declining to serve would break image generation on part of a multi-provider gateway to avoid an exposure already incurred. **The residual is deliberate and worth stating**: on those providers an addressable artifact does exist upstream, and this family's guarantee is only that it never hands one to a caller. `n` MUST be honored where the resolved site's model can honor it: the adopted format supports generating several images in one request and the provider abstraction exposes a multi-image call, so refusing it outright would be this family declining something the standard supports. A value below `1` MUST be refused with a validation error naming the field; omitting it means one. A request for several images against a model that can only produce one MUST be refused with a validation error naming `n`, **not** surfaced as an upstream failure. The capability is declared on the provider abstraction but only some implementations honour it, and the unsupported case throws from inside the client library; translating that into FR-031's upstream error would return a retryable status for a request that can never succeed, sending a standard client's back-off into retrying forever. Refusing per-model is also what the adopted format itself does — it documents `n` on the operation while restricting it for models that cannot honour it — so this is conformance, not a dotCMS deviation. Support MUST be determined from the model rather than from a hard-coded list of provider names, which would rot on the first library upgrade. `n` MUST also be bounded above by a configurable maximum (FR-037), because each additional image is provider spend on one priced request and FR-032 puts per-site quotas out of scope — without a ceiling, one accepted request is an unbounded bill. The default ceiling MUST be the one the adopted format documents for this field, so a client written against the standard meets the same limit here that it already handles there. Values below the minimum and above the maximum are both refused naming the field rather than clamped: a clamp answers a request for four hundred images with a handful and a bill, and the caller never learns that what they asked for is not what they got. **Corrected 2026-09-14** — an earlier draft refused any `n` other than 1, justified by the claim that the provider abstraction returns a single image per call. That claim was false and was not checked before being written; the refusal reached the tests before the error was found. The correction then introduced two further gaps, both recorded above: it assumed interface support implied implementation support, and it removed an accidental spend ceiling without replacing it. `size` MUST be passed through to the provider, for the same reason FR-013 passes the sampling parameters through: it changes both what the caller receives and what the site pays. Where the site also carries an `imageSize` App setting, the caller's value wins and the site setting is the default for a request that omits it — which is what the existing dotAI image endpoint already does via `applyRequestSize`, so FR-027's "behaves as the existing endpoints do" is satisfied rather than strained. Values are the format's `WIDTHxHEIGHT` strings; a value a given provider will not accept is the provider's refusal to make, translated per FR-031, not something this family maintains a per-vendor table to pre-empt. - **FR-013**: The system MUST ignore request fields that do not change output semantics — the incidental fields a standard client sends by default — so a client's default payload succeeds. It MUST NOT silently ignore a field that changes what the caller gets or pays for. Specifically, the common sampling parameters (`temperature`, `max_tokens`, `top_p`, `stop`) MUST be passed through to the provider, and a field that changes output semantics but cannot be honored (for example a request for several choices, which this family does not support) MUST be rejected with a standard-shaped validation error naming it, rather than accepted and disregarded. Silently dropping a caller's token ceiling is a cost and correctness failure, not a compatibility courtesy. - **FR-014**: The legacy non-chat completion operation is explicitly out of scope and MUST NOT be added; it is deprecated in the format being adopted. @@ -171,7 +171,7 @@ The same client credentials and base URL also serve text embeddings and image ge - **FR-034**: The three existing operations superseded by this family — text generation, image generation, and raw-prompt completion — MUST be documented as superseded while remaining fully functional. - **FR-035**: The generated API description document MUST be regenerated from the new endpoint annotations and committed alongside them. - **FR-036**: The system MUST record only **metadata** about an exchange — resolved site, model, token counts, status, duration — and MUST NOT write request or response bodies to any log or durable store. Prompts and tool arguments routinely carry customer data, and FR-020 mandates logging on the fallback path, so silence here would leave an implementer to guess. Conversation content is not retained after the response is delivered; this family is stateless. -- **FR-037**: The system MUST bound capacity explicitly, with a maximum number of concurrent streams, a hard completion timeout, and a maximum request size, all configurable and all with stated defaults. An over-size request MUST be refused with a standard-shaped error naming the limit, rather than left to a container-level rejection the caller cannot interpret. Removing the legacy 4096-character prompt cap (FR-002) was correct — it was a character count standing in for a model's context window — but it left request size unbounded, and dotCMS parses and forwards a payload before any provider rejects it, so the memory cost lands on the node and the token cost on the site's bill first. Streaming parks a request thread for the life of a completion, which the Edge Cases identify as the dominant capacity risk on this family, and an unbounded risk is not a managed one. +- **FR-037**: The system MUST bound capacity explicitly, with a maximum number of concurrent streams, a hard completion timeout, a maximum request size, and a maximum number of images per generation request, all configurable and all with stated defaults. An over-size request MUST be refused with a standard-shaped error naming the limit, rather than left to a container-level rejection the caller cannot interpret. Removing the legacy 4096-character prompt cap (FR-002) was correct — it was a character count standing in for a model's context window — but it left request size unbounded, and dotCMS parses and forwards a payload before any provider rejects it, so the memory cost lands on the node and the token cost on the site's bill first. Streaming parks a request thread for the life of a completion, which the Edge Cases identify as the dominant capacity risk on this family, and an unbounded risk is not a managed one. - **FR-038**: The internal representation a request is mapped into MUST NOT be a direct binding of the chat-completions JSON, and tool-call identity MUST be first-class within it rather than reconstructed from a streaming index. The chat-completions surface is then one serialization of that representation. This is what keeps a future `/api/inference/v1/responses` (FR-001) a second serializer rather than a re-plumb: an internal model shaped directly by chat-completions semantics discards the item identity a richer format needs before that format is ever written. This requirement constrains the internal shape only; it does not add any Responses-format behavior to this scope. - **FR-039**: When a streamed response fails after the stream has begun — an upstream error mid-generation, or the completion timeout of FR-037 firing — the system MUST emit the standard error object as a final stream event and then close the connection **without** the terminal done marker. A status code is no longer available once the first chunk is sent, so the error event is what lets a client distinguish failure from a short answer; withholding the done marker means a client that does not parse the event still sees an incomplete stream rather than a cleanly finished one. Closing silently, or closing with the done marker, are both prohibited: the first leaves the caller unable to tell an outage from a timeout, and the second reports a truncated answer as complete. From 39c5e286ef40d518536625a4ad6a3a1d0e4462cf Mon Sep 17 00:00:00 2001 From: Freddy Montes Date: Wed, 16 Sep 2026 08:08:56 -0600 Subject: [PATCH 4/4] fix(ai): drop final from CDI-intercepted resource methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four /api/inference/v1 resource methods were declared final while carrying @RequestCost, which is a CDI interceptor binding. Weld intercepts by subclassing, so a final method cannot be proxied, and it refuses the deployment: WELD-001504: Intercepted bean method ... public final ChatCompletionsResource.completions(...) cannot be declared final That fails DotRestApplication's servlet init, which does not break these four endpoints — it takes down every REST endpoint in dotCMS. Each subsequent request then retries the init and logs a secondary "resource configuration is not modifiable" error, which reads like an unrelated Jersey problem and is where an investigation naturally starts. No other @RequestCost method in the codebase is final. Nothing in the test suite could have caught this: every integration test in this family invokes the resource methods directly, so none of them passes through Weld or Jersey. The suite was green while the application could not start. InterceptedMethodsAreNotFinalTest closes that specific gap by reflection, over every declared method rather than a list of today's four, so a fifth operation added later is covered too. It was verified to fail by restoring final on one method. Co-Authored-By: Claude Opus 5 (1M context) --- .../rest/ChatCompletionsResource.java | 2 +- .../inference/rest/EmbeddingsResource.java | 2 +- .../dotcms/inference/rest/ImagesResource.java | 2 +- .../dotcms/inference/rest/ModelsResource.java | 2 +- .../InterceptedMethodsAreNotFinalTest.java | 68 +++++++++++++++++++ 5 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 dotCMS/src/test/java/com/dotcms/inference/InterceptedMethodsAreNotFinalTest.java diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java index e10828ee323c..cfe426f2ac3c 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java @@ -190,7 +190,7 @@ public class ChatCompletionsResource { @Path("/completions") @Consumes(MediaType.APPLICATION_JSON) @Produces({MediaType.APPLICATION_JSON, EVENT_STREAM}) - public final Response completions(@Context final HttpServletRequest request, + public Response completions(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @QueryParam("siteId") final String siteId, @RequestBody(description = "The completion to run", diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java index 8bde6dd2d46f..d478c1c3c4e8 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java @@ -152,7 +152,7 @@ public class EmbeddingsResource { @RequestCost(Price.HTTP_FETCH) @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public final Response embeddings(@Context final HttpServletRequest request, + public Response embeddings(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @QueryParam("siteId") final String siteId, @RequestBody(description = "What to embed", diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java index d3d144250738..21d5ac2ebb7b 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java @@ -175,7 +175,7 @@ public class ImagesResource { @Path("/generations") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - public final Response generations(@Context final HttpServletRequest request, + public Response generations(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @QueryParam("siteId") final String siteId, @RequestBody(description = "What to generate", diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java index ffb18620adef..e2f2f4a31207 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java @@ -127,7 +127,7 @@ public class ModelsResource { @InferenceEndpoint @RequestCost(Price.HTTP_FETCH) @Produces(MediaType.APPLICATION_JSON) - public final Response models(@Context final HttpServletRequest request, + public Response models(@Context final HttpServletRequest request, @Context final HttpServletResponse response, @QueryParam("siteId") final String siteId) { diff --git a/dotCMS/src/test/java/com/dotcms/inference/InterceptedMethodsAreNotFinalTest.java b/dotCMS/src/test/java/com/dotcms/inference/InterceptedMethodsAreNotFinalTest.java new file mode 100644 index 000000000000..a44ef048a55e --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/InterceptedMethodsAreNotFinalTest.java @@ -0,0 +1,68 @@ +package com.dotcms.inference; + +import com.dotcms.inference.rest.ChatCompletionsResource; +import com.dotcms.inference.rest.EmbeddingsResource; +import com.dotcms.inference.rest.ImagesResource; +import com.dotcms.inference.rest.ModelsResource; +import com.dotcms.cost.RequestCost; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertTrue; + +/** + * Fails if a CDI-intercepted resource method is declared {@code final}. + * + *

Weld cannot intercept a final method: it proxies by subclassing, and a final method cannot be + * overridden. Annotating one with {@code @RequestCost} — an interceptor binding — makes Weld throw + * {@code WELD-001504} while building the injection target, which fails + * {@code DotRestApplication}'s servlet init. That does not break one endpoint; it takes down + * every REST endpoint in dotCMS, and the log then fills with a secondary + * "resource configuration is not modifiable" error from each retry, which looks like an entirely + * different problem and is where an investigation naturally starts.

+ * + *

This exists because nothing else here would catch it. Every integration test in this family + * invokes the resource methods directly, so they never pass through Weld or Jersey at all — the + * whole suite was green while the application could not start. That blind spot is structural, not + * an oversight in any one test, so the guard has to be explicit.

+ * + *

Asserted by reflection over every method rather than against a list of known ones: a fifth + * operation added later with {@code public final} is precisely the mistake this catches, and a + * test naming today's four would not.

+ */ +public class InterceptedMethodsAreNotFinalTest { + + /** + * Given every resource in this endpoint family + * When a method carries a CDI interceptor binding + * Then it is not final, so Weld can proxy it + */ + @Test + public void test_noInterceptedResourceMethodIsFinal() { + final List offenders = new ArrayList<>(); + + for (final Class resource : new Class[]{ + ChatCompletionsResource.class, + EmbeddingsResource.class, + ImagesResource.class, + ModelsResource.class}) { + + for (final Method method : resource.getDeclaredMethods()) { + if (method.isAnnotationPresent(RequestCost.class) + && Modifier.isFinal(method.getModifiers())) { + offenders.add(resource.getSimpleName() + "." + method.getName()); + } + } + } + + assertTrue("Weld proxies an intercepted bean by subclassing it, so a final method cannot " + + "be intercepted: it throws WELD-001504 during deployment and takes the " + + "whole REST servlet down with it, not just these endpoints. Drop the " + + "final keyword from: " + offenders, + offenders.isEmpty()); + } +}