Class LLMSupport

java.lang.Object
dev.agenor.adapters.llm.LLMSupport

public final class LLMSupport extends Object
Shared machinery behind the three LLMProvider adapters.

Plumbing, not surface: it is public only because those three live in sibling packages, and nothing outside this module should call it. The API is LLMProvider and LLMProviderFactory.

It does two things none of the three should be duplicating. LangChain4j's chat models are synchronous while LLMProvider is not, so the call runs on a virtual thread (ADR-001). Each provider previously called CompletableFuture.supplyAsync(Supplier) with no executor, which is the common ForkJoinPool — sized cores - 1, and shared with everything else in the application — for an HTTP request that lasts seconds. The embedding side of this module and the Redis adapters already ran on virtual threads; the LLM side was the exception.

It also classifies a provider failure into the LLMException.ErrorType a caller can branch on (wrap(java.lang.String, java.lang.String, java.lang.Throwable)), the same job EmbeddingSupport does for embeddings — before this, a chat failure either travelled bare inside a CompletionException (OpenAI, Anthropic) or arrived as an LLMException always of type UNKNOWN (Ollama).

Since:
0.35.0
  • Method Details

    • supplyAsync

      public static <T> CompletableFuture<T> supplyAsync(Supplier<T> work)
      Runs a blocking provider call on a virtual thread.
      Type Parameters:
      T - the result type
      Parameters:
      work - the blocking call
      Returns:
      a future completed with the result, or failed with whatever work threw
    • resolveModel

      public static String resolveModel(LLMRequest request, String builtWith)
      Resolves the model for one request, per ADR-017: request.model() when it names one, otherwise the model the provider was built with.

      The result belongs on the request handed to the client, not only on the label of the response. Every adapter used to put it on the label alone, or not read it at all, so a request could name one model and be answered by another with nothing reporting it.

      Parameters:
      request - the request, which may or may not name a model
      builtWith - the provider's own model, used when the request names none
      Returns:
      the model to ask, and to label the answer with
    • applyCommonParameters

      public static void applyCommonParameters(dev.langchain4j.model.chat.request.ChatRequest.Builder builder, LLMRequest request)
      Applies every LLMRequest sampling field that ChatRequest.Builder exposes directly, skipping whatever the request left unset. Shared by all three adapters, which previously read none of these — a request could ask for topP or a stop sequence and every adapter would silently ignore it.

      Deliberately not applied here: LLMRequest.n(), which no bundled adapter's client library can honour — LangChain4j has no multiple-completions parameter to set it on — and LLMRequest.additionalParameters(), which has no generic equivalent below ChatRequest.Builder; each adapter maps it through its own provider-specific escape hatch where one exists (OpenAI's customParameters) rather than pretending one generic mapping covers all three.

      Parameters:
      builder - the request being built for the client
      request - the request as the caller specified it
    • wrap

      public static LLMException wrap(String provider, String model, Throwable cause)
      Wraps cause as an LLMException, classified as far as it can be.
      Parameters:
      provider - provider name, attached to the exception and used in its message
      model - the model the failed request named, or null if it was never resolved (a failure before the request reached the client)
      cause - whatever the LangChain4j client threw
      Returns:
      the classified exception; never wraps an LLMException a second time
    • orGeneratedId

      public static String orGeneratedId(String id)
      Returns id if present, or a freshly generated one.

      LLMResponse.builder(java.lang.String, java.lang.String) requires a non-null id, and OpenAI and Anthropic pass ChatResponse.id() straight through — a completed chat response that carries no id metadata therefore failed with a bare NullPointerException from inside the builder, on success, not on a provider failure. Ollama already synthesizes a UUID for this reason; this gives the other two the same fallback instead of the same defect twice, so the field means the same thing regardless of provider.

      Parameters:
      id - the id the client reported, or null
      Returns:
      id, or a new random one if it was null