Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
317 changes: 317 additions & 0 deletions docs/backend/INFERENCE_API.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.dotcms.ai.client.langchain4j;

import com.dotcms.ai.app.AppConfig;
import com.dotcms.inference.model.CallerSafeException;
import com.dotcms.inference.model.InferenceError;
import com.dotcms.inference.model.InferenceLimits;
import com.dotcms.inference.model.MultipleImagesUnsupportedException;
Expand Down Expand Up @@ -180,8 +181,12 @@ public void stream(final AppConfig appConfig,
} catch (final RuntimeException e) {
// Nothing reached the sink — every model in the chain failed to start — so the sink
// still has to learn that this stream will not finish.
Logger.warn(InferenceAIClient.class,
"Inference stream could not be started: " + e.getClass().getSimpleName());
// Logged with the exception rather than just its type. What the caller receives is a
// safe sentence that says the detail is in the log, so the detail has to actually be
// here — a bare class name leaves an operator with "InvalidRequestException" and no
// way to tell an exhausted account from a rejected key from a malformed request. The
// buffered path has always logged the full exception; this matches it.
Logger.warn(InferenceAIClient.class, "Inference stream could not be started", e);
state.fail(toInferenceError(e));
}
}
Expand Down Expand Up @@ -343,8 +348,7 @@ public void onCompleteResponse(final ChatResponse response) {

@Override
public void onError(final Throwable throwable) {
Logger.warn(InferenceAIClient.class,
"Inference stream failed: " + throwable.getClass().getSimpleName());
Logger.warn(InferenceAIClient.class, "Inference stream failed", throwable);
state.fail(toInferenceError(throwable));
}
});
Expand Down Expand Up @@ -695,13 +699,24 @@ private static InferenceUsage toInferenceUsage(final TokenUsage tokenUsage) {
* @param throwable what went wrong
* @return the error to report
*/
private static InferenceError toInferenceError(final Throwable throwable) {
if (throwable instanceof IllegalArgumentException) {
return InferenceError.invalidRequest(nullToEmpty(throwable.getMessage()).isBlank()
? "The request could not be served with this site's configuration"
: throwable.getMessage(), null);
static InferenceError toInferenceError(final Throwable throwable) {
if (throwable instanceof CallerSafeException
&& !nullToEmpty(throwable.getMessage()).isBlank()) {
// dotCMS wrote this sentence, so it is returned: it names something about the site's
// configuration that the caller or an operator can actually act on.
return InferenceError.invalidRequest(throwable.getMessage(), null);
}
return InferenceError.upstream(UPSTREAM_FAILURE_MESSAGE);
if (throwable instanceof IllegalArgumentException) {
// dotCMS did not write this one. The fallback chain reports a failed model
// initialisation as an IllegalArgumentException with the provider client's own message
// appended, and that message can carry the provider's endpoint, its account
// identifiers, or a fragment of the prompt. Logged in full, never returned.
return InferenceError.invalidRequest(
"The request could not be served with this site's configuration", null);
}
// A streamed exchange is rate limited exactly as often as a buffered one, and a client
// reading an error event backs off on the same status, so the translation belongs here too.
return InferenceError.fromProviderFailure(throwable, UPSTREAM_FAILURE_MESSAGE);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.dotcms.ai.AiKeys;
import com.dotcms.ai.app.AIModelType;
import com.dotcms.ai.app.AppConfig;
import com.dotcms.inference.model.CallerSafeException;
import com.dotcms.ai.client.AIClient;
import com.dotcms.ai.client.AIRequest;
import com.dotcms.ai.client.JSONObjectAIRequest;
Expand Down Expand Up @@ -82,7 +83,6 @@ public class LangChain4jAIClient implements AIClient {
* Response format asked of an image provider by the {@code /api/inference/v1} family, so the
* bytes arrive inline and no separately-addressable artifact is minted upstream.
*/
private static final String INLINE_IMAGE_FORMAT = "b64_json";

/**
* Cache-key discriminator for models the inference family borrows. Those differ from the ones
Expand Down Expand Up @@ -214,9 +214,17 @@ public <R> R withEmbeddingModel(final AppConfig appConfig,
* <p>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.</p>
* and what the site pays.</p>
*
* <p><strong>The output-format parameter is deliberately not sent.</strong> 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.</p>
*
* <p>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
Expand All @@ -234,8 +242,7 @@ public <R> R withImageModel(final AppConfig appConfig,
final ProviderConfig baseConfig = parseSection(appConfig.getProviderConfig(), IMAGE_SECTION);
final boolean sized = size != null && !size.isBlank();
final ProviderConfig requestConfig = ImmutableProviderConfig.copyOf(baseConfig)
.withSize(sized ? size : baseConfig.size())
.withResponseFormat(INLINE_IMAGE_FORMAT);
.withSize(sized ? size : baseConfig.size());

return executeWithFallbackTyped(
cacheKeyPrefix(appConfig) + INFERENCE_KEY_SEGMENT
Expand Down Expand Up @@ -554,7 +561,10 @@ <M, R> R executeWithFallbackTyped(
final BiFunction<M, String, R> executor) {
final List<String> models = effectiveModels(baseConfig);
if (models.isEmpty()) {
throw new IllegalArgumentException(
// CallerSafeException, not a bare IllegalArgumentException: this sentence was written
// here, names the site's own configuration, and is the one thing that tells a caller
// what to fix. The type is what marks it returnable — see CallerSafeException.
throw new CallerSafeException(
"No model configured in providerConfig." + section + " — set 'model'");
}
// Each failure is logged immediately. The last exception is rethrown only after
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ default List<String> allModels() {
* made by the caller that builds the model, which is why it is left null by default and the
* strategies pass it on only when somebody asked for one.
*
* <p>{@code /api/inference/v1/images/generations} sets {@code b64_json} because FR-012 wants
* <p>{@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.</p>
Expand Down
10 changes: 5 additions & 5 deletions dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,11 @@ private static Host resolveFromRequest(final HttpServletRequest request) {
*
* <p>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.</p>
* at the server name (`HostWebAPIImpl.getCurrentHostFromRequest`), and those parameters must
* not decide which site's credentials get spent here. 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.</p>
*
* @return the default site
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.dotcms.inference.model;

/**
* An invalid-request failure whose message dotCMS wrote and is therefore safe to return.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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);
}
}
100 changes: 98 additions & 2 deletions dotCMS/src/main/java/com/dotcms/inference/model/InferenceError.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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;
Expand Down
Loading
Loading