+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import java.util.Objects;
+import net.ladenthin.llama.LlamaModel;
+
+/**
+ * langchain4j {@link ChatModel} backed by an in-process java-llama.cpp model (over JNI, no HTTP).
+ *
+ * The model is borrowed: this adapter never loads or closes it. Construct it from a
+ * {@link LlamaModel} you already own and keep managing that model's lifecycle (try-with-resources or
+ * an explicit {@code close()}). One {@code LlamaModel} can back several adapters at once.
+ *
+ *
Mapped today: messages (system/user/assistant/tool-result) and the sampling parameters
+ * {@code temperature}/{@code topP}/{@code topK}/{@code maxOutputTokens}/{@code stopSequences}.
+ * Tool specifications on the request are not yet forwarded, so this returns assistant text,
+ * not tool calls — see the module README for the planned tool-calling bridge.
+ */
+public final class JllamaChatModel implements ChatModel {
+
+ private final LlamaModel model;
+
+ /**
+ * Creates a chat model over a borrowed {@link LlamaModel}.
+ *
+ * @param model the loaded model to drive; not closed by this adapter
+ */
+ public JllamaChatModel(LlamaModel model) {
+ this.model = Objects.requireNonNull(model, "model");
+ }
+
+ @Override
+ public ChatResponse doChat(ChatRequest chatRequest) {
+ net.ladenthin.llama.value.ChatResponse response =
+ model.chat(LangChain4jMapping.toJllamaRequest(chatRequest));
+ return LangChain4jMapping.toLangChainResponse(response);
+ }
+}
diff --git a/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java
new file mode 100644
index 00000000..9a4b965f
--- /dev/null
+++ b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import dev.langchain4j.data.embedding.Embedding;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.model.output.Response;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import net.ladenthin.llama.LlamaModel;
+
+/**
+ * langchain4j {@link EmbeddingModel} backed by an in-process java-llama.cpp model.
+ *
+ * The backing {@link LlamaModel} must be loaded in embedding mode
+ * ({@code ModelParameters.enableEmbedding()}). The model is borrowed (never closed here) —
+ * see {@link JllamaChatModel}.
+ */
+public final class JllamaEmbeddingModel implements EmbeddingModel {
+
+ private final LlamaModel model;
+
+ /**
+ * Creates an embedding model over a borrowed {@link LlamaModel}.
+ *
+ * @param model the loaded embedding-mode model to drive; not closed by this adapter
+ */
+ public JllamaEmbeddingModel(LlamaModel model) {
+ this.model = Objects.requireNonNull(model, "model");
+ }
+
+ @Override
+ public Response> embedAll(List textSegments) {
+ List embeddings = new ArrayList<>(textSegments.size());
+ for (TextSegment segment : textSegments) {
+ embeddings.add(Embedding.from(model.embed(segment.text())));
+ }
+ return Response.from(embeddings);
+ }
+}
diff --git a/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java
new file mode 100644
index 00000000..37473c28
--- /dev/null
+++ b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.output.Response;
+import dev.langchain4j.model.scoring.ScoringModel;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import net.ladenthin.llama.LlamaModel;
+
+/**
+ * langchain4j {@link ScoringModel} (re-ranker) backed by an in-process java-llama.cpp model.
+ *
+ * Maps onto java-llama.cpp's native rerank endpoint, so the backing {@link LlamaModel} must be
+ * loaded in reranking mode ({@code ModelParameters.enableReranking()}). Scores are returned in the
+ * same order as the input segments. The model is borrowed (never closed here) — see
+ * {@link JllamaChatModel}.
+ */
+public final class JllamaScoringModel implements ScoringModel {
+
+ private final LlamaModel model;
+
+ /**
+ * Creates a scoring model over a borrowed {@link LlamaModel}.
+ *
+ * @param model the loaded reranking-mode model to drive; not closed by this adapter
+ */
+ public JllamaScoringModel(LlamaModel model) {
+ this.model = Objects.requireNonNull(model, "model");
+ }
+
+ @Override
+ public Response> scoreAll(List segments, String query) {
+ String[] documents = new String[segments.size()];
+ for (int i = 0; i < segments.size(); i++) {
+ documents[i] = segments.get(i).text();
+ }
+ double[] scores = LangChain4jMapping.parseRerankScores(model.handleRerank(query, documents), documents.length);
+ List result = new ArrayList<>(scores.length);
+ for (double score : scores) {
+ result.add(score);
+ }
+ return Response.from(result);
+ }
+}
diff --git a/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java
new file mode 100644
index 00000000..9bf2124a
--- /dev/null
+++ b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.model.chat.StreamingChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
+import dev.langchain4j.model.output.FinishReason;
+import java.util.Objects;
+import net.ladenthin.llama.LlamaIterable;
+import net.ladenthin.llama.LlamaModel;
+import net.ladenthin.llama.value.LlamaOutput;
+
+/**
+ * langchain4j {@link StreamingChatModel} backed by an in-process java-llama.cpp model.
+ *
+ * Each generated token is forwarded to {@link StreamingChatResponseHandler#onPartialResponse}; a
+ * final {@link StreamingChatResponseHandler#onCompleteResponse} carries the assembled assistant
+ * message. Any failure during generation is reported via {@link StreamingChatResponseHandler#onError}.
+ *
+ *
The model is borrowed (never closed here) — see {@link JllamaChatModel}. Tool
+ * specifications are not yet forwarded; this streams plain assistant text.
+ */
+public final class JllamaStreamingChatModel implements StreamingChatModel {
+
+ private final LlamaModel model;
+
+ /**
+ * Creates a streaming chat model over a borrowed {@link LlamaModel}.
+ *
+ * @param model the loaded model to drive; not closed by this adapter
+ */
+ public JllamaStreamingChatModel(LlamaModel model) {
+ this.model = Objects.requireNonNull(model, "model");
+ }
+
+ @Override
+ public void doChat(ChatRequest chatRequest, StreamingChatResponseHandler handler) {
+ StringBuilder full = new StringBuilder();
+ try (LlamaIterable stream = model.generateChat(LangChain4jMapping.toStreamingParameters(chatRequest))) {
+ for (LlamaOutput output : stream) {
+ full.append(output.text);
+ handler.onPartialResponse(output.text);
+ }
+ } catch (Exception e) {
+ handler.onError(e);
+ return;
+ }
+ handler.onCompleteResponse(
+ ChatResponse.builder()
+ .aiMessage(AiMessage.from(full.toString()))
+ .finishReason(FinishReason.STOP)
+ .build());
+ }
+}
diff --git a/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java
new file mode 100644
index 00000000..da0ca32b
--- /dev/null
+++ b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java
@@ -0,0 +1,188 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.data.message.Content;
+import dev.langchain4j.data.message.ContentType;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.TextContent;
+import dev.langchain4j.data.message.ToolExecutionResultMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.output.FinishReason;
+import dev.langchain4j.model.output.TokenUsage;
+import java.io.IOException;
+import java.util.List;
+import net.ladenthin.llama.json.RerankResponseParser;
+import net.ladenthin.llama.parameters.InferenceParameters;
+
+/**
+ * Pure (model-free) translation between langchain4j chat types and java-llama.cpp parameters.
+ *
+ * Every method here is a deterministic data transform with no JNI and no loaded model, so the
+ * mapping is unit-testable on its own (see {@code LangChain4jMappingTest}). The adapters keep the
+ * live-model calls; this class only reshapes their inputs and outputs.
+ */
+final class LangChain4jMapping {
+
+ private LangChain4jMapping() {}
+
+ /**
+ * Build a java-llama.cpp typed chat request from a langchain4j chat request. Messages map by
+ * role; sampling parameters ({@code temperature}/{@code topP}/{@code topK}/{@code
+ * maxOutputTokens}/{@code stopSequences}) ride along as an inference customizer.
+ */
+ static net.ladenthin.llama.parameters.ChatRequest toJllamaRequest(ChatRequest request) {
+ net.ladenthin.llama.parameters.ChatRequest jllama =
+ net.ladenthin.llama.parameters.ChatRequest.empty();
+ for (ChatMessage message : request.messages()) {
+ jllama = jllama.appendMessage(toJllamaMessage(message));
+ }
+ return jllama.withInferenceCustomizer(params -> applySampling(params, request));
+ }
+
+ /**
+ * Build the streaming inference parameters (messages JSON + sampling) for {@code generateChat}.
+ * Shares {@link #toJllamaRequest(ChatRequest)} so blocking and streaming stay in lockstep.
+ */
+ static InferenceParameters toStreamingParameters(ChatRequest request) {
+ net.ladenthin.llama.parameters.ChatRequest jllama = toJllamaRequest(request);
+ InferenceParameters params =
+ InferenceParameters.empty().withMessagesJson(jllama.buildMessagesJson());
+ return jllama.applyCustomizer(params);
+ }
+
+ /** Wrap a java-llama.cpp chat result as a langchain4j {@link ChatResponse}. */
+ static ChatResponse toLangChainResponse(net.ladenthin.llama.value.ChatResponse response) {
+ ChatResponse.Builder builder =
+ ChatResponse.builder().aiMessage(AiMessage.from(response.getFirstContent()));
+ net.ladenthin.llama.value.Usage usage = response.getUsage();
+ if (usage != null) {
+ builder.tokenUsage(
+ new TokenUsage((int) usage.getPromptTokens(), (int) usage.getCompletionTokens()));
+ }
+ List choices = response.getChoices();
+ String finishReason = choices.isEmpty() ? null : choices.get(0).getFinishReason();
+ return builder.finishReason(toFinishReason(finishReason)).build();
+ }
+
+ /**
+ * Map java-llama.cpp's OpenAI-style finish-reason string to the langchain4j enum. A {@code null}
+ * (no choices / reason absent) is treated as a normal {@code STOP}; an unrecognized value maps to
+ * {@code OTHER} rather than guessing.
+ */
+ static FinishReason toFinishReason(String reason) {
+ if (reason == null) {
+ return FinishReason.STOP;
+ }
+ switch (reason) {
+ case "stop":
+ return FinishReason.STOP;
+ case "length":
+ return FinishReason.LENGTH;
+ case "tool_calls":
+ return FinishReason.TOOL_EXECUTION;
+ case "content_filter":
+ return FinishReason.CONTENT_FILTER;
+ default:
+ return FinishReason.OTHER;
+ }
+ }
+
+ /**
+ * Align native rerank scores to input order. The native response is a JSON array of
+ * {@code {document, index, score}} objects whose {@code index} is the position in the input
+ * documents array; results may arrive in any order, so we place each score at its index.
+ *
+ * @param json the raw native rerank JSON array
+ * @param count the number of input documents (output length)
+ * @return scores indexed by input position; positions absent from the response stay {@code 0.0}
+ */
+ static double[] parseRerankScores(String json, int count) {
+ double[] scores = new double[count];
+ try {
+ JsonNode array = RerankResponseParser.OBJECT_MAPPER.readTree(json);
+ if (array.isArray()) {
+ int position = 0;
+ for (JsonNode entry : array) {
+ // "index" is the input position; fall back to array order when the field is
+ // absent so a response without it never silently yields all-zero scores.
+ int index = entry.path("index").asInt(position);
+ if (index >= 0 && index < count) {
+ scores[index] = entry.path("score").asDouble(0.0);
+ }
+ position++;
+ }
+ }
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to parse rerank response", e);
+ }
+ return scores;
+ }
+
+ private static net.ladenthin.llama.value.ChatMessage toJllamaMessage(ChatMessage message) {
+ switch (message.type()) {
+ case SYSTEM:
+ return new net.ladenthin.llama.value.ChatMessage(
+ "system", ((SystemMessage) message).text());
+ case USER:
+ return new net.ladenthin.llama.value.ChatMessage("user", userText((UserMessage) message));
+ case AI:
+ String aiText = ((AiMessage) message).text();
+ return new net.ladenthin.llama.value.ChatMessage(
+ "assistant", aiText == null ? "" : aiText);
+ case TOOL_EXECUTION_RESULT:
+ ToolExecutionResultMessage tool = (ToolExecutionResultMessage) message;
+ return net.ladenthin.llama.value.ChatMessage.toolResult(tool.id(), tool.text());
+ default:
+ // CUSTOM and any future type: no faithful chat-role mapping exists.
+ throw new IllegalArgumentException("Unsupported message type: " + message.type());
+ }
+ }
+
+ /** Flatten a (possibly multimodal) user message to text; non-text parts (images) are dropped. */
+ private static String userText(UserMessage message) {
+ if (message.hasSingleText()) {
+ return message.singleText();
+ }
+ StringBuilder text = new StringBuilder();
+ for (Content content : message.contents()) {
+ if (content.type() == ContentType.TEXT) {
+ text.append(((TextContent) content).text());
+ }
+ }
+ return text.toString();
+ }
+
+ private static InferenceParameters applySampling(InferenceParameters params, ChatRequest request) {
+ if (request.temperature() != null) {
+ params = params.withTemperature(request.temperature().floatValue());
+ }
+ if (request.topP() != null) {
+ params = params.withTopP(request.topP().floatValue());
+ }
+ if (request.topK() != null) {
+ params = params.withTopK(request.topK());
+ }
+ if (request.maxOutputTokens() != null) {
+ params = params.withNPredict(request.maxOutputTokens());
+ }
+ if (request.frequencyPenalty() != null) {
+ params = params.withFrequencyPenalty(request.frequencyPenalty().floatValue());
+ }
+ if (request.presencePenalty() != null) {
+ params = params.withPresencePenalty(request.presencePenalty().floatValue());
+ }
+ List stops = request.stopSequences();
+ if (stops != null && !stops.isEmpty()) {
+ params = params.withStopStrings(stops.toArray(new String[0]));
+ }
+ return params;
+ }
+}
diff --git a/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/package-info.java b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/package-info.java
new file mode 100644
index 00000000..28310e04
--- /dev/null
+++ b/langchain4j-jllama/src/main/java/net/ladenthin/llama/langchain4j/package-info.java
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+/**
+ * langchain4j adapters backed by an in-process java-llama.cpp {@link net.ladenthin.llama.LlamaModel}
+ * over JNI — no HTTP server, no separate process.
+ *
+ *
+ * - {@link net.ladenthin.llama.langchain4j.JllamaChatModel} — {@code ChatModel}
+ * - {@link net.ladenthin.llama.langchain4j.JllamaStreamingChatModel} — {@code StreamingChatModel}
+ * - {@link net.ladenthin.llama.langchain4j.JllamaEmbeddingModel} — {@code EmbeddingModel}
+ * - {@link net.ladenthin.llama.langchain4j.JllamaScoringModel} — {@code ScoringModel} (re-ranking)
+ *
+ *
+ * Every adapter borrows a model the caller has already loaded and keeps owning: the
+ * adapter never loads or closes the native model. This artifact depends on {@code langchain4j-core}
+ * but the core {@code net.ladenthin:llama} binding does not depend on langchain4j, so plain
+ * java-llama.cpp users never pull langchain4j transitively.
+ */
+package net.ladenthin.llama.langchain4j;
diff --git a/langchain4j-jllama/src/test/java/net/ladenthin/llama/langchain4j/JllamaChatModelIntegrationTest.java b/langchain4j-jllama/src/test/java/net/ladenthin/llama/langchain4j/JllamaChatModelIntegrationTest.java
new file mode 100644
index 00000000..ea4b18ec
--- /dev/null
+++ b/langchain4j-jllama/src/test/java/net/ladenthin/llama/langchain4j/JllamaChatModelIntegrationTest.java
@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import net.ladenthin.llama.LlamaModel;
+import net.ladenthin.llama.parameters.ModelParameters;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end smoke test over a real model. Self-skips unless a GGUF is provided via
+ * {@code -Dnet.ladenthin.llama.model.path=/abs/path/to/model.gguf} (and the native library is on
+ * the path), mirroring the core project's model-gated tests, so a model-free checkout stays green.
+ */
+class JllamaChatModelIntegrationTest {
+
+ private static Path modelPath() {
+ String path = System.getProperty("net.ladenthin.llama.model.path");
+ Assumptions.assumeTrue(path != null && !path.isEmpty(), "model path property not set");
+ Path resolved = Paths.get(path);
+ Assumptions.assumeTrue(Files.exists(resolved), "model file not present: " + resolved);
+ return resolved;
+ }
+
+ @Test
+ void chatReturnsAssistantText() {
+ Path model = modelPath();
+ try (LlamaModel llama = new LlamaModel(new ModelParameters().setModel(model.toString()))) {
+ JllamaChatModel chat = new JllamaChatModel(llama);
+
+ ChatResponse response =
+ chat.chat(
+ ChatRequest.builder()
+ .messages(UserMessage.from("Reply with the single word: ok"))
+ .maxOutputTokens(8)
+ .build());
+
+ assertThat(response.aiMessage(), is(notNullValue()));
+ assertThat(response.aiMessage().text(), is(notNullValue()));
+ }
+ }
+
+ @Test
+ void streamingDeliversTokensThenCompletes() throws Exception {
+ Path model = modelPath();
+ try (LlamaModel llama = new LlamaModel(new ModelParameters().setModel(model.toString()))) {
+ JllamaStreamingChatModel streaming = new JllamaStreamingChatModel(llama);
+ StringBuilder streamed = new StringBuilder();
+ CompletableFuture done = new CompletableFuture<>();
+
+ streaming.chat(
+ ChatRequest.builder()
+ .messages(UserMessage.from("Reply with the single word: ok"))
+ .maxOutputTokens(8)
+ .build(),
+ new StreamingChatResponseHandler() {
+ @Override
+ public void onPartialResponse(String partial) {
+ streamed.append(partial);
+ }
+
+ @Override
+ public void onCompleteResponse(ChatResponse complete) {
+ done.complete(complete);
+ }
+
+ @Override
+ public void onError(Throwable error) {
+ done.completeExceptionally(error);
+ }
+ });
+
+ ChatResponse complete = done.get(60, TimeUnit.SECONDS);
+ assertThat(complete.aiMessage().text(), is(streamed.toString()));
+ }
+ }
+}
diff --git a/langchain4j-jllama/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java b/langchain4j-jllama/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java
new file mode 100644
index 00000000..745213c0
--- /dev/null
+++ b/langchain4j-jllama/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java
@@ -0,0 +1,135 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: MIT
+
+package net.ladenthin.llama.langchain4j;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.SystemMessage;
+import dev.langchain4j.data.message.TextContent;
+import dev.langchain4j.data.message.ToolExecutionResultMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.output.FinishReason;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import net.ladenthin.llama.parameters.InferenceParameters;
+import net.ladenthin.llama.value.ChatMessage;
+import org.junit.jupiter.api.Test;
+
+/** Model-free tests for the pure langchain4j<->java-llama.cpp transforms. */
+class LangChain4jMappingTest {
+
+ @Test
+ void mapsEveryRoleAndContent() {
+ ChatRequest request =
+ ChatRequest.builder()
+ .messages(
+ SystemMessage.from("you are terse"),
+ UserMessage.from("hi"),
+ AiMessage.from("hello"),
+ ToolExecutionResultMessage.from("call_1", "search", "42"))
+ .build();
+
+ List messages = LangChain4jMapping.toJllamaRequest(request).getMessages();
+
+ List roles = new ArrayList<>();
+ List contents = new ArrayList<>();
+ for (ChatMessage message : messages) {
+ roles.add(message.getRole());
+ contents.add(message.getContent());
+ }
+ assertThat(roles, contains("system", "user", "assistant", "tool"));
+ assertThat(contents, contains("you are terse", "hi", "hello", "42"));
+ }
+
+ @Test
+ void flattensMultimodalUserMessageToText() {
+ ChatRequest request =
+ ChatRequest.builder()
+ .messages(UserMessage.from(TextContent.from("Hello "), TextContent.from("world")))
+ .build();
+
+ ChatMessage mapped = LangChain4jMapping.toJllamaRequest(request).getMessages().get(0);
+
+ assertThat(mapped.getRole(), is("user"));
+ assertThat(mapped.getContent(), is("Hello world"));
+ }
+
+ @Test
+ void appliesSamplingParametersToInferenceJson() {
+ ChatRequest request =
+ ChatRequest.builder()
+ .messages(UserMessage.from("hi"))
+ .temperature(0.3)
+ .topK(40)
+ .maxOutputTokens(64)
+ .frequencyPenalty(0.5)
+ .presencePenalty(0.25)
+ .stopSequences(Arrays.asList("STOP"))
+ .build();
+
+ String json = LangChain4jMapping.toStreamingParameters(request).toString();
+
+ assertThat(json, containsString("\"temperature\""));
+ assertThat(json, containsString("\"top_k\""));
+ assertThat(json, containsString("\"n_predict\""));
+ assertThat(json, containsString("\"frequency_penalty\""));
+ assertThat(json, containsString("\"presence_penalty\""));
+ assertThat(json, containsString("\"stop\""));
+ // Messages must survive into the streaming parameter blob too.
+ assertThat(json, containsString("hi"));
+ }
+
+ @Test
+ void mapsFinishReasonStrings() {
+ assertThat(LangChain4jMapping.toFinishReason("stop"), is(FinishReason.STOP));
+ assertThat(LangChain4jMapping.toFinishReason("length"), is(FinishReason.LENGTH));
+ assertThat(LangChain4jMapping.toFinishReason("tool_calls"), is(FinishReason.TOOL_EXECUTION));
+ assertThat(LangChain4jMapping.toFinishReason("content_filter"), is(FinishReason.CONTENT_FILTER));
+ assertThat(LangChain4jMapping.toFinishReason("something_new"), is(FinishReason.OTHER));
+ // No choices / absent reason is the normal terminal state.
+ assertThat(LangChain4jMapping.toFinishReason(null), is(FinishReason.STOP));
+ }
+
+ @Test
+ void rerankScoresAlignToInputOrderNotResponseOrder() {
+ // Native results arrive out of order; "index" is the input position.
+ String json =
+ "[{\"document\":\"b\",\"index\":1,\"score\":0.9},"
+ + "{\"document\":\"a\",\"index\":0,\"score\":0.1}]";
+
+ double[] scores = LangChain4jMapping.parseRerankScores(json, 2);
+
+ assertThat(scores.length, is(2));
+ assertThat(scores[0], is(0.1));
+ assertThat(scores[1], is(0.9));
+ }
+
+ @Test
+ void rerankScoresDefaultToZeroForMissingEntries() {
+ double[] scores = LangChain4jMapping.parseRerankScores("[]", 3);
+
+ assertThat(scores.length, is(3));
+ assertThat(scores[0], is(0.0));
+ assertThat(scores[1], is(0.0));
+ assertThat(scores[2], is(0.0));
+ }
+
+ @Test
+ void rerankScoresFallBackToArrayOrderWhenIndexAbsent() {
+ // No "index" field: array position is used, so scores are not silently all-zero.
+ String json = "[{\"document\":\"a\",\"score\":0.7},{\"document\":\"b\",\"score\":0.2}]";
+
+ double[] scores = LangChain4jMapping.parseRerankScores(json, 2);
+
+ assertThat(scores[0], is(0.7));
+ assertThat(scores[1], is(0.2));
+ }
+}