Skip to content

feat(ai): OpenAI-compatible inference endpoints at /api/inference/v1 (#37431) - #37559

Open
fmontes wants to merge 4 commits into
mainfrom
fmontes/37431-openai-compatible-inference
Open

fmontes wants to merge 4 commits into
mainfrom
fmontes/37431-openai-compatible-inference

Conversation

@fmontes

@fmontes fmontes commented Sep 15, 2026

Copy link
Copy Markdown
Member

Implementation for #37431. The spec was #37493, approved and merged.

Split in two. This PR is the feature. The hardening — three defects found while reviewing it, plus the conformance test — is #37561, stacked on this branch. Land them together; the hardening protects the endpoints added here.

Adds an OpenAI-wire-format endpoint family at /api/inference/v1 so any standard client or agentic framework can use dotCMS as its model provider, with the customer's own provider credentials, resolved per site.

Operation
POST /api/inference/v1/chat/completions buffered and SSE streaming, tool calls, usage on request
GET /api/inference/v1/models what the resolved site can actually run
POST /api/inference/v1/embeddings single string or batched array
POST /api/inference/v1/images/generations always inline base64, never a hosted URL

Bearer token only. The model is required on every request and validated against the site's configuration for that capability, so a chat model is refused on the images endpoint. Every response names the serving site in X-dotCMS-Resolved-Site.

Reading order

Three commits, each a complete slice — reviewing them in order is far easier than reading the diff:

  1. bb86ffd0 chat completions and streaming — the internal representation, the mappers, the SSE serializer, and the two chat endpoints. The largest commit and the one that sets every pattern the others follow.
  2. d94dc463 bearer-only auth and the legacy site-param leak — US3. Two real defects: the legacy host_id/Host request parameters were honoured on the unmatched-host fallback path, where they could redirect which site's credentials were spent; and bearer-only had never been implemented, so a session cookie authenticated.
  3. 9d95eb0e models, embeddings and images — the remaining three operations, on the patterns established in 1.

Roughly 40% of the insertions are tests, most of it explanatory javadoc. The production surface is much smaller than the line count suggests.

Two things to read before the code

1. The Test-First gates did not hold, and I have not ticked them as though they did.

Constitution Principle V requires tests → developer approval → Red → implementation. US3's implementation was built before its tests existed, so they could not gate it. US4 and US5 were written test-first and did fail first, but the Red run was the agent's own and no developer approved the sets beforehand. That process fact cannot be made true retroactively.

What was recoverable is the outcome the gate exists to produce — proof these tests fail for the right reason when the behaviour they guard is removed. Mutations were applied one at a time, each deleting one load-bearing behaviour, each followed by a build, a targeted test run, and a revert. All were detected. The ones covering this PR:

Mutation Requirement Test
bearer-only check removed FR-015 (US3) InferenceAuthorizationTest
required-model gate removed FR-023 (US3) InferenceModelValidationTest 3/3
legacy host_id honoured on fallback FR-025 (US3) InferenceSiteResolutionTest 2/2
image n bounds removed FR-012 (US5) InferenceImagesTest
provider capability probe always true FR-012 (US5) InferenceImagesTest
per-element input validation weakened FR-011 (US5) InferenceEmbeddingsTest 2/2

US3 carried the real risk — tests written against existing code can encode what the code does rather than what the requirement says — and all three US3 mutations killed their tests, on top of the two genuine defects those tests found when first written. Decide for yourself how much extra scrutiny US3–US5 warrant; the gate did not do that job for you. Six further mutations covering #37561 are listed there.

The prevention is process, not code: run /speckit-implement in per-story slices that stop at each gate, rather than executing the whole task list in one pass. That habit also produced a PR this size.

2. Four requirements were amended after the spec was approved, and need re-approval.

What changed
FR-011 embeddings input accepts a single string or an array; every element validated, offending index named
FR-012 n is honored (an earlier draft refused it on a false premise), bounded above, and refused per-model where the provider cannot honor it
FR-031 split: relay the provider's Retry-After where the abstraction exposes it — it does not — and always send one on dotCMS's own capacity refusals
FR-037 adds a maximum images per request

FR-031 and FR-037's changes land in #37561; FR-011 and FR-012 are here. The diff is five lines in spec.md, and the reasoning is recorded inline in each requirement — including the false premise that produced the original FR-012 and how it was caught.

Scope and blast radius

Additive. The shipped /api/v1/ai/* endpoints keep their behaviour exactly (FR-033) — AiHostResolver.resolveHost/resolveHostStrict and LangChain4jAIClient.toSseChunk are untouched, and the new family got its own paths alongside them. executeWithFallback was generalised to a typed variant the original delegates to, so the fallback chain, cache keying and logging have one implementation.

One trap worth knowing for any future endpoint here: a new REST package must be registered twiceDotRestApplication's scanned packages for Jersey, and swagger-maven-plugin's resourcePackages for the contract. Miss the second and the endpoint works while never appearing in openapi.yaml, with CI green.

Tests

All ten integration classes are registered in MainSuite2b. An unregistered class compiles, passes locally, and never runs in CI — three of these were in that state until it was caught.

Run several of these classes together with -Dit.test.forkcount=1; they share the fixed WireMock port 50505 and the module defaults to four forks. Without it you get ten bogus "Failed to bind" failures that look like a broad regression.

Rollback

M-3, MEDIUM — this adds a public REST API contract. Nothing is renamed or removed and there is no schema change, so a rollback is clean for existing behaviour; the risk is that clients which integrated against the new endpoints in this release get 404s if it is reverted. Labelled AI: Not Safe To Rollback on that basis — downgrade it if you read M-3 differently for a purely additive surface.

Follow-ups, deliberately not here

Still outstanding before merge: the live-provider demo (quickstart step 8) and the Postman collection run.

🤖 Generated with Claude Code

fmontes and others added 3 commits September 14, 2026 16:38
User Stories 1 and 2 of #37431: an unmodified OpenAI-compatible client can
run a multi-turn, tool-calling conversation against dotCMS with only a base
URL and an API token, streamed or not.

71 tests green: 60 unit, 4 integration (tool round trip), 7 integration
(streaming), all against a live instance.

Structure
- New top-level package com.dotcms.inference, sibling to com.dotcms.ai
  rather than nested inside what it supersedes.
- com.dotcms.inference.model holds the internal representation and carries
  NO Jackson annotations: FR-038 asks that it not be a binding of the wire
  JSON, and making serialization structurally impossible there is the only
  way to guarantee it. InferenceStreamEvent is a sealed interface, so the
  SSE serializer is a total function and a sixth variant breaks the build
  rather than falling through a default branch.

Provider access
- New InferenceAIClient owns the standard-bound semantics, which are driven
  by an external standard and will change when it does, separately from the
  dotAI endpoints which evolve on dotCMS's terms.
- It does NOT own model construction, caching or eviction. AIAppListener
  flushes a site's cached providers on credential rotation through
  LangChain4jAIClient alone; a second cache would keep serving a revoked key
  until the TTL expired, with no symptom. So the new client borrows models
  through two additive accessors, withChatModel/withStreamingChatModel.
- executeWithFallback generalised to a typed variant the original delegates
  to, so the fallback chain, cache keying and logging have one
  implementation and shipped behaviour is bit-identical.

Notable behaviour
- A failed stream withholds the [DONE] marker, so it can never be mistaken
  for a finished one. Verified against both provider error and connection
  fault.
- Tool-call identity is announced once, on the first fragment. langchain4j
  repeats it on every fragment; passing that through made one call read as
  two. Caught by an integration test.
- Streamed usage is emitted only when the client asks, and suppressed when a
  provider volunteers it unasked -- its empty choices array is what breaks
  readers assuming every chunk carries one.
- stream_options is never forwarded: four of seven providers do not
  understand it. dotCMS builds the chunk from the counts the unified
  provider abstraction returns, which works for all of them.

Also
- com.dotcms.inference.rest registered in BOTH DotRestApplication (Jersey)
  and swagger-maven-plugin resourcePackages. Missing the second is silent:
  the endpoint works, the contract omits it, CI still passes.
- openapi.yaml regenerated and committed.
- Integration fixtures grant DOTCMS_BACK_END_USER explicitly; the role check
  matches by key and does not walk inheritance, so admin does not imply it.

Refs #37431

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds User Story 3's test coverage -- per-site credential governance -- and
fixes the two real defects it found. 26 tests across five suites, all green.

The tests were written after the implementation, because US3's code was
built as part of the foundation. That inverts the TDD gate and is recorded
as such in tasks.md rather than papered over. It was worth doing anyway:
11 of the 14 initial tests merely verified working code, and the other 3
found defects that review had missed and that the chat-completion tests
passed straight over.

FR-025 -- legacy request parameters honoured where they did damage
  AiHostResolver.resolveFromRequest fell through to getCurrentHostNoThrow,
  which reads the host_id and Host request parameters before it ever looks
  at the server name. The parameters were already ignored whenever the host
  name matched a site, because that path returns earlier -- so the effect
  was that a legacy override was ignored everywhere it was harmless and
  honoured in precisely the case where it could redirect which site's
  credentials get spent. Now resolves the default site explicitly.
  resolveHost/resolveHostStrict keep the old call: they serve the shipped
  endpoints and FR-033 puts them out of bounds. That duplication is
  #37491's to resolve.

FR-015 -- bearer-only was never implemented
  A request with no Authorization header but a live session was served
  normally; basic auth would have been too. That undercuts the reasoning
  for emitting no CORS headers, which rests on the credential being a token
  someone deliberately issued and placed on a server rather than one a
  browser attaches by itself. The rule now lives in one method that both a
  name-bound filter and the resource call -- the filter so it covers the
  three resources not yet written, the resource because a guarantee that
  only exists inside the JAX-RS chain is invisible to tests that invoke
  resource methods directly, which is how every integration test here
  reaches one.

Two authorization tests were changed. They asserted the refusal arrives as
a thrown WebApplicationException, which encoded the behaviour from before
this family refused for itself. A 401 now carries InferenceErrorView, as
the contract's status table requires, so a client library can deserialize a
refusal into its own error type. The assertions were strengthened to check
the body shape as well as the status, not relaxed.

Refs #37431

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes US4 and US5: the three remaining operations, each resolving the
site and validating the model through the same shared component the chat
endpoint uses, and each returning the standard wire shape.

Images: `n` is honored, with both bounds refused rather than clamped.

An earlier draft of FR-012 refused every `n` other than 1, justified by the
claim that the provider abstraction returns a single image per call. That
claim was false — `ImageModel.generate(prompt, n)` returns a list, and the
adopted format documents up to 10 images per request. Correcting it surfaced
two further problems, both fixed here:

- `generate(prompt, n)` is a default method that throws unless overridden.
  OpenAiImageModel and OpenAiOfficialImageModel override it;
  GoogleAiGeminiImageModel does not. So on a Gemini-configured site a request
  for several images threw inside the client library and reached the caller as
  a 502 — a retryable status for a request that can never succeed, which sends
  a standard client's back-off into an unwinnable loop. It is now a 400 naming
  the field. Support is probed from the model's declaring class rather than a
  provider-name list, so it cannot rot on a library upgrade.
- Honoring `n` removed the spend ceiling the old rule had imposed by accident,
  and FR-032 puts per-site quotas out of scope. Adds
  DOT_INFERENCE_MAX_IMAGES_PER_REQUEST, defaulting to 10 — the ceiling the
  OpenAI images API documents for this field, so a client written against the
  standard meets the same limit here it already handles there.

Also registers all three new test classes in MainSuite2b. Unregistered
integration tests compile and pass locally but are silently never run in CI.

Tests: 60 unit, 72 integration across the ten inference classes, all green.
Run several dotAI classes at once with -Dit.test.forkcount=1; they share the
fixed WireMock port 50505 and forkCount defaults to 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fmontes's task in 5m 44s —— View job


Claude PR Review

Reviewed the production surface of the new /api/inference/v1 family (resources, filters, mappers, SSE serializer, the new InferenceAIClient, and the AiHostResolver / LangChain4jAIClient changes). The design is careful and the doc-comments are unusually good at recording why. A few real defects, none in the auth/site-resolution logic the PR flags for extra scrutiny — those hold up.

New Issues

  • 🟠 High: dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java:344-361Streaming silently loses the fallback chain on async provider errors. When a streaming provider reports failure through the onError callback (the normal way a streaming model surfaces auth failure / model-not-found / rate-limit), streamWithModel calls state.fail(...) — which emits a terminal Error event and counts down the latch — and then model.chat(...) returns normally. awaitTerminal returns true, streamWithModel returns without throwing, and executeWithFallbackTyped treats that as success, so the remaining configured models are never tried. The non-streaming complete() path does fall back for the same failure (the exception propagates out of model.chat). Net effect: a site with chat: [primary, backup] whose primary fails at the provider before the first token will fall back for buffered requests but not for streamed ones — contradicting the method's own promise ("a failure there is rethrown and the next model gets its turn"), and not covered by the mutation table. Only synchronous start failures (the catch at line 351) fall back. Fix this →

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java:698-704 (with the message built at LangChain4jAIClient.java:571-572) — Provider/builder error text can reach the caller verbatim, against the stated guarantee. toInferenceError returns throwable.getMessage() unchanged for any IllegalArgumentException. When every model fails to initialize, executeWithFallbackTyped throws new IllegalArgumentException("Failed to initialize " + section + " model '" + modelName + "': " + e.getMessage(), e) — embedding the raw builder/provider message. On the streaming path stream() catches this and calls state.fail(toInferenceError(e)), so that concatenated string is serialized to the client. The class doc says "A provider's own message is never passed through," and the buffered/embeddings/images resources honor that (they return a fixed upstream message); only this path doesn't. Fix this →

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java:745-751HttpClient built per image fetch and never closed. On Java 25 java.net.http.HttpClient is AutoCloseable and owns a selector-manager thread and executor. fetchAndEncode builds a fresh client on every URL-backed image and never closes it (no try-with-resources, not reused), so its threads/pool linger until GC. Accumulates under repeated generation from providers that answer with a URL. Reuse a shared client or wrap in try-with-resources. Fix this →

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/inference/rest/RequestSizeLimitFilter.java:34-37The request-size ceiling is bypassable via chunked transfer-encoding. When getLength() is < 0 (chunked or unknown length) the filter returns without enforcing anything, and the comment says "the resource enforces the ceiling as it reads." But the resources bind a fully-parsed @RequestBody object — Jersey/Jackson has already read the whole body before the method runs — so no resource enforces a read-side limit. DOT_INFERENCE_MAX_REQUEST_BYTES therefore does not bound a chunked request. Either enforce on the input stream (a bounded/counting MessageBodyReader or servlet-level cap) or correct the comment. Fix this →

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/InferenceAIClient.java:143-148Buffered completions have no dotCMS-level timeout. InferenceLimits.completionTimeoutSeconds is documented as a "hard ceiling on a single completion" but is only applied in streamWithModel; complete() calls model.chat(chatRequest) synchronously with no wrapping deadline. Assumption: a hung provider on a buffered call is bounded only by the provider client's own timeout (ProviderConfig.timeout). What to verify: whether langchain4j applies a sane default timeout when the site leaves timeout unset — if not, a buffered request against a stalled provider parks a request thread indefinitely, which the "hard ceiling" doc implies it should not.

Verified clean (checked, not issues)

  • Bearer-only + site-resolution changes (AiHostResolver, BearerOnlyAuthFilter) — the fallback no longer honors legacy host_id/Host params, and the bearer check is duplicated in each resource so it holds off the JAX-RS chain.
  • StreamState single-terminal-event guarantee and thread-safety (synchronized emit/finish/fail, re-checked terminated).
  • Requested model is a pure gate everywhere (chat/embeddings/images all run the configured chain from the top and report the actual serving model) — consistent and documented.
  • Cache keys still start with host + ":", so flushCachesForHost eviction on credential rotation still works.
  • SSE frame shapes, [DONE]-marker withholding on error, and the exhaustive sealed-type switches.

Blocking on the 🟠 High; the Mediums are worth fixing but don't block. Since #37561 is the hardening PR stacked on this branch, the streaming-fallback and size-limit gaps may be natural fits there if you'd rather not expand this one.

· branch fmontes/37431-openai-compatible-inference

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!

  • Category: M-3 — REST / GraphQL / Headless API Contract Change
  • Risk Level: 🟡 MEDIUM
  • Why it is unsafe: This PR ships a brand-new public REST API surface (/api/inference/v1/* — an OpenAI-compatible chat/embeddings/images/models family) whose entire purpose is to be adopted by external headless/integration clients. Once a client starts calling these endpoints on release N, rolling back to N-1 makes them disappear entirely (404) — the same "persistent consumer that survives a rollback breaks" failure mode M-3 describes for contract changes, just triggered by absence rather than a renamed/removed field. The PR spec itself calls this out directly: specs/37431-openai-compatible-inference/spec.md (Legacy Considerations section) states: "Because this adds a public API contract, it falls in a rollback-sensitive category and should be labeled accordingly."
  • Code that makes it unsafe:
    • dotCMS/src/main/java/com/dotcms/inference/rest/ChatCompletionsResource.java:92@Path("/inference/v1/chat"), POST /completions (lines 185-190)
    • dotCMS/src/main/java/com/dotcms/inference/rest/EmbeddingsResource.java:73@Path("/inference/v1/embeddings"), POST (line 148)
    • dotCMS/src/main/java/com/dotcms/inference/rest/ImagesResource.java:87@Path("/inference/v1/images"), POST /generations (lines 170-175)
    • dotCMS/src/main/java/com/dotcms/inference/rest/ModelsResource.java:69@Path("/inference/v1/models"), GET (line 124)
    • dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java:148 — registers the new com.dotcms.inference.rest package with the REST application
    • specs/37431-openai-compatible-inference/spec.md — the rollback-sensitivity note quoted above, written by the PR spec itself
  • Alternative (if possible): No schema/DB/ES risk exists here (no runonce tasks, no ES mapping or model-version changes were found), so this is not a merge blocker — it is an operational labeling matter. Per M-3 guidance: document the new endpoint family as rollback-sensitive in the release notes, so operators know that a rollback after these endpoints are adopted by any client will surface as 404s for that client, not a data-safety issue.

The four /api/inference/v1 resource methods were declared final while carrying
@RequestCost, which is a CDI interceptor binding. Weld intercepts by
subclassing, so a final method cannot be proxied, and it refuses the deployment:

    WELD-001504: Intercepted bean method ... public final
    ChatCompletionsResource.completions(...) cannot be declared final

That fails DotRestApplication's servlet init, which does not break these four
endpoints — it takes down every REST endpoint in dotCMS. Each subsequent
request then retries the init and logs a secondary "resource configuration is
not modifiable" error, which reads like an unrelated Jersey problem and is
where an investigation naturally starts. No other @RequestCost method in the
codebase is final.

Nothing in the test suite could have caught this: every integration test in
this family invokes the resource methods directly, so none of them passes
through Weld or Jersey. The suite was green while the application could not
start. InterceptedMethodsAreNotFinalTest closes that specific gap by
reflection, over every declared method rather than a list of today's four, so a
fifth operation added later is covered too. It was verified to fail by
restoring final on one method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant