diff --git a/CLAUDE.md b/CLAUDE.md
index 76a2579afcdb..76f942cd1045 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -120,6 +120,7 @@ How it's built + upgrade re-apply notes: [.specify/CUSTOMIZATIONS.md](.specify/C
- [Java Standards](docs/backend/JAVA_STANDARDS.md) — Coding patterns, immutables, exceptions, utilities
- [When to Use Virtual Threads](docs/backend/VIRTUAL_THREADS.md) — Socket I/O yes, file I/O no; carrier pinning
- [REST API Patterns](docs/backend/REST_API_PATTERNS.md) — JAX-RS, Swagger, @Schema rules
+- [Inference API](docs/backend/INFERENCE_API.md) — OpenAI-compatible `/api/inference/v1` family: chat completions, streaming, models, embeddings, images; bearer-only auth, per-site resolution, limits
- [Maven Build System](docs/backend/MAVEN_BUILD_SYSTEM.md) — Dependency management
- [Configuration Patterns](docs/backend/CONFIGURATION_PATTERNS.md) — Config.getProperty() usage
- [Database Patterns](docs/backend/DATABASE_PATTERNS.md) — DotConnect, transactions
diff --git a/docs/README.md b/docs/README.md
index d6ca3f4edf1a..1e61cfdd7019 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,6 +16,7 @@ Standards that apply to all development:
Backend-specific patterns and standards:
- **JAVA_STANDARDS.md** - Java development patterns and API usage
- **MAVEN_BUILD_SYSTEM.md** - Dependency and plugin management
+- **[INFERENCE_API.md](backend/INFERENCE_API.md)** - OpenAI-compatible `/api/inference/v1` endpoint family: operations, bearer-only auth, site resolution, limits, errors
### `/docs/frontend/` - Angular/TypeScript Development
Frontend-specific patterns and standards. **Start at the index: [frontend/README.md](frontend/README.md)** — it lists every doc and when to load it.
diff --git a/docs/backend/INFERENCE_API.md b/docs/backend/INFERENCE_API.md
new file mode 100644
index 000000000000..83202949449d
--- /dev/null
+++ b/docs/backend/INFERENCE_API.md
@@ -0,0 +1,317 @@
+# OpenAI-Compatible Inference API (`/api/inference/v1`)
+
+`/api/inference/v1` is a REST endpoint family that speaks the OpenAI chat-completions wire format — the request and response JSON shapes published by OpenAI and served by most AI gateways (OpenRouter, LiteLLM, Vercel AI Gateway, Ollama, vLLM). Because the format is the one standard client libraries and agent frameworks already emit, pointing such a client at dotCMS takes exactly two settings: a base URL and a dotCMS API token. No dotCMS-specific client code is written.
+
+The endpoints sit in front of dotAI, which is already a multi-provider gateway with per-site credentials held in App secrets, model fallback chains, and provider selection. What the family adds is the door: an application that previously had to embed a provider key of its own can route through dotCMS instead, so the site's credentials stay server-side, the provider can be swapped centrally, and every call can be attributed to a site.
+
+**This is not a replacement for `/api/v1/ai/*`.** Those endpoints stay and keep their behavior. Retrieval-augmented completions, semantic search, the vector-corpus operations and provider administration have no equivalent verb in the OpenAI format and remain first-class there. Three `/api/v1/ai/*` operations are superseded by this family and are now `@Deprecated` — see [Superseded endpoints](#superseded-endpoints).
+
+Source: `dotCMS/src/main/java/com/dotcms/inference/`. Design intent lives in `specs/37431-openai-compatible-inference/`; the published contract is the generated `openapi.yaml`.
+
+---
+
+## Operations
+
+| Operation | Path | Notes |
+|---|---|---|
+| Chat completion | `POST /api/inference/v1/chat/completions` | Multi-turn messages, tool calling, optional SSE streaming |
+| Model listing | `GET /api/inference/v1/models` | Every model the resolved site configured — chat, embeddings and images — chat first |
+| Embeddings | `POST /api/inference/v1/embeddings` | One string or an array of strings |
+| Image generation | `POST /api/inference/v1/images/generations` | Always base64, never a URL |
+
+The trailing `v1` is the **wire-protocol** version, not dotCMS's own `/api/v1` resource versioning. A second wire format would be added as a sibling under the same version (`/api/inference/v1/responses`), not as `/api/inference/v2`. OpenAI's legacy non-chat `POST /completions` is deliberately absent.
+
+---
+
+## Authentication
+
+**Bearer token only.** Every endpoint requires `Authorization: Bearer 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.
The output-format parameter is deliberately not sent. Asking for the + * inline form was the obvious way to stop a provider minting a hosted artifact, and it is what + * this did first — but the current generation of image models rejects the parameter outright + * ("Unknown parameter: 'response_format'"), because they only ever return base64 and there is + * nothing to choose. Sending it broke image generation against every one of them while working + * against the older models this family's tests stubbed. Not sending it is both simpler and + * safer than naming the models that accept it: the set that rejects it grows with every + * release, so any list would rot. Where a legacy model returns a URL instead, the caller still + * receives base64 — the answer is fetched and re-encoded, which this family already does.
* *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
@@ -234,8 +242,7 @@ public {@code /api/inference/v1/images/generations} sets {@code b64_json} because FR-012 wants
+ * {@code /api/inference/v1/images/generations} sets {@code b64_json} because it returns
* 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. 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.
Exists because "is this text safe to show a caller?" is not a question that can be answered + * by looking at an exception's type. Most of what reaches the error translator is an + * {@link IllegalArgumentException}, and they arrive from two very different places: some carry a + * sentence dotCMS composed about the site's own configuration, which is exactly what a caller + * needs to fix the problem; others carry a provider client's message, which can hold the + * provider's endpoint, its account identifiers, or a fragment of the prompt. Passing the message + * through whenever the type matched meant the second kind reached the caller too.
+ * + *So the decision is made where the text is written rather than where it is rendered. Throwing + * this type is a statement that the message was authored here and may be returned; everything + * else is answered with a generic sentence and logged in full. Extending + * {@link IllegalArgumentException} keeps existing callers that catch the broader type working + * unchanged, which matters because the throw sites are shared with the older dotAI endpoints, + * whose behaviour must not shift.
+ */ +public class CallerSafeException extends IllegalArgumentException { + + private static final long serialVersionUID = 1L; + + /** + * @param message a message written by dotCMS, safe to return to a caller + */ + public CallerSafeException(final String message) { + super(message); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java index a24e642a6556..e80ace571d53 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java +++ b/dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java @@ -41,9 +41,17 @@ public static InferenceError invalidRequest(final String message, final String p * @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) { + public static InferenceError noSuchModel(final String model, final String capability) { + // Naming the capability rather than saying "not configured for this site", which is true + // of the site's chat models only in the sense that matters and false in the sense a caller + // reads. The model listing returns every model the site has configured across all three + // capabilities, because the adopted format has nowhere to record what a model is for — so + // a caller can see an id in the list and then be told it is not configured, with no way to + // resolve the contradiction from the API alone. This refusal is the only place that + // information can live. return new InferenceError("invalid_request_error", - "The model '" + model + "' is not configured for this site", "model", 404); + "The model '" + model + "' is not configured for " + capability + " on this site", + "model", 404); } /** @@ -54,6 +62,94 @@ public static InferenceError upstream(final String message) { return new InferenceError("api_error", message, null, 502); } + /** + * Translates a provider failure into the status a standard client's back-off keys off. + * + *A provider refusing on rate limit and a provider genuinely broken are different + * events with different right answers, and collapsing both into 502 tells a client to retry a + * throttled request on the wrong schedule while telling it nothing about when to come back. + * Rate limiting is 429; anything else upstream stays 502.
+ * + *The provider's own message is never carried out of here — it can hold its endpoint, its + * account identifiers, and occasionally a fragment of the prompt. Only the status is taken + * from it, which is why the caller supplies the wording.
+ * + *Three outcomes, not two, because "the provider said no" splits along a line a client + * acts on. A rate limit is temporary: 429, come back later. A refusal the provider will + * repeat — an exhausted account, a rejected key, a model the provider does not serve — is + * permanent until somebody changes something, and answering 502 there tells a standard + * client's back-off to retry a request that cannot ever succeed. Everything left is a + * genuine upstream fault, which is what 502 is for.
+ * + *Permanence is read from the provider library's own classification rather than from a + * list of exception types or status codes: it already sorts its failures into retriable and + * non-retriable, and keying off that means a type added in a later release is classified + * correctly without this method being touched.
+ * + *The cause chain is walked rather than the top exception inspected, because the provider + * client wraps: a rate limit surfaces as its own type or as a generic HTTP failure carrying + * 429, and by the time a retrying fallback chain has rethrown it, either can be two or three + * causes down.
+ * + * @param failure the exception the provider client raised + * @param upstreamMessage the wording to return for a non-rate-limit failure + * @return the error to answer with + */ + public static InferenceError fromProviderFailure(final Throwable failure, + final String upstreamMessage) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof dev.langchain4j.exception.RateLimitException) { + return rateLimited(); + } + if (current instanceof dev.langchain4j.exception.HttpException httpFailure) { + if (httpFailure.statusCode() == 429) { + return rateLimited(); + } + // Any other 4xx is the provider saying the request itself is wrong — a parameter + // it does not accept, a model it does not serve, an account it will not bill. It + // will say the same thing to an identical retry, so it must not leave here with a + // retryable status. This arrives as a bare HttpException rather than one of the + // library's classified types, which is how a permanently malformed request used + // to be reported as a temporary upstream fault. + if (httpFailure.statusCode() >= 400 && httpFailure.statusCode() < 500) { + return providerRefused(); + } + } + if (current instanceof dev.langchain4j.exception.NonRetriableException) { + return providerRefused(); + } + if (current.getCause() == current) { + break; + } + } + return upstream(upstreamMessage); + } + + /** + * @return the standard rate-limit refusal, which a client reads as "retry later" + */ + /** + * @return the refusal for a provider failure that a retry cannot fix + */ + public static InferenceError providerRefused() { + // 400 rather than 502, because the status is the only thing carrying retryability and a + // 5xx would keep a client coming back. It is an imperfect fit — the caller has usually + // done nothing wrong, and the thing to fix is the site's provider account or + // configuration — so the message says where to look rather than implying the request was + // malformed. The provider's own wording, which names the account and sometimes the + // prompt, stays in the log. + return new InferenceError("invalid_request_error", + "The model provider refused this request, and will refuse it again on retry. " + + "This is a provider account or site configuration problem rather than " + + "a problem with the request; the details are in the server log.", + null, 400); + } + + public static InferenceError rateLimited() { + return new InferenceError("rate_limit_error", + "The model provider is rate limiting this site's requests", null, 429); + } + /** @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/rest/ChatCompletionsResource.java b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java index cfe426f2ac3c..65617e18c5d3 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java @@ -18,6 +18,7 @@ import com.dotcms.inference.rest.view.InferenceErrorView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.annotation.NoCors; import com.dotcms.rest.api.v1.DotObjectMapperProvider; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Logger; @@ -91,6 +92,7 @@ */ @Path("/inference/v1/chat") @Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +@NoCors public class ChatCompletionsResource { /** Media type a streamed completion is served as. */ @@ -109,6 +111,21 @@ public class ChatCompletionsResource { private static final String SITE_HEADER = "X-dotCMS-Site"; /** Prefix the completion id carries, as standard clients expect. */ + /** The header a standard client reads to learn how long to wait before retrying. */ + private static final String RETRY_AFTER_HEADER = "Retry-After"; + + /** + * How long to tell a caller to wait when this node is at its streaming ceiling. + * + *Five seconds, which is a judgement rather than a measurement: a streamed completion runs + * for seconds to minutes, so no single number is right for every deployment. It is chosen to be + * long enough that a refused client does not immediately return and re-refuse — turning one + * ceiling into a retry storm that keeps the node at capacity — and short enough that capacity + * freed a moment later does not sit idle. It is not a configurable limit because it is not a + * limit: the ceiling itself is configurable, and this only says how long to wait for it.
+ */ + private static final int CAPACITY_RETRY_AFTER_SECONDS = 5; + private static final String COMPLETION_ID_PREFIX = "chatcmpl-"; /** What a caller is told when the provider failed; never the provider's own words. */ @@ -186,6 +203,17 @@ public class ChatCompletionsResource { @JSONP @NoCache @InferenceEndpoint + // One flat price for an operation whose real cost varies by orders of magnitude, and it + // under-counts a stream worst of all: a streamed completion holds a request thread for the + // whole generation rather than for one round trip, and the tokens it spends are unknown until + // it ends. Deliberately not "fixed" here by picking a larger constant — a bigger flat number + // is the same mistake scaled, and it would silently re-price every non-streaming caller too. + // Pricing this honestly means charging on what a request actually consumed, token counts being + // the unit that matches what the provider bills, which is a design this endpoint cannot settle + // on its own: it needs a decision about where usage is metered, what happens when a stream + // fails halfway, and how it reconciles with per-site spend attribution, which is not + // something this endpoint family answers. Until that exists, the flat price stands and is + // known to be wrong for streams. @RequestCost(Price.HTTP_FETCH) @Path("/completions") @Consumes(MediaType.APPLICATION_JSON) @@ -277,7 +305,7 @@ private Response completeWhole(final ResolvedAiContext context, // 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)); + return errorResponse(InferenceError.fromProviderFailure(e, UPSTREAM_FAILURE_MESSAGE)); } } @@ -302,12 +330,17 @@ private Response streamCompletion(final ResolvedAiContext context, ACTIVE_STREAMS.decrementAndGet(); Logger.warn(this, "Refusing a streamed completion: the node is already serving its " + "ceiling of " + maxConcurrentStreams + " concurrent streams"); + // Carries Retry-After because this refusal is dotCMS's own: the wait is until a + // stream on this node finishes, not until some provider decides to let us back in. + // "Retry shortly" in the message is prose a client cannot act on; the header is the + // same instruction in the form every standard client already honours. return errorResponse(new InferenceError( "rate_limit_error", "This node is already serving its ceiling of " + maxConcurrentStreams + " concurrent streamed completions; retry shortly", null, - 429)); + 429), + CAPACITY_RETRY_AFTER_SECONDS); } // Fixed for the whole stream: a client correlates the chunks it stitches together by the @@ -359,7 +392,7 @@ private Response refuseUnconfiguredModel(final ResolvedAiContext 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 errorResponse(InferenceError.noSuchModel(echoable(requestedModel), "chat")); } return null; @@ -440,6 +473,26 @@ private static Response errorResponse(final InferenceError error) { .build(); } + /** + * Renders a refusal that tells the caller when to come back. + * + *Only for refusals whose duration dotCMS actually knows — its own capacity ceilings. An + * upstream rate limit is not one of those: the provider knows how long it wants to be left + * alone and says so in a header the provider abstraction discards before dotCMS sees it, and + * inventing a number there would be a guess presented to the client as an instruction.
+ * + * @param error the refusal + * @param retryAfterSeconds how long the caller should wait before trying again + * @return the refusal, carrying {@code Retry-After} + */ + private static Response errorResponse(final InferenceError error, final int retryAfterSeconds) { + return Response.status(error.httpStatus()) + .entity(InferenceErrorView.of(error)) + .type(MediaType.APPLICATION_JSON) + .header(RETRY_AFTER_HEADER, retryAfterSeconds) + .build(); + } + /** * Writes stream events out as server-sent event frames, and decides how the stream ends. * 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 d478c1c3c4e8..cc5608f621fd 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java @@ -13,6 +13,7 @@ import com.dotcms.inference.rest.view.InferenceErrorView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.annotation.NoCors; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Logger; import com.fasterxml.jackson.databind.JsonNode; @@ -72,6 +73,7 @@ */ @Path("/inference/v1/embeddings") @Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +@NoCors public class EmbeddingsResource { /** Section of the site's {@code providerConfig} JSON that configures embeddings. */ @@ -232,6 +234,7 @@ private Response embed(final ResolvedAiContext context, Logger.error(this, "The embeddings provider returned " + batch.vectors().size() + " vectors for " + inputs.size() + " inputs on site " + AiHostResolver.sanitize(context.servingSiteId())); + // No exception to inspect here: the provider answered, it just answered short. return errorResponse(InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE)); } @@ -243,7 +246,7 @@ private Response embed(final ResolvedAiContext context, // 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)); + return errorResponse(InferenceError.fromProviderFailure(e, UPSTREAM_FAILURE_MESSAGE)); } } @@ -300,7 +303,7 @@ private Response refuseUnconfiguredModel(final ResolvedAiContext context, 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 errorResponse(InferenceError.noSuchModel(echoable(requestedModel), "embeddings")); } return null; 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 21d5ac2ebb7b..aede5ab1f2c7 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java @@ -14,6 +14,7 @@ import com.dotcms.inference.rest.view.InferenceErrorView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.annotation.NoCors; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Logger; import com.liferay.portal.model.User; @@ -86,6 +87,7 @@ */ @Path("/inference/v1/images") @Tag(name = "AI", description = "AI-powered content generation and analysis endpoints") +@NoCors public class ImagesResource { /** Section of the site's {@code providerConfig} JSON that configures image generation. */ @@ -298,7 +300,7 @@ private Response generate(final ResolvedAiContext context, // 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)); + return errorResponse(InferenceError.fromProviderFailure(e, UPSTREAM_FAILURE_MESSAGE)); } } @@ -328,7 +330,7 @@ private Response refuseUnconfiguredModel(final ResolvedAiContext context, 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 errorResponse(InferenceError.noSuchModel(echoable(requestedModel), "images")); } return null; diff --git a/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java b/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java index 319b5facc87b..3fae3e4a697b 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/InferenceRequestAttributes.java @@ -5,7 +5,7 @@ * *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 + * that failed after it began. The serving site is reported on every response, and a * value only the happy path can set would not deliver that.
*/ public final class InferenceRequestAttributes { 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 e2f2f4a31207..daf87c88ee90 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java @@ -10,6 +10,7 @@ import com.dotcms.inference.rest.view.ModelListView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.annotation.NoCors; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.Logger; import com.liferay.portal.model.User; @@ -34,6 +35,7 @@ import javax.ws.rs.core.Response; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; @@ -42,7 +44,7 @@ * *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 + * this family accepts — a caller who wants "whatever this site runs" reads the * list and takes the first entry. Two consequences follow, and both are deliberate.
* *Order is load-bearing rather than cosmetic. There is no implicit default model, and the + * documented way to ask for "whatever this site runs" is to read this list and take the first + * entry — so the first entry has to remain the site's primary chat model, as it was when this + * listed nothing else.
+ */ + private static final ListAll of them, not just chat. The adopted format has one flat model list and its model + * object carries no field for what a model is for — no type, no mode, no modality — so the + * reference implementation returns chat, embedding and image models together and leaves a + * client to know which is which. Listing only chat would be the narrower, tidier answer and + * was what shipped first, but it leaves the embeddings and image operations with no discovery + * at all: both require an exact model name, and this is the only place a caller could learn + * one.
+ * + *The alternative — adding a capability field to each entry — was rejected after looking at + * what other gateways do. Nobody extends the standard model object that way: implementations + * either keep this endpoint's shape and expose capability on a separate, non-standard one, or + * publish an entirely different object of their own. Inventing a field inside the standard + * shape is the one thing none of them does, and it is exactly what the no-adapter promise of + * this family exists to avoid.
+ * + *A name configured in more than one section is listed once. Duplicates would be harmless + * to a client but would misrepresent the site as running two things.
+ * + * @param context the resolved site and its configuration + * @return the configured names, chat first, in configured order, without repeats + */ + private static ListUsed when the request declares no length. Counting on the way through is the only way to + * bound a body whose size is not known until it ends, and refusing mid-read is the point: the + * alternative is to buffer the whole thing to measure it, which hands an attacker exactly the + * memory cost the ceiling exists to prevent.
+ * + *The refusal is a {@link WebApplicationException} carrying the same typed 413 the declared + * path returns, so a caller cannot tell which route refused it — and neither can a client + * library, which is the point of a contract.
+ */ + static final class CeilingEnforcingStream extends FilterInputStream { + + private final int maxRequestBytes; + private long seen; + + CeilingEnforcingStream(final InputStream delegate, final int maxRequestBytes) { + super(delegate); + this.maxRequestBytes = maxRequestBytes; + } + + @Override + public int read() throws IOException { + final int value = super.read(); + if (value != -1) { + count(1); + } + return value; + } + + @Override + public int read(final byte[] buffer, final int offset, final int length) + throws IOException { + final int read = super.read(buffer, offset, length); + if (read > 0) { + count(read); + } + return read; + } + + /** + * @param justRead how many bytes the last read produced + */ + private void count(final int justRead) { + seen += justRead; + if (seen > maxRequestBytes) { + throw new WebApplicationException(tooLarge(maxRequestBytes)); + } + } + } } 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 index de43475314c5..17c9e360a704 100644 --- a/dotCMS/src/main/java/com/dotcms/inference/rest/view/InferenceErrorView.java +++ b/dotCMS/src/main/java/com/dotcms/inference/rest/view/InferenceErrorView.java @@ -32,7 +32,7 @@ public record InferenceErrorView(@JsonProperty("error") @Schema(description = "T @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, + example = "The model 'gpt-4o' is not configured for chat on 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", diff --git a/dotCMS/src/main/java/com/dotcms/rest/annotation/NoCors.java b/dotCMS/src/main/java/com/dotcms/rest/annotation/NoCors.java new file mode 100644 index 000000000000..6ae6e4a7d102 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/annotation/NoCors.java @@ -0,0 +1,32 @@ +package com.dotcms.rest.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a REST resource that must never receive cross-origin response headers. + * + *dotCMS ships CORS switched on for every endpoint through the {@code api.cors.default.*} + * properties, so a resource that says nothing gets {@code Access-Control-Allow-Origin: *}. That is + * the right default for the delivery APIs a browser is meant to call. It is the wrong one for an + * endpoint whose credential is a long-lived bearer token carrying the full authority of its owner: + * advertising cross-origin support invites putting that token in browser JavaScript, where any + * script on the page can read it.
+ * + *Annotating the resource is what makes the exemption survive: the alternative is per-resource
+ * {@code api.cors.
Most failures reaching the stream's error translator are {@link IllegalArgumentException}s, + * and they come from two places that look identical at the catch site. Some carry a sentence + * dotCMS composed about the site's configuration — the caller needs that to fix the problem. + * Others are the fallback chain's wrapper around a failed model initialisation, which appends the + * provider client's own message, and that can hold the provider's endpoint, its account + * identifiers, or a fragment of the prompt.
+ * + *Passing the message through on type alone returned both. These tests pin the distinction by + * intent — {@link CallerSafeException} is returned, everything else is not — so a future + * contributor who throws a bare {@code IllegalArgumentException} carrying provider text gets a + * generic sentence rather than a leak.
+ */ +public class InferenceErrorLeakTest { + + /** What a provider client's message can look like, and must never reach a caller. */ + private static final String LEAKY = + "api.openai.com rejected key sk-live-9f3c for org acme-corp while completing " + + "'draft the Q3 board memo'"; + + /** + * Given a model initialisation that failed with the provider's own message attached + * When the failure is translated for the caller + * Then the caller is told the request could not be served, and none of the provider's text + * reaches them + * + *This is the shape the fallback chain actually produces: it catches whatever the provider + * client threw and rethrows an {@code IllegalArgumentException} whose message is dotCMS's + * prefix followed by the provider's. Before this test, that whole string was returned.
+ */ + @Test + public void test_wrappedProviderMessage_isNotReturnedToTheCaller() { + final IllegalArgumentException asTheFallbackChainThrowsIt = new IllegalArgumentException( + "Failed to initialize chat model 'gpt-4o-mini': " + LEAKY, + new RuntimeException(LEAKY)); + + final InferenceError error = + InferenceAIClient.toInferenceError(asTheFallbackChainThrowsIt); + + assertEquals(400, error.httpStatus()); + assertEquals("The request could not be served with this site's configuration", + error.message()); + assertFalse("The provider's endpoint must not reach the caller", + error.message().contains("api.openai.com")); + assertFalse("An API key must never reach the caller", + error.message().contains("sk-live-9f3c")); + assertFalse("The caller's own prompt must not be quoted back through an error", + error.message().contains("board memo")); + assertFalse("Even the internal wrapper text is withheld; it names nothing actionable", + error.message().contains("Failed to initialize")); + } + + /** + * Given a configuration problem dotCMS itself described + * When the failure is translated + * Then the caller gets that sentence, because it is the one thing that says what to fix + * + *The other half of the rule, and the reason this is a type distinction rather than a + * blanket suppression: answering every configuration problem with "could not be served" would + * be safe and useless, and a site administrator would have nothing to act on.
+ */ + @Test + public void test_messageDotcmsWrote_isReturned() { + final InferenceError error = InferenceAIClient.toInferenceError( + new CallerSafeException("No model configured in providerConfig.chat — set 'model'")); + + assertEquals(400, error.httpStatus()); + assertEquals("No model configured in providerConfig.chat — set 'model'", error.message()); + } + + /** + * Given a provider failure that is not an argument problem at all + * When it is translated + * Then it stays on the upstream path, rate limits included + * + *Guards against the fix above swallowing the translation of upstream status codes: the new + * branch sits in front of it, and a version that matched too eagerly would turn every 429 into + * a 400 and stop clients backing off.
+ */ + @Test + public void test_upstreamFailures_stillTranslateByStatus() { + assertEquals("A rate limit must still be a 429", + 429, InferenceAIClient.toInferenceError(new RateLimitException(LEAKY)).httpStatus()); + + final InferenceError upstream = + InferenceAIClient.toInferenceError(new HttpException(503, LEAKY)); + assertEquals(502, upstream.httpStatus()); + assertTrue("An upstream failure keeps the api_error type", + "api_error".equals(upstream.type())); + assertFalse(upstream.message().contains("sk-live-9f3c")); + } + + /** + * Given a caller-safe exception carrying no message + * When it is translated + * Then the caller still gets a usable sentence rather than a blank one + */ + @Test + public void test_blankSafeMessage_fallsBackToTheGenericSentence() { + final InferenceError error = InferenceAIClient.toInferenceError(new CallerSafeException(" ")); + + assertEquals(400, error.httpStatus()); + assertEquals("The request could not be served with this site's configuration", + error.message()); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java index f1f405e15e7a..8bb6591adae8 100644 --- a/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java +++ b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionRequestMapperTest.java @@ -286,7 +286,7 @@ public void test_toInferenceRequest_responseFormatJsonObject_mapsToJsonObjectTyp /** * 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 + * Then every sampling parameter reaches the internal field it belongs to — a dropped * {@code max_tokens} is both a cost and a correctness failure, so it is asserted by value. */ @Test @@ -311,7 +311,7 @@ public void test_toInferenceRequest_samplingParameters_passThroughUnchanged() th * 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). + * sampling parameters stay null rather than being invented. */ @Test public void test_toInferenceRequest_streamAbsent_defaultsToFalse() throws Exception { @@ -332,7 +332,7 @@ public void test_toInferenceRequest_streamAbsent_defaultsToFalse() throws Except /** * 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). + * Then {@code stream} is true and {@code includeUsageInStream} is true. */ @Test public void test_toInferenceRequest_streamOptionsIncludeUsage_mapsToIncludeUsageInStream() @@ -352,8 +352,8 @@ public void test_toInferenceRequest_streamOptionsIncludeUsage_mapsToIncludeUsage /** * 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 + * Then streaming is on but usage is withheld — {@code includeUsageInStream} defaults to false. + * The view is built through its canonical constructor here, so the default does not * depend on Jackson leaving the field null. */ @Test diff --git a/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java index a57d4593dfaf..e1553ab878e5 100644 --- a/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java +++ b/dotCMS/src/test/java/com/dotcms/inference/ChatCompletionResponseMapperTest.java @@ -27,13 +27,13 @@ * *Coverage:
*The distinction this pins is not cosmetic. A standard client decides what to do next from the + * HTTP status alone — the error shape carries no retryable field, and inventing one would break the + * no-adapter promise — so answering 502 to a throttled request tells the client the + * provider is broken rather than busy, and it retries on the wrong schedule. Every assertion here + * is about that one decision.
+ */ +public class InferenceErrorTranslationTest { + + private static final String UPSTREAM = "The model provider failed to complete the request"; + + /** Stands in for a provider message: endpoint, key and a fragment of the caller's prompt. */ + private static final String LEAKY = + "api.openai.com rejected key sk-live-9f3c for org acme-corp while completing " + + "'draft the Q3 board memo'"; + + /** + * Given a provider that refused because the site is over its rate limit + * When the failure is translated + * Then the caller is told 429, in the standard rate-limit shape + */ + @Test + public void test_rateLimitFailure_becomesA429() { + final InferenceError error = InferenceError.fromProviderFailure( + new RateLimitException("slow down"), UPSTREAM); + + assertEquals("A throttled request is 429, which is what a client's back-off keys off", + 429, error.httpStatus()); + assertEquals("rate_limit_error", error.type()); + assertTrue(error.isRetryable()); + } + + /** + * Given a provider that answered with HTTP 429 rather than a typed exception + * When the failure is translated + * Then it is still a 429 + * + *Both shapes occur: the provider client raises its own type for some providers and a plain + * HTTP failure carrying the status for others, and a translation that recognised only the typed + * one would be correct for whichever provider it was written against and wrong for the rest.
+ */ + @Test + public void test_http429_becomesA429() { + final InferenceError error = InferenceError.fromProviderFailure( + new HttpException(429, "Too Many Requests"), UPSTREAM); + + assertEquals(429, error.httpStatus()); + assertEquals("rate_limit_error", error.type()); + } + + /** + * Given a rate limit buried under the wrappers a retrying fallback chain adds + * When the failure is translated + * Then it is still a 429 + * + *This is the case that actually reaches production. The fallback chain catches, logs and + * rethrows as it walks a site's configured models, so by the time the resource sees the failure + * the rate limit is several causes down. A translation that inspected only the top exception + * would pass every test above and still answer 502 to every real throttled request.
+ */ + @Test + public void test_rateLimitWrappedByTheFallbackChain_isStillA429() { + final InferenceError error = InferenceError.fromProviderFailure( + new RuntimeException("image model 'dall-e-3' failed", + new IllegalStateException("retrying", + new RateLimitException("slow down"))), + UPSTREAM); + + assertEquals("A rate limit the fallback chain wrapped is still a rate limit", + 429, error.httpStatus()); + } + + /** + * Given a provider that genuinely failed, and a self-referencing cause chain + * When the failure is translated + * Then it is 502, and the translation terminates + * + *The 5xx half of the translation rule, plus the loop guard: a cause chain that points at + * itself is rare but legal, and a walk without the guard hangs the request thread rather than + * failing it.
+ */ + @Test + public void test_ordinaryFailure_becomes502_andSelfReferencingCauseTerminates() { + final InferenceError plain = InferenceError.fromProviderFailure( + new HttpException(500, "Internal Server Error"), UPSTREAM); + assertEquals(502, plain.httpStatus()); + assertEquals("api_error", plain.type()); + assertEquals(UPSTREAM, plain.message()); + + final RuntimeException selfReferencing = new RuntimeException("broken") { + @Override + public synchronized Throwable getCause() { + return this; + } + }; + assertEquals(502, InferenceError.fromProviderFailure(selfReferencing, UPSTREAM).httpStatus()); + } + + /** + * Given a provider that refused on an exhausted account + * When the failure is translated + * Then the caller gets a 400, not a retryable 502 + * + *The case that found this. An OpenRouter account out of credit answers 402 with "requires + * more credits, or fewer max_tokens"; the provider library maps that to a non-retriable + * exception, and this family used to answer 502. A 502 is the one thing that must not happen + * there: it is the status a client's back-off reads as "try again", so a request that cannot + * succeed until somebody adds credit gets retried on a schedule, burning a round trip each + * time. The live log showed three attempts before the caller ever saw an answer.
+ */ + @Test + public void test_exhaustedProviderAccount_isNotReportedAsRetryable() { + final InferenceError error = InferenceError.fromProviderFailure( + new InvalidRequestException( + "This request requires more credits, or fewer max_tokens. You requested " + + "up to 65536 tokens, but can only afford 30546"), + UPSTREAM); + + assertEquals("A refusal a retry cannot fix must not carry a retryable status", + 400, error.httpStatus()); + assertFalse("The whole point is that a client stops retrying", error.isRetryable()); + assertFalse("The provider's wording names the account and the limits; it stays in the log", + error.message().contains("65536")); + assertFalse(error.message().contains("credits")); + } + + /** + * Given each kind of refusal the provider library classifies as non-retriable + * When they are translated + * Then none of them is reported as retryable + * + *Asserted across the family rather than on the one that was reported, because they share + * a parent in the library and the translation keys off that parent. A bad key and a model the + * provider does not serve are as permanent as an empty account, and all three used to be + * 502.
+ */ + @Test + public void test_everyNonRetriableRefusal_isTerminal() { + for (final RuntimeException refusal : new RuntimeException[]{ + new InvalidRequestException(LEAKY), + new AuthenticationException(LEAKY), + new ModelNotFoundException(LEAKY)}) { + + final InferenceError error = InferenceError.fromProviderFailure(refusal, UPSTREAM); + assertFalse(refusal.getClass().getSimpleName() + + " cannot be fixed by retrying, so it must not be reported as retryable", + error.isRetryable()); + assertFalse(error.message().contains("sk-live-9f3c")); + } + } + + /** + * Given a provider that rejected the request itself, delivered as a plain HTTP failure + * When it is translated + * Then the caller is told it is terminal, not asked to retry + * + *The case that broke image generation. OpenAI answers {@code 400 unknown_parameter} when + * sent a field the model does not accept, and that arrives as a bare {@link HttpException} + * rather than one of the library's classified exception types — so it fell past the + * non-retriable check and was reported as a retryable 502. A malformed request is as permanent + * as an empty account: the same request will be rejected the same way forever.
+ * + *Checked across the 4xx range rather than on 400 alone, since the same reasoning covers a + * model the provider does not serve and an account it will not bill.
+ */ + @Test + public void test_providerRejectedTheRequest_isTerminal() { + for (final int status : new int[]{400, 403, 404, 422}) { + final InferenceError error = InferenceError.fromProviderFailure( + new HttpException(status, "{\"error\":{\"message\":\"Unknown parameter: " + + "'response_format'.\",\"code\":\"unknown_parameter\"}}"), + UPSTREAM); + + assertFalse("A provider " + status + " will be a " + status + " on retry too, so it " + + "must not carry a retryable status", error.isRetryable()); + assertFalse("The provider's own wording stays in the log", + error.message().contains("response_format")); + } + } + + /** + * Given a provider 5xx delivered as a plain HTTP failure + * When it is translated + * Then it stays retryable + * + *The boundary. Narrowing 4xx to terminal must not drag the 5xx range with it — a provider + * having a bad minute is the case 502 and a client back-off exist for.
+ */ + @Test + public void test_providerServerError_staysRetryable() { + final InferenceError error = + InferenceError.fromProviderFailure(new HttpException(503, LEAKY), UPSTREAM); + + assertEquals(502, error.httpStatus()); + assertTrue(error.isRetryable()); + } + + /** + * Given a genuine upstream fault + * When it is translated + * Then it stays a retryable 502 + * + *The boundary in the other direction: the library calls this one retriable, and it is — + * a provider having a bad minute is exactly what 502 and a client back-off are for. A fix + * that turned every provider failure into a terminal 4xx would be as wrong as the bug.
+ */ + @Test + public void test_genuineUpstreamFault_staysRetryable() { + final InferenceError error = + InferenceError.fromProviderFailure(new InternalServerException(LEAKY), UPSTREAM); + + assertEquals(502, error.httpStatus()); + assertTrue("A provider having a bad minute is worth retrying", error.isRetryable()); + assertEquals(UPSTREAM, error.message()); + } + + /** + * Given a provider failure of any kind + * When it is translated + * Then the provider's own wording never reaches the caller + * + *A provider message can carry its endpoint, account identifiers, or a fragment of the + * prompt, so only the status is taken from it.
+ */ + @Test + public void test_providerWordingIsNeverReturned() { + final String leaky = "api.openai.com rejected key sk-secret for org acme: 'draft a memo'"; + + final InferenceError rateLimited = InferenceError.fromProviderFailure( + new RateLimitException(leaky), UPSTREAM); + final InferenceError failed = InferenceError.fromProviderFailure( + new HttpException(503, leaky), UPSTREAM); + + for (final InferenceError error : new InferenceError[]{rateLimited, failed}) { + assertFalse("The provider's own message must never be handed to a caller", + error.message().contains("sk-secret")); + assertFalse(error.message().contains("api.openai.com")); + assertFalse(error.message().contains("draft a memo")); + } + } +} diff --git a/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java b/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java index 879e5169b919..b3cbfbfa6095 100644 --- a/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java +++ b/dotCMS/src/test/java/com/dotcms/inference/InferenceRequestValidationTest.java @@ -30,7 +30,7 @@ * {@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 + * the implementation is written against, in particular this rule: 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. * @@ -53,7 +53,7 @@ public class InferenceRequestValidationTest { /** * 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). + * Then it is rejected naming {@code model} — there is no implicit default. */ @Test public void test_inferenceRequest_blankModel_throwsNamingModel() { @@ -83,7 +83,7 @@ public void test_inferenceRequest_nullModel_throwsNamingModel() { /** * 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). + * Then it is rejected naming {@code message} — there is nothing to infer from. */ @Test public void test_inferenceRequest_emptyMessages_throwsNamingMessages() { @@ -97,7 +97,7 @@ public void test_inferenceRequest_emptyMessages_throwsNamingMessages() { * 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). + * without diffing the conversation by hand. */ @Test public void test_inferenceRequest_uncorrelatedToolResult_throwsNamingOffendingId() { @@ -116,7 +116,7 @@ public void test_inferenceRequest_uncorrelatedToolResult_throwsNamingOffendingId * 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). + * by position. */ @Test public void test_inferenceRequest_correlatedToolResult_isAccepted() { @@ -192,7 +192,7 @@ public void test_inferenceMessage_noContentAndNoToolCalls_throwsNamingContent() * 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). + * pays for is not. */ @Test public void test_toInferenceRequest_nGreaterThanOne_throwsNamingN() { @@ -209,7 +209,7 @@ public void test_toInferenceRequest_nGreaterThanOne_throwsNamingN() { * 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). + * any provider is chosen. */ @Test public void test_toInferenceRequest_absentModel_throwsNamingModel() { @@ -225,7 +225,7 @@ public void test_toInferenceRequest_absentModel_throwsNamingModel() { /** * 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). + * Then it is rejected with a validation error naming {@code messages}. */ @Test public void test_toInferenceRequest_emptyMessages_throwsNamingMessages() { diff --git a/dotCMS/src/test/java/com/dotcms/inference/RequestSizeLimitFilterTest.java b/dotCMS/src/test/java/com/dotcms/inference/RequestSizeLimitFilterTest.java new file mode 100644 index 000000000000..65bf0a0ca5d9 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/inference/RequestSizeLimitFilterTest.java @@ -0,0 +1,165 @@ +package com.dotcms.inference; + +import com.dotcms.inference.model.InferenceLimits; +import com.dotcms.inference.rest.RequestSizeLimitFilter; +import com.dotcms.inference.rest.view.InferenceErrorView; +import com.dotmarketing.util.Config; +import org.junit.After; +import org.junit.Test; + +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.Response; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the request-size ceiling, including the case that used to skip it entirely. + * + *A body that declares its length is easy: the filter compares one number. A body that does + * not — chunked, or simply streamed by a client that omits {@code Content-Length}, which several + * HTTP clients do by default — has no number to compare, and an earlier version of the filter + * returned at that point with a comment saying the resource enforced the ceiling as it read. No + * resource did. The single control on body size could be skipped by leaving out one header, and + * the comment is what kept anyone from noticing.
+ * + *These tests exist to keep that from coming back: the undeclared path is exercised here in + * exactly the way the declared one is, so the two cannot drift apart again.
+ */ +public class RequestSizeLimitFilterTest { + + private final RequestSizeLimitFilter filter = new RequestSizeLimitFilter(); + + @After + public void after() { + Config.setProperty(InferenceLimits.MAX_REQUEST_BYTES_KEY, null); + } + + /** + * Given a request that declares a length over the ceiling + * When the filter runs + * Then it is refused with a typed 413 before the body is read at all + */ + @Test + public void test_declaredOverSizeBody_isRefusedBeforeItIsRead() throws IOException { + Config.setProperty(InferenceLimits.MAX_REQUEST_BYTES_KEY, "10"); + + final ContainerRequestContext context = mock(ContainerRequestContext.class); + when(context.getLength()).thenReturn(64); + final AtomicReferenceThe ordinary case, and the one a wrapper most easily breaks: every streamed request with + * no {@code Content-Length} now passes through this stream, so a bug here would refuse valid + * traffic rather than merely fail to refuse invalid traffic.
+ */ + @Test + public void test_undeclaredLengthUnderTheCeiling_passesThroughIntact() throws IOException { + Config.setProperty(InferenceLimits.MAX_REQUEST_BYTES_KEY, "1024"); + + final byte[] body = "{\"model\":\"gpt-4o\"}".getBytes(); + final InputStream wrapped = wrapUndeclared(new ByteArrayInputStream(body)); + + assertEquals("The body must arrive byte for byte", new String(body), + new String(wrapped.readAllBytes())); + } + + /** + * Given a request that declares no length and runs past the ceiling + * When its body is read + * Then it is refused mid-read with the same typed 413 the declared path returns + * + *This is the hole. Before the fix the same request was accepted in full, because the + * filter had no length to check and nothing downstream checked either. Refusing mid-read + * rather than measuring first is deliberate: buffering the body to size it would hand over + * exactly the memory the ceiling exists to bound.
+ */ + @Test + public void test_undeclaredLengthOverTheCeiling_isRefusedMidRead() throws IOException { + Config.setProperty(InferenceLimits.MAX_REQUEST_BYTES_KEY, "16"); + + final InputStream wrapped = wrapUndeclared(new ByteArrayInputStream(new byte[4096])); + + final WebApplicationException refused = + assertThrows("A body with no declared length must still be bounded", + WebApplicationException.class, wrapped::readAllBytes); + + assertEquals("The refusal must be the same 413 a declared over-size body gets, so a " + + "client cannot tell which route refused it", + 413, refused.getResponse().getStatus()); + assertTrue(refused.getResponse().getEntity() instanceof InferenceErrorView); + } + + /** + * Given a request that declares no length + * When the filter runs + * Then the request is not aborted outright — it is allowed to proceed under a counted stream + * + *Refusing every undeclared-length body would also close the hole, and would break every + * client that streams its request. The fix has to bound the body without rejecting the shape.
+ */ + @Test + public void test_undeclaredLength_isNotRefusedOutright() throws IOException { + Config.setProperty(InferenceLimits.MAX_REQUEST_BYTES_KEY, "1024"); + + final ContainerRequestContext context = mock(ContainerRequestContext.class); + when(context.getLength()).thenReturn(-1); + when(context.getEntityStream()).thenReturn(new ByteArrayInputStream(new byte[8])); + + filter.filter(context); + + verify(context, never()).abortWith(any()); + verify(context).setEntityStream(any()); + } + + /** + * @param body the raw body an undeclared-length request would carry + * @return the stream the filter installs in place of it + */ + private InputStream wrapUndeclared(final InputStream body) throws IOException { + final ContainerRequestContext context = mock(ContainerRequestContext.class); + when(context.getLength()).thenReturn(-1); + when(context.getEntityStream()).thenReturn(body); + + final AtomicReferenceOnce 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, @@ -61,7 +61,6 @@ public class SseFailureTest { * 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 */ @@ -124,7 +123,7 @@ public void test_toFrame_errorEvent_omitsHttpStatusFromBody() throws Exception { * *
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)
+ * from treating a truncated answer as a complete one. */ @Test public void test_shouldWriteDoneMarker_errorEvent_returnsFalse() { @@ -143,7 +142,7 @@ public void test_shouldWriteDoneMarker_errorEvent_returnsFalse() { * 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) + * tell a timed-out answer from a complete one only by luck. */ @Test public void test_shouldWriteDoneMarker_completionTimeoutErrorEvent_returnsFalse() { @@ -190,7 +189,6 @@ public void test_shouldWriteDoneMarker_finishAndUsageEvents_returnTrue() { * 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() { @@ -222,7 +220,7 @@ public void test_toFrame_errorEvent_frameNeitherIsNorContainsDoneMarker() { * 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) + * the second way to fail mid-stream is indistinguishable in handling from the first. * * @throws Exception when the rendered frame cannot be parsed */ diff --git a/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java b/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java index 1fe73e172aed..cb7b116f4f2e 100644 --- a/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java +++ b/dotCMS/src/test/java/com/dotcms/inference/SseSerializerTest.java @@ -17,7 +17,7 @@ * 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 + *
This class covers the chunk envelope; incremental tool calls are covered by * {@code SseToolCallStreamTest}.
* *Coverage:
@@ -26,7 +26,7 @@ * tells an SSE reader the event is complete. Without the terminator a client blocks holding * a chunk it already has.A tool call does not arrive whole. Its identity and name come on the first fragment, its @@ -50,7 +50,7 @@ public class SseToolCallStreamTest { * 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) + * {@code function} and the function name. * * @throws Exception if the frame does not carry parseable JSON */ diff --git a/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java b/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java index f2051bf58072..f3441adac9af 100644 --- a/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java +++ b/dotCMS/src/test/java/com/dotcms/inference/SseUsageGatingTest.java @@ -14,7 +14,7 @@ /** * Unit tests for the streamed usage chunk rendered by - * {@link SseSerializer#toFrame(InferenceStreamEvent, String, String, long)} (FR-009, research R5). + * {@link SseSerializer#toFrame(InferenceStreamEvent, String, String, long)}. * *
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} @@ -27,7 +27,7 @@ *
Worth its own test because the gap it closes is invisible: {@code api.cors.default.*} ships + * enabled, so a resource that says nothing is served {@code Access-Control-Allow-Origin: *} and + * nothing in the resource's own code hints at it. A regression here would not fail any of this + * family's other tests — they call the resource methods directly and never reach a response + * filter — and would quietly advertise to browsers that a long-lived bearer token is welcome in + * page JavaScript.
+ */ +public class CorsFilterTest { + + /** A resource that has said nothing about CORS, as most of dotCMS has. */ + private static class OrdinaryResource { + } + + @NoCors + private static class OptedOutResource { + } + + /** + * Given each resource of the inference family + * When the CORS filter decides whether to add its headers + * Then every one of them is exempt + * + *Asserted per class rather than on one of them: the annotation is applied by hand + * to each resource, so a fifth operation added later without it is exactly the mistake this + * catches, and testing a single representative would not.
+ */ + @Test + public void test_everyInferenceResource_optsOutOfCors() { + final CorsFilter filter = new CorsFilter(); + + for (final Object resource : new Object[]{ + new ChatCompletionsResource(), + new EmbeddingsResource(), + new ImagesResource(), + new ModelsResource()}) { + assertTrue(resource.getClass().getSimpleName() + + " must not receive cross-origin headers: its credential is a " + + "long-lived bearer token, and advertising CORS invites putting it " + + "in browser JavaScript", + filter.optsOutOfCors(resource)); + } + } + + /** + * Given a resource that has not opted out, and the unmatched case + * When the CORS filter decides + * Then the opt-out does not apply, so dotCMS's default behaviour is untouched + * + *The other half of that guarantee, and the one that keeps this change additive: the + * exemption has to be something a resource asks for, not something the filter now does to + * everyone. A null stands in for a request that matched no resource, which the filter already + * tolerates.
+ */ + @Test + public void test_resourcesThatDidNotOptOut_areUnaffected() { + final CorsFilter filter = new CorsFilter(); + + assertFalse("A resource that never asked to be exempt must keep dotCMS's default " + + "cross-origin behaviour", filter.optsOutOfCors(new OrdinaryResource())); + assertFalse("An unmatched request must not blow up the filter", + filter.optsOutOfCors(null)); + assertTrue(filter.optsOutOfCors(new OptedOutResource())); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java index 8b39a1493beb..10ebcaf90591 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java @@ -70,12 +70,15 @@ import com.dotcms.inference.rest.ChatCompletionsStreamingTest; import com.dotcms.inference.rest.ChatCompletionsTest; import com.dotcms.inference.rest.InferenceAuthorizationTest; +import com.dotcms.inference.rest.InferenceClientConformanceTest; import com.dotcms.inference.rest.InferenceEmbeddingsTest; import com.dotcms.inference.rest.InferenceFallbackTest; import com.dotcms.inference.rest.InferenceImagesTest; +import com.dotcms.inference.rest.InferenceLoggingTest; import com.dotcms.inference.rest.InferenceModelsTest; import com.dotcms.inference.rest.InferenceModelValidationTest; import com.dotcms.inference.rest.InferenceSiteIsolationTest; +import com.dotcms.inference.rest.InferenceTestsAreRegisteredTest; import com.dotcms.inference.rest.InferenceSiteResolutionTest; import com.dotcms.integritycheckers.ContentFileAssetIntegrityCheckerTest; import com.dotcms.integritycheckers.ContentPageIntegrityCheckerTest; @@ -457,12 +460,15 @@ ChatCompletionsTest.class, ChatCompletionsStreamingTest.class, InferenceAuthorizationTest.class, + InferenceClientConformanceTest.class, InferenceEmbeddingsTest.class, InferenceFallbackTest.class, InferenceImagesTest.class, + InferenceLoggingTest.class, InferenceModelsTest.class, InferenceModelValidationTest.class, InferenceSiteIsolationTest.class, + InferenceTestsAreRegisteredTest.class, InferenceSiteResolutionTest.class, TimeMachineAPITest.class, Task240513UpdateContentTypesSystemFieldTest.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 index af77bf124bd6..72ab2b9b838d 100644 --- a/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsStreamingTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsStreamingTest.java @@ -6,12 +6,15 @@ 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.InferenceErrorView; +import com.dotcms.inference.model.InferenceLimits; 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.dotmarketing.util.Config; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.WireMockServer; @@ -58,15 +61,15 @@ * ends, which is the only part a client cannot recover from on its own. * *Covers the ceiling itself, which had no coverage at all until now, together with the + * {@code Retry-After} header. The two belong in one test because the ceiling is only half an + * answer without it: a refusal that says "retry shortly" in a prose message tells a program + * nothing, and a client that cannot read a wait interval invents one — which under load means + * every refused caller + * returning at once and holding the node at capacity it was trying to shed.
+ * + *The ceiling is driven to zero rather than fifty streams being opened. Fifty real streams + * would make the test slow, machine-dependent and flaky, and would test the thread pool rather + * than the refusal; the limit is read from configuration on every request precisely so it can + * be changed without a restart, and that is the seam used here.
+ */ + @Test + public void test_atTheStreamCeiling_refusesWith429AndRetryAfter() { + final String previous = Config.getStringProperty( + InferenceLimits.MAX_CONCURRENT_STREAMS_KEY, null); + Config.setProperty(InferenceLimits.MAX_CONCURRENT_STREAMS_KEY, "0"); + try { + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + assertNotNull(response); + assertEquals("A node at its ceiling refuses rather than queues, so the caller can go " + + "elsewhere instead of waiting on a thread that will not free up", + 429, response.getStatus()); + + assertEquals("The caller has to be told how long to wait in a form it can act on, not " + + "only in prose it cannot parse", + String.valueOf(5), response.getHeaderString("Retry-After")); + + assertTrue(response.getEntity() instanceof InferenceErrorView); + final InferenceErrorView.Body error = + ((InferenceErrorView) response.getEntity()).error(); + assertNotNull(error); + assertEquals("rate_limit_error", error.type()); + + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo(COMPLETIONS_PATH))); + } finally { + if (previous == null) { + Config.setProperty(InferenceLimits.MAX_CONCURRENT_STREAMS_KEY, null); + } else { + Config.setProperty(InferenceLimits.MAX_CONCURRENT_STREAMS_KEY, previous); + } + } + } + private static ChatCompletionRequestView streamingRequest(final StreamOptionsView streamOptions) { return new ChatCompletionRequestView( CHAT_MODEL, @@ -524,6 +579,60 @@ private static ChatCompletionRequestView streamingRequest(final StreamOptionsVie null, null, null, null, null); } + /** + * Given a site whose provider configuration makes every chat model fail to initialise + * When a streamed completion is requested + * Then what the caller receives names nothing about the provider or the site's credentials + * + *The fallback chain reports a failed initialisation by rethrowing an + * {@code IllegalArgumentException} whose message is dotCMS's prefix followed by whatever the + * provider client said — and a provider client's message can carry its endpoint, its account + * identifiers, or a fragment of the prompt. The stream's error translator used to return that + * message on type alone, so it reached the caller in the error frame. The buffered paths never + * did, and {@code ImagesResource} documents that they must not, which is what made the + * streaming path's behaviour a discrepancy rather than a policy.
+ * + *Asserted on what the caller can read rather than on the translator, because that is the + * boundary the requirement is about: the full detail is still logged, and is still expected to + * be there for an operator.
+ */ + @Test + public void test_whenEveryModelFailsToStart_theCallerLearnsNothingAboutTheProvider() + throws Exception { + AiTest.aiAppSecretsWithProviderConfig(host, providerConfigThatCannotInitialise()); + + final Response response = resource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), streamingRequest(null)); + + final String visible = clientVisibleBody(response); + + assertFalse("The internal wrapper text names nothing a caller can act on and must not " + + "be returned", visible.contains("Failed to initialize")); + assertFalse("A site's credential must never appear in a response", + visible.contains(AiTest.API_KEY)); + assertFalse("The provider endpoint dotCMS calls is infrastructure detail", + visible.contains(String.valueOf(AiTest.PORT))); + assertFalse(visible.contains("localhost")); + } + + /** + * @return a provider configuration whose chat section cannot produce a model, so the failure + * happens during initialisation rather than at the provider + */ + private static String providerConfigThatCannotInitialise() { + final String endpoint = String.format("http://localhost:%d/", AiTest.PORT); + return String.format( + "{" + + "\"chat\":{\"provider\":\"openai\",\"apiKey\":\"\",\"model\":\"%1$s\"," + + "\"endpoint\":\"%2$s\",\"maxRetries\":0}," + + "\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"%3$s\"," + + "\"model\":\"%4$s\",\"endpoint\":\"%2$s\",\"maxRetries\":0}," + + "\"image\":{\"provider\":\"openai\",\"apiKey\":\"%3$s\",\"model\":\"%5$s\"," + + "\"endpoint\":\"%2$s\",\"maxRetries\":0}" + + "}", + CHAT_MODEL, endpoint, AiTest.API_KEY, AiTest.EMBEDDINGS_MODEL, AiTest.IMAGE_MODEL); + } + /** * Writes the streamed entity out in full, the way the container would. * 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 index 945178aa4590..3d4a8db40481 100644 --- a/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/ChatCompletionsTest.java @@ -57,13 +57,13 @@ * The four tests here cover exactly that arc. * *The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in @@ -158,8 +158,8 @@ public static void beforeClass() throws Exception { 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 + // frontend user, and is rejected; it also cannot read the site these tests pass + // as an explicit override, which is also checked. 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. 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 index ddf12b6b08fd..119594ce7dfc 100644 --- a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceAuthorizationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceAuthorizationTest.java @@ -54,15 +54,15 @@ * next to the others:
* *FR-015. The refusal carries the standard error shape, as the contract's status table + *
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 @@ -205,7 +205,7 @@ public void test_completions_withNoCredential_isUnauthorized() { * 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 + *
Only a dotCMS API token presented as a bearer credential is accepted here. 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.
@@ -233,7 +233,7 @@ public void test_completions_withSessionCredentialOnly_isUnauthorized() { * 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 + *
The caller authenticates and clears the role gate; what stops them is READ on the * site whose credentials they asked to spend.
*/ @Test @@ -259,7 +259,7 @@ public void test_completions_withOverrideForUnreadableSite_isForbidden() { * When a completion is requested * Then it is served * - *FR-016, one half. Stated alongside the frontend case because the requirement is that + *
Stated alongside the frontend case because the rule is that * both are admitted; either one alone would be satisfied by a rule that excluded the * other.
*/ @@ -280,7 +280,7 @@ public void test_completions_withBackendUser_isAccepted() { * 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, + *
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 diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceClientConformanceTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceClientConformanceTest.java new file mode 100644 index 000000000000..3578b89f8699 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceClientConformanceTest.java @@ -0,0 +1,531 @@ +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.EmbeddingsRequestView; +import com.dotcms.inference.rest.view.ImageGenerationRequestView; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +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.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.image.Image; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.image.ImageModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.openai.OpenAiEmbeddingModel; +import dev.langchain4j.model.openai.OpenAiImageModel; +import dev.langchain4j.model.openai.internal.models.ModelsListResponse; +import dev.langchain4j.model.output.Response; +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.StreamingOutput; +import java.io.ByteArrayOutputStream; +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.get; +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.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +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; + +/** + * Proves that every one of the four operations returns payloads that deserialize into a + * standard client library's own result types with no adapter. + * + *Every other test in this family asserts the shape dotCMS intended to produce, by + * reading the view objects it returned. That is a different claim, and a weaker one: a payload can + * satisfy every assertion this codebase makes about it and still fail in the only place that + * matters, because a real client library is stricter than the test that wrote the field. A + * required property spelled correctly but typed as a string instead of a number, an enum value the + * library maps to an enum constant, a field the library refuses to ignore — none of those show up + * until something that was not written here tries to read the bytes.
+ * + *So this test does not read the view objects at all. It serializes what the endpoint returned + * exactly as the REST layer would, serves those bytes from WireMock, and points an + * unmodified OpenAI client at them. The client is LangChain4j's, chosen because it + * is already a dependency and is a genuine third-party OpenAI-compatible implementation: nothing in + * it was written with dotCMS in mind, and it is the same client code a customer would point at + * these endpoints. Assertions are made on the library's own result types — {@link ChatResponse}, + * {@link Embedding}, {@link Image}, {@link ModelsListResponse} — never on ours.
+ * + *"No adapter" is the load-bearing phrase, and it is what the absence of code here demonstrates: + * the payload goes from the endpoint to the client untouched except by {@code writeValueAsString}. + * If a field had to be renamed, re-typed, or removed to make the client accept it, that work would + * be visible in this file, and the no-adapter promise would be false.
+ * + *Two WireMock roles share one server, separated by path prefix. The provider stubs sit at the + * root ({@code /chat/completions}) and stand in for OpenAI as dotCMS's upstream. The client-facing + * stubs sit under {@value #DOTCMS_FACING_PREFIX} and replay dotCMS's own answer back to the + * library. Reusing one server is deliberate: a second would need a second fixed port, and the port + * these tests already share is the thing most likely to collide.
+ */ +public class InferenceClientConformanceTest { + + /** Where the client-facing stubs live, so they cannot be confused with the provider stubs. */ + private static final String DOTCMS_FACING_PREFIX = "/dotcms-facing"; + + private static final String BASE_URL = + "http://localhost:" + AiTest.PORT + DOTCMS_FACING_PREFIX; + + private static final String CHAT_MODEL = "gpt-4o-mini"; + private static final String EMBEDDINGS_MODEL = AiTest.EMBEDDINGS_MODEL; + private static final String IMAGE_MODEL = AiTest.IMAGE_MODEL; + + private static final String PROMPT = "A cat in a hammock"; + private static final String ANSWER = "A cat is asleep in a hammock."; + + /** A 1x1 PNG, so the image assertions are about transport rather than about pixels. */ + private static final String IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQ=="; + + /** What the stubbed provider answers dotCMS for a completion. */ + private static final String PROVIDER_CHAT_RESPONSE = """ + { + "id": "chatcmpl-conformance", + "object": "chat.completion", + "created": 1789000000, + "model": "%s", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "%s"}, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18} + } + """.formatted(CHAT_MODEL, ANSWER); + + /** What the stubbed provider answers dotCMS for a streamed completion. */ + private static final String PROVIDER_STREAM_RESPONSE = + "data: {\"id\":\"chatcmpl-conformance\",\"object\":\"chat.completion.chunk\"," + + "\"created\":1789000000,\"model\":\"" + CHAT_MODEL + "\",\"choices\":" + + "[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"A cat \"}," + + "\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chatcmpl-conformance\",\"object\":\"chat.completion.chunk\"," + + "\"created\":1789000000,\"model\":\"" + CHAT_MODEL + "\",\"choices\":" + + "[{\"index\":0,\"delta\":{\"content\":\"is asleep.\"}," + + "\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chatcmpl-conformance\",\"object\":\"chat.completion.chunk\"," + + "\"created\":1789000000,\"model\":\"" + CHAT_MODEL + "\",\"choices\":" + + "[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" + + "data: [DONE]\n\n"; + + private static final String PROVIDER_EMBEDDINGS_RESPONSE = """ + { + "object": "list", + "model": "%s", + "data": [ + {"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]} + ], + "usage": {"prompt_tokens": 4, "total_tokens": 4} + } + """.formatted(EMBEDDINGS_MODEL); + + private static final String PROVIDER_IMAGE_RESPONSE = """ + { + "created": 1789000000, + "data": [{"b64_json": "%s", "revised_prompt": "A cat asleep in a hammock."}] + } + """.formatted(IMAGE_BASE64); + + /** The mapper the REST layer serializes responses with, so the bytes match what a caller gets. */ + private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + private Host host; + + private final ChatCompletionsResource chatResource = new ChatCompletionsResource(); + private final ModelsResource modelsResource = new ModelsResource(); + private final EmbeddingsResource embeddingsResource = new EmbeddingsResource(); + private final ImagesResource imagesResource = new ImagesResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + stubProvider(); + + 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-conformance-" + UUID.randomUUID() + ".dotcms.com") + .nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig( + host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + } + + @After + public void after() throws Exception { + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a completion produced by dotCMS + * When an unmodified OpenAI client library reads it + * Then it deserializes into the library's own {@link ChatResponse}, carrying the answer, the + * finish reason and the token usage + * + *This is the operation every agent framework drives first. The finish reason and usage + * are asserted alongside the text because they are what a framework acts on rather than + * displays: a client decides whether to continue a tool loop from the finish reason, and bills + * or budgets from the usage, so a payload that carries readable text and an unreadable finish + * reason is not usable even though it looks right.
+ */ + @Test + public void test_chatCompletion_deserializesIntoTheClientLibrarysOwnType() throws Exception { + final javax.ws.rs.core.Response dotcmsAnswer = chatResource.completions( + mockRequest("POST", bearerToken), mockResponse(), host.getIdentifier(), + new ChatCompletionRequestView(CHAT_MODEL, + List.of(new ChatCompletionRequestView.MessageView("user", PROMPT, null, null, null)), + null, null, null, null, null, null, null, null, null, null)); + assertEquals(200, dotcmsAnswer.getStatus()); + + replayToClient("/chat/completions", MAPPER.writeValueAsString(dotcmsAnswer.getEntity())); + + final ChatResponse parsed = OpenAiChatModel.builder() + .apiKey("not-used-by-the-stub") + .baseUrl(BASE_URL) + .modelName(CHAT_MODEL) + .maxRetries(0) + .build() + .chat(dev.langchain4j.model.chat.request.ChatRequest.builder() + .messages(UserMessage.from(PROMPT)) + .build()); + + assertNotNull("The client library produced no response at all", parsed); + assertNotNull(parsed.aiMessage()); + assertEquals("The answer text must survive the round trip into the library's own type", + ANSWER, parsed.aiMessage().text()); + assertNotNull("A client decides whether to keep going from the finish reason, so an " + + "unreadable one breaks tool loops even when the text is fine", + parsed.finishReason()); + assertNotNull("A client budgets from usage; it has to deserialize", parsed.tokenUsage()); + assertEquals(Integer.valueOf(11), parsed.tokenUsage().inputTokenCount()); + assertEquals(Integer.valueOf(7), parsed.tokenUsage().outputTokenCount()); + } + + /** + * Given a streamed completion produced by dotCMS, as the bytes a caller receives + * When an unmodified OpenAI client library consumes the event stream + * Then it reassembles the answer from the library's own streaming callbacks + * + *This is the case most likely to break in a way no other test in + * this file would catch. Streaming is not JSON — it is a framing format, and a client can parse + * every individual chunk correctly while still failing on the frame boundaries, the terminal + * {@code [DONE]} sentinel, or a usage event whose {@code choices} array is empty. The assertion + * is on the text the library reassembled, because that is the only evidence that every frame + * was read in order and none was dropped.
+ */ + @Test + public void test_streamedCompletion_isReadableByTheClientLibrarysOwnStreamReader() + throws Exception { + final javax.ws.rs.core.Response dotcmsAnswer = chatResource.completions( + mockRequest("POST", bearerToken), mockResponse(), host.getIdentifier(), + new ChatCompletionRequestView(CHAT_MODEL, + List.of(new ChatCompletionRequestView.MessageView("user", PROMPT, null, null, null)), + null, null, null, Boolean.TRUE, null, null, null, null, null, null)); + assertEquals(200, dotcmsAnswer.getStatus()); + assertTrue("A streamed completion must come back as a stream, not a buffered body", + dotcmsAnswer.getEntity() instanceof StreamingOutput); + + final ByteArrayOutputStream captured = new ByteArrayOutputStream(); + ((StreamingOutput) dotcmsAnswer.getEntity()).write(captured); + final String sse = captured.toString(java.nio.charset.StandardCharsets.UTF_8); + + assertTrue("dotCMS must terminate the stream with the sentinel the format defines, or a " + + "client waits forever", sse.contains("data: [DONE]")); + + wireMockServer.stubFor(post(urlPathEqualTo(DOTCMS_FACING_PREFIX + "/chat/completions")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(sse))); + + final StringBuilder reassembled = new StringBuilder(); + final java.util.concurrent.CompletableFutureThis is the one operation with no high-level client API to drive, + * so the library's response type is used directly rather than through a model class — which is + * the same thing the library's own client does with the bytes once they arrive.
+ */ + @Test + public void test_modelListing_deserializesIntoTheClientLibrarysOwnType() throws Exception { + final javax.ws.rs.core.Response dotcmsAnswer = modelsResource.models( + mockRequest("GET", bearerToken), mockResponse(), host.getIdentifier()); + assertEquals(200, dotcmsAnswer.getStatus()); + + final String wire = MAPPER.writeValueAsString(dotcmsAnswer.getEntity()); + + // Configured the way an OpenAI client configures its own reader — snake_case, because the + // format spells fields owned_by rather than ownedBy, and tolerant of properties this + // version's type does not model. Both are the client's settings rather than a concession + // made for dotCMS: a reader that rejected unknown fields could not survive OpenAI adding + // one, so no real client is written that way, and pretending otherwise would fail this + // test on payloads every deployed client accepts. + final ObjectMapper clientMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + final ModelsListResponse parsed = clientMapper.readValue(wire, ModelsListResponse.class); + + assertNotNull(parsed); + assertEquals("The listing must announce itself as a list, which is what a client keys on", + "list", parsed.getObject()); + assertNotNull(parsed.getData()); + assertFalse("A site with a configured chat model must list at least that model", + parsed.getData().isEmpty()); + assertTrue("The configured model has to appear in the listing a caller picks from", + parsed.getData().stream().anyMatch(m -> CHAT_MODEL.equals(m.id()))); + } + + /** + * Given embeddings produced by dotCMS + * When an unmodified OpenAI client library reads them + * Then they deserialize into the library's own {@link Embedding} vectors + * + *This is the operation that carries the most data and the least tolerance for a wrong + * type: an embedding is a list of numbers, and a payload that renders them as strings parses + * as JSON and fails in the client. The vector's contents are asserted, not merely its presence, + * for that reason.
+ */ + @Test + public void test_embeddings_deserializeIntoTheClientLibrarysOwnType() throws Exception { + final ArrayNode input = MAPPER.createArrayNode(); + input.add(new TextNode("The quick brown fox")); + + final javax.ws.rs.core.Response dotcmsAnswer = embeddingsResource.embeddings( + mockRequest("POST", bearerToken), mockResponse(), host.getIdentifier(), + new EmbeddingsRequestView(EMBEDDINGS_MODEL, input)); + assertEquals(200, dotcmsAnswer.getStatus()); + + replayToClient("/embeddings", MAPPER.writeValueAsString(dotcmsAnswer.getEntity())); + + final EmbeddingModel client = OpenAiEmbeddingModel.builder() + .apiKey("not-used-by-the-stub") + .baseUrl(BASE_URL) + .modelName(EMBEDDINGS_MODEL) + .maxRetries(0) + .build(); + + final ResponseThis is the place the inline-only rule for images meets a real client: the + * library populates either the URL or the base64 field depending on what arrived, so asserting + * that it found base64 is simultaneously a conformance check and a check that dotCMS never + * handed out a hosted artifact.
+ */ + @Test + public void test_generatedImage_deserializesIntoTheClientLibrarysOwnType() throws Exception { + final javax.ws.rs.core.Response dotcmsAnswer = imagesResource.generations( + mockRequest("POST", bearerToken), mockResponse(), host.getIdentifier(), + new ImageGenerationRequestView(IMAGE_MODEL, PROMPT, 1, AiTest.IMAGE_SIZE)); + assertEquals(200, dotcmsAnswer.getStatus()); + + replayToClient("/images/generations", MAPPER.writeValueAsString(dotcmsAnswer.getEntity())); + + final ImageModel client = OpenAiImageModel.builder() + .apiKey("not-used-by-the-stub") + .baseUrl(BASE_URL) + .modelName(IMAGE_MODEL) + .maxRetries(0) + .build(); + + final ResponseFR-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 + *
This endpoint is not "expose an embeddings endpoint" — the vector round trip is the easy half. + * What actually matters is that the model gate 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 @@ -65,17 +65,18 @@ * 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 + *
The second thing this endpoint gets right 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 + *
Accepting "an array of strings" costs an implementation several things, 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 @@ -84,7 +85,7 @@ * "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
+ * This file also settles two things that used to be left 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
@@ -102,22 +103,22 @@
* so a message that happens to contain a digit cannot pass for one that locates the fault. The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in
@@ -156,7 +157,7 @@ public class InferenceEmbeddingsTest {
private static final String EMPTY_INPUT = "";
/**
- * A string with nothing in it but whitespace. FR-011 refuses it on the same grounds as the
+ * A string with nothing in it but whitespace. It is refused 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.
@@ -261,8 +262,8 @@ public static void beforeClass() throws Exception {
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
+ // user and is rejected on that ground; it also cannot read the site these tests pass as an
+ // explicit override, which is checked separately. 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.
@@ -347,7 +348,7 @@ public void test_embeddings_withInputText_returnsVectorInStandardShape() {
* 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
+ * The 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
@@ -396,11 +397,12 @@ public void test_embeddings_withArrayInput_returnsOneEntryPerInputInOrder() {
* 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. The scalar form is the half of this behavior 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. 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
+ * success, and the caller would index nothing and never find out why. This is now stated 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. FR-011 requires every element of the array to be a string, and this is
+ * Every element of the array is required 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
@@ -498,7 +500,7 @@ public void test_embeddings_withNonStringArrayElements_isRejectedNamingTheField(
* 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
+ * Naming the field is no longer enough on its own. The message is required 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
@@ -527,7 +529,7 @@ public void test_embeddings_withMixedArrayElements_isRejectedIdentifyingTheEleme
* 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
+ * A blank or whitespace-only string is refused 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
@@ -551,7 +553,7 @@ public void test_embeddings_withBlankStringInput_isRejectedNamingTheField() {
* 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
+ * dropping it would shift the index of every entry after it, and those indexes are 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
@@ -574,7 +576,7 @@ public void test_embeddings_withBlankArrayElement_isRejectedIdentifyingTheElemen
* 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
+ * "Absent" and "null" are refused 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
@@ -638,7 +640,8 @@ public void test_embeddings_withArrayInput_reportsUsageForWholeBatch() {
* 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
+ * Model validation and section separation, 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. 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. The requirement that {@code model} be explicit 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. FR-015. Accepts a thrown {@link WebApplicationException} as well as a returned 401, since
+ * 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. FR-020 asks for the serving site on every response, and it reaches a real caller as the
+ * The serving site is published 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
@@ -831,7 +835,7 @@ private static EmbeddingListView.EmbeddingView embeddingAtIndex(final EmbeddingL
}
/**
- * Asserts that an {@code input} FR-011 refuses is refused here — as a 400 in the standard
+ * Asserts that an invalid {@code input} 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
@@ -867,7 +871,7 @@ private InferenceErrorView.Body assertInputRefusedNamingTheField(final JsonNode
* 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
+ * {@code param} stays 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
@@ -906,7 +910,7 @@ private static EmbeddingsRequestView embeddingFor(final String model, final Stri
/**
* 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.
+ * builds the empty array, which is refused.
*
* @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
@@ -926,7 +930,7 @@ private static EmbeddingsRequestView embeddingForAll(final String model,
* 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
+ * the authentication handshake and the site-attribution logic publish through them, and a mock
* that forgot what was set on it would not behave like a servlet container.
- *
*
*
FR-020 asks for the fallback to be logged as well. Asserting on the log would mean + *
The fallback is expected 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 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 index aedf5803ab29..f096f1c43729 100644 --- a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceImagesTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceImagesTest.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; import com.liferay.portal.model.User; import org.junit.After; import org.junit.AfterClass; @@ -31,6 +32,7 @@ import java.util.Base64; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -55,8 +57,8 @@ * 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.
+ *Two decisions about how a generated image is returned 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, @@ -68,13 +70,13 @@ * 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 + * Requesting base64 is required — 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 + * family's own response shape can create — but 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 + * is called. Where no such choice exists the rule says the opposite thing: dotCMS fetches + * the provider's URL and re-encodes it rather than refusing the provider, and the residual is + * stated 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 @@ -88,9 +90,9 @@ *
{@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 + * caller's {@code size} through is dotCMS's own behaviour: the caller named a size, and 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} + * accepts it. It is the same logic applied 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 @@ -102,19 +104,19 @@ * 200/404 in the right shapes.
* *The provider is a WireMock server standing in for an OpenAI-compatible endpoint, wired in @@ -214,7 +216,7 @@ public class InferenceImagesTest { * {@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. + * what this family deals in. */ private static final String PROVIDER_RESPONSE = """ { @@ -286,8 +288,8 @@ public static void beforeClass() throws Exception { 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 + // user and is rejected on that ground; it also cannot read the site these tests pass as an + // explicit override, which is checked separately. 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. @@ -358,7 +360,7 @@ public void test_generations_withPrompt_returnsImageInStandardShape() { * 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 + *
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 @@ -378,7 +380,7 @@ public void test_generations_withGeneratedImage_returnsBase64AndNoHostedUrl() { 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); + assertNotNull("The image is returned inline, so b64_json is not optional", b64Json); assertFalse(b64Json.isBlank()); try { @@ -393,7 +395,7 @@ public void test_generations_withGeneratedImage_returnsBase64AndNoHostedUrl() { 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 " + assertFalse("No hosted, separately-addressable artifact is created, so no url may " + "reach the caller", entry.hasNonNull("url")); } @@ -402,23 +404,23 @@ public void test_generations_withGeneratedImage_returnsBase64AndNoHostedUrl() { * 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.
+ *The rule for {@code n} 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 that is refused + * 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 + *
An {@code n} of 2 is deliberately not among these values. An earlier version of this + * endpoint 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 behavior was * corrected. Two images are served, by * {@link #test_generations_withNOfTwo_returnsTwoDistinctImages()}.
*/ @@ -434,9 +436,9 @@ public void test_generations_withNBelowOne_isRejectedNamingTheField() { * 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 + *
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.
* @@ -474,7 +476,7 @@ public void test_generations_withNOfTwo_returnsTwoDistinctImages() { 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 + assertNotNull("Every image is returned 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()); @@ -498,14 +500,13 @@ public void test_generations_withNOfTwo_returnsTwoDistinctImages() { * 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.
+ *{@code size} is passed through where the provider accepts it, on the same reasoning applied + * 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 the only condition on passing it through.
* *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 @@ -544,7 +545,7 @@ public void test_generations_withSize_passesTheSizeToTheProvider() { * 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 + * another assertion inside one: what is refused 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 @@ -665,7 +666,7 @@ public void test_generations_withoutPrompt_isRejectedNamingTheField() { * 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 + *
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.
*/ @@ -694,11 +695,11 @@ request, mockResponse(), host.getIdentifier(), * 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.
+ *Images are priced per image, so without a ceiling one accepted request multiplies a site's + * provider spend by whatever number it carried, and per-site spend quotas are out of scope here + * — 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 @@ -735,9 +736,9 @@ public void test_generations_aboveTheImageCeiling_isRejectedNamingTheField() { * 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 + *
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 + * does not. Left to the ordinary upstream-failure translation, 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 @@ -789,6 +790,42 @@ private static String geminiImageProviderConfigJson() { EMBEDDINGS_MODEL, GEMINI_IMAGE_MODEL); } + /** + * Given an ordinary image request + * When the provider is called + * Then the outbound request carries no output-format parameter + * + *
Every other test in this file stubs a provider that accepts whatever it is sent, so all + * of them passed while image generation was broken against every current OpenAI image model. + * Those models reject the format parameter outright — "Unknown parameter: 'response_format'" — + * because they only ever return base64 and there is nothing to choose. Sending it was a + * deliberate attempt to stop a provider minting a hosted artifact, and it worked against the + * older models these stubs imitate; against the current ones it turned every request into a + * provider-side 400.
+ * + *Asserted on what leaves dotCMS rather than on what comes back, because a stub cannot fail + * the way a real provider does unless it is told to. The requirement this protects is still + * met: those models return base64 by themselves, and a legacy model that answers with a URL is + * fetched and re-encoded before the caller sees anything.
+ */ + @Test + public void test_generations_sendsNoOutputFormatParameter() throws Exception { + final Response response = resource.generations( + mockRequest(host.getHostname(), bearerToken), mockResponse(), + host.getIdentifier(), generationFor(IMAGE_MODEL, PROMPT)); + + assertEquals(200, response.getStatus()); + + final ListRequest 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 + * the authentication handshake and the site-attribution logic 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 diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceLoggingTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceLoggingTest.java new file mode 100644 index 000000000000..7562c6d817b9 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceLoggingTest.java @@ -0,0 +1,386 @@ +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.EmbeddingsRequestView; +import com.dotcms.inference.rest.view.ImageGenerationRequestView; +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.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.liferay.portal.model.User; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +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.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +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.assertFalse; +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; + +/** + * Holds the line between what may be logged and what may not: metadata may be logged, the + * caller's content may not. + * + *No request or response body is written to a log today. That was true before this test + * existed and is exactly why the test is worth having — nothing enforced it. Every log line in + * this family carries identifiers, sizes, model names and timings, and a contributor adding + * {@code Logger.debug(this, "request: " + requestView)} while chasing a bug would break the + * requirement without failing a single other test in the suite. The leak would then sit in the + * logs of every deployment until someone read one.
+ * + *The method is a sentinel: a string that could only have come from the caller's content is + * planted in the prompt and the embeddings input, every log record emitted anywhere in the JVM + * during the call is captured, and the test fails if the sentinel appears in any of them. Because + * the appender is attached to the root logger rather than to a named one, a future log statement + * in a class nobody thought of is covered too.
+ * + *The failure paths are exercised alongside the happy ones, because that is where content + * leaks into logs in practice: the natural thing to write when a provider rejects a request is + * the request that was rejected.
+ */ +public class InferenceLoggingTest { + + /** + * Planted in the caller's content. Nonsense on purpose — a realistic prompt could plausibly + * appear in a log for some unrelated reason, and the assertion has to mean one thing. + */ + private static final String SENTINEL = "zqx-carrier-pigeon-7741-confidential"; + + private static final String CHAT_MODEL = "gpt-4o-mini"; + private static final String COMPLETIONS_PATH = "/chat/completions"; + private static final String EMBEDDINGS_PATH = "/embeddings"; + private static final String IMAGES_PATH = "/images/generations"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static WireMockServer wireMockServer; + private static User user; + private static String bearerToken; + + private Host host; + private CapturingAppender appender; + private Logger rootLogger; + + private final ChatCompletionsResource chatResource = new ChatCompletionsResource(); + private final EmbeddingsResource embeddingsResource = new EmbeddingsResource(); + private final ImagesResource imagesResource = new ImagesResource(); + + @BeforeClass + public static void beforeClass() throws Exception { + IntegrationTestInitService.getInstance().init(); + IPUtils.disabledIpPrivateSubnet(true); + wireMockServer = AiTest.prepareWireMock(); + + 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-logging-" + UUID.randomUUID() + ".dotcms.com") + .nextPersisted(); + AiTest.aiAppSecretsWithProviderConfig( + host, AiTest.providerConfigJson(AiTest.PORT, CHAT_MODEL)); + + appender = new CapturingAppender(); + appender.start(); + rootLogger = (Logger) LogManager.getRootLogger(); + rootLogger.addAppender(appender); + } + + @After + public void after() throws Exception { + if (rootLogger != null && appender != null) { + rootLogger.removeAppender(appender); + appender.stop(); + } + AiTest.removeAiAppSecrets(host); + } + + /** + * Given a completion whose prompt carries a sentinel, served successfully + * When the exchange finishes + * Then the sentinel appears in no log record + */ + @Test + public void test_completion_doesNotLogThePrompt() { + stubProvider(COMPLETIONS_PATH, 200, providerChatResponse()); + + chatResource.completions(mockRequest(), mockResponse(), host.getIdentifier(), + chatRequest(false)); + + assertSentinelNeverLogged("a completion that succeeded"); + } + + /** + * Given a streamed completion whose prompt carries a sentinel + * When the stream is consumed to its end + * Then the sentinel appears in no log record + * + *Separated from the buffered case because the streaming path logs on its own threads and + * has its own failure and timeout reporting, none of which the buffered path exercises.
+ */ + @Test + public void test_streamedCompletion_doesNotLogThePrompt() throws Exception { + stubProvider(COMPLETIONS_PATH, 200, providerChatResponse()); + + final Response response = chatResource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), chatRequest(true)); + if (response.getEntity() instanceof StreamingOutput streaming) { + streaming.write(new ByteArrayOutputStream()); + } + + assertSentinelNeverLogged("a streamed completion"); + } + + /** + * Given a completion the provider refuses + * When the failure is reported + * Then the sentinel appears in no log record + * + *The case that matters most. When a provider rejects a request the obvious thing to log + * is the request that was rejected, so this is where a prompt reaches a log file first.
+ */ + @Test + public void test_failedCompletion_doesNotLogThePrompt() { + stubProvider(COMPLETIONS_PATH, 500, "{\"error\":{\"message\":\"upstream exploded\"}}"); + + chatResource.completions(mockRequest(), mockResponse(), host.getIdentifier(), + chatRequest(false)); + + assertSentinelNeverLogged("a completion the provider refused"); + } + + /** + * Given embeddings whose input carries a sentinel, and a provider that refuses + * When the failure is reported + * Then the sentinel appears in no log record + * + *Embeddings carry the most caller content of any operation here — whole documents, in + * batches — so they are the costliest thing to leak and are checked on the failure path.
+ */ + @Test + public void test_failedEmbeddings_doesNotLogTheInput() { + stubProvider(EMBEDDINGS_PATH, 500, "{\"error\":{\"message\":\"upstream exploded\"}}"); + + final ArrayNode input = MAPPER.createArrayNode(); + input.add(new TextNode("Please embed " + SENTINEL)); + + embeddingsResource.embeddings(mockRequest(), mockResponse(), host.getIdentifier(), + new EmbeddingsRequestView(AiTest.EMBEDDINGS_MODEL, input)); + + assertSentinelNeverLogged("embeddings the provider refused"); + } + + /** + * Given an image request whose prompt carries a sentinel, and a provider that refuses + * When the failure is reported + * Then the sentinel appears in no log record + */ + @Test + public void test_failedImageGeneration_doesNotLogThePrompt() { + stubProvider(IMAGES_PATH, 500, "{\"error\":{\"message\":\"upstream exploded\"}}"); + + imagesResource.generations(mockRequest(), mockResponse(), host.getIdentifier(), + new ImageGenerationRequestView(AiTest.IMAGE_MODEL, + "Draw " + SENTINEL, 1, AiTest.IMAGE_SIZE)); + + assertSentinelNeverLogged("an image request the provider refused"); + } + + /** + * Given a streamed completion the provider refuses + * When the failure is reported + * Then the provider's own explanation reaches the log + * + *The mirror of every other assertion in this file, and it exists because the opposite + * mistake is just as damaging. The caller gets a safe sentence saying the detail is in the + * server log; if the log holds only an exception class name, that sentence is a lie and an + * operator has no way to tell an exhausted account from a rejected key from a malformed + * request. That is exactly what happened: the streaming path logged + * {@code "Inference stream failed: InvalidRequestException"} and nothing else, and a live + * failure took three rounds of guessing to diagnose because the evidence was being discarded + * at the moment it was produced.
+ * + *Note this does not contradict the sentinel assertions above. What must never be logged is + * the caller's content; what must always be logged is the provider's + * explanation of why it refused.
+ */ + @Test + public void test_failedStream_logsTheProvidersExplanation() throws Exception { + final String providerReason = "zqx-provider-said-no-9f3c"; + stubProvider(COMPLETIONS_PATH, 400, + "{\"error\":{\"message\":\"" + providerReason + "\",\"code\":\"bad_request\"}}"); + + final Response response = chatResource.completions( + mockRequest(), mockResponse(), host.getIdentifier(), chatRequest(true)); + if (response.getEntity() instanceof StreamingOutput streaming) { + streaming.write(new ByteArrayOutputStream()); + } + + final boolean logged = appender.messages().stream() + .anyMatch(message -> message != null && message.contains(providerReason)); + + assertTrue("The caller is told the detail is in the server log, so it has to be there: a " + + "bare exception type leaves an operator with nothing to act on", + logged); + } + + /** + * Fails when the caller's content reached a log, naming what was being exercised. + * + * @param what a description of the exchange, so a failure says which path leaked + */ + private void assertSentinelNeverLogged(final String what) { + final ListThe throwable matters as much as the message: a provider client's exception can quote the + * request back, so logging a safe message with an unsafe cause leaks just as effectively.
+ */ + private static final class CapturingAppender extends AbstractAppender { + + private final ListThe 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 + * endpoints drift to opposite model policies, and it would make that invariant unstatable as a + * blanket rule. 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.
* *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 + *
This is the no-administrator-exemption 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.
*/ @@ -336,7 +337,7 @@ private static void configureChain(final Host site, final ListFR-024 removed every reserved alias and every implicit default from this family, which makes + *
Every reserved alias and every implicit default was removed 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 @@ -49,10 +49,10 @@ * unconfigured instance says so with an empty list rather than with somebody else's models.
* *FR-010. Each entry of the chain is a name the completions endpoint accepts, so a list + *
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 @@ -198,7 +201,11 @@ public void test_models_withFallbackChain_listsEveryEntryInOrder() throws Except final ModelListView view = (ModelListView) response.getEntity(); assertEquals(OBJECT_LIST, view.object()); - assertEquals(List.of(CHAIN_PRIMARY, CHAIN_FALLBACK), modelIds(view)); + assertEquals("Every entry of the chat chain is listed, in configured order, ahead of the " + + "other capabilities' models", + List.of(CHAIN_PRIMARY, CHAIN_FALLBACK, + AiTest.EMBEDDINGS_MODEL, AiTest.IMAGE_MODEL), + modelIds(view)); assertEquals("The primary model is the first entry", CHAIN_PRIMARY, view.data().get(0).id()); view.data().forEach( @@ -210,7 +217,7 @@ public void test_models_withFallbackChain_listsEveryEntryInOrder() throws Except * 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 + *
The reserved alias was removed 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.
*/ @@ -227,9 +234,13 @@ public void test_models_withConfiguredChain_addsNothingSynthetic() throws Except assertEquals(200, response.getStatus()); final ModelListView view = (ModelListView) response.getEntity(); + final ListFR-022. The failure this guards against is not an error but a wrong success: falling + *
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.
*/ @@ -282,7 +293,8 @@ public void test_models_withSiteOverride_listsTheResolvedSitesModels() throws Ex assertEquals(200, response.getStatus()); final ModelListView view = (ModelListView) response.getEntity(); - assertEquals(List.of(OTHER_CHAT_MODEL), modelIds(view)); + assertEquals(List.of(OTHER_CHAT_MODEL, AiTest.EMBEDDINGS_MODEL, AiTest.IMAGE_MODEL), + modelIds(view)); assertFalse("The other site's model must not appear", modelIds(view).contains(CHAT_MODEL)); } @@ -292,7 +304,7 @@ public void test_models_withSiteOverride_listsTheResolvedSitesModels() throws Ex * 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 + *
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.
@@ -315,7 +327,7 @@ public void test_models_withSiteOverride_publishesTheResolvedSiteId() { * 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 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 @@ -337,14 +349,25 @@ public void test_models_withoutBearerToken_isUnauthorized() { * 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 + * Then all three are listed, with the chat model first + * + *Every operation in this family requires an exact model name and there is no implicit + * default, so this listing is the only way a caller can learn one. Listing chat alone — which + * is what shipped first — left the embeddings and image operations with no discovery at all: + * a caller had to be told their model names out of band, which is precisely what a discovery + * endpoint exists to avoid.
* - *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.
+ *Chat first is asserted rather than incidental. The documented way to ask for "whatever + * this site runs" is to read this list and take the first entry, so an ordering that put an + * embeddings model at the head would silently break every caller following that advice.
+ * + *The format offers nowhere to say which entry serves which operation — its model object + * has no type, mode or modality field — so a caller that picks the wrong one is refused by + * the operation itself, naming the field. That refusal is the mechanism; this list is + * deliberately not the place to duplicate it.
*/ @Test - public void test_models_withDistinctSectionModels_listsOnlyTheChatSection() throws Exception { + public void test_models_withDistinctSectionModels_listsEveryCapability() throws Exception { final Host site = siteConfiguredWith(CHAT_MODEL, EMBEDDINGS_MODEL, IMAGE_MODEL); final Response response = resource.models( @@ -354,11 +377,30 @@ public void test_models_withDistinctSectionModels_listsOnlyTheChatSection() thro 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)); + assertEquals("Every configured model is discoverable, chat first", + List.of(CHAT_MODEL, EMBEDDINGS_MODEL, IMAGE_MODEL), modelIds(view)); + assertEquals("A caller taking the first entry must still get the site's primary chat " + + "model", CHAT_MODEL, modelIds(view).get(0)); + } + + /** + * Given a site whose sections name the same model + * When the models are listed + * Then it appears once + * + *A repeat would be harmless to a client but would misrepresent the site as running two + * things, and a caller counting entries to decide what is available would be wrong.
+ */ + @Test + public void test_models_withOneModelServingEverySection_listsItOnce() throws Exception { + final Host site = siteConfiguredWith(CHAT_MODEL, CHAT_MODEL, CHAT_MODEL); + + final Response response = resource.models( + mockRequest(), mockResponse(), site.getIdentifier()); + + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertEquals(List.of(CHAT_MODEL), modelIds((ModelListView) response.getEntity())); } /** 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 index 18e24cdf57e0..364812674751 100644 --- a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteIsolationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteIsolationTest.java @@ -46,7 +46,7 @@ /** * Specifies cross-site isolation for * {@link ChatCompletionsResource#completions(HttpServletRequest, HttpServletResponse, String, ChatCompletionRequestView)} - * — FR-027 and FR-028. + * — cross-site isolation of provider configuration and credentials. * *Isolation here is not enforced by a check anywhere; it is a property of one line, the cache * key {@code host + ":" + providerConfigHash} in @@ -63,11 +63,11 @@ * made it.
* *The serving site is what FR-020 puts on every response and what a reconciliation + *
The serving site is what is published 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.
*/ 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 index bf2fca40bb28..0f655e6d487e 100644 --- a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteResolutionTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceSiteResolutionTest.java @@ -56,15 +56,15 @@ * 404 for site B's model on the same host name is proof that site B's was not. * *What is asserted for FR-020. The serving site reaches a real caller as the + *
What is asserted for the serving site. It 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. @@ -82,8 +82,8 @@ public class InferenceSiteResolutionTest { 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. + * Legacy request parameter that elsewhere in dotCMS selects the current site. This family + * is required to ignore it. */ private static final String LEGACY_HOST_ID_PARAM = "host_id"; @@ -272,7 +272,7 @@ public void test_completions_withHeaderAndQueryParameterDisagreeing_prefersTheHe * 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 + *
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 @@ -323,8 +323,8 @@ public void test_completions_withLegacyHostParameter_ignoresIt() { * 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 + *
There is no exception for the fallback path, and it is the path that matters most. The + * unmatched host name is not an edge case here — the default-site fallback is kept * 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; @@ -372,7 +372,7 @@ public void test_completions_withLegacyHostParameterAndUnmatchedHostName_ignores * 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 + *
The serving site is reported 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.
*/ diff --git a/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceTestsAreRegisteredTest.java b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceTestsAreRegisteredTest.java new file mode 100644 index 000000000000..51b86649af7c --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/inference/rest/InferenceTestsAreRegisteredTest.java @@ -0,0 +1,85 @@ +package com.dotcms.inference.rest; + +import com.dotcms.MainSuite2b; +import org.junit.Test; +import org.junit.runners.Suite; + +import java.io.File; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Fails if an integration test in this family is not registered in a suite CI actually runs. + * + *A test class that no {@code MainSuite} lists compiles, passes locally under + * {@code -Dit.test=}, and is never executed in CI. There is no error, no warning + * and no coverage — the build stays green and the class silently protects nothing. Three classes + * in this family were in exactly that state and it was caught by eye, not by tooling; at least one + * class elsewhere in the repo still is.
+ * + *This guard exists because the failure mode is invisible by construction: the only thing that + * would notice an unregistered test is another test. It reads the suite's own registration list + * rather than a copy, so it cannot drift from what CI runs, and it discovers test classes from the + * compiled output rather than a hand-maintained list, so a class added tomorrow is covered without + * anyone remembering this file exists.
+ */ +public class InferenceTestsAreRegisteredTest { + + /** + * Given every compiled test class in this package + * When the suite CI runs is inspected + * Then each one is registered in it + */ + @Test + public void test_everyInferenceTestClassIsRegisteredInTheSuite() throws Exception { + final Set