From d6a9be5b37c2214276754d76f439cdec80d52169 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:54:04 +0000 Subject: [PATCH 001/589] Do not truncate a tool schema with trailing content ChatRequest.buildToolsJson() read each ToolDefinition's schema with a plain readTree, which parses the first JSON value and ignores the rest. A schema string holding more than one value was therefore sent as a different, shorter schema without any error. It is now read with FAIL_ON_TRAILING_TOKENS, as JsonParameters does, so such a string takes the existing invalid-JSON fallback instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/parameters/ChatRequest.java | 10 +++++++++- .../llama/parameters/ChatRequestTest.java | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java b/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java index b855241d8..dbc9ad2e9 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java @@ -4,7 +4,9 @@ package net.ladenthin.llama.parameters; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -77,6 +79,12 @@ public final class ChatRequest { private static final ObjectMapper MAPPER = new ObjectMapper(); + + // Reads a tool's parameter schema. FAIL_ON_TRAILING_TOKENS makes a string holding more than one + // JSON value take the fallback below instead of being truncated to its first value without a + // word -- the same reason JsonParameters reads with it. + private static final ObjectReader SCHEMA_READER = + MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private static final ParameterJsonSerializer MESSAGE_SERIALIZER = new ParameterJsonSerializer(); /** @@ -339,7 +347,7 @@ public Optional buildToolsJson() { fn.put("name", t.getName()); fn.put("description", t.getDescription()); try { - fn.set("parameters", MAPPER.readTree(t.getParametersSchemaJson())); + fn.set("parameters", SCHEMA_READER.readTree(t.getParametersSchemaJson())); } catch (IOException e) { fn.put("parameters", t.getParametersSchemaJson()); } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java index 2b1ae2dcd..f34822346 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java @@ -227,5 +227,25 @@ void buildMessagesJsonEmitsInputAudioParts() { void buildToolsJsonEmptyWhenNoTools() { assertThat(ChatRequest.empty().buildToolsJson().isPresent(), is(false)); } + + @Test + void buildToolsJsonEmbedsAValidSchemaAsAnObject() { + String json = ChatRequest.empty() + .appendTool(new ToolDefinition("f", "d", "{\"type\":\"object\"}")) + .buildToolsJson() + .orElseThrow(IllegalStateException::new); + assertThat(json, containsString("\"parameters\":{\"type\":\"object\"}")); + } + + @Test + void buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent() { + // A schema string holding more than one JSON value is not a schema; keeping only the first + // value would send a different schema than the caller wrote, without any error. + String json = ChatRequest.empty() + .appendTool(new ToolDefinition("f", "d", "{\"type\":\"object\"} {\"required\":[\"x\"]}")) + .buildToolsJson() + .orElseThrow(IllegalStateException::new); + assertThat(json, not(containsString("\"parameters\":{\"type\":\"object\"}"))); + } } } From a4e5b221a2d57af2b257e419aa44c5c99cccb393 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 22:58:39 +0000 Subject: [PATCH 002/589] Saturate the awaitModelLoaded deadline instead of overflowing System.currentTimeMillis() + timeoutMillis wrapped to a negative deadline for a timeout near Long.MAX_VALUE, the natural way to say "as long as it takes". The loop then never polled and the method threw "did not reach LOADED" for a model that was already loaded. The deadline now saturates at Long.MAX_VALUE. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../java/net/ladenthin/llama/server/RouterClient.java | 5 ++++- .../net/ladenthin/llama/server/RouterClientTest.java | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index f433142cc..6ea211481 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -201,7 +201,10 @@ public void unloadModel(String modelId) throws IOException { * failed (exit code included), or the timeout elapses */ public RouterModel awaitModelLoaded(String modelId, long timeoutMillis) throws IOException, InterruptedException { - long deadline = System.currentTimeMillis() + timeoutMillis; + long now = System.currentTimeMillis(); + // Saturate instead of overflowing: Long.MAX_VALUE ("as long as it takes") would otherwise wrap + // into a deadline in the past and time out before the first poll. + long deadline = timeoutMillis > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + timeoutMillis; RouterModel last = null; while (System.currentTimeMillis() < deadline) { Optional model = findModel(modelId); diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java index f0177535c..8ba73dba1 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java @@ -185,6 +185,17 @@ public void awaitModelLoaded_pollsUntilLoaded() throws Exception { assertThat(loaded.getStatus(), is(RouterModel.Status.LOADED)); } + @Test + public void awaitModelLoaded_longMaxValueTimeoutStillPolls() throws Exception { + // "wait as long as it takes": currentTimeMillis() + Long.MAX_VALUE must not overflow into a + // deadline in the past, which would skip polling and report a timeout for a loaded model. + modelsBody.set("{\"data\":[" + entry("qwen", "loaded") + "]}"); + + RouterModel loaded = client.awaitModelLoaded("qwen", Long.MAX_VALUE); + + assertThat(loaded.getStatus(), is(RouterModel.Status.LOADED)); + } + @Test public void awaitModelLoaded_failsFastOnFailedWorker() { modelsBody.set("{\"data\":[{\"id\":\"qwen\"," From 5b98dc78e2fe04ecef5dae573c886ed8a9ba7c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:00:29 +0000 Subject: [PATCH 003/589] Bracket an IPv6 literal host in RouterClient's request URL RouterClient built "http://" + host + ":" + port, so a router bound with --host ::1 and addressed as new RouterClient("::1", port) produced "http://::1:port/...", which java.net.URL rejects with a MalformedURLException before any connection is made. An IPv6 literal is now bracketed; a host that is already bracketed is left as it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/server/RouterClient.java | 13 ++++++++++++- .../ladenthin/llama/server/RouterClientTest.java | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index 6ea211481..3f14aec77 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -247,7 +247,7 @@ private static String modelBody(String modelId) { private String request(String method, String path, @Nullable String body) throws IOException { // URI.create(...).toURL() instead of the URL(String) constructor: the latter is // deprecated (no validation/encoding) — flagged by CodeQL; this form is Java-8-safe. - URL url = URI.create("http://" + host + ":" + port + path).toURL(); + URL url = URI.create("http://" + urlHost(host) + ":" + port + path).toURL(); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); try { connection.setRequestMethod(method); @@ -275,6 +275,17 @@ private String request(String method, String path, @Nullable String body) throws } } + /** + * Returns {@code host} as it goes into a URL authority: an IPv6 literal is bracketed + * ({@code ::1} -> {@code [::1]}), anything else is unchanged. + * + * @param host the configured host name or address + * @return the host part of the request URL + */ + private static String urlHost(String host) { + return host.indexOf(':') >= 0 && !host.startsWith("[") ? "[" + host + "]" : host; + } + @Override public String toString() { return "RouterClient(http://" + host + ":" + port + ")"; diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java index 8ba73dba1..50ca8cd9c 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java @@ -16,6 +16,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.net.MalformedURLException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -196,6 +197,19 @@ public void awaitModelLoaded_longMaxValueTimeoutStillPolls() throws Exception { assertThat(loaded.getStatus(), is(RouterModel.Status.LOADED)); } + @Test + public void ipv6LiteralHostIsBracketedInTheRequestUrl() { + // A router started with --host ::1 is reached as http://[::1]:port. An unbracketed literal is + // not a valid authority: "http://::1:1/models" fails as a MalformedURLException before any + // connection is attempted. Port 1 has nothing listening, so the bracketed URL fails to connect + // instead (or, on a host without IPv6, to create the socket) -- an IOException of another kind. + RouterClient ipv6 = new RouterClient("::1", 1); + + IOException e = assertThrows(IOException.class, ipv6::listModels); + + assertThat(e instanceof MalformedURLException, is(false)); + } + @Test public void awaitModelLoaded_failsFastOnFailedWorker() { modelsBody.set("{\"data\":[{\"id\":\"qwen\"," From 61ea8f5dff8b446f2cfda9df1cd7e5782db3b90a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:10:36 +0000 Subject: [PATCH 004/589] Keep the loader's temp cleanup away from foreign llama*/ggml* entries cleanup() deleted every entry of the temp directory whose name starts with jllama, llama or ggml and is not a jllama-backend-* directory, at once and recursively, on every start that reaches the natives jars. It was meant for the single library files of the layout before 6.0.0, but the temp directory is shared: a llama.cpp checkout in /tmp/llama.cpp, a /tmp/ggml-models directory or a downloaded /tmp/llama-2-7b.gguf went with them. Only regular files named like a native library (.so, .dll, .dylib, .jnilib, .metal) are treated as the old layout now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../ladenthin/llama/loader/LlamaLoader.java | 29 +++++++++++++++++-- .../llama/loader/LlamaLoaderTest.java | 15 ++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java b/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java index eb7e607e5..35d2521fc 100644 --- a/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java +++ b/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java @@ -259,6 +259,10 @@ public class LlamaLoader { */ static final Duration STALE_EXTRACTION_AGE = Duration.ofMinutes(10); + /** File-name endings of what the flat layout before 6.0.0 extracted (see {@link #isFlatLayoutLibraryName}). */ + private static final List FLAT_LAYOUT_SUFFIXES = + Collections.unmodifiableList(Arrays.asList(".so", ".dll", ".dylib", ".jnilib", ".metal")); + /** Shader source a non-embedding Metal build ships next to its library. */ private static final String METAL_SOURCE_FILE = "ggml-metal.metal"; @@ -329,8 +333,8 @@ private static void cleanup(Set keep) { } /** - * Whether {@link #cleanup(Set)} removes {@code path}: a {@link #shouldCleanPath named} temp entry - * that is not a per-build extraction directory (the pre-6.0.0 flat layout), or an extraction + * Whether {@link #cleanup(Set)} removes {@code path}: a {@link #shouldCleanPath named} regular file + * with a {@link #isFlatLayoutLibraryName library name} (the pre-6.0.0 flat layout), or an extraction * directory of a build not in {@code keep} whose last modification is more than * {@link #STALE_EXTRACTION_AGE} before {@code now}. A directory whose modification time cannot be * read is left alone. @@ -347,7 +351,10 @@ static boolean isStaleExtraction(Path path, Set keep, Instant now) { Path fileNamePath = path.getFileName(); String name = fileNamePath == null ? "" : fileNamePath.toString(); if (!name.startsWith(BACKEND_TEMP_DIR_PREFIX) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { - return true; + // The old flat layout extracted single library files. The temp directory is shared with + // everything else on the machine, so only a regular file named like a native library goes: + // a llama.cpp checkout or a downloaded model carries the same prefix and is not ours. + return Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && isFlatLayoutLibraryName(name); } if (keep.contains(name)) { return false; @@ -362,6 +369,22 @@ static boolean isStaleExtraction(Path path, Set keep, Instant now) { } } + /** + * Whether a temp-directory file name is one the layout before 6.0.0 extracted: a native library + * ({@code .so}, {@code .dll}, {@code .dylib}, {@code .jnilib}) or the Metal shader source. + * + * @param name the file name, already known to carry a {@link #shouldCleanPath cleanup prefix} + * @return whether the file is an extracted library of the old layout + */ + static boolean isFlatLayoutLibraryName(String name) { + for (String suffix : FLAT_LAYOUT_SUFFIXES) { + if (name.endsWith(suffix)) { + return true; + } + } + return false; + } + /** * The extraction directory names this classpath uses -- one per library backend present, keyed by * the library and the modules selected with it -- computed before anything is extracted so the diff --git a/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java b/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java index 978771edf..89db1fd4c 100644 --- a/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java +++ b/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java @@ -691,6 +691,21 @@ public void staleExtractionRemovesTheFlatFilesOfTheOldLayoutAtOnce(@TempDir Path assertFalse(LlamaLoader.isStaleExtraction(unrelated, Collections.emptySet(), now)); } + @Test + public void staleExtractionLeavesForeignEntriesWithAMatchingPrefixAlone(@TempDir Path dir) throws IOException { + // The temp directory is shared with everything else on the machine. A llama.cpp checkout or a + // model downloaded there carries the same prefixes as the old flat layout, but is neither a + // library nor ours: cleanup() deleted both, the checkout recursively, on every start. + Path checkout = Files.createDirectory(dir.resolve("llama.cpp")); + Files.write(checkout.resolve("README.md"), new byte[] {1}); + Path models = Files.createDirectory(dir.resolve("ggml-models")); + Path model = Files.write(dir.resolve("llama-2-7b.Q4_K_M.gguf"), new byte[] {1}); + Instant now = Instant.now(); + assertFalse(LlamaLoader.isStaleExtraction(checkout, Collections.emptySet(), now)); + assertFalse(LlamaLoader.isStaleExtraction(models, Collections.emptySet(), now)); + assertFalse(LlamaLoader.isStaleExtraction(model, Collections.emptySet(), now)); + } + @Test public void extractionDirectoriesOfThisClasspathNameTheLibraryWithItsModules() { String archProp = LlamaSystemProperties.PREFIX + ".osinfo.architecture"; From ef7295c024e723d5c8ffaaf86f6ee041e647f1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:31:53 +0000 Subject: [PATCH 005/589] Map Ollama message images onto image_url content parts /api/chat copied each Ollama message as it came. Ollama sends images as an "images" array of bare base64 strings beside "content", which the OpenAI-side parser never reads, so the images were dropped silently and a vision model answered as if none had been sent -- while /api/show advertises the "vision" capability. The images now become image_url data URL parts after the text, with the MIME type read from the encoded magic bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/OllamaApiSupport.java | 43 +++++++++++++++++++ .../llama/server/OllamaApiSupportTest.java | 18 ++++++++ 2 files changed, 61 insertions(+) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 21f566b7f..6872e5aa6 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -163,6 +163,26 @@ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { ObjectNode message = ollamaMessage.deepCopy(); + // Ollama carries images as an "images" array of bare base64 strings beside "content"; the + // OpenAI parser reads only image_url content parts, so without this the images were dropped + // and a vision model answered as if none had been sent. + JsonNode images = message.remove("images"); + if (images != null && images.isArray() && images.size() > 0) { + ArrayNode parts = OBJECT_MAPPER.createArrayNode(); + JsonNode content = message.path("content"); + if (content.isTextual() && !content.asText().isEmpty()) { + parts.addObject().put("type", "text").put("text", content.asText()); + } + for (JsonNode image : images) { + if (image.isTextual()) { + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", toImageDataUrl(image.asText())); + } + } + message.set("content", parts); + } JsonNode toolCalls = message.path("tool_calls"); if (toolCalls.isArray()) { for (JsonNode toolCall : toolCalls) { @@ -175,6 +195,29 @@ private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { return message; } + /** + * Turns an Ollama image (bare base64, or already a data URL) into the data URL an OpenAI + * {@code image_url} part carries. The MIME type is read from the encoded magic bytes; llama.cpp + * decodes by content and only requires the {@code data:image/...;base64,} shape, so an unknown + * format falls back to {@code image/png}. + */ + private static String toImageDataUrl(String image) { + if (image.startsWith("data:")) { + return image; + } + String mimeType; + if (image.startsWith("/9j/")) { + mimeType = "image/jpeg"; + } else if (image.startsWith("R0lGOD")) { + mimeType = "image/gif"; + } else if (image.startsWith("UklGR")) { + mimeType = "image/webp"; + } else { + mimeType = "image/png"; + } + return "data:" + mimeType + ";base64," + image; + } + private static void copyNumber(JsonNode from, String fromKey, ObjectNode to, String toKey) { JsonNode value = from.path(fromKey); if (value.isNumber()) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 660d3cb96..53a8b9c53 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -89,6 +89,24 @@ public void toOpenAiChatRequestStringifiesAssistantToolCallArguments() throws IO assertThat(read(arguments.asText()).path("a").asInt(), is(1)); } + @Test + public void toOpenAiChatRequestTurnsOllamaImagesIntoImageUrlParts() throws IOException { + // Ollama sends images as a "images" array of bare base64 strings next to "content"; the + // OpenAI surface only reads image_url content parts, so the images must be converted or + // the model answers without them (while /api/show advertises "vision"). + String png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest(read("{\"messages\":[{\"role\":\"user\"," + + "\"content\":\"what is this?\",\"images\":[\"" + png + "\"]}]}")); + JsonNode message = openAi.path("messages").get(0); + JsonNode content = message.path("content"); + assertThat(content.isArray(), is(true)); + assertThat(content.get(0).path("type").asText(), is("text")); + assertThat(content.get(0).path("text").asText(), is("what is this?")); + assertThat(content.get(1).path("type").asText(), is("image_url")); + assertThat(content.get(1).path("image_url").path("url").asText(), is("data:image/png;base64," + png)); + assertThat(message.has("images"), is(false)); + } + @Test public void toOpenAiChatRequestMapsFormatJsonToResponseFormat() throws IOException { JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest( From abe776ce626c3df8ceffab3b9978cf8077666718 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:37:46 +0000 Subject: [PATCH 006/589] Keep setRopeScaling within what llama.cpp's --rope-scaling accepts llama.cpp parses --rope-scaling as none, linear or yarn and throws "invalid value" for anything else (common/arg.cpp at b11639), which makes the whole model fail to load. RopeScalingType also offers UNSPECIFIED, LONGROPE and MAX_VALUE, and setRopeScaling forwarded them as-is. UNSPECIFIED now removes the option and leaves the model's choice, as setPoolingType does; LONGROPE and MAX_VALUE, which exist in the C API but not on the command line, are rejected with an IllegalArgumentException at the call. testSetRopeScalingAllValues asserted the string mapping of every constant and so pinned the defect; it now covers the three accepted values, and the reflective setter driver of WireNameRegistryTest steps back to the next constant when one is rejected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/parameters/ModelParameters.java | 13 +++++++++++ .../ModelParametersExtendedTest.java | 23 +++++++++++++++++-- .../parameters/WireNameRegistryTest.java | 4 +++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index eb39125ca..90d097c56 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -659,11 +659,24 @@ public ModelParameters setPoolingType(PoolingType type) { /** * Set RoPE frequency scaling method (default: linear unless specified by the model). + * {@link RopeScalingType#UNSPECIFIED} removes the option, leaving the model's own choice. + * llama.cpp's {@code --rope-scaling} accepts only {@code none}, {@code linear} and {@code yarn}; + * {@link RopeScalingType#LONGROPE} and {@link RopeScalingType#MAX_VALUE} exist in its C API but not + * on its command line, and passed through they would make the model fail to load. * * @param type the RoPE frequency scaling method * @return this builder + * @throws IllegalArgumentException for {@link RopeScalingType#LONGROPE} or {@link RopeScalingType#MAX_VALUE} */ public ModelParameters setRopeScaling(RopeScalingType type) { + if (type == RopeScalingType.UNSPECIFIED) { + removeOption(ModelOption.ROPE_SCALING); + return this; + } + if (type == RopeScalingType.LONGROPE || type == RopeScalingType.MAX_VALUE) { + throw new IllegalArgumentException("--rope-scaling accepts none, linear or yarn; " + type + " (\"" + + type.getArgValue() + "\") is rejected by llama.cpp's argument parser"); + } return putEnum(ModelOption.ROPE_SCALING, type); } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java index d96552cc6..d2c2d1410 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java @@ -15,6 +15,7 @@ import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.sameInstance; import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.HashMap; import java.util.Map; @@ -1070,13 +1071,31 @@ public void testSetFitFalse() { // ------------------------------------------------------------------------- @Test - public void testSetRopeScalingAllValues() { - for (RopeScalingType type : RopeScalingType.values()) { + public void testSetRopeScalingAllValuesTheParserAccepts() { + // llama.cpp's --rope-scaling accepts exactly none, linear and yarn (common/arg.cpp) and throws + // "invalid value" for anything else, which makes the model fail to load. + for (RopeScalingType type : + new RopeScalingType[] {RopeScalingType.NONE, RopeScalingType.LINEAR, RopeScalingType.YARN2}) { ModelParameters p = new ModelParameters().setRopeScaling(type); assertThat(p.parameters.get("--rope-scaling"), is(type.getArgValue())); } } + @Test + public void testSetRopeScalingUnspecifiedLeavesTheModelDefault() { + ModelParameters p = new ModelParameters() + .setRopeScaling(RopeScalingType.LINEAR) + .setRopeScaling(RopeScalingType.UNSPECIFIED); + assertThat(p.parameters.containsKey("--rope-scaling"), is(false)); + } + + @Test + public void testSetRopeScalingRejectsValuesTheParserDoesNotAccept() { + for (RopeScalingType type : new RopeScalingType[] {RopeScalingType.LONGROPE, RopeScalingType.MAX_VALUE}) { + assertThrows(IllegalArgumentException.class, () -> new ModelParameters().setRopeScaling(type)); + } + } + // ------------------------------------------------------------------------- // MiroStat — all values // ------------------------------------------------------------------------- diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java index bb8f5f61d..5406414a7 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java @@ -353,8 +353,10 @@ private static Set drivePublicSetters() { } else if (type == String.class) { args[i] = sampleString(shape); } else if (type.isEnum() && CliArg.class.isAssignableFrom(type)) { + // The last constant first; each further shape steps one back, so a setter that + // rejects a constant llama.cpp does not accept (setRopeScaling) is still reached. Object[] values = type.getEnumConstants(); - args[i] = values[values.length - 1]; + args[i] = values[values.length - 1 - (shape % values.length)]; } else if (java.util.Map.class.isAssignableFrom(type)) { args[i] = sampleMap(shape); } else if (java.util.Collection.class.isAssignableFrom(type)) { From 558422a5fe40a5a35f9258ba667dfa9e40bcab98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:06:38 +0000 Subject: [PATCH 007/589] Keep input_image parts of a Responses API request The Responses translator flattened every message content array to its text, so an input_image part never reached the chat core and a vision model answered as if no image had been sent. Messages carrying images now become OpenAI content-part arrays with image_url parts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/ResponsesApiSupport.java | 42 ++++++++++++++++++- .../llama/server/ResponsesApiSupportTest.java | 16 +++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 23d1ce035..ac50ae451 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; +import org.jspecify.annotations.Nullable; /** * Pure translators between the OpenAI Responses API ({@code POST /v1/responses}) and the internal OpenAI @@ -134,12 +135,51 @@ private static void appendInput(ArrayNode messages, JsonNode input) { default: ObjectNode message = messages.addObject(); message.put("role", item.path("role").asText("user")); - message.put("content", inputContentText(item.path("content"))); + JsonNode imageContent = inputContentWithImages(item.path("content")); + if (imageContent != null) { + message.set("content", imageContent); + } else { + message.put("content", inputContentText(item.path("content"))); + } break; } } } + /** + * The OpenAI content-part array for a Responses content array that carries {@code input_image} + * parts, or {@code null} when it carries none. The OpenAI parser reads images only from + * {@code image_url} parts, so flattening such a message to its text dropped the image and a + * vision model answered as if none had been sent. + */ + private static @Nullable ArrayNode inputContentWithImages(JsonNode content) { + if (!content.isArray()) { + return null; + } + ArrayNode parts = OBJECT_MAPPER.createArrayNode(); + boolean hasImage = false; + for (JsonNode part : content) { + if ("input_image".equals(part.path("type").asText(""))) { + JsonNode imageUrl = part.path("image_url"); + String url = imageUrl.isTextual() + ? imageUrl.asText() + : imageUrl.path("url").asText(""); + if (!url.isEmpty()) { + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", url); + hasImage = true; + } + } else if (part.path("text").isTextual()) { + parts.addObject() + .put("type", "text") + .put("text", part.path("text").asText()); + } + } + return hasImage ? parts : null; + } + private static String inputContentText(JsonNode content) { if (content.isTextual()) { return content.asText(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 75e82bd68..101a8b96a 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -71,6 +71,22 @@ public void requestMapsInputArrayMessageAndFunctionCallItems() throws IOExceptio assertThat(openAi.path("messages").get(2).path("content").asText(), is("ok")); } + @Test + public void requestTurnsInputImagePartsIntoImageUrlParts() throws IOException { + String responses = "{\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[" + + "{\"type\":\"input_text\",\"text\":\"what is this?\"}," + + "{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,iVBORw0K\"}]}]}"; + JsonNode content = ResponsesApiSupport.toOpenAiChatRequest(read(responses)) + .path("messages") + .get(0) + .path("content"); + assertThat(content.isArray(), is(true)); + assertThat(content.get(0).path("type").asText(), is("text")); + assertThat(content.get(0).path("text").asText(), is("what is this?")); + assertThat(content.get(1).path("type").asText(), is("image_url")); + assertThat(content.get(1).path("image_url").path("url").asText(), is("data:image/png;base64,iVBORw0K")); + } + @Test public void requestWithoutInstructionsHasNoSystemMessage() throws IOException { JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":\"hi\"}")); From 5df523ff05852b30599a8a5d58069b086bb99de5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:13:34 +0000 Subject: [PATCH 008/589] Serialize NativeServer start, isRunning and close close() read the handle, checked it for zero and cleared it without synchronization, so two concurrent calls (a shutdown hook and a try-with-resources, or NativeServer.main's own hook and finally) could both pass the check and free the native server twice. isRunning() could likewise query a handle a concurrent close() was freeing. A private lock now serializes the three; measured without the native library, 4 of 20000 paired close() calls reached the native stop twice before, 0 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../ladenthin/llama/server/NativeServer.java | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java b/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java index 1e87d28ba..fae453e28 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java @@ -103,6 +103,10 @@ public final class NativeServer implements AutoCloseable { /** Native handle (pointer) while running, or {@code 0} when not started / stopped. */ private volatile long handle; + /** Serializes {@link #start()}, {@link #isRunning()} and {@link #close()} over {@link #handle}. */ + @ToString.Exclude + private final Object lifecycleLock = new Object(); + /** * Creates a native-server bridge for the given llama-server arguments. * @@ -172,23 +176,27 @@ public NativeServer(LlamaModel model, String... args) { * {@code NativeServer} is already running in this process */ public NativeServer start() { - if (handle != 0) { - throw new IllegalStateException("NativeServer already started"); - } - if (!RUNNING.compareAndSet(false, true)) { - throw new IllegalStateException( - "another NativeServer is already running in this process (only one is supported)"); - } - try { - // Load libjllama lazily here (not in a static initializer) so construction, argument - // parsing and close() stay usable — and unit-testable — without the native library. - LlamaLoader.initialize(); - handle = attachedModel != null ? startAttachedNativeServer(attachedModel, args) : startNativeServer(args); - } catch (final RuntimeException | Error e) { - RUNNING.set(false); - throw e; + synchronized (lifecycleLock) { + if (handle != 0) { + throw new IllegalStateException("NativeServer already started"); + } + if (!RUNNING.compareAndSet(false, true)) { + throw new IllegalStateException( + "another NativeServer is already running in this process (only one is supported)"); + } + try { + // Load libjllama lazily here (not in a static initializer) so construction, argument + // parsing and close() stay usable — and unit-testable — without the native library. + LlamaLoader.initialize(); + handle = attachedModel != null + ? startAttachedNativeServer(attachedModel, args) + : startNativeServer(args); + } catch (final RuntimeException | Error e) { + RUNNING.set(false); + throw e; + } + return this; } - return this; } /** @@ -200,8 +208,10 @@ public NativeServer start() { * @return {@code true} if the server has been started and its worker has not yet exited */ public boolean isRunning() { - final long h = handle; - return h != 0 && isRunningNative(h); + synchronized (lifecycleLock) { + final long h = handle; + return h != 0 && isRunningNative(h); + } } /** @@ -265,19 +275,24 @@ public int getPort() { /** * Stops the native server if it is running and releases the native handle. Blocks until the * server has fully shut down. Safe to call more than once and from try-with-resources even if - * {@link #start()} was never called (no-op then). + * {@link #start()} was never called (no-op then). Serialized with {@link #start()} and + * {@link #isRunning()}: the native stop frees the handle, so two concurrent calls (a shutdown + * hook and a try-with-resources, say) must not both pass the zero check, and a concurrent + * {@link #isRunning()} must not query a handle that is being freed. */ @Override public void close() { - final long h = handle; - if (h == 0) { - return; - } - handle = 0; - try { - stopNativeServer(h); - } finally { - RUNNING.set(false); + synchronized (lifecycleLock) { + final long h = handle; + if (h == 0) { + return; + } + handle = 0; + try { + stopNativeServer(h); + } finally { + RUNNING.set(false); + } } } From 2cf36404cc19ee137507f44079796bce0ee7806f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 05:55:41 +0000 Subject: [PATCH 009/589] Advertise the whole context window for small -c values OpenAiServerCli caps the output budget at ctx/2 below twice its 2048 default but still subtracted the full default for the input budget, so -c 2048 advertised a single input token (and -c 1024 a window of 513 instead of 1024). Clients that size their prompt from maxInputTokens or the advertised context length, such as Copilot, got a near-zero budget. The input budget is now the rest of the window after the output budget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/server/OpenAiServerCli.java | 9 ++++++--- .../ladenthin/llama/server/OpenAiServerCliTest.java | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index 51c7fddd5..fea260b78 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -713,9 +713,12 @@ public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFty builder.apiKey(apiKey); } if (ctxSize > 0) { - builder.maxOutputTokens( - Math.min(OpenAiServerConfig.DEFAULT_MAX_OUTPUT_TOKENS, Math.max(1, ctxSize / 2))); - builder.maxInputTokens(Math.max(1, ctxSize - OpenAiServerConfig.DEFAULT_MAX_OUTPUT_TOKENS)); + final int maxOutputTokens = + Math.min(OpenAiServerConfig.DEFAULT_MAX_OUTPUT_TOKENS, Math.max(1, ctxSize / 2)); + builder.maxOutputTokens(maxOutputTokens); + // The rest of the window, not ctx minus the output default: below twice that default + // the output budget is ctx/2, and the subtraction of the default left 1 input token. + builder.maxInputTokens(Math.max(1, ctxSize - maxOutputTokens)); } return builder.build(); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index cf9ac76e4..03f209045 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -272,6 +272,19 @@ public void contextSizeDerivesAdvertisedTokenBudgets() { assertThat(config.getMaxInputTokens(), is(6144)); } + @Test + public void smallContextSizeSplitsTheWholeWindowBetweenInputAndOutput() { + // Below twice the output default the output budget is ctx/2. The input budget must be the rest + // of the window, not ctx minus the 2048 default: with -c 2048 that advertised a single input + // token, and clients that size their prompt from it (Copilot's maxInputTokens) send none. + for (int ctx : new int[] {1024, 2048, 3000}) { + OpenAiServerConfig config = OpenAiServerCli.parse("-m", "m.gguf", "-c", String.valueOf(ctx)) + .toServerConfig(); + assertThat(config.getMaxOutputTokens(), is(ctx / 2)); + assertThat(config.getMaxInputTokens() + config.getMaxOutputTokens(), is(ctx)); + } + } + @Test public void modelParametersIncludeModelPath() { String json = From 78fd0af6a701bbed769fe9ae596fb49cc34f344f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:02:54 +0000 Subject: [PATCH 010/589] Accept x-api-key and a lowercase bearer scheme on the compat server With --api-key the server accepted only "Authorization: Bearer ". Anthropic SDKs authenticate the /v1/messages surface with an x-api-key header, so every Anthropic client of a server with a key got 401, and the scheme name is case-insensitive (RFC 7235), so "bearer " was rejected too. Both are now accepted, compared in constant time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/OpenAiCompatServer.java | 15 +++++++- .../server/OpenAiCompatServerHttpTest.java | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 0807f545b..6bdd3ff37 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -140,6 +140,10 @@ public final class OpenAiCompatServer implements AutoCloseable { private static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8"; private static final String CONTENT_TYPE_SSE = "text/event-stream; charset=utf-8"; private static final String BEARER_PREFIX = "Bearer "; + + /** Header the Anthropic Messages API authenticates with. */ + private static final String API_KEY_HEADER = "x-api-key"; + private static final String ERROR_TYPE_REQUEST = "invalid_request_error"; private static final String ERROR_TYPE_SERVER = "server_error"; private static final String HEALTH_BODY = "{\"status\":\"ok\"}"; @@ -884,13 +888,20 @@ private boolean authorized(HttpExchange exchange) { if (expected == null) { return true; } + byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); + // Anthropic clients (the /v1/messages surface) authenticate with x-api-key, not a bearer token. + String apiKeyHeader = exchange.getRequestHeaders().getFirst(API_KEY_HEADER); + if (apiKeyHeader != null + && MessageDigest.isEqual(expectedBytes, apiKeyHeader.getBytes(StandardCharsets.UTF_8))) { + return true; + } String header = exchange.getRequestHeaders().getFirst("Authorization"); - if (header == null || !header.startsWith(BEARER_PREFIX)) { + // The scheme name is case-insensitive (RFC 7235), so "bearer " is the same credential. + if (header == null || !header.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { return false; } // Constant-time comparison so response timing does not leak how many leading bytes // of the bearer token matched. - byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8); byte[] presentedBytes = header.substring(BEARER_PREFIX.length()).getBytes(StandardCharsets.UTF_8); return MessageDigest.isEqual(expectedBytes, presentedBytes); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 2ce0f5220..8c4748ab7 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -494,6 +494,43 @@ public void authRequiredWhenApiKeyConfigured() throws IOException { } } + @Test + public void anthropicApiKeyHeaderAndLowercaseBearerSchemeAreAccepted() throws IOException { + // The Anthropic Messages surface is authenticated the Anthropic way, with an x-api-key header + // (what Anthropic SDKs send), and the Authorization scheme name is case-insensitive (RFC 7235). + // Accepting only "Bearer " answered 401 to every Anthropic client of a server with a key. + OpenAiServerConfig cfg = OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .apiKey("secret") + .build(); + String messages = "{\"model\":\"test-model\",\"max_tokens\":16," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), cfg).start()) { + int port = server.getPort(); + assertThat(postWithHeader(port, "/v1/messages", messages, "x-api-key", "secret"), is(200)); + assertThat(postWithHeader(port, "/v1/messages", messages, "x-api-key", "wrong"), is(401)); + assertThat( + postWithHeader(port, "/v1/chat/completions", CHAT_BODY, "Authorization", "bearer secret"), is(200)); + } + } + + private static int postWithHeader(int port, String path, String body, String header, String value) + throws IOException { + java.net.HttpURLConnection conn = + (java.net.HttpURLConnection) new java.net.URL("http://127.0.0.1:" + port + path).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty(header, value); + try (java.io.OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + int code = conn.getResponseCode(); + conn.disconnect(); + return code; + } + @Test public void healthEndpointIsUnauthenticated() throws IOException { OpenAiServerConfig cfg = OpenAiServerConfig.builder() From d38840e1afe6ac0af0139686826e230550597a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:09:30 +0000 Subject: [PATCH 011/589] Send a negative-infinity logit bias as llama.cpp's ban form JSON has no infinity, so Jackson wrote a Float.NEGATIVE_INFINITY bias from withTokenIdBias / withTokenBias as the string "-Infinity". llama.cpp's logit_bias parser accepts only a number or false and skips anything else, so the natural way to ban a token silently did nothing (NaN and positive infinity likewise). Negative infinity is now sent as false, llama.cpp's spelling of a ban, and NaN or positive infinity are rejected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../parameters/ParameterJsonSerializer.java | 24 ++++++++++++++++-- .../json/ParameterJsonSerializerTest.java | 25 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java index fccf130cc..8a7beff5d 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java @@ -199,7 +199,7 @@ public ArrayNode buildTokenIdBiasArray(Map biases) { for (Map.Entry entry : biases.entrySet()) { ArrayNode pair = OBJECT_MAPPER.createArrayNode(); pair.add(entry.getKey()); - pair.add(entry.getValue()); + addBias(pair, entry.getValue()); arr.add(pair); } return arr; @@ -217,12 +217,32 @@ public ArrayNode buildTokenStringBiasArray(Map biases) { for (Map.Entry entry : biases.entrySet()) { ArrayNode pair = OBJECT_MAPPER.createArrayNode(); pair.add(entry.getKey()); - pair.add(entry.getValue()); + addBias(pair, entry.getValue()); arr.add(pair); } return arr; } + /** + * Appends one bias value in a form llama.cpp's {@code logit_bias} parser accepts: a number, or + * {@code false} for negative infinity (a ban). JSON has no infinity, and Jackson writes a + * non-finite float as a string, which that parser skips -- a {@code -Infinity} bias then + * silently did nothing. + * + * @param pair the {@code [token, bias]} pair to append to + * @param bias the bias value + * @throws IllegalArgumentException if {@code bias} is NaN or positive infinity + */ + private static void addBias(ArrayNode pair, float bias) { + if (bias == Float.NEGATIVE_INFINITY) { + pair.add(false); + } else if (Float.isFinite(bias)) { + pair.add(bias); + } else { + throw new IllegalArgumentException("logit bias must be finite or negative infinity, got " + bias); + } + } + /** * Build a disable-token array for integer token IDs: * {@code [[15043, false], [50256, false]]}. diff --git a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java index a5e6d3abe..31352a1c2 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java @@ -204,6 +204,31 @@ public void testBuildTokenIdBiasArray_structure() { assertEquals(-0.5, arr.get(1).get(1).asDouble(), 0.001); } + @Test + public void testBuildTokenIdBiasArray_negativeInfinityBansTheToken() { + // JSON has no -Infinity: Jackson wrote the string "-Infinity", which llama.cpp's logit_bias + // parser skips (only a number or false is a bias), so the ban silently did nothing. false is + // llama.cpp's spelling of -inf. + ArrayNode ids = serializer.buildTokenIdBiasArray(Collections.singletonMap(15043, Float.NEGATIVE_INFINITY)); + assertThat(ids.get(0).get(1).isBoolean(), is(true)); + assertThat(ids.get(0).get(1).asBoolean(), is(false)); + ArrayNode strings = + serializer.buildTokenStringBiasArray(Collections.singletonMap("Hello", Float.NEGATIVE_INFINITY)); + assertThat(strings.get(0).get(1).isBoolean(), is(true)); + assertThat(strings.get(0).get(1).asBoolean(), is(false)); + } + + @Test + public void testBuildTokenIdBiasArray_nanOrPositiveInfinityIsRejected() { + // Neither has a JSON spelling llama.cpp accepts; silently dropping the entry hides the mistake. + assertThrows( + IllegalArgumentException.class, + () -> serializer.buildTokenIdBiasArray(Collections.singletonMap(1, Float.NaN))); + assertThrows( + IllegalArgumentException.class, + () -> serializer.buildTokenStringBiasArray(Collections.singletonMap("a", Float.POSITIVE_INFINITY))); + } + @Test public void testBuildTokenIdBiasArray_empty() { ArrayNode arr = serializer.buildTokenIdBiasArray(Collections.emptyMap()); From 2c32c84422b22557f5ed316d352acc0f726f7d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:22:23 +0000 Subject: [PATCH 012/589] Keep tool calls when serializing ChatMessage lists ParameterJsonSerializer.buildMessages(List) wrote only role and content. ChatRequest patched tool_calls and tool_call_id in afterwards, but InferenceParameters.withMessages(List) used the serializer directly, so a tool-calling round trip built that way sent an assistant turn without its calls and a tool result the model could not pair with them. The serializer now writes both fields, and ChatRequest no longer duplicates the step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/parameters/ChatRequest.java | 23 +------------------ .../parameters/ParameterJsonSerializer.java | 15 ++++++++++++ .../parameters/InferenceParametersTest.java | 15 ++++++++++++ 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java b/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java index dbc9ad2e9..c411fa019 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java @@ -18,7 +18,6 @@ import lombok.EqualsAndHashCode; import lombok.ToString; import net.ladenthin.llama.value.ChatMessage; -import net.ladenthin.llama.value.ToolCall; import net.ladenthin.llama.value.ToolDefinition; import org.jspecify.annotations.Nullable; @@ -309,27 +308,7 @@ public int getMaxToolRounds() { * @return the JSON array as a string */ public String buildMessagesJson() { - ArrayNode arr = MESSAGE_SERIALIZER.buildMessages(messages); - for (int i = 0; i < messages.size(); i++) { - ChatMessage m = messages.get(i); - ObjectNode obj = (ObjectNode) arr.get(i); - m.getToolCallId().ifPresent(id -> obj.put("tool_call_id", id)); - if (!m.getToolCalls().isEmpty()) { - ArrayNode tc = MAPPER.createArrayNode(); - for (ToolCall call : m.getToolCalls()) { - ObjectNode entry = MAPPER.createObjectNode(); - entry.put("id", call.getId()); - entry.put("type", "function"); - ObjectNode fn = MAPPER.createObjectNode(); - fn.put("name", call.getName()); - fn.put("arguments", call.getArgumentsJson()); - entry.set("function", fn); - tc.add(entry); - } - obj.set("tool_calls", tc); - } - } - return arr.toString(); + return MESSAGE_SERIALIZER.buildMessages(messages).toString(); } /** diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java index 8a7beff5d..49ca2b580 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java @@ -17,6 +17,7 @@ import net.ladenthin.llama.value.ChatMessage; import net.ladenthin.llama.value.ContentPart; import net.ladenthin.llama.value.Pair; +import net.ladenthin.llama.value.ToolCall; import org.jspecify.annotations.Nullable; /** @@ -146,6 +147,20 @@ public ArrayNode buildMessages(List messages) { } else { msg.put("content", message.getContent()); } + // Tool-calling round trips: without these the model sees a tool turn it cannot pair with + // the call it made (and InferenceParameters.withMessages dropped them, unlike ChatRequest). + message.getToolCallId().ifPresent(id -> msg.put("tool_call_id", id)); + if (!message.getToolCalls().isEmpty()) { + ArrayNode toolCalls = msg.putArray("tool_calls"); + for (ToolCall call : message.getToolCalls()) { + ObjectNode entry = toolCalls.addObject(); + entry.put("id", call.getId()); + entry.put("type", "function"); + ObjectNode function = entry.putObject("function"); + function.put("name", call.getName()); + function.put("arguments", call.getArgumentsJson()); + } + } arr.add(msg); } return arr; diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java index 7dc59527e..eb3919483 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java @@ -602,6 +602,21 @@ public void testDisableTokensEmpty() { // setMessages // ------------------------------------------------------------------------- + @Test + public void withChatMessagesKeepsToolCallsAndToolCallIds() { + // ChatMessage.assistantToolCalls / toolResult carry the fields a tool-calling round trip needs. + // ChatRequest added them itself, but the shared serializer behind withMessages(List) + // wrote only role and content, so the model saw a tool turn it could not pair with its call. + List messages = Arrays.asList( + net.ladenthin.llama.value.ChatMessage.assistantToolCalls( + "", Collections.singletonList(new net.ladenthin.llama.value.ToolCall("call_1", "f", "{}"))), + net.ladenthin.llama.value.ChatMessage.toolResult("call_1", "42")); + String value = + InferenceParameters.empty().withMessages(messages).parameters.get("messages"); + assertThat(value, containsString("\"tool_calls\"")); + assertThat(value, containsString("\"tool_call_id\":\"call_1\"")); + } + @Test public void testSetMessagesWithSystemAndUserMessages() { List> messages = Collections.singletonList(new Pair<>("user", "Hi")); From 3d38dd0b77c6092e442f6f8482492e49fa2859f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:29:02 +0000 Subject: [PATCH 013/589] Keep image blocks of an Anthropic Messages request AnthropicApiSupport flattened a message's content blocks to their text and tool calls, so an image block ({type:image, source:{type:base64,...}}) never reached the chat core and a vision model answered as if no image had been sent. Messages with images now become OpenAI content-part arrays with image_url parts (a data URL for a base64 source, the URL for a url source). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/AnthropicApiSupport.java | 42 ++++++++++++++++++- .../llama/server/AnthropicApiSupportTest.java | 20 +++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 6a8dbca58..d978e1993 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -133,6 +133,10 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag } StringBuilder text = new StringBuilder(); + // OpenAI content parts in block order; only used when the message carries an image, since the + // OpenAI parser reads images from image_url parts alone. + ArrayNode parts = OBJECT_MAPPER.createArrayNode(); + boolean hadImage = false; ArrayNode toolCalls = OBJECT_MAPPER.createArrayNode(); boolean hadToolResult = false; for (JsonNode block : content) { @@ -140,6 +144,19 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag switch (type) { case "text": text.append(block.path("text").asText("")); + parts.addObject() + .put("type", "text") + .put("text", block.path("text").asText("")); + break; + case "image": + String url = imageUrl(block.path("source")); + if (url != null) { + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", url); + hadImage = true; + } break; case "tool_use": // Assistant tool call: Anthropic input (object) -> OpenAI arguments (JSON string). @@ -162,7 +179,14 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag break; } } - if (text.length() > 0 || toolCalls.size() > 0) { + if (hadImage) { + ObjectNode message = out.addObject(); + message.put("role", role); + message.set("content", parts); + if (toolCalls.size() > 0) { + message.set("tool_calls", toolCalls); + } + } else if (text.length() > 0 || toolCalls.size() > 0) { ObjectNode message = out.addObject(); message.put("role", role); if (toolCalls.size() > 0 && text.length() == 0) { @@ -182,6 +206,22 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag } } + /** + * The OpenAI {@code image_url} URL for an Anthropic image {@code source}: a data URL for a base64 + * source, the URL itself for a url source, {@code null} for anything else. + */ + private static @org.jspecify.annotations.Nullable String imageUrl(JsonNode source) { + String sourceType = source.path("type").asText(""); + if ("base64".equals(sourceType) && source.path("data").isTextual()) { + return "data:" + source.path("media_type").asText("image/png") + ";base64," + + source.path("data").asText(); + } + if ("url".equals(sourceType) && source.path("url").isTextual()) { + return source.path("url").asText(); + } + return null; + } + private static String toolResultText(JsonNode content) { if (content.isTextual()) { return content.asText(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index dd76e4681..cc65f41df 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -25,6 +25,26 @@ private static JsonNode read(String json) throws IOException { return MAPPER.readTree(json); } + @Test + public void requestTurnsImageBlocksIntoImageUrlParts() throws IOException { + // Anthropic clients send images as {type:image, source:{type:base64, media_type, data}}. The + // OpenAI parser reads images only from image_url parts, so dropping the block left a vision + // model answering as if no image had been sent. + String anthropic = "{\"messages\":[{\"role\":\"user\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"what is this?\"}," + + "{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\"," + + "\"data\":\"iVBORw0K\"}}]}]}"; + JsonNode content = AnthropicApiSupport.toOpenAiChatRequest(read(anthropic)) + .path("messages") + .get(0) + .path("content"); + assertThat(content.isArray(), is(true)); + assertThat(content.get(0).path("type").asText(), is("text")); + assertThat(content.get(0).path("text").asText(), is("what is this?")); + assertThat(content.get(1).path("type").asText(), is("image_url")); + assertThat(content.get(1).path("image_url").path("url").asText(), is("data:image/png;base64,iVBORw0K")); + } + @Test public void isStreamingDefaultsFalse() throws IOException { assertThat(AnthropicApiSupport.isStreaming(read("{}")), is(false)); From e824ae59ab6388935c1a48214497b95ba1c61aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:34:10 +0000 Subject: [PATCH 014/589] Repeat the stop of an RPC server that failed to start until its thread ends The native server clears its stop flag when it binds the socket, so the single stop a failed start sent (timeout or interrupted caller) was lost whenever it arrived before the bind. The server then kept listening although start() had thrown and released the single-instance slot. The failed start now repeats the stop while the server thread lives, also when the calling thread is interrupted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../java/net/ladenthin/llama/RpcServer.java | 34 ++++++++++++-- .../llama/RpcServerLifecycleTest.java | 46 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/RpcServer.java b/llama/src/main/java/net/ladenthin/llama/RpcServer.java index a24696687..0585de5b3 100644 --- a/llama/src/main/java/net/ladenthin/llama/RpcServer.java +++ b/llama/src/main/java/net/ladenthin/llama/RpcServer.java @@ -66,6 +66,9 @@ public final class RpcServer implements AutoCloseable { /** How long {@link #close()} waits for the server thread to end. */ static final long STOP_TIMEOUT_MILLIS = 30_000L; + /** How long a failed start waits for the server thread before it repeats the stop. */ + static final long STOP_RETRY_MILLIS = 100L; + private static final Logger LOGGER = LoggerFactory.getLogger(RpcServer.class); private static final AtomicBoolean ACTIVE = new AtomicBoolean(); @@ -244,7 +247,7 @@ static RpcServer start( thread.setDaemon(true); thread.start(); if (!awaitListening(thread, backend, startTimeoutMillis)) { - joinQuietly(thread, STOP_TIMEOUT_MILLIS); + stopAndJoin(thread, backend, STOP_TIMEOUT_MILLIS); Throwable cause = failure.get(); throw new LlamaException("RPC server could not listen on " + endpoint + (cause == null ? " (is the port already in use?)" : ": " + cause.getMessage())); @@ -509,11 +512,36 @@ private static boolean awaitListening(Thread thread, Backend backend, long timeo break; } } - // still binding after the timeout, or interrupted: make sure the thread does not outlive us - backend.stop(); + // still binding after the timeout, or interrupted: the caller stops the thread return false; } + /** + * Stops a server thread that did not come up and waits for it to end. The stop is repeated while + * the thread lives: the native server clears its stop flag when it binds the socket, so a stop that + * arrives before the bind (a slow device initialization, an interrupted start) is lost, and the + * server would go on listening after the start has failed and released the single-instance slot. + * An interrupt of the calling thread does not cut the wait short; it is restored afterwards. + */ + private static void stopAndJoin(Thread thread, Backend backend, long timeoutMillis) { + boolean interrupted = Thread.interrupted(); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + try { + while (thread.isAlive() && System.nanoTime() < deadline) { + backend.stop(); + try { + thread.join(STOP_RETRY_MILLIS); + } catch (InterruptedException e) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + private static void joinQuietly(Thread thread, long millis) { try { thread.join(millis); diff --git a/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java b/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java index c0b8b8076..371f85a38 100644 --- a/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java +++ b/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java @@ -186,6 +186,52 @@ public boolean listening() { assertSlotIsFree(); } + @Test + public void aStopThatArrivesBeforeTheBindDoesNotLeaveTheServerRunning() throws Exception { + // like the native server: binding the socket clears the stop flag, so an early stop is lost + CountDownLatch bind = new CountDownLatch(1); + CountDownLatch ended = new CountDownLatch(1); + WorkingBackend slowBind = new WorkingBackend() { + volatile boolean stop; + + @Override + public void serve(String host, int port, int threads, @Nullable String cacheDir, String devices) { + try { + bind.await(); + stop = false; + listening = true; + while (!stop) { + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + listening = false; + ended.countDown(); + } + } + + @Override + public void stop() { + super.stop(); + stop = true; + } + }; + Thread binder = new Thread(() -> { + try { + Thread.sleep(300); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + bind.countDown(); + }); + binder.start(); + assertThrows(LlamaException.class, () -> RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, slowBind, 100)); + binder.join(); + assertThat("the server thread ended", ended.await(1, TimeUnit.SECONDS), is(true)); + assertSlotIsFree(); + } + @Test public void theCacheDirectoryIsCreatedAndPassedOn() throws Exception { Path cache = tempDir.resolve("a").resolve("b"); From 3f0a6adc55f9a32a12ec55e97b89471e42988c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:42:46 +0000 Subject: [PATCH 015/589] Add the bug-hunt ledgers: found bugs and checked classes BUGHUNT-FOUND.md records every bug found on this working branch with its file, severity, reproduction test, status and commit. BUGHUNT-CHECKED.md lists every production class that was read in full, with the blob hash it was checked at, so a later pass re-checks only files that changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 76 ++++++++++++++++ BUGHUNT-FOUND.md | 210 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 BUGHUNT-CHECKED.md create mode 100644 BUGHUNT-FOUND.md diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md new file mode 100644 index 000000000..35c00e31a --- /dev/null +++ b/BUGHUNT-CHECKED.md @@ -0,0 +1,76 @@ + + + +# Bug-Hunt: geprüfte Stellen — java-llama.cpp + +Jede Zeile ist eine gründlich gelesene Produktionsklasse: `Pfad` · Blob-Hash der Datei beim Prüfen · Datum · +Ergebnis — Notiz. Eine Klasse gilt als erledigt, solange `git rev-parse --short HEAD:` noch denselben +Blob-Hash liefert; hat sich die Datei seitdem geändert, wird sie erneut geprüft und die Zeile ersetzt. +Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsicher` (offen, siehe +[`BUGHUNT-FOUND.md`](BUGHUNT-FOUND.md)), `Fund Fnn` (Fehler gefunden und dort beschrieben). + +## Geprüft + +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingField.java` · `8a305f9` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `c411fa0` · 2026-10-17 · Fund F05 — java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert +- `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-17 · kein Fund — `json/RouterModelsResponseParser`, `value/RouterModel`: Feldnamen gegen die Testfixtures des Routers (b11538) konsistent, Null/fehlende Felder abgefangen. +- `llama/src/main/java/net/ladenthin/llama/value/ChatChoice.java` · `ad1c75f` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). +- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` · `31d8fb7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). +- `llama/src/main/java/net/ladenthin/llama/Diagnose.java` · `1a229ec` · 2026-10-17 · kein Fund — java-llama.cpp `Diagnose`, `loader/DiagnosticReport`: Backend-Filter-Auswertung (`namedBy`) deckt sich mit `LlamaLoader.selectModules` (inkl. Leer-/Whitespace-Fall); Versionsvergleich per `startsWith` würde nur bei einer 6-stelligen Build-Nummer mit gleichem Präfix irren. +- `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` · `a15ed7b` · 2026-10-17 · kein Fund — java-llama.cpp `Diagnose`, `loader/DiagnosticReport`: Backend-Filter-Auswertung (`namedBy`) deckt sich mit `LlamaLoader.selectModules` (inkl. Leer-/Whitespace-Fall); Versionsvergleich per `startsWith` würde nur bei einer 6-stelligen Build-Nummer mit gleichem Präfix irren. +- `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` · `35d2521` · 2026-10-17 · Fund F09 — java-llama.cpp: Loader löscht fremde `llama*`/`ggml*`-Einträge im Temp-Verzeichnis, Verzeichnisse rekursiv +- `llama/src/main/java/net/ladenthin/llama/value/CompletionResult.java` · `934a56d` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… +- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` · `707296e` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` · `b224a28` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` · `62e0ac1` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` · `37473c2` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java` · `9a4b965` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` · `b69653a` · 2026-10-17 · kein Fund — java-llama.cpp `ToolCallingAgent`: Rundenzählung = max. Anzahl Chat-Aufrufe, deckt sich mit Javadoc („loops until … the round cap is reached“); Handler-Exceptions/unbekannte Tools werden als `{"error":…}` zurückgegeben. +- `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` · `eda2bb6` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… +- `llama/src/main/java/net/ladenthin/llama/value/GgufMetadata.java` · `6261a64` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… +- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b578b98` · 2026-10-17 · kein Fund — java-llama.cpp `server/OaiRerankSupport`: Sortierung absteigend, `top_n<=0` = alle, fehlender `index` wird von der nativen Seite (`rerank_results_to_json`) bereits abgewiesen. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `6bdd3ff` · 2026-10-17 · Fund F25 — java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung +- `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` · `6f2a458` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. +- `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` · `e167fa7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ContentPart`: Validierung ok. Randnotiz: Lombok-`@ToString` gibt die komplette Base64-Nutzlast (`imageUrl`/`audioData`, schnell mehrere MB) aus – teuer, falls ein `ChatMessage`/`ChatRequest` geloggt wird. +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `6872e5a` · 2026-10-17 · Fund F12 — java-llama.cpp: Ollama-`/api/chat` verwirft Bilder (`images`), obwohl `/api/show` „vision“ meldet +- `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) +- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild +- `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `fae453e` · 2026-10-17 · Fund F18 — java-llama.cpp: `NativeServer.close()` nicht atomar → doppeltes Freigeben des nativen Servers bei parallelen Aufrufen +- `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `0e5f9e5` · 2026-10-17 · Fund F01 — java-llama.cpp: `TrainingParameters` validiert die dokumentierten Grenzen nicht +- `llama/src/main/java/net/ladenthin/llama/value/LoraAdapter.java` · `0351765` · 2026-10-17 · kein Fund — java-llama.cpp `value/LoraAdapter` (+ `json/LoraAdapterResponseParser`): Schlüssel `id/path/scale/task_name/prompt_prefix` gegen `server-task.cpp` b11639 identisch; das Javadoc „nicht gelistete Adapter werden auf 0 gesetzt“ stimmt mit `construct_lora_list` überein. +- `llama/src/main/java/net/ladenthin/llama/value/TokenLogprob.java` · `645a0f9` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… +- `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` · `138b590` · 2026-10-17 · kein Fund — java-llama.cpp `loader/OSInfo`: Abbildung von `os.name`/`os.arch` auf die Natives-Verzeichnisse für alle ausgelieferten Plattformen (Linux/Windows x86_64 + aarch64, Mac aarch64, s390x, Android) korrekt. Randnotizen (niedrig): `isMusl()` fällt nur bei einer Ausnahme von `Files.list` auf `/etc/os-r… +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `5bddfea` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiRequestMapper`: Pflichtfeld-Prüfung, `max_completion_tokens` vor `max_tokens`, `stop` als String/Array, `stream_options`/`response_format` werden durchgereicht. Randnotizen (niedrig, Funktionslücken, kein Fehler): `logprobs`/`top_logprobs`/`logit_bias` werden nicht we… +- `llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java` · `dbb8b3b` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/RequestField`: alle `SCHEMA`-Schlüssel gegen `server-schema.cpp` b11639 (64 Felder) abgeglichen – vorhanden, `grammar` als Alias von `json_schema` (`add_alias`). Wird zusätzlich vom C++-Test `test_wire_contracts.cpp` erzwungen. +- `llama/src/main/java/net/ladenthin/llama/server/ServerLauncher.java` · `d8433a0` · 2026-10-17 · kein Fund — java-llama.cpp `server/ServerLauncher`: entfernt jedes Vorkommen des Markers und dispatcht korrekt; trivial. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` · `41c9e8b` · 2026-10-17 · kein Fund — java-llama.cpp `llama-ffm/…/FfmLlamaModel`: `handle()` prüft `closed` und ruft danach ohne Sperre nativ auf; ein paralleles `close()` kann den `jllama_context` dazwischen freigeben (`jllama_model_free` wartet nur auf Aufrufe, die die native Benutzerreferenz schon halten) → Use-after-free. Das ist… +- `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` · `a6b12c5` · 2026-10-17 · kein Fund — java-llama.cpp `value/RpcEndpoint`: Parser (genau ein ':', Port 1..65535, max. 5 Ziffern vor `parseInt` → kein Überlauf), Host-Zeichensatz und Länge, `parseList` mit leeren Teilen korrekt; equals/hashCode über host+port. +- `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` · `e9aa330` · 2026-10-17 · kein Fund — java-llama.cpp `value/ModelMeta`: alle Getter-Schlüssel gegen den Erzeuger in `jllama_engine.cpp` abgeglichen (`vocab_type`, `n_ctx_train`, `n_params`, `size`, `modalities.*`, `input/output_modalities`, `special_tokens.bos/eos/eot`, `metadata`, `chat_template`, …) – identisch; Default `-1` für fe… +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` · `fea260b` · 2026-10-17 · Fund F24 — java-llama.cpp: `OpenAiServerCli` bewirbt bei kleinem `-c` fast kein Eingabebudget (`-c 2048` → 1 Token) +- `llama/src/main/java/net/ladenthin/llama/args/ModelFlag.java` · `0807fe4` · 2026-10-17 · übersprungen — java-llama.cpp `args/ModelFlag`: reine Wire-Name-Registry, gegen den echten llama.cpp-Parser durch `test_model_flags.cpp` erzwungen – kein eigenständig prüfbarer Code. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/Jllama.java` · `1c19474` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` · `6399310` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j/…/JsonSchemaElementSerializer`: alle 11 `JsonSchemaElement`-Typen abgedeckt, `$ref` → `#/$defs/` passend zu den unter `$defs` geschriebenen Definitionen, Enum als String mit `enum`-Liste wie bei langchain4j. Randnotiz (niedrig): `JsonRawSchema` wird mit `re… +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` · `7f9c370` · 2026-10-17 · Fund F25 — java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung +- `llama/src/main/java/net/ladenthin/llama/Session.java` · `7979c5c` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `51665a1` · 2026-10-17 · kein Fund — java-llama.cpp `server/ResponsesStreamTranslator`: Event-Folge (`created` → `output_item.added` → `content_part.added` → `output_text.delta`… → `output_text.done` → `content_part.done` → `output_item.done` → Function-Calls → `completed`) mit monotonen `sequence_number`s und konsistenten `output_i… +- `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` · `90d097c` · 2026-10-17 · Fund F13 — java-llama.cpp: `setRopeScaling(UNSPECIFIED | LONGROPE | MAX_VALUE)` macht das Modell unladbar +- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` · `386d73f` · 2026-10-17 · Fund F26 — java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert +- `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` · `b0428f5` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/CliParameters`: argv-Aufbau (Option + Wert, Flag ohne Wert, `argv[0] = ""`), Floats als `String.valueOf` (`1.0E-5`, von `std::stof` gelesen) korrekt. Randnotiz (niedrig, Sicherheit): `ModelParameters.toString()` gibt den Wert von `--hf-token` (`setHfToken`) im Klartext … +- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `49ca2b5` · 2026-10-17 · Fund F26 — java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `1a04e66` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiSseFormatter`: SSE-Framing, Fehler-JSON, `ensureUsageCachedTokens` (Schnellpfad per Substring kann nicht von einem im Text maskierten `\"usage\":null` getäuscht werden, unveränderte Ausgabe wenn `cached_tokens` schon da ist) und `/props`/`/v1/models`-Payloads korrekt. +- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `c26ba48` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiCompatServer` (Auth, Body-Limit, CORS, Routen). Alle Inferenz-Routen laufen über `requirePostJson` (Auth + Größenlimit); `/health`, `/props` und die Ollama-Discovery-Routen (`/api/version`, `/api/tags`, `/api/show`) sind laut Kommentar **bewusst** ohne Auth. Notizen (… +- `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `d978e19` · 2026-10-17 · Fund F29 — java-llama.cpp: Anthropic-Messages-API (`POST /v1/messages`) verwirft `image`-Blöcke → Vision-Modell sieht kein Bild +- `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-17 · Fund F30 — java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · unsicher — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … +- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab +- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7c95542` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md new file mode 100644 index 000000000..c9e5ca91a --- /dev/null +++ b/BUGHUNT-FOUND.md @@ -0,0 +1,210 @@ + + + +# Bug-Hunt: gefundene Fehler — java-llama.cpp + +Fortlaufendes Fundbuch der Fehlersuche auf dem Arbeitsbranch `ccr-90fc5f6a-vs4s0f`. Jeder Fund hat eine +globale ID (`Fnn`, über alle drei Repos eindeutig), Datei:Zeile, Schwere, Beschreibung, Reproduktion (Testname), +Status und Commit. Neue Funde werden **unten angehängt**; die nächste freie ID ist die höchste ID in allen drei +Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BUGHUNT-CHECKED.md). + +## Übersicht + +- [F01](#f01) — niedrig — *unsicher / nur beschrieben* +- [F05](#f05) — niedrig — *gefixt* +- [F06](#f06) — mittel — *gefixt* +- [F07](#f07) — niedrig — *gefixt* +- [F09](#f09) — hoch — *gefixt* +- [F12](#f12) — mittel — *gefixt* +- [F13](#f13) — mittel — *gefixt* +- [F16](#f16) — mittel — *nur beschrieben* +- [F17](#f17) — mittel — *gefixt* +- [F18](#f18) — mittel — *gefixt* +- [F24](#f24) — mittel — *gefixt* +- [F25](#f25) — mittel — *gefixt* +- [F26](#f26) — mittel — *gefixt* +- [F28](#f28) — mittel — *gefixt* +- [F29](#f29) — mittel — *gefixt* +- [F30](#f30) — mittel — *gefixt* + +## Funde + + +### F01 – java-llama.cpp: `TrainingParameters` validiert die dokumentierten Grenzen nicht +- **Zeit:** 2026-10-16 22:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java:46` (`epochs`, „at least 1“), `:19` (Klassen-Javadoc „exactly one of trainingText / trainingFile“), `valSplit` +- **Schwere:** niedrig +- **Beschreibung:** Javadoc (und `LlamaTrainer.finetune(Path,String,Path,int,float)`) sagt `epochs` „at least 1“ und „genau eins von trainingText/trainingFile“. Nichts davon wird geprüft: `epochs(0)` oder negativ läuft nativ 0 Epochen und `train_engine.cpp` schreibt danach das **unveränderte** Modell nach `outputPath` – ein stiller Erfolg ohne Training. Sind beide Korpus-Felder gesetzt, gewinnt nativ stillschweigend `trainingFile`. `valSplit` außerhalb `[0,1)` ergibt in `train_engine.cpp:75` einen negativen bzw. 0-großen `idata_split` (Verhalten in ggml nicht geprüft, möglicherweise ein `GGML_ASSERT`, der die JVM beendet). Fehlende `modelPath`/`outputPath` geben eine nackte NPE aus `toJson()`. +- **Reproduktion:** keine (API-/Designentscheidung: Validierung in `toJson()` oder `LlamaTrainer.finetune` würde Verhalten ändern; `LlamaTrainer` lädt im static-Block die Native-Lib, ein Test dort braucht libjllama). +- **Status:** unsicher / nur beschrieben – Vorschlag: Prüfung in `LlamaTrainer.finetune(TrainingParameters)` vor dem JNI-Aufruf (`IllegalArgumentException`). +- **Commit:** – + + +### F05 – java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert +- **Zeit:** 2026-10-16 23:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` (`buildToolsJson`, Zeile mit `SCHEMA_READER.readTree`) +- **Schwere:** niedrig +- **Beschreibung:** Das Schema jeder `ToolDefinition` wurde mit einfachem `readTree` gelesen; das liest den ersten JSON-Wert und ignoriert den Rest. `{"type":"object"} {"required":["x"]}` wurde so ohne Fehler als `{"type":"object"}` gesendet – ein anderes Schema als vom Aufrufer geschrieben. `JsonParameters` nutzt aus genau diesem Grund `FAIL_ON_TRAILING_TOKENS` (siehe CLAUDE.md), `ChatRequest` umging das. Jetzt gleicher strikter Reader → solche Strings nehmen den vorhandenen Fallback für ungültiges JSON. +- **Weitere Beobachtung (nicht geändert):** Ungültiges JSON wird absichtlich als **String** unter `parameters` weitergereicht, `""` und Leerzeichen werden zu `"parameters":null`. Ob eine `IllegalArgumentException` (wie in `JsonParameters`) besser wäre, ist eine API-Entscheidung. +- **Reproduktion:** `ChatRequestTest$…#buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent` (vorher rot, danach grün), plus Positivtest `buildToolsJsonEmbedsAValidSchemaAsAnObject`; `ChatRequestTest` 24/24 grün. `spotbugs:check` nicht gelaufen. +- **Status:** gefixt +- **Commit:** java-llama.cpp `399ff0a` + + +### F06 – java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab +- **Zeit:** 2026-10-16 23:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`, Deadline-Berechnung) +- **Schwere:** mittel +- **Beschreibung:** `System.currentTimeMillis() + timeoutMillis` läuft bei `Long.MAX_VALUE` („so lange wie nötig“, eine naheliegende Aufrufform) über und wird negativ. Die Schleife pollt nie und wirft „did not reach LOADED within 9223372036854775807 ms (last status: null)“, auch wenn das Modell längst geladen ist. Fix: Deadline sättigt bei `Long.MAX_VALUE`. +- **Reproduktion:** `RouterClientTest#awaitModelLoaded_longMaxValueTimeoutStillPolls` (vorher rot mit genau dieser Meldung, danach grün) +- **Status:** gefixt +- **Commit:** java-llama.cpp `ec12331` + + +### F07 – java-llama.cpp: `RouterClient` mit IPv6-Literal (`"::1"`) kann nie verbinden +- **Zeit:** 2026-10-16 23:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`request`, URL-Aufbau) +- **Schwere:** niedrig +- **Beschreibung:** Die URL wurde als `"http://" + host + ":" + port` gebaut. Für `new RouterClient("::1", port)` (Router mit `--host ::1`; Javadoc: „host name or address“) entsteht `http://::1:port/models` → `MalformedURLException: Error at index 0 in ":1:8080"` vor jedem Verbindungsversuch. Workaround war `"[::1]"`. Fix: IPv6-Literal wird geklammert, bereits geklammerte Hosts bleiben unverändert. Nicht abgedeckt: Zonen-IDs (`fe80::1%eth0`) bräuchten zusätzlich `%25`-Kodierung. `toString()` zeigt den Host weiterhin ungeklammert. +- **Reproduktion:** `RouterClientTest#ipv6LiteralHostIsBracketedInTheRequestUrl` (vorher rot: `MalformedURLException`, danach andere `IOException`, weil auf Port 1 nichts lauscht). Der Sandbox fehlt IPv6, ein echter Server auf `::1` war hier nicht testbar. +- **Status:** gefixt +- **Commit:** java-llama.cpp `0f84984` + + +### F09 – java-llama.cpp: Loader löscht fremde `llama*`/`ggml*`-Einträge im Temp-Verzeichnis, Verzeichnisse rekursiv +- **Zeit:** 2026-10-16 23:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` (`isStaleExtraction` / `cleanup` / `cleanPath`) +- **Schwere:** hoch (Datenverlust außerhalb der eigenen Dateien) +- **Beschreibung:** `cleanup()` lief bei jedem Start, der die Natives-Jars erreicht, über `java.io.tmpdir` (bzw. `net.ladenthin.llama.tmpdir`) und löschte **sofort und rekursiv** jeden Eintrag, dessen Name mit `jllama`, `llama` oder `ggml` beginnt und kein `jllama-backend-*`-Verzeichnis ist. Gedacht war das für die einzelnen Bibliotheksdateien des flachen Layouts vor 6.0.0 (so steht es in der Javadoc). Getroffen wurde aber auch z. B. ein `/tmp/llama.cpp`-Checkout, ein `/tmp/ggml-models/`-Ordner oder ein heruntergeladenes `/tmp/llama-2-7b.Q4_K_M.gguf`. Fix: Aus dem alten Layout werden nur noch **reguläre Dateien mit Bibliotheksendung** gelöscht (`.so`, `.dll`, `.dylib`, `.jnilib`, `.metal`). +- **Reproduktion:** `LlamaLoaderTest#staleExtractionLeavesForeignEntriesWithAMatchingPrefixAlone` (vorher rot, danach grün); `LlamaLoaderTest` 63/63, inkl. der bestehenden Fälle `jllama-3f2a.so` / `ggml-metal.metal` (werden weiter gelöscht). +- **Bitte prüfen:** Die genauen Dateinamen des alten Layouts konnte ich nicht verifizieren, weil der Clone flach ist und keine Historie vor 6.0.0 enthält. Falls es damals auch Endungen wie `.tmp` oder `.lck` gab, bleiben die jetzt liegen. Das ist harmlos, nur Restmüll. +- **Status:** gefixt +- **Commit:** java-llama.cpp `3ae1b98` + + +### F12 – java-llama.cpp: Ollama-`/api/chat` verwirft Bilder (`images`), obwohl `/api/show` „vision“ meldet +- **Zeit:** 2026-10-16 23:55 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiMessage`) +- **Schwere:** mittel (Feature stillschweigend wirkungslos) +- **Beschreibung:** Ollama-Clients schicken Bilder als `"images": ["", …]` neben `"content"`. `toOpenAiMessage` kopierte die Nachricht unverändert; der OpenAI-Parser von llama.cpp liest nur `image_url`-Content-Parts, das Feld `images` wurde also ignoriert. Das Vision-Modell antwortete ohne Bild, kein Fehler. Gleichzeitig meldet `/api/show` die Fähigkeit `vision`, wenn `--mmproj` gesetzt ist. Fix: `images` wird entfernt und als `image_url`-Parts (Data-URL, MIME-Typ aus den Magic-Bytes: JPEG/GIF/WebP, sonst PNG – llama.cpp dekodiert nach Inhalt) hinter dem Text angehängt. +- **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestTurnsOllamaImagesIntoImageUrlParts` (vorher rot, danach grün); `OllamaApiSupportTest` 20/20, `OpenAiCompatServerHttpTest` 43/43. Ende-zu-Ende mit echtem Vision-Modell nicht geprüft (kein Modell im Sandbox). +- **Nebenbefund (nicht geändert):** Die gestreamte Abschlusszeile (`toOllamaDoneLine`) setzt `done_reason` immer auf `"stop"`, auch bei Abbruch durch `length`; die nicht-gestreamte Antwort übernimmt `finish_reason` korrekt. +- **Status:** gefixt +- **Commit:** java-llama.cpp `d31164b` + + +### F13 – java-llama.cpp: `setRopeScaling(UNSPECIFIED | LONGROPE | MAX_VALUE)` macht das Modell unladbar +- **Zeit:** 2026-10-17 00:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setRopeScaling`), `args/RopeScalingType.java` +- **Schwere:** mittel +- **Beschreibung:** llama.cpp akzeptiert bei `--rope-scaling` nur `none`, `linear`, `yarn` (`common/arg.cpp` @ b11639, Zeile 2337ff.) und wirft sonst `invalid value` → `LlamaModel` lädt nicht („Failed to parse model parameters“). `RopeScalingType` bietet zusätzlich `UNSPECIFIED` (laut Javadoc „use the model default“), `LONGROPE` und `MAX_VALUE`; der Setter reichte sie ungeprüft als `unspecified`/`longrope`/`maxvalue` durch. Genau die Fehlerklasse aus CLAUDE.md („a Java test asserting the string mapping passes forever“): `testSetRopeScalingAllValues` prüfte nur die String-Abbildung aller Konstanten. Gegencheck aller anderen `args`-Enums gegen den Upstream-Parser (CacheType, DraftSampling, FlashAttn, GpuSplitMode, LazyMode, LoadMode, MiroStat, NumaStrategy, PoolingType, ReasoningFormat, Sampler): alle Werte akzeptiert. +- **Fix:** `UNSPECIFIED` entfernt die Option (wie `setPoolingType`), `LONGROPE`/`MAX_VALUE` → `IllegalArgumentException` beim Aufruf. Kein vorher funktionierender Pfad geht verloren (diese drei Werte scheiterten ohnehin beim Laden). Die Enum-Konstanten selbst bleiben (API-Kompatibilität) – ob sie gelöscht werden sollen, ist deine Entscheidung (CLAUDE.md: „A name with no counterpart is deleted, never deprecated“). +- **Reproduktion:** `ModelParametersExtendedTest#testSetRopeScalingUnspecifiedLeavesTheModelDefault`, `#testSetRopeScalingRejectsValuesTheParserDoesNotAccept` (beide vorher rot), `#testSetRopeScalingAllValuesTheParserAccepts` (ersetzt den alten Test). `WireNameRegistryTest`-Treiber nimmt bei Ablehnung die nächste Konstante. Grün: ModelParametersExtendedTest 148, ModelParametersTest 92, WireNameRegistryTest 8, RopeScalingTypeTest 24. Nicht gelaufen: C++-`test_model_flags` (prüft nur Optionsnamen, nicht Werte), `spotbugs:check`. +- **Status:** gefixt +- **Commit:** java-llama.cpp `8be77fe` + + +### F16 – java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) +- **Zeit:** 2026-10-17 00:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java:95-113` (JNI) und `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` (FFM); nativ `llama/src/main/cpp/tts_engine.cpp:201` (`engine_free` = `delete engine`), `jllama_api.cpp:673` +- **Schwere:** mittel (Fehlbedienung durch zwei Threads, aber Folge ist ein nativer Absturz statt einer Exception) +- **Beschreibung:** `synthesize()` prüft nur `handle == 0` bzw. `closed.get()` und ruft dann nativ auf, ohne Lock. `close()` (JNI: `synchronized`, FFM: CAS) ruft `jllama_tts_free` → `delete engine`. Schließt Thread B, während Thread A in `engine_synthesize` steckt, löscht B die Engine samt `synthesize_mutex`, `llama_context` und `mtmd`-Kontext unter A weg → Use-after-free im nativen Code. Bei `LlamaModel` gibt es dafür die Nutzer-Referenzzählung (`jllama_context_guard`, `free` wartet auf `users == 0`); die TTS-Engine hat nichts Vergleichbares. Mehrere gleichzeitige `synthesize()` sind dagegen sicher (nativ per `synthesize_mutex` serialisiert). +- **Vorschlag:** In beiden Klassen `synthesize` und `close` über denselben Monitor laufen lassen (z. B. `synchronized` auch auf `synthesize`; kostet nichts, weil nativ ohnehin serialisiert wird) – oder eine Referenzzählung wie bei `jllama_context` in `jllama_api.cpp`. +- **Reproduktion:** keine – beide Klassen brauchen die Native-Library (JNI) bzw. ein JDK 22 (FFM), beides gibt es in dieser Sandbox nicht, und ein Rennen gegen `delete` ist ohne Modell nicht prüfbar. +- **Status:** nur beschrieben (ohne lauffähigen Test nicht fixen) +- **Commit:** – + + +### F17 – java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild +- **Zeit:** 00:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:138` (`appendInput`, Fall `message`) +- **Schwere:** mittel +- **Beschreibung:** Der Translator flachte jedes Content-Array einer `message` auf seinen Text ab (`inputContentText` liest nur `text`). Ein `{"type":"input_image","image_url":"data:…"}`-Teil kam nie im Chat-Kern an; ein Vision-Modell antwortete, als wäre kein Bild geschickt worden – still, ohne Fehler. Gleiche Fehlerklasse wie der Ollama-`images`-Fund oben. +- **Fix:** Enthält das Array `input_image`-Teile, wird ein OpenAI-Content-Part-Array gebaut (`text`-Teile + `image_url`-Teile; `image_url` als String oder `{url}`). Reine Text-Nachrichten bleiben unverändert ein String. `file_id`-Bilder (ohne URL) werden weiterhin ignoriert. +- **Reproduktion:** `ResponsesApiSupportTest#requestTurnsInputImagePartsIntoImageUrlParts` (vorher rot, nachher 10/10 grün) +- **Status:** gefixt +- **Commit:** java-llama.cpp `4c08a90` + + +### F18 – java-llama.cpp: `NativeServer.close()` nicht atomar → doppeltes Freigeben des nativen Servers bei parallelen Aufrufen +- **Zeit:** 00:17 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java:284` (`close`, ebenso `isRunning`, `start`) +- **Schwere:** mittel +- **Beschreibung:** `close()` las `handle`, prüfte auf 0 und setzte es danach auf 0 – ohne Synchronisation. Zwei gleichzeitige `close()` (Shutdown-Hook + try-with-resources, oder in `NativeServer.main` der Hook und der `finally`-Block, wenn der Worker genau beim Ctrl-C selbst endet) kamen beide durch und riefen `stopNativeServer(h)` doppelt: zweimal `join()` und zweimal `delete` desselben `native_server` → JVM-Absturz. Ebenso konnte `isRunning()` einen Handle abfragen, den ein paralleles `close()` gerade freigab (Use-after-free). Der Kommentar in `main` („the finally and the hook both firing is safe“) gilt nur nacheinander. +- **Fix:** privates `lifecycleLock` serialisiert `start`, `isRunning` und `close` (keine `synchronized`-Methoden, SpotBugs USO_UNSAFE_METHOD_SYNCHRONIZATION). `spotbugs:check` grün, `NativeServerSmokeTest` + `LlamaArchitectureTest` grün. +- **Reproduktion:** kein committeter Test – ein künstlicher Handle würde mit vorhandener nativer Bibliothek (CI) die JVM abstürzen lassen. Probe ohne native Bibliothek: `/home/user/BUGHUNT-2026-10-17-F18-probe.java` (zwei Threads, Barriere, `close()` auf per Reflection gesetztem Handle, zählt `UnsatisfiedLinkError` = „hat den nativen Stop erreicht“): vorher **4 / 20 000** Durchläufe doppelt, nachher **0 / 20 000**. +- **Status:** gefixt (nur Probe, kein Regressionstest) +- **Commit:** java-llama.cpp `c1e5dcd` + + +### F24 – java-llama.cpp: `OpenAiServerCli` bewirbt bei kleinem `-c` fast kein Eingabebudget (`-c 2048` → 1 Token) +- **Zeit:** 05:55 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java:721` (`Options.toServerConfig`) +- **Schwere:** mittel +- **Beschreibung:** Das Ausgabebudget ist `min(2048, ctx/2)`, das Eingabebudget wurde aber als `ctx − 2048` (der Default, nicht das tatsächliche Ausgabebudget) berechnet. Für `ctx < 4096` passt das nicht zusammen: `-c 2048` → Eingabe **1** Token, Ausgabe 1024; `-c 1024` → beworbenes Fenster 513 statt 1024; `-c 3000` → 2452. Das Eingabebudget landet in `maxInputTokens` (Copilot-Konfigurationsausgabe) und in der beworbenen Kontextlänge (`/api/show`, `/props`: `maxInputTokens + maxOutputTokens`); Clients, die ihren Prompt danach zuschneiden, schicken praktisch nichts. Für `-c ≥ 4096` (README-Beispiel 8192 → 6144) war das Ergebnis zufällig richtig. +- **Fix:** `maxInputTokens = max(1, ctx − maxOutputTokens)` mit dem tatsächlich gesetzten Ausgabebudget; 8192 → 2048/6144 unverändert. +- **Reproduktion:** `OpenAiServerCliTest#smallContextSizeSplitsTheWholeWindowBetweenInputAndOutput` (ctx 1024/2048/3000; vorher `Expected 1024 but was 513`, nachher 38/38 grün). +- **Status:** gefixt +- **Commit:** java-llama.cpp `b210ca4` + + +### F25 – java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung +- **Zeit:** 06:03 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:893` (`authorized`; gefunden beim Review von `OpenAiServerConfig`) +- **Schwere:** mittel +- **Beschreibung:** Akzeptiert wurde nur `Authorization: Bearer ` mit exakt großgeschriebenem `Bearer`. Die Anthropic-Messages-Oberfläche (`POST /v1/messages`) wird von Anthropic-SDKs aber per `x-api-key`-Header authentifiziert – mit gesetztem `--api-key` bekam jeder solche Client 401, die Anthropic-Oberfläche war damit nur ohne Schlüssel nutzbar. Zudem ist der Schema-Name laut RFC 7235 case-insensitiv; `bearer ` wurde abgelehnt. +- **Fix:** `x-api-key` wird zusätzlich akzeptiert, das Schema case-insensitiv geprüft; beide Vergleiche weiterhin zeitkonstant (`MessageDigest.isEqual`). `spotbugs:check` grün. +- **Reproduktion:** `OpenAiCompatServerHttpTest#anthropicApiKeyHeaderAndLowercaseBearerSchemeAreAccepted` (vorher 401 statt 200, nachher Klasse 44/44 grün). +- **Status:** gefixt +- **Commit:** java-llama.cpp `e98e290` + + +### F26 – java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert +- **Zeit:** 06:09 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java:236` (`buildTokenIdBiasArray`/`buildTokenStringBiasArray`; gefunden beim Review von `InferenceParameters`) +- **Schwere:** mittel +- **Beschreibung:** JSON kennt kein Unendlich; Jackson schreibt einen nicht-endlichen `float` als String: `{"logit_bias":[[5,"-Infinity"]]}` (gemessen). llama.cpps Parser (`server-schema.cpp` b11639, `parse_bias`) akzeptiert nur eine Zahl oder `false` und überspringt alles andere per `continue`. Der naheliegende Weg, ein Token zu verbieten (`withTokenIdBias(Map.of(id, Float.NEGATIVE_INFINITY))`), bewirkte damit gar nichts – ohne Fehler. `NaN` und `+Infinity` ebenso. +- **Fix:** `-Infinity` wird als `false` (llama.cpps Schreibweise für ein Verbot, wie schon bei `withDisabledTokenIds`) gesendet, `NaN`/`+Infinity` werfen `IllegalArgumentException`. +- **Reproduktion:** `ParameterJsonSerializerTest#testBuildTokenIdBiasArray_negativeInfinityBansTheToken`, `#testBuildTokenIdBiasArray_nanOrPositiveInfinityIsRejected` (vorher rot, nachher 34/34); `InferenceParametersTest` 105/105, `WireNameRegistryTest` 8/8 grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `ea721a2` + + +### F28 – java-llama.cpp: `InferenceParameters.withMessages(List)` verwirft `tool_calls` und `tool_call_id` +- **Zeit:** 06:22 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java:152` (`buildMessages(List)`) +- **Schwere:** mittel +- **Beschreibung:** Der gemeinsame Serializer schrieb nur `role` und `content` (bzw. Content-Parts). `ChatRequest.buildMessagesJson` ergänzte `tool_calls`/`tool_call_id` nachträglich selbst – `InferenceParameters.withMessages(List)` nutzt den Serializer aber direkt. Eine Tool-Calling-Runde über diesen Weg (`ChatMessage.assistantToolCalls(...)`, `ChatMessage.toolResult(...)`) schickte einen Assistant-Turn ohne seine Aufrufe und ein Tool-Ergebnis ohne Zuordnung: `[{"role":"assistant","content":""},{"role":"tool","content":"42"}]` (gemessen). +- **Fix:** die beiden Felder schreibt jetzt der Serializer; die Duplikat-Schleife in `ChatRequest` entfällt (Ausgabe dort identisch). `spotbugs:check` grün. +- **Reproduktion:** `InferenceParametersTest#withChatMessagesKeepsToolCallsAndToolCallIds` (vorher rot, nachher 106/106); `ChatRequestTest` 24/24, `ParameterJsonSerializerTest` 34/34, `OpenAiCompatServerHttpTest` 44/44 grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `855d22e` + + +### F29 – java-llama.cpp: Anthropic-Messages-API (`POST /v1/messages`) verwirft `image`-Blöcke → Vision-Modell sieht kein Bild +- **Zeit:** 06:29 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:151` (`appendOpenAiMessages`) +- **Schwere:** mittel +- **Beschreibung:** Gleiche Fehlerklasse wie F17 (Responses) und der Ollama-`images`-Fund: der Translator kannte nur `text`, `tool_use` und `tool_result`; ein `{"type":"image","source":{"type":"base64","media_type":…,"data":…}}`-Block (so senden Anthropic-Clients Bilder) fiel im `default`-Zweig weg. Ein Vision-Modell hinter `/v1/messages` antwortete, als wäre kein Bild geschickt worden – still. +- **Fix:** Bild-Blöcke werden zu OpenAI-`image_url`-Teilen (Data-URL für `base64`, die URL selbst für `url`-Quellen); eine Nachricht mit Bild bekommt Content als Teile-Array in Blockreihenfolge, Tool-Calls bleiben erhalten. Nachrichten ohne Bild unverändert. `spotbugs:check` grün. +- **Reproduktion:** `AnthropicApiSupportTest#requestTurnsImageBlocksIntoImageUrlParts` (vorher rot, nachher 12/12); `OpenAiCompatServerHttpTest` 44/44 grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `d8c653c` + + +### F30 – java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt +- **Zeit:** 06:34 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/RpcServer.java:526` (vorher: einmaliges `backend.stop()` in `awaitListening`) +- **Schwere:** mittel +- **Beschreibung:** Läuft der Start in den Timeout (z. B. langsame Geräte-Initialisierung) oder ist der aufrufende Thread unterbrochen, schickt `start` genau **einen** `stop()` und gibt den Single-Instance-Slot frei. Der native Server (patches/0015) setzt aber beim Binden des Sockets `g_rpc_server_stop = false` – ein Stopp vor dem Bind geht verloren. Der Server lauscht danach als verwaister Daemon-Thread weiter, obwohl `start` eine `LlamaException` geworfen hat; ein zweiter `start` belegt dann denselben globalen Server-Zustand. Beim unterbrochenen Aufrufer kehrte `join` zudem sofort zurück, statt zu warten. Fix: `stopAndJoin` wiederholt den Stopp alle 100 ms, solange der Thread lebt (bis `STOP_TIMEOUT_MILLIS`), und stellt das Interrupt-Flag danach wieder her. +- **Reproduktion:** `RpcServerLifecycleTest#aStopThatArrivesBeforeTheBindDoesNotLeaveTheServerRunning` (Fake-Backend, das wie der native Server das Stopp-Flag beim Binden zurücksetzt; ohne Fix rot nach 31 s, mit Fix 15/15 grün) +- **Status:** gefixt +- **Commit:** 32d465d + +## Offene Verdachte ohne Fund-ID + +- *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. From d5c2622c3844f177cfe99a2983dcd656fb2558bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:53:52 +0000 Subject: [PATCH 016/589] Mark F18 as described only in the bug-hunt ledger The NativeServer lifecycle change c1e5dcd shipped without a committed test that fails against the old code; a probe outside the repository is no proof. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c9e5ca91a..f90b5e30b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -23,7 +23,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F13](#f13) — mittel — *gefixt* - [F16](#f16) — mittel — *nur beschrieben* - [F17](#f17) — mittel — *gefixt* -- [F18](#f18) — mittel — *gefixt* +- [F18](#f18) — mittel — *nur beschrieben* - [F24](#f24) — mittel — *gefixt* - [F25](#f25) — mittel — *gefixt* - [F26](#f26) — mittel — *gefixt* @@ -137,7 +137,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** `close()` las `handle`, prüfte auf 0 und setzte es danach auf 0 – ohne Synchronisation. Zwei gleichzeitige `close()` (Shutdown-Hook + try-with-resources, oder in `NativeServer.main` der Hook und der `finally`-Block, wenn der Worker genau beim Ctrl-C selbst endet) kamen beide durch und riefen `stopNativeServer(h)` doppelt: zweimal `join()` und zweimal `delete` desselben `native_server` → JVM-Absturz. Ebenso konnte `isRunning()` einen Handle abfragen, den ein paralleles `close()` gerade freigab (Use-after-free). Der Kommentar in `main` („the finally and the hook both firing is safe“) gilt nur nacheinander. - **Fix:** privates `lifecycleLock` serialisiert `start`, `isRunning` und `close` (keine `synchronized`-Methoden, SpotBugs USO_UNSAFE_METHOD_SYNCHRONIZATION). `spotbugs:check` grün, `NativeServerSmokeTest` + `LlamaArchitectureTest` grün. - **Reproduktion:** kein committeter Test – ein künstlicher Handle würde mit vorhandener nativer Bibliothek (CI) die JVM abstürzen lassen. Probe ohne native Bibliothek: `/home/user/BUGHUNT-2026-10-17-F18-probe.java` (zwei Threads, Barriere, `close()` auf per Reflection gesetztem Handle, zählt `UnsatisfiedLinkError` = „hat den nativen Stop erreicht“): vorher **4 / 20 000** Durchläufe doppelt, nachher **0 / 20 000**. -- **Status:** gefixt (nur Probe, kein Regressionstest) +- **Status:** nur beschrieben (Code geändert in `c1e5dcd`, aber ohne roten Regressionstest; die Probe ist kein Beweis im Sinne des Rot-Grün-Zyklus) - **Commit:** java-llama.cpp `c1e5dcd` From 8845eca8196c019349d1949dbd0ce54472f72f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:54:21 +0000 Subject: [PATCH 017/589] Test NativeServer's lifecycle locking through a backend seam NativeServer's start, isRunning and close now reach the JNI methods through a package-private Backend interface, so a fake can stand in for libjllama. NativeServerLifecycleTest holds the fake inside isRunning and inside start to pin the two windows the lifecycle lock of c1e5dcd closes: close() must not free the handle while isRunning() still uses it, and must not return early while start() is still starting the server. Both tests fail against the unsynchronized lifecycle and pass with the lock. The JNI methods stay in NativeServer, so their symbol names are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 9 +- .../ladenthin/llama/server/NativeServer.java | 93 +++++++++- .../server/NativeServerLifecycleTest.java | 169 ++++++++++++++++++ 4 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 llama/src/test/java/net/ladenthin/llama/server/NativeServerLifecycleTest.java diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 35c00e31a..76f240a24 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -43,7 +43,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild -- `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `fae453e` · 2026-10-17 · Fund F18 — java-llama.cpp: `NativeServer.close()` nicht atomar → doppeltes Freigeben des nativen Servers bei parallelen Aufrufen +- `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` - `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… - `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `0e5f9e5` · 2026-10-17 · Fund F01 — java-llama.cpp: `TrainingParameters` validiert die dokumentierten Grenzen nicht - `llama/src/main/java/net/ladenthin/llama/value/LoraAdapter.java` · `0351765` · 2026-10-17 · kein Fund — java-llama.cpp `value/LoraAdapter` (+ `json/LoraAdapterResponseParser`): Schlüssel `id/path/scale/task_name/prompt_prefix` gegen `server-task.cpp` b11639 identisch; das Javadoc „nicht gelistete Adapter werden auf 0 gesetzt“ stimmt mit `construct_lora_list` überein. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f90b5e30b..9d9d0931f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -23,7 +23,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F13](#f13) — mittel — *gefixt* - [F16](#f16) — mittel — *nur beschrieben* - [F17](#f17) — mittel — *gefixt* -- [F18](#f18) — mittel — *nur beschrieben* +- [F18](#f18) — mittel — *gefixt* - [F24](#f24) — mittel — *gefixt* - [F25](#f25) — mittel — *gefixt* - [F26](#f26) — mittel — *gefixt* @@ -136,9 +136,10 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** mittel - **Beschreibung:** `close()` las `handle`, prüfte auf 0 und setzte es danach auf 0 – ohne Synchronisation. Zwei gleichzeitige `close()` (Shutdown-Hook + try-with-resources, oder in `NativeServer.main` der Hook und der `finally`-Block, wenn der Worker genau beim Ctrl-C selbst endet) kamen beide durch und riefen `stopNativeServer(h)` doppelt: zweimal `join()` und zweimal `delete` desselben `native_server` → JVM-Absturz. Ebenso konnte `isRunning()` einen Handle abfragen, den ein paralleles `close()` gerade freigab (Use-after-free). Der Kommentar in `main` („the finally and the hook both firing is safe“) gilt nur nacheinander. - **Fix:** privates `lifecycleLock` serialisiert `start`, `isRunning` und `close` (keine `synchronized`-Methoden, SpotBugs USO_UNSAFE_METHOD_SYNCHRONIZATION). `spotbugs:check` grün, `NativeServerSmokeTest` + `LlamaArchitectureTest` grün. -- **Reproduktion:** kein committeter Test – ein künstlicher Handle würde mit vorhandener nativer Bibliothek (CI) die JVM abstürzen lassen. Probe ohne native Bibliothek: `/home/user/BUGHUNT-2026-10-17-F18-probe.java` (zwei Threads, Barriere, `close()` auf per Reflection gesetztem Handle, zählt `UnsatisfiedLinkError` = „hat den nativen Stop erreicht“): vorher **4 / 20 000** Durchläufe doppelt, nachher **0 / 20 000**. -- **Status:** nur beschrieben (Code geändert in `c1e5dcd`, aber ohne roten Regressionstest; die Probe ist kein Beweis im Sinne des Rot-Grün-Zyklus) -- **Commit:** java-llama.cpp `c1e5dcd` +- **Reproduktion:** `NativeServerLifecycleTest` über die neue paketinterne Naht `NativeServer.Backend` (ein Fake statt der JNI-Aufrufe, damit kein erfundener Handle libjllama erreicht): `close_whileIsRunningIsInsideTheNativeCall_stopsOnlyAfterItReturned`, `close_whileStartIsStillStarting_stopsTheServerOnceItStarted`, `close_calledTwice_stopsTheHandleOnce`. Der Fake hält `isRunning` bzw. `start` im „nativen“ Aufruf fest, dadurch sind beide Race-Fenster deterministisch. (Die alte Probe `/home/user/BUGHUNT-2026-10-17-F18-probe.java`: vorher 4 / 20 000 doppelte Stopps, nachher 0 / 20 000.) +- **Rot-Grün-Nachweis:** Gegen den Lebenszyklus vor `c1e5dcd` (ohne `lifecycleLock`, Naht beibehalten) **rot**, 2 von 3: „the handle was stopped while isRunning() still used it – Expected: is but: was “ und „close() returned while the server was still starting, so it kept running – Expected: is <1> but: was <0>“. Mit Fix **grün** 3/3 (skipped 0), dreimal hintereinander, ebenso `NativeServerSmokeTest` 10/10, `NativeServerWorkerCommandTest` 5/5, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. Das doppelte `close()` aus zwei Threads selbst ist mit der Naht nicht steuerbar (das Fenster liegt zwischen Lesen und Nullsetzen des Feldes); `close_calledTwice_stopsTheHandleOnce` deckt nur den sequentiellen Fall ab. +- **Status:** gefixt (Fix `c1e5dcd`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `209ee6c`) +- **Commit:** java-llama.cpp `c1e5dcd` (Fix), Test und Naht im nächsten Commit nach `209ee6c` ### F24 – java-llama.cpp: `OpenAiServerCli` bewirbt bei kleinem `-c` fast kein Eingabebudget (`-c 2048` → 1 Token) diff --git a/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java b/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java index fae453e28..c67961909 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java @@ -89,6 +89,72 @@ public final class NativeServer implements AutoCloseable { */ private static final int DEFAULT_PORT = 9931; + /** + * The native calls behind {@link NativeServer}'s lifecycle. The JNI methods themselves stay in + * this class (their symbol names are bound to it); this seam exists so the lifecycle's locking + * can be tested with a fake that cannot crash the JVM on a made-up handle. + */ + interface Backend { + /** + * Starts the classic server. + * + * @param args the llama-server argument vector + * @return the native handle + */ + long start(String[] args); + + /** + * Starts the attach-mode server over a loaded model. + * + * @param model the model to serve + * @param args the HTTP-side argument vector + * @return the native handle + */ + long startAttached(LlamaModel model, String[] args); + + /** + * Stops the server and frees the handle. + * + * @param handle a handle returned by a start method + */ + void stop(long handle); + + /** + * Reports whether the server's worker is still running. + * + * @param handle a handle returned by a start method + * @return whether the worker is running + */ + boolean isRunning(long handle); + } + + /** The real backend: loads libjllama lazily, then calls the JNI methods of this class. */ + private static final Backend NATIVE_BACKEND = new Backend() { + @Override + public long start(String[] args) { + // Load libjllama lazily here (not in a static initializer) so construction, argument + // parsing and close() stay usable — and unit-testable — without the native library. + LlamaLoader.initialize(); + return startNativeServer(args); + } + + @Override + public long startAttached(LlamaModel model, String[] args) { + LlamaLoader.initialize(); + return startAttachedNativeServer(model, args); + } + + @Override + public void stop(long handle) { + stopNativeServer(handle); + } + + @Override + public boolean isRunning(long handle) { + return isRunningNative(handle); + } + }; + /** The llama-server argument vector, forwarded verbatim to the native entry point. */ private final String[] args; @@ -107,6 +173,10 @@ public final class NativeServer implements AutoCloseable { @ToString.Exclude private final Object lifecycleLock = new Object(); + /** The native entry points; a test replaces them to drive the lifecycle without libjllama. */ + @ToString.Exclude + private final Backend backend; + /** * Creates a native-server bridge for the given llama-server arguments. * @@ -118,12 +188,23 @@ public final class NativeServer implements AutoCloseable { * elements */ public NativeServer(String... args) { + this(NATIVE_BACKEND, args); + } + + /** + * Creates a classic-lifecycle server over the given backend (tests use a fake one). + * + * @param backend the native entry points + * @param args the llama-server command-line arguments + */ + NativeServer(Backend backend, String... args) { Objects.requireNonNull(args, "args"); for (final String arg : args) { Objects.requireNonNull(arg, "args element"); } this.args = args.clone(); this.attachedModel = null; + this.backend = backend; } /** @@ -164,6 +245,7 @@ public NativeServer(LlamaModel model, String... args) { } this.args = args.clone(); this.attachedModel = model; + this.backend = NATIVE_BACKEND; } /** @@ -185,12 +267,7 @@ public NativeServer start() { "another NativeServer is already running in this process (only one is supported)"); } try { - // Load libjllama lazily here (not in a static initializer) so construction, argument - // parsing and close() stay usable — and unit-testable — without the native library. - LlamaLoader.initialize(); - handle = attachedModel != null - ? startAttachedNativeServer(attachedModel, args) - : startNativeServer(args); + handle = attachedModel != null ? backend.startAttached(attachedModel, args) : backend.start(args); } catch (final RuntimeException | Error e) { RUNNING.set(false); throw e; @@ -210,7 +287,7 @@ public NativeServer start() { public boolean isRunning() { synchronized (lifecycleLock) { final long h = handle; - return h != 0 && isRunningNative(h); + return h != 0 && backend.isRunning(h); } } @@ -289,7 +366,7 @@ public void close() { } handle = 0; try { - stopNativeServer(h); + backend.stop(h); } finally { RUNNING.set(false); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/NativeServerLifecycleTest.java b/llama/src/test/java/net/ladenthin/llama/server/NativeServerLifecycleTest.java new file mode 100644 index 000000000..6e45d9c48 --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/server/NativeServerLifecycleTest.java @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.server; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.ladenthin.llama.ClaudeGenerated; +import net.ladenthin.llama.LlamaModel; +import org.junit.jupiter.api.Test; + +@ClaudeGenerated( + purpose = "Pin that NativeServer serializes start(), isRunning() and close() over its native " + + "handle: close() must neither free the handle while isRunning() is inside the native " + + "call nor return early while start() is still starting the server. Driven through the " + + "Backend seam with a fake, so a made-up handle never reaches libjllama.") +public class NativeServerLifecycleTest { + + private static final long HANDLE = 42L; + + private static final long WAIT_SECONDS = 5L; + + /** A backend whose start and isRunning can be held inside the "native" call. */ + static class FakeBackend implements NativeServer.Backend { + final CountDownLatch startEntered = new CountDownLatch(1); + final CountDownLatch releaseStart = new CountDownLatch(1); + final CountDownLatch isRunningEntered = new CountDownLatch(1); + final CountDownLatch releaseIsRunning = new CountDownLatch(1); + final AtomicInteger stops = new AtomicInteger(); + final AtomicInteger insideIsRunning = new AtomicInteger(); + volatile boolean holdStart; + volatile boolean holdIsRunning; + volatile boolean stoppedDuringIsRunning; + + @Override + public long start(String[] args) { + startEntered.countDown(); + if (holdStart) { + await(releaseStart); + } + return HANDLE; + } + + @Override + public long startAttached(LlamaModel model, String[] args) { + throw new UnsupportedOperationException("attach mode is not used here"); + } + + @Override + public void stop(long handle) { + if (insideIsRunning.get() > 0) { + stoppedDuringIsRunning = true; + } + stops.incrementAndGet(); + } + + @Override + public boolean isRunning(long handle) { + insideIsRunning.incrementAndGet(); + try { + isRunningEntered.countDown(); + if (holdIsRunning) { + await(releaseIsRunning); + } + return true; + } finally { + insideIsRunning.decrementAndGet(); + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Test + public void close_whileIsRunningIsInsideTheNativeCall_stopsOnlyAfterItReturned() throws Exception { + FakeBackend backend = new FakeBackend(); + backend.holdIsRunning = true; + NativeServer server = new NativeServer(backend).start(); + try { + Thread prober = new Thread(server::isRunning, "prober"); + prober.start(); + assertThat(backend.isRunningEntered.await(WAIT_SECONDS, TimeUnit.SECONDS), is(true)); + + Thread closer = new Thread(server::close, "closer"); + closer.start(); + awaitBlockedOrDone(closer); + backend.releaseIsRunning.countDown(); + prober.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + closer.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + + assertThat( + "the handle was stopped while isRunning() still used it", + backend.stoppedDuringIsRunning, + is(false)); + assertThat(backend.stops.get(), is(1)); + } finally { + backend.releaseIsRunning.countDown(); + server.close(); + } + assertSlotIsFree(); + } + + @Test + public void close_whileStartIsStillStarting_stopsTheServerOnceItStarted() throws Exception { + FakeBackend backend = new FakeBackend(); + backend.holdStart = true; + NativeServer server = new NativeServer(backend); + Thread starter = new Thread(server::start, "starter"); + starter.start(); + try { + assertThat(backend.startEntered.await(WAIT_SECONDS, TimeUnit.SECONDS), is(true)); + + Thread closer = new Thread(server::close, "closer"); + closer.start(); + awaitBlockedOrDone(closer); + backend.releaseStart.countDown(); + starter.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + closer.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + + assertThat( + "close() returned while the server was still starting, so it kept running", + backend.stops.get(), + is(1)); + assertThat(server.isRunning(), is(false)); + } finally { + backend.releaseStart.countDown(); + starter.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + server.close(); + } + assertSlotIsFree(); + } + + @Test + public void close_calledTwice_stopsTheHandleOnce() { + FakeBackend backend = new FakeBackend(); + NativeServer server = new NativeServer(backend).start(); + server.close(); + server.close(); + assertThat(backend.stops.get(), is(1)); + assertSlotIsFree(); + } + + /** Waits until the thread is blocked on a monitor or has finished, whichever comes first. */ + private static void awaitBlockedOrDone(Thread thread) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(WAIT_SECONDS); + while (thread.isAlive() && thread.getState() != Thread.State.BLOCKED && System.nanoTime() < deadline) { + thread.join(10); + } + } + + /** The process-wide single-instance slot must be free again: a fresh server starts and stops. */ + private static void assertSlotIsFree() { + FakeBackend probe = new FakeBackend(); + NativeServer server = new NativeServer(probe).start(); + server.close(); + assertThat(probe.stops.get(), is(1)); + } +} From f6f2b0e306c442f7386d6754d3aa3e1354c41a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 08:54:42 +0000 Subject: [PATCH 018/589] Record the commit of the F18 test in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9d9d0931f..7cb3bd154 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -139,7 +139,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `NativeServerLifecycleTest` über die neue paketinterne Naht `NativeServer.Backend` (ein Fake statt der JNI-Aufrufe, damit kein erfundener Handle libjllama erreicht): `close_whileIsRunningIsInsideTheNativeCall_stopsOnlyAfterItReturned`, `close_whileStartIsStillStarting_stopsTheServerOnceItStarted`, `close_calledTwice_stopsTheHandleOnce`. Der Fake hält `isRunning` bzw. `start` im „nativen“ Aufruf fest, dadurch sind beide Race-Fenster deterministisch. (Die alte Probe `/home/user/BUGHUNT-2026-10-17-F18-probe.java`: vorher 4 / 20 000 doppelte Stopps, nachher 0 / 20 000.) - **Rot-Grün-Nachweis:** Gegen den Lebenszyklus vor `c1e5dcd` (ohne `lifecycleLock`, Naht beibehalten) **rot**, 2 von 3: „the handle was stopped while isRunning() still used it – Expected: is but: was “ und „close() returned while the server was still starting, so it kept running – Expected: is <1> but: was <0>“. Mit Fix **grün** 3/3 (skipped 0), dreimal hintereinander, ebenso `NativeServerSmokeTest` 10/10, `NativeServerWorkerCommandTest` 5/5, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. Das doppelte `close()` aus zwei Threads selbst ist mit der Naht nicht steuerbar (das Fenster liegt zwischen Lesen und Nullsetzen des Feldes); `close_calledTwice_stopsTheHandleOnce` deckt nur den sequentiellen Fall ab. - **Status:** gefixt (Fix `c1e5dcd`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `209ee6c`) -- **Commit:** java-llama.cpp `c1e5dcd` (Fix), Test und Naht im nächsten Commit nach `209ee6c` +- **Commit:** java-llama.cpp `c1e5dcd` (Fix), Test und Naht `669a472` ### F24 – java-llama.cpp: `OpenAiServerCli` bewirbt bei kleinem `-c` fast kein Eingabebudget (`-c 2048` → 1 Token) From aaa7b5a76c31f982383b02509d611e4e61b6c1f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:13:17 +0000 Subject: [PATCH 019/589] Validate TrainingParameters before it reaches the native trainer The native trainer reads the configuration with defaults and checks none of what the javadoc promises: epochs below 1 wrote the untouched model as a successful run, setting both corpora silently dropped trainingText, and a validation split outside [0, 1) left no training data. toJson() now rejects these with IllegalArgumentException and names a missing path. WireNameRegistryTest reaches the two corpus keys through two configurations, since one may no longer carry both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 10 ++-- .../llama/parameters/TrainingParameters.java | 19 +++++++ .../parameters/TrainingParametersTest.java | 55 +++++++++++++++++++ .../parameters/WireNameRegistryTest.java | 18 ++++-- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 76f240a24..0f5a12523 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -45,7 +45,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild - `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` - `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… -- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `0e5f9e5` · 2026-10-17 · Fund F01 — java-llama.cpp: `TrainingParameters` validiert die dokumentierten Grenzen nicht +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `40fec26` · 2026-10-17 · Fund F01 — F01 Validierung in `toJson()`: Pfade, genau ein Korpus, epochs >= 1, valSplit in [0, 1) - `llama/src/main/java/net/ladenthin/llama/value/LoraAdapter.java` · `0351765` · 2026-10-17 · kein Fund — java-llama.cpp `value/LoraAdapter` (+ `json/LoraAdapterResponseParser`): Schlüssel `id/path/scale/task_name/prompt_prefix` gegen `server-task.cpp` b11639 identisch; das Javadoc „nicht gelistete Adapter werden auf 0 gesetzt“ stimmt mit `construct_lora_list` überein. - `llama/src/main/java/net/ladenthin/llama/value/TokenLogprob.java` · `645a0f9` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… - `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` · `138b590` · 2026-10-17 · kein Fund — java-llama.cpp `loader/OSInfo`: Abbildung von `os.name`/`os.arch` auf die Natives-Verzeichnisse für alle ausgelieferten Plattformen (Linux/Windows x86_64 + aarch64, Mac aarch64, s390x, Android) korrekt. Randnotizen (niedrig): `isMusl()` fällt nur bei einer Ausnahme von `Files.list` auf `/etc/os-r… diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7cb3bd154..88599e370 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -14,7 +14,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU ## Übersicht -- [F01](#f01) — niedrig — *unsicher / nur beschrieben* +- [F01](#f01) — niedrig — *gefixt* - [F05](#f05) — niedrig — *gefixt* - [F06](#f06) — mittel — *gefixt* - [F07](#f07) — niedrig — *gefixt* @@ -39,9 +39,11 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java:46` (`epochs`, „at least 1“), `:19` (Klassen-Javadoc „exactly one of trainingText / trainingFile“), `valSplit` - **Schwere:** niedrig - **Beschreibung:** Javadoc (und `LlamaTrainer.finetune(Path,String,Path,int,float)`) sagt `epochs` „at least 1“ und „genau eins von trainingText/trainingFile“. Nichts davon wird geprüft: `epochs(0)` oder negativ läuft nativ 0 Epochen und `train_engine.cpp` schreibt danach das **unveränderte** Modell nach `outputPath` – ein stiller Erfolg ohne Training. Sind beide Korpus-Felder gesetzt, gewinnt nativ stillschweigend `trainingFile`. `valSplit` außerhalb `[0,1)` ergibt in `train_engine.cpp:75` einen negativen bzw. 0-großen `idata_split` (Verhalten in ggml nicht geprüft, möglicherweise ein `GGML_ASSERT`, der die JVM beendet). Fehlende `modelPath`/`outputPath` geben eine nackte NPE aus `toJson()`. -- **Reproduktion:** keine (API-/Designentscheidung: Validierung in `toJson()` oder `LlamaTrainer.finetune` würde Verhalten ändern; `LlamaTrainer` lädt im static-Block die Native-Lib, ein Test dort braucht libjllama). -- **Status:** unsicher / nur beschrieben – Vorschlag: Prüfung in `LlamaTrainer.finetune(TrainingParameters)` vor dem JNI-Aufruf (`IllegalArgumentException`). -- **Commit:** – +- **Reproduktion:** `TrainingParametersTest#lessThanOneEpochIsRejected`, `#validationSplitOutsideZeroToOneIsRejected`, `#bothOrNoCorpusIsRejected`, `#missingPathsNameTheField` +- **Fix:** `toJson()` ruft `validate()`: `modelPath`/`outputPath` per `Objects.requireNonNull` mit Feldnamen, genau eins von `trainingText`/`trainingFile`, `epochs >= 1`, `valSplit` in `[0, 1)` (NaN eingeschlossen) – sonst `IllegalArgumentException` vor dem JNI-Aufruf. +- **Rot-Grün-Nachweis:** ohne Fix rot, 4 von 7 („Expected IllegalArgumentException to be thrown, but nothing was thrown“ bzw. bare NPE ohne Feldnamen); mit Fix grün 7/7 (skipped 0), `WireNameRegistryTest` 8/8; Gegenprobe per `git stash` rot 4/7, nach `stash pop` grün 7/7. `WireNameRegistryTest#everyTrainingFieldIsWrittenByAFullyPopulatedConfiguration` setzte beide Korpus-Felder zugleich und nutzt jetzt zwei Konfigurationen. +- **Status:** gefixt +- **Commit:** siehe Git-Log („Validate TrainingParameters before it reaches the native trainer“) ### F05 – java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java index 0e5f9e562..40fec26f9 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.file.Path; +import java.util.Objects; import lombok.Builder; import lombok.Getter; import lombok.ToString; @@ -93,6 +94,7 @@ public final class TrainingParameters { * @return a compact JSON string */ public String toJson() { + validate(); ObjectNode node = MAPPER.createObjectNode(); node.put(TrainingField.MODEL_PATH.getKey(), modelPath.toString()); if (trainingText != null) { @@ -115,4 +117,21 @@ public String toJson() { node.put(TrainingField.N_UBATCH.getKey(), nUbatch); return node.toString(); } + + // The native trainer reads the JSON with defaults and checks none of these: epochs below 1 wrote the + // untouched model as a successful run, both corpora set silently dropped trainingText, and a + // validation split outside [0, 1) left no training data. + private void validate() { + Objects.requireNonNull(modelPath, "modelPath"); + Objects.requireNonNull(outputPath, "outputPath"); + if ((trainingText == null) == (trainingFile == null)) { + throw new IllegalArgumentException("exactly one of trainingText and trainingFile must be set"); + } + if (epochs < 1) { + throw new IllegalArgumentException("epochs must be at least 1, was " + epochs); + } + if (!(valSplit >= 0f && valSplit < 1f)) { + throw new IllegalArgumentException("valSplit must be in [0, 1), was " + valSplit); + } + } } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java index a982928bb..9df4d3f7b 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java @@ -5,7 +5,9 @@ package net.ladenthin.llama.parameters; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -97,4 +99,57 @@ void learningScheduleKeysSerialize() throws Exception { assertThat(node.get("weight_decay").floatValue(), is(0.01f)); assertThat(node.get("val_split").floatValue(), is(0.2f)); } + + private static TrainingParameters.TrainingParametersBuilder valid() { + return TrainingParameters.builder() + .modelPath(Paths.get("base.gguf")) + .trainingText("hello world") + .outputPath(Paths.get("tuned.gguf")); + } + + /** epochs below 1 would train nothing and write the untouched model, a silent success. */ + @Test + void lessThanOneEpochIsRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> valid().epochs(0).build().toJson()); + assertThat(e.getMessage(), containsString("epochs")); + } + + /** A held-out fraction outside [0, 1) leaves no training data, or a negative split. */ + @Test + void validationSplitOutsideZeroToOneIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> valid().valSplit(1f).build().toJson()); + assertThrows( + IllegalArgumentException.class, + () -> valid().valSplit(-0.1f).build().toJson()); + assertThrows( + IllegalArgumentException.class, + () -> valid().valSplit(Float.NaN).build().toJson()); + } + + /** Exactly one corpus: with both set the native layer silently ignored trainingText. */ + @Test + void bothOrNoCorpusIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> valid().trainingFile(Paths.get("corpus.txt")).build().toJson()); + assertThrows( + IllegalArgumentException.class, + () -> valid().trainingText(null).build().toJson()); + } + + /** A missing path names the field instead of failing with a bare NullPointerException. */ + @Test + void missingPathsNameTheField() { + NullPointerException model = assertThrows( + NullPointerException.class, + () -> valid().modelPath(null).build().toJson()); + assertThat(model.getMessage(), is("modelPath")); + NullPointerException output = assertThrows( + NullPointerException.class, + () -> valid().outputPath(null).build().toJson()); + assertThat(output.getMessage(), is("outputPath")); + } } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java index 5406414a7..f023f67e5 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/WireNameRegistryTest.java @@ -274,13 +274,19 @@ private static String readSpotbugsExclude() { */ @Test public void everyTrainingFieldIsWrittenByAFullyPopulatedConfiguration() { + // exactly one corpus per configuration, so the two corpus keys come from two of them String json = TrainingParameters.builder() - .modelPath(java.nio.file.Paths.get("base.gguf")) - .trainingText("corpus") - .trainingFile(java.nio.file.Paths.get("corpus.txt")) - .outputPath(java.nio.file.Paths.get("out.gguf")) - .build() - .toJson(); + .modelPath(java.nio.file.Paths.get("base.gguf")) + .trainingText("corpus") + .outputPath(java.nio.file.Paths.get("out.gguf")) + .build() + .toJson() + + TrainingParameters.builder() + .modelPath(java.nio.file.Paths.get("base.gguf")) + .trainingFile(java.nio.file.Paths.get("corpus.txt")) + .outputPath(java.nio.file.Paths.get("out.gguf")) + .build() + .toJson(); Set missing = new TreeSet<>(); for (TrainingField field : TrainingField.values()) { From 6bd8d130918a7b5903e57c49960c7c1dccf127cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:13:17 +0000 Subject: [PATCH 020/589] Record the commit of F01 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 88599e370..e74e1f798 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -43,7 +43,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `toJson()` ruft `validate()`: `modelPath`/`outputPath` per `Objects.requireNonNull` mit Feldnamen, genau eins von `trainingText`/`trainingFile`, `epochs >= 1`, `valSplit` in `[0, 1)` (NaN eingeschlossen) – sonst `IllegalArgumentException` vor dem JNI-Aufruf. - **Rot-Grün-Nachweis:** ohne Fix rot, 4 von 7 („Expected IllegalArgumentException to be thrown, but nothing was thrown“ bzw. bare NPE ohne Feldnamen); mit Fix grün 7/7 (skipped 0), `WireNameRegistryTest` 8/8; Gegenprobe per `git stash` rot 4/7, nach `stash pop` grün 7/7. `WireNameRegistryTest#everyTrainingFieldIsWrittenByAFullyPopulatedConfiguration` setzte beide Korpus-Felder zugleich und nutzt jetzt zwei Konfigurationen. - **Status:** gefixt -- **Commit:** siehe Git-Log („Validate TrainingParameters before it reaches the native trainer“) +- **Commit:** java-llama.cpp `0aa7c94` ### F05 – java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert From 4d6df58be165cef86a46482f2c2146f6810f1920 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:15:12 +0000 Subject: [PATCH 021/589] Name the corpus state in TrainingParameters' exactly-one check SpotBugs (WEM_WEAK_EXCEPTION_MESSAGING) rejects the static message of 0aa7c94; the message now says whether neither or both corpora were set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- .../net/ladenthin/llama/parameters/TrainingParameters.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 0f5a12523..f7e22cdf6 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -45,7 +45,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild - `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` - `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… -- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `40fec26` · 2026-10-17 · Fund F01 — F01 Validierung in `toJson()`: Pfade, genau ein Korpus, epochs >= 1, valSplit in [0, 1) +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `8447e35` · 2026-10-17 · Fund F01 — F01 Validierung in `toJson()`: Pfade, genau ein Korpus, epochs >= 1, valSplit in [0, 1) - `llama/src/main/java/net/ladenthin/llama/value/LoraAdapter.java` · `0351765` · 2026-10-17 · kein Fund — java-llama.cpp `value/LoraAdapter` (+ `json/LoraAdapterResponseParser`): Schlüssel `id/path/scale/task_name/prompt_prefix` gegen `server-task.cpp` b11639 identisch; das Javadoc „nicht gelistete Adapter werden auf 0 gesetzt“ stimmt mit `construct_lora_list` überein. - `llama/src/main/java/net/ladenthin/llama/value/TokenLogprob.java` · `645a0f9` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… - `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` · `138b590` · 2026-10-17 · kein Fund — java-llama.cpp `loader/OSInfo`: Abbildung von `os.name`/`os.arch` auf die Natives-Verzeichnisse für alle ausgelieferten Plattformen (Linux/Windows x86_64 + aarch64, Mac aarch64, s390x, Android) korrekt. Randnotizen (niedrig): `isMusl()` fällt nur bei einer Ausnahme von `Files.list` auf `/etc/os-r… diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java index 40fec26f9..8447e351a 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java @@ -125,7 +125,8 @@ private void validate() { Objects.requireNonNull(modelPath, "modelPath"); Objects.requireNonNull(outputPath, "outputPath"); if ((trainingText == null) == (trainingFile == null)) { - throw new IllegalArgumentException("exactly one of trainingText and trainingFile must be set"); + throw new IllegalArgumentException("exactly one of trainingText and trainingFile must be set, got " + + (trainingText == null ? "neither" : "both, trainingFile " + trainingFile)); } if (epochs < 1) { throw new IllegalArgumentException("epochs must be at least 1, was " + epochs); From 6ed949e32469ff52369bd5c707c797f6f85e0013 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:30:57 +0000 Subject: [PATCH 022/589] Keep TextToSpeech from freeing the engine during a synthesis close() was synchronized but synthesize() was not, so a close() while a synthesis ran freed the native engine, its own synthesize mutex included, under the running call. synthesize() now holds the read side of a ReentrantReadWriteLock for the whole native call and close() the write side; concurrent syntheses stay possible, the engine orders them itself. The JNI calls go through a package-private Backend seam, which is what TextToSpeechLifecycleTest drives with a fake; the library is loaded on the first load rather than in a static initializer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 10 +- .../net/ladenthin/llama/TextToSpeech.java | 123 +++++++++++++-- .../llama/TextToSpeechLifecycleTest.java | 142 ++++++++++++++++++ 4 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 llama/src/test/java/net/ladenthin/llama/TextToSpeechLifecycleTest.java diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f7e22cdf6..64a1ba0c5 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -73,4 +73,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-17 · Fund F30 — java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · unsicher — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab -- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7c95542` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) +- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-17 · Fund F16 — F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e74e1f798..9fd956cc9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -21,7 +21,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F09](#f09) — hoch — *gefixt* - [F12](#f12) — mittel — *gefixt* - [F13](#f13) — mittel — *gefixt* -- [F16](#f16) — mittel — *nur beschrieben* +- [F16](#f16) — mittel — *gefixt* - [F17](#f17) — mittel — *gefixt* - [F18](#f18) — mittel — *gefixt* - [F24](#f24) — mittel — *gefixt* @@ -116,9 +116,11 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** mittel (Fehlbedienung durch zwei Threads, aber Folge ist ein nativer Absturz statt einer Exception) - **Beschreibung:** `synthesize()` prüft nur `handle == 0` bzw. `closed.get()` und ruft dann nativ auf, ohne Lock. `close()` (JNI: `synchronized`, FFM: CAS) ruft `jllama_tts_free` → `delete engine`. Schließt Thread B, während Thread A in `engine_synthesize` steckt, löscht B die Engine samt `synthesize_mutex`, `llama_context` und `mtmd`-Kontext unter A weg → Use-after-free im nativen Code. Bei `LlamaModel` gibt es dafür die Nutzer-Referenzzählung (`jllama_context_guard`, `free` wartet auf `users == 0`); die TTS-Engine hat nichts Vergleichbares. Mehrere gleichzeitige `synthesize()` sind dagegen sicher (nativ per `synthesize_mutex` serialisiert). - **Vorschlag:** In beiden Klassen `synthesize` und `close` über denselben Monitor laufen lassen (z. B. `synchronized` auch auf `synthesize`; kostet nichts, weil nativ ohnehin serialisiert wird) – oder eine Referenzzählung wie bei `jllama_context` in `jllama_api.cpp`. -- **Reproduktion:** keine – beide Klassen brauchen die Native-Library (JNI) bzw. ein JDK 22 (FFM), beides gibt es in dieser Sandbox nicht, und ein Rennen gegen `delete` ist ohne Modell nicht prüfbar. -- **Status:** nur beschrieben (ohne lauffähigen Test nicht fixen) -- **Commit:** – +- **Reproduktion:** `TextToSpeechLifecycleTest#close_whileSynthesizeIsInsideTheEngine_freesItOnlyAfterSynthesizeReturned` (Fake über die neue paketinterne Naht `TextToSpeech.Backend` hält `synthesize` im „nativen“ Aufruf fest), dazu `#synthesize_afterClose_throwsIllegalState`, `#close_calledTwice_freesTheEngineOnce`, `#synthesize_whileOpen_returnsTheBackendsWav` +- **Fix:** `ReentrantReadWriteLock`: `synthesize` hält die Lesesperre über den ganzen nativen Aufruf, `close` die Schreibsperre. Gleichzeitige Synthesen bleiben möglich (die Engine ordnet sie selbst über `synthesize_mutex`), aber `close` gibt die Engine (samt diesem Mutex) erst frei, wenn keine mehr läuft. Die JNI-Aufrufe laufen über eine paketinterne Naht `Backend`; `LlamaLoader.initialize()` wanderte aus dem static-Block in deren `load`. +- **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral eingezogen; dagegen rot 1/4 („the engine was freed while synthesize() still ran in it – Expected: is but: was “). Mit Fix grün 4/4 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot 1/4, wiederhergestellt grün 4/4. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** siehe Git-Log („Keep TextToSpeech from freeing the engine during a synthesis“) ### F17 – java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild diff --git a/llama/src/main/java/net/ladenthin/llama/TextToSpeech.java b/llama/src/main/java/net/ladenthin/llama/TextToSpeech.java index 7c95542ae..7b6b88ddc 100644 --- a/llama/src/main/java/net/ladenthin/llama/TextToSpeech.java +++ b/llama/src/main/java/net/ladenthin/llama/TextToSpeech.java @@ -4,6 +4,7 @@ package net.ladenthin.llama; +import java.util.concurrent.locks.ReentrantReadWriteLock; import net.ladenthin.llama.loader.LlamaLoader; import org.jspecify.annotations.Nullable; @@ -26,10 +27,88 @@ */ public final class TextToSpeech implements AutoCloseable { - static { - LlamaLoader.initialize(); + /** + * The native calls behind {@link TextToSpeech}. The JNI methods stay in this class (their symbol + * names are bound to it); this seam exists so the lifecycle can be tested with a fake that never + * hands a made-up handle to libjllama. + */ + interface Backend { + /** + * Loads the pipeline. + * + * @param modelPath path to the backbone GGUF + * @param mmprojPath path to the mmproj GGUF + * @param gpuLayers layers to offload to the GPU + * @param threads CPU threads for the backbone + * @return the native handle + */ + long load(String modelPath, String mmprojPath, int gpuLayers, int threads); + + /** + * Synthesizes speech. + * + * @param handle a handle returned by {@link #load} + * @param text the text to speak + * @param speakerReferenceAudioPath reference clip, or {@code null} + * @param language language name, or {@code null} + * @param maxFrames cap on generated audio frames + * @param topK top-k cutoff + * @param seed sampler seed + * @return the WAV bytes + */ + byte[] synthesize( + long handle, + String text, + @Nullable String speakerReferenceAudioPath, + @Nullable String language, + int maxFrames, + int topK, + int seed); + + /** + * Frees the pipeline. + * + * @param handle a handle returned by {@link #load} + */ + void delete(long handle); } + /** The real backend: loads libjllama on the first load, then calls the JNI methods of this class. */ + private static final Backend NATIVE_BACKEND = new Backend() { + @Override + public long load(String modelPath, String mmprojPath, int gpuLayers, int threads) { + LlamaLoader.initialize(); + return loadNative(modelPath, mmprojPath, gpuLayers, threads); + } + + @Override + public byte[] synthesize( + long handle, + String text, + @Nullable String speakerReferenceAudioPath, + @Nullable String language, + int maxFrames, + int topK, + int seed) { + return synthesizeNative(handle, text, speakerReferenceAudioPath, language, maxFrames, topK, seed); + } + + @Override + public void delete(long handle) { + deleteNative(handle); + } + }; + + private final Backend backend; + + /** + * Guards {@link #handle}: {@code synthesize} holds the read lock for the whole native call and + * {@code close} the write lock, so the engine is never freed while a synthesis still runs in it + * (its own mutex is part of the freed memory). Concurrent syntheses stay possible; the engine + * orders them itself. + */ + private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock(); + private long handle; /** @@ -51,7 +130,21 @@ public TextToSpeech(String modelPath, String mmprojPath) { * @param threads CPU threads for the backbone (0 = a small default) */ public TextToSpeech(String modelPath, String mmprojPath, int gpuLayers, int threads) { - this.handle = loadNative(modelPath, mmprojPath, gpuLayers, threads); + this(NATIVE_BACKEND, modelPath, mmprojPath, gpuLayers, threads); + } + + /** + * Load the TTS pipeline through the given backend (tests use a fake one). + * + * @param backend the native entry points + * @param modelPath path to the backbone (text) GGUF + * @param mmprojPath path to the mmproj GGUF + * @param gpuLayers number of layers to offload to the GPU + * @param threads CPU threads for the backbone + */ + TextToSpeech(Backend backend, String modelPath, String mmprojPath, int gpuLayers, int threads) { + this.backend = backend; + this.handle = backend.load(modelPath, mmprojPath, gpuLayers, threads); } /** @@ -99,17 +192,27 @@ public byte[] synthesize( int maxFrames, int topK, int seed) { - if (handle == 0L) { - throw new IllegalStateException("TextToSpeech is closed"); + lifecycleLock.readLock().lock(); + try { + if (handle == 0L) { + throw new IllegalStateException("TextToSpeech is closed"); + } + return backend.synthesize(handle, text, speakerReferenceAudioPath, language, maxFrames, topK, seed); + } finally { + lifecycleLock.readLock().unlock(); } - return synthesizeNative(handle, text, speakerReferenceAudioPath, language, maxFrames, topK, seed); } @Override - public synchronized void close() { - if (handle != 0L) { - deleteNative(handle); - handle = 0L; + public void close() { + lifecycleLock.writeLock().lock(); + try { + if (handle != 0L) { + backend.delete(handle); + handle = 0L; + } + } finally { + lifecycleLock.writeLock().unlock(); } } diff --git a/llama/src/test/java/net/ladenthin/llama/TextToSpeechLifecycleTest.java b/llama/src/test/java/net/ladenthin/llama/TextToSpeechLifecycleTest.java new file mode 100644 index 000000000..1fb6963c0 --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/TextToSpeechLifecycleTest.java @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +@ClaudeGenerated( + purpose = "Pin that TextToSpeech.close() never frees the native engine while synthesize() is " + + "still inside it: the engine's own mutex is part of the freed memory, so a close() " + + "during a synthesis was a use-after-free. Driven through the Backend seam with a " + + "fake, so no made-up handle reaches libjllama.") +public class TextToSpeechLifecycleTest { + + private static final long HANDLE = 7L; + + private static final long WAIT_SECONDS = 5L; + + private static final byte[] WAV = {1, 2, 3}; + + /** A backend whose synthesize can be held inside the "native" call. */ + static class FakeBackend implements TextToSpeech.Backend { + final CountDownLatch synthesizeEntered = new CountDownLatch(1); + final CountDownLatch releaseSynthesize = new CountDownLatch(1); + final AtomicInteger insideSynthesize = new AtomicInteger(); + final AtomicInteger deletes = new AtomicInteger(); + volatile boolean holdSynthesize; + volatile boolean deletedDuringSynthesize; + + @Override + public long load(String modelPath, String mmprojPath, int gpuLayers, int threads) { + return HANDLE; + } + + @Override + public byte[] synthesize( + long handle, + String text, + @Nullable String speakerReferenceAudioPath, + @Nullable String language, + int maxFrames, + int topK, + int seed) { + insideSynthesize.incrementAndGet(); + try { + synthesizeEntered.countDown(); + if (holdSynthesize) { + try { + releaseSynthesize.await(WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + return WAV.clone(); + } finally { + insideSynthesize.decrementAndGet(); + } + } + + @Override + public void delete(long handle) { + if (insideSynthesize.get() > 0) { + deletedDuringSynthesize = true; + } + deletes.incrementAndGet(); + } + } + + @Test + public void close_whileSynthesizeIsInsideTheEngine_freesItOnlyAfterSynthesizeReturned() throws Exception { + FakeBackend backend = new FakeBackend(); + backend.holdSynthesize = true; + TextToSpeech tts = new TextToSpeech(backend, "model.gguf", "mmproj.gguf", 0, 0); + Thread speaker = new Thread(() -> tts.synthesize("hello"), "speaker"); + speaker.start(); + try { + assertThat(backend.synthesizeEntered.await(WAIT_SECONDS, TimeUnit.SECONDS), is(true)); + + Thread closer = new Thread(tts::close, "closer"); + closer.start(); + awaitWaitingOrDone(closer); + backend.releaseSynthesize.countDown(); + speaker.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + closer.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + + assertThat( + "the engine was freed while synthesize() still ran in it", + backend.deletedDuringSynthesize, + is(false)); + assertThat(backend.deletes.get(), is(1)); + } finally { + backend.releaseSynthesize.countDown(); + speaker.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + tts.close(); + } + } + + @Test + public void synthesize_afterClose_throwsIllegalState() { + FakeBackend backend = new FakeBackend(); + TextToSpeech tts = new TextToSpeech(backend, "model.gguf", "mmproj.gguf", 0, 0); + tts.close(); + assertThrows(IllegalStateException.class, () -> tts.synthesize("hello")); + } + + @Test + public void close_calledTwice_freesTheEngineOnce() { + FakeBackend backend = new FakeBackend(); + TextToSpeech tts = new TextToSpeech(backend, "model.gguf", "mmproj.gguf", 0, 0); + tts.close(); + tts.close(); + assertThat(backend.deletes.get(), is(1)); + } + + @Test + public void synthesize_whileOpen_returnsTheBackendsWav() { + FakeBackend backend = new FakeBackend(); + try (TextToSpeech tts = new TextToSpeech(backend, "model.gguf", "mmproj.gguf", 0, 0)) { + assertThat(tts.synthesize("hello"), is(WAV)); + } + } + + /** Waits until the thread waits for a lock or has finished, whichever comes first. */ + private static void awaitWaitingOrDone(Thread thread) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(WAIT_SECONDS); + while (thread.isAlive() + && thread.getState() != Thread.State.BLOCKED + && thread.getState() != Thread.State.WAITING + && System.nanoTime() < deadline) { + thread.join(10); + } + } +} From 341d89d6d0131e9ac527e3e7d564d3b318216b5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:30:57 +0000 Subject: [PATCH 023/589] Record the commit of F16 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9fd956cc9..937903fee 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -120,7 +120,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `ReentrantReadWriteLock`: `synthesize` hält die Lesesperre über den ganzen nativen Aufruf, `close` die Schreibsperre. Gleichzeitige Synthesen bleiben möglich (die Engine ordnet sie selbst über `synthesize_mutex`), aber `close` gibt die Engine (samt diesem Mutex) erst frei, wenn keine mehr läuft. Die JNI-Aufrufe laufen über eine paketinterne Naht `Backend`; `LlamaLoader.initialize()` wanderte aus dem static-Block in deren `load`. - **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral eingezogen; dagegen rot 1/4 („the engine was freed while synthesize() still ran in it – Expected: is but: was “). Mit Fix grün 4/4 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot 1/4, wiederhergestellt grün 4/4. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** siehe Git-Log („Keep TextToSpeech from freeing the engine during a synthesis“) +- **Commit:** java-llama.cpp `128be3a` ### F17 – java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild From e927c7103c004349dbd574f35a40e1e203bad5b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 10:53:42 +0000 Subject: [PATCH 024/589] Note why the takeStrings suspicion is still open in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 937903fee..695855292 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -212,4 +212,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU ## Offene Verdachte ohne Fund-ID -- *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. +- *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 79fa666d1407a1da8a6d283973966cea89d59187 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:40:37 +0000 Subject: [PATCH 025/589] Keep a cancelled LlamaIterator finished next() set hasNext = !output.stop after every result. A cancel() from another thread while next() waited for its result was undone by the result that arrived afterwards: hasNext() reported more tokens and the next next() failed with "Task not found", since cancel() had already released the reader. hasNext now only goes from true to false. The task calls go through a package-private TaskSource seam, which LlamaIteratorCancelTest drives with a fake; the JNI methods stay in LlamaModel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 ++ .../net/ladenthin/llama/LlamaIterator.java | 97 +++++++++++++- .../llama/LlamaIteratorCancelTest.java | 123 ++++++++++++++++++ 4 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 llama/src/test/java/net/ladenthin/llama/LlamaIteratorCancelTest.java diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 64a1ba0c5..f2203fc02 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -74,3 +74,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · unsicher — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-17 · Fund F16 — F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen +- `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36 — F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 695855292..f31eb98c1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -30,6 +30,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F28](#f28) — mittel — *gefixt* - [F29](#f29) — mittel — *gefixt* - [F30](#f30) — mittel — *gefixt* +- [F36](#f36) — mittel — *gefixt* ## Funde @@ -210,6 +211,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** 32d465d + +### F36 – java-llama.cpp: `LlamaIterator` wird nach `cancel()` aus einem zweiten Thread durch ein spät eintreffendes Ergebnis wiederbelebt +- **Zeit:** 2026-10-17 16:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java:85` (`next`) +- **Schwere:** mittel +- **Beschreibung:** `next()` setzte nach jedem Ergebnis `hasNext = !output.stop`. Ruft ein anderer Thread `cancel()` auf, während `next()` in `receiveCompletionJson` auf das nächste Ergebnis wartet (das Feld ist genau dafür `volatile`, und die Slot-Arbeit läuft nach `cancel()` weiter), dann überschreibt das danach eintreffende Nicht-Stopp-Ergebnis das `false` von `cancel()` wieder mit `true`. Das Iterator „lebt“ wieder, `hasNext()` meldet weitere Tokens, und der nächste `next()` scheitert mit „Task not found“, weil `cancel()` den Reader schon freigegeben hat – entgegen dem Javadoc („the iterator stops yielding tokens immediately“). Betrifft `generate`/`generateChat` und jede `LlamaIterable`, die aus einem zweiten Thread abgebrochen wird. +- **Fix:** `hasNext` geht nur noch von `true` nach `false`; `next()` schreibt `false` nur beim Stopp-Ergebnis und nie `true`. Für den Test bekommt der Iterator eine paketinterne Naht `LlamaIterator.TaskSource` (die fünf Task-Aufrufe, Adapter `TaskSource.of(LlamaModel)`); die JNI-Methoden bleiben in `LlamaModel`. +- **Reproduktion:** `LlamaIteratorCancelTest#cancel_whileNextWaitsForAResultOnAnotherThread_iteratorStaysFinished` (der Fake hält `receiveCompletionJson` fest, bis `cancel()` gelaufen ist), dazu `#next_untilTheStopResult_releasesTheTaskOnceAndEnds`, `#close_beforeTheStopResult_cancelsTheTaskOnce` +- **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral; dagegen rot 1/3 („a result that arrived after cancel() revived the iterator – Expected: is but: was “). Mit Fix grün 3/3 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot, wiederhergestellt grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** siehe Git-Log („Keep a cancelled LlamaIterator finished“) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaIterator.java b/llama/src/main/java/net/ladenthin/llama/LlamaIterator.java index c1206b7f3..f7963f2c2 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaIterator.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaIterator.java @@ -33,7 +33,7 @@ public final class LlamaIterator implements Iterator, AutoCloseable // Reference back to the owning LlamaModel — dumping it would recursively render // the entire native model state and produce log spam. @ToString.Exclude - private final LlamaModel model; + private final TaskSource model; private final int taskId; private final CompletionResponseParser completionParser = new CompletionResponseParser(); @@ -47,6 +47,17 @@ public final class LlamaIterator implements Iterator, AutoCloseable } LlamaIterator(LlamaModel model, InferenceParameters parameters, boolean chat) { + this(TaskSource.of(model), parameters, chat); + } + + /** + * Creates an iterator over the given task source (tests use a fake one). + * + * @param model the native task operations + * @param parameters the inference parameters + * @param chat whether to start a chat completion instead of a raw completion + */ + LlamaIterator(TaskSource model, InferenceParameters parameters, boolean chat) { this.model = model; // Pin the stream flag on a local derivation so the caller's parameters object // is not mutated — InferenceParameters is immutable and withStream returns a @@ -71,8 +82,10 @@ public LlamaOutput next() { } String json = model.receiveCompletionJson(taskId); LlamaOutput output = completionParser.parse(json); - hasNext = !output.stop; + // hasNext only ever goes from true to false. Writing !output.stop here would revive an + // iterator that cancel() ended from another thread while this call waited for the result. if (output.stop) { + hasNext = false; model.releaseTask(taskId); } return output; @@ -112,4 +125,84 @@ public void close() { cancel(); } } + + /** + * The native task operations this iterator drives. They are package-private natives of + * {@link LlamaModel}; this seam exists so the iterator's handling of a concurrent + * {@link #cancel()} can be tested without a loaded model. + */ + interface TaskSource { + /** + * Starts a streamed raw completion. + * + * @param json the request body + * @return the task id + */ + int requestCompletion(String json); + + /** + * Starts a streamed chat completion. + * + * @param json the request body + * @return the task id + */ + int requestChatCompletion(String json); + + /** + * Waits for the next result of the task. + * + * @param taskId the task + * @return the result as JSON + */ + String receiveCompletionJson(int taskId); + + /** + * Releases a task that has finished. + * + * @param taskId the task + */ + void releaseTask(int taskId); + + /** + * Releases a task that has not finished. + * + * @param taskId the task + */ + void cancelCompletion(int taskId); + + /** + * The task operations of a loaded model. + * + * @param model the model + * @return its task operations + */ + static TaskSource of(LlamaModel model) { + return new TaskSource() { + @Override + public int requestCompletion(String json) { + return model.requestCompletion(json); + } + + @Override + public int requestChatCompletion(String json) { + return model.requestChatCompletion(json); + } + + @Override + public String receiveCompletionJson(int taskId) { + return model.receiveCompletionJson(taskId); + } + + @Override + public void releaseTask(int taskId) { + model.releaseTask(taskId); + } + + @Override + public void cancelCompletion(int taskId) { + model.cancelCompletion(taskId); + } + }; + } + } } diff --git a/llama/src/test/java/net/ladenthin/llama/LlamaIteratorCancelTest.java b/llama/src/test/java/net/ladenthin/llama/LlamaIteratorCancelTest.java new file mode 100644 index 000000000..01da5679f --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/LlamaIteratorCancelTest.java @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.NoSuchElementException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.ladenthin.llama.parameters.InferenceParameters; +import org.junit.jupiter.api.Test; + +@ClaudeGenerated( + purpose = "Pin that a cancel() from another thread ends a LlamaIterator for good, also while " + + "next() is waiting for a result, and that a natural stop releases the task once. " + + "Driven through the TaskSource seam, so no model is needed.") +public class LlamaIteratorCancelTest { + + private static final int TASK_ID = 3; + + private static final long WAIT_SECONDS = 5L; + + private static final String CHUNK = "{\"content\":\"x\",\"stop\":false}"; + + private static final String LAST = "{\"content\":\"y\",\"stop\":true,\"stop_type\":\"eos\"}"; + + /** A task source whose receive can be held until the test releases it. */ + static class FakeSource implements LlamaIterator.TaskSource { + final CountDownLatch receiveEntered = new CountDownLatch(1); + final CountDownLatch releaseReceive = new CountDownLatch(1); + final AtomicInteger releases = new AtomicInteger(); + final AtomicInteger cancels = new AtomicInteger(); + volatile boolean holdReceive; + volatile String[] results = {CHUNK}; + private final AtomicInteger received = new AtomicInteger(); + + @Override + public int requestCompletion(String json) { + return TASK_ID; + } + + @Override + public int requestChatCompletion(String json) { + return TASK_ID; + } + + @Override + public String receiveCompletionJson(int taskId) { + receiveEntered.countDown(); + if (holdReceive) { + try { + releaseReceive.await(WAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + String[] localResults = results; + return localResults[Math.min(received.getAndIncrement(), localResults.length - 1)]; + } + + @Override + public void releaseTask(int taskId) { + releases.incrementAndGet(); + } + + @Override + public void cancelCompletion(int taskId) { + cancels.incrementAndGet(); + } + } + + @Test + public void cancel_whileNextWaitsForAResultOnAnotherThread_iteratorStaysFinished() throws Exception { + FakeSource source = new FakeSource(); + source.holdReceive = true; + LlamaIterator iterator = new LlamaIterator(source, new InferenceParameters("hello"), false); + Thread consumer = new Thread(iterator::next, "consumer"); + consumer.start(); + assertThat(source.receiveEntered.await(WAIT_SECONDS, TimeUnit.SECONDS), is(true)); + + iterator.cancel(); + source.releaseReceive.countDown(); + consumer.join(TimeUnit.SECONDS.toMillis(WAIT_SECONDS)); + + assertThat("a result that arrived after cancel() revived the iterator", iterator.hasNext(), is(false)); + assertThrows(NoSuchElementException.class, iterator::next); + assertThat(source.cancels.get(), is(1)); + } + + @Test + public void next_untilTheStopResult_releasesTheTaskOnceAndEnds() { + FakeSource source = new FakeSource(); + source.results = new String[] {CHUNK, LAST}; + LlamaIterator iterator = new LlamaIterator(source, new InferenceParameters("hello"), true); + + assertThat(iterator.next().stop, is(false)); + assertThat(iterator.hasNext(), is(true)); + assertThat(iterator.next().stop, is(true)); + + assertThat(iterator.hasNext(), is(false)); + assertThat(source.releases.get(), is(1)); + iterator.close(); + assertThat("close() after the natural stop must not cancel", source.cancels.get(), is(0)); + } + + @Test + public void close_beforeTheStopResult_cancelsTheTaskOnce() { + FakeSource source = new FakeSource(); + LlamaIterator iterator = new LlamaIterator(source, new InferenceParameters("hello"), false); + iterator.next(); + + iterator.close(); + iterator.close(); + + assertThat(source.cancels.get(), is(1)); + assertThat(iterator.hasNext(), is(false)); + } +} From 5ea7dcee7151eefa409e2acde96453b51fa22233 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:40:38 +0000 Subject: [PATCH 026/589] Record the commit of F36 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f31eb98c1..818971e17 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -221,7 +221,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LlamaIteratorCancelTest#cancel_whileNextWaitsForAResultOnAnotherThread_iteratorStaysFinished` (der Fake hält `receiveCompletionJson` fest, bis `cancel()` gelaufen ist), dazu `#next_untilTheStopResult_releasesTheTaskOnceAndEnds`, `#close_beforeTheStopResult_cancelsTheTaskOnce` - **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral; dagegen rot 1/3 („a result that arrived after cancel() revived the iterator – Expected: is but: was “). Mit Fix grün 3/3 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot, wiederhergestellt grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** siehe Git-Log („Keep a cancelled LlamaIterator finished“) +- **Commit:** java-llama.cpp `f4407bb` ## Offene Verdachte ohne Fund-ID From 3c36510bc1a20e72384b661a91260bceca0e3990 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:45:05 +0000 Subject: [PATCH 027/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f2203fc02..3814b2012 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -75,3 +75,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-17 · Fund F16 — F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen - `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36 — F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `7f002e9` · 2026-10-17 · kein Fund — Stop-Grund-Abbildung (`length`→`max_tokens`, `tool_calls`→`tool_use`, sonst `end_turn`) passt zu den gesendeten `tool_use`-Blöcken (llama.cpp meldet `tool_calls` genau dann, wenn der Stream Tool-Aufrufe enthält); Blockindizes Text vor Tools, Usage ohne Cache-Treffer wie bei Anthropic. Funktionslücke, kein Fehler: `reasoning_content` wird nicht in Thinking-Blöcke übersetzt. From 204604d7a2e8e7578475cbea6e9fd8b532f6993f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:50:27 +0000 Subject: [PATCH 028/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3814b2012..96fd0576f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -76,3 +76,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-17 · Fund F16 — F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen - `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36 — F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `7f002e9` · 2026-10-17 · kein Fund — Stop-Grund-Abbildung (`length`→`max_tokens`, `tool_calls`→`tool_use`, sonst `end_turn`) passt zu den gesendeten `tool_use`-Blöcken (llama.cpp meldet `tool_calls` genau dann, wenn der Stream Tool-Aufrufe enthält); Blockindizes Text vor Tools, Usage ohne Cache-Treffer wie bei Anthropic. Funktionslücke, kein Fehler: `reasoning_content` wird nicht in Thinking-Blöcke übersetzt. +- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `3d47cac` · 2026-10-17 · kein Fund — Abbruch bei Sink-Fehler: `stream` reicht die IOException durch, `LlamaModel.streamChatCompletion` gibt die Task im `finally` frei; `streamCompletions` schließt den Iterator per try-with-resources (cancel); finish_reason `length`/`stop` korrekt. Funktionslücke: gestreamte `/v1/completions` beachten `stream_options.include_usage` nicht (kein Usage-Chunk). From 2940edda7109ad7fe6b9ae52626894707cd8f8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:00:36 +0000 Subject: [PATCH 029/589] Send empty Ollama tool-call arguments as an empty object parseArgumentsToObject fell back to {} only on an IOException, but readTree("") returns a MissingNode, serialized as null. A parameterless tool call whose OpenAI arguments are "" reached Ollama clients as "arguments": null instead of an object. Anything that does not parse to an object or array now becomes {}. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 3 ++- BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/OllamaApiSupport.java | 8 ++++++-- .../llama/server/OllamaApiSupportTest.java | 13 +++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 96fd0576f..87755c624 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -38,7 +38,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `6bdd3ff` · 2026-10-17 · Fund F25 — java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung - `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` · `6f2a458` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. - `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` · `e167fa7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ContentPart`: Validierung ok. Randnotiz: Lombok-`@ToString` gibt die komplette Base64-Nutzlast (`imageUrl`/`audioData`, schnell mehrere MB) aus – teuer, falls ein `ChatMessage`/`ChatRequest` geloggt wird. -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `6872e5a` · 2026-10-17 · Fund F12 — java-llama.cpp: Ollama-`/api/chat` verwirft Bilder (`images`), obwohl `/api/show` „vision“ meldet +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `c1840dc` · 2026-10-17 · Fund F37 — F37 parseArgumentsToObject: nur Objekt/Array durchreichen, sonst {} - `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-17 · kein Fund — gelesen, keine Notiz - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. @@ -77,3 +77,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36 — F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `7f002e9` · 2026-10-17 · kein Fund — Stop-Grund-Abbildung (`length`→`max_tokens`, `tool_calls`→`tool_use`, sonst `end_turn`) passt zu den gesendeten `tool_use`-Blöcken (llama.cpp meldet `tool_calls` genau dann, wenn der Stream Tool-Aufrufe enthält); Blockindizes Text vor Tools, Usage ohne Cache-Treffer wie bei Anthropic. Funktionslücke, kein Fehler: `reasoning_content` wird nicht in Thinking-Blöcke übersetzt. - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `3d47cac` · 2026-10-17 · kein Fund — Abbruch bei Sink-Fehler: `stream` reicht die IOException durch, `LlamaModel.streamChatCompletion` gibt die Task im `finally` frei; `streamCompletions` schließt den Iterator per try-with-resources (cancel); finish_reason `length`/`stop` korrekt. Funktionslücke: gestreamte `/v1/completions` beachten `stream_options.include_usage` nicht (kein Usage-Chunk). +- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-17 · kein Fund — Gruppierung nach `index` (TreeMap, sortierte Ausgabe), Argument-Deltas werden verkettet, `id`/`name` aus dem ersten Delta; ein Aufruf ohne Argument-Deltas ergibt `arguments: ""`, was erst der Ollama-Konsument falsch abbildete (F37). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 818971e17..974190df2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -31,6 +31,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F29](#f29) — mittel — *gefixt* - [F30](#f30) — mittel — *gefixt* - [F36](#f36) — mittel — *gefixt* +- [F37](#f37) — niedrig — *gefixt* ## Funde @@ -223,6 +224,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f4407bb` + +### F37 – java-llama.cpp: Ollama-Tool-Call mit leeren Argumenten kommt als `"arguments": null` statt `{}` an +- **Zeit:** 2026-10-17 17:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:345` (`parseArgumentsToObject`, genutzt von `toOllamaToolCalls` für `/api/chat` gestreamt und blockierend) +- **Schwere:** niedrig +- **Beschreibung:** Ollama erwartet `tool_calls[].function.arguments` als Objekt. Die Umwandlung aus dem OpenAI-String parste mit `readTree` und fiel nur bei einer `IOException` auf `{}` zurück (so der Kommentar). `readTree("")` wirft aber nicht, sondern liefert einen `MissingNode`, der als `null` serialisiert wird; ebenso ergab ein skalarer Text (`"5"`) einen Skalar. Ein parameterloser Aufruf, für den llama.cpp `"arguments": ""` liefert (oder dessen gestreamte Deltas keine Argumente enthalten, `ToolCallDeltaAccumulator` setzt dann `""`), kam bei Ollama-Clients als `"arguments": null` an. Fix: alles, was nicht zu einem Objekt oder Array parst, wird `{}`. +- **Reproduktion:** `OllamaApiSupportTest#toOllamaChatResponseTurnsEmptyToolCallArgumentsIntoAnEmptyObject` (Jackson-Verhalten vorab per Probe bestätigt: `readTree("")` → `MissingNode` → `"arguments":null`) +- **Rot-Grün-Nachweis:** ohne Fix rot 1/21 („Expected: is but: was “: kein Objekt), mit Fix grün 21/21 (skipped 0); Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** siehe Git-Log („Send empty Ollama tool-call arguments as an empty object“) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 6872e5aa6..c1840dc49 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -342,10 +342,14 @@ private static JsonNode parseArgumentsToObject(JsonNode arguments) { } if (arguments.isTextual()) { try { - return OBJECT_MAPPER.readTree(arguments.asText()); + // readTree("") returns a MissingNode, which serializes as null, rather than throwing; + // a parameterless call's empty arguments must still reach Ollama as an object. + JsonNode parsed = OBJECT_MAPPER.readTree(arguments.asText()); + if (parsed.isObject() || parsed.isArray()) { + return parsed; + } } catch (IOException e) { // Fall through to an empty object on unparseable arguments. - return OBJECT_MAPPER.createObjectNode(); } } return OBJECT_MAPPER.createObjectNode(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 53a8b9c53..def9a9fc6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -164,6 +164,19 @@ public void toOllamaChatResponseConvertsToolCallArgumentsToObject() throws IOExc assertThat(arguments.path("city").asText(), is("Paris")); } + @Test + public void toOllamaChatResponseTurnsEmptyToolCallArgumentsIntoAnEmptyObject() throws IOException { + // a parameterless call can come back with "arguments": "" -- Ollama expects an object + String openAi = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":null," + + "\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\"," + + "\"function\":{\"name\":\"get_time\",\"arguments\":\"\"}}]}}]}"; + JsonNode out = read(OllamaApiSupport.toOllamaChatResponse(openAi, "m")); + JsonNode arguments = + out.path("message").path("tool_calls").get(0).path("function").path("arguments"); + assertThat(arguments.isObject(), is(true)); + assertThat(arguments.size(), is(0)); + } + @Test public void toOllamaContentLineEmitsDeltaAndSkipsEmpty() throws IOException { String line = OllamaApiSupport.toOllamaContentLine("{\"choices\":[{\"delta\":{\"content\":\"he\"}}]}", "m"); From 2692c37e6fa7cde0adfd3ac30ccdc4fafee9ceb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:00:36 +0000 Subject: [PATCH 030/589] Record the commit of F37 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 974190df2..85f0a1d16 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -233,7 +233,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toOllamaChatResponseTurnsEmptyToolCallArgumentsIntoAnEmptyObject` (Jackson-Verhalten vorab per Probe bestätigt: `readTree("")` → `MissingNode` → `"arguments":null`) - **Rot-Grün-Nachweis:** ohne Fix rot 1/21 („Expected: is but: was “: kein Objekt), mit Fix grün 21/21 (skipped 0); Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** siehe Git-Log („Send empty Ollama tool-call arguments as an empty object“) +- **Commit:** java-llama.cpp `6590af2` ## Offene Verdachte ohne Fund-ID From 2a5c835461394add4cf8b7c3bd3bd0bf4d99fe38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:10:19 +0000 Subject: [PATCH 031/589] Reject an empty JSON parameter value instead of sending null JsonParameters promises that every stored value is exactly one well-formed JSON value, checked on write, and relied on readTree throwing for anything else. For empty or blank input even the strict reader returns a MissingNode, which rendered as "key":null: withJsonSchema("") and the other JSON setters were accepted silently and the request went out without the value. parseOrThrow now rejects a MissingNode like any other invalid value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 13 +++++++++++ .../llama/parameters/JsonParameters.java | 22 ++++++++++++++----- .../llama/parameters/JsonParametersTest.java | 13 +++++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 87755c624..436369fd4 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -67,7 +67,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` · `b0428f5` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/CliParameters`: argv-Aufbau (Option + Wert, Flag ohne Wert, `argv[0] = ""`), Floats als `String.valueOf` (`1.0E-5`, von `std::stof` gelesen) korrekt. Randnotiz (niedrig, Sicherheit): `ModelParameters.toString()` gibt den Wert von `--hf-token` (`setHfToken`) im Klartext … - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `49ca2b5` · 2026-10-17 · Fund F26 — java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `1a04e66` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiSseFormatter`: SSE-Framing, Fehler-JSON, `ensureUsageCachedTokens` (Schnellpfad per Substring kann nicht von einem im Text maskierten `\"usage\":null` getäuscht werden, unveränderte Ausgabe wenn `cached_tokens` schon da ist) und `/props`/`/v1/models`-Payloads korrekt. -- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `c26ba48` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiCompatServer` (Auth, Body-Limit, CORS, Routen). Alle Inferenz-Routen laufen über `requirePostJson` (Auth + Größenlimit); `/health`, `/props` und die Ollama-Discovery-Routen (`/api/version`, `/api/tags`, `/api/show`) sind laut Kommentar **bewusst** ohne Auth. Notizen (… +- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `1c4c9f7` · 2026-10-17 · Fund F38 — F38 parseOrThrow weist MissingNode ab; Ein-Wert-Invariante und Excerpt-Grenze geprüft - `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `d978e19` · 2026-10-17 · Fund F29 — java-llama.cpp: Anthropic-Messages-API (`POST /v1/messages`) verwirft `image`-Blöcke → Vision-Modell sieht kein Bild - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-17 · Fund F30 — java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 85f0a1d16..9006f698c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -32,6 +32,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F30](#f30) — mittel — *gefixt* - [F36](#f36) — mittel — *gefixt* - [F37](#f37) — niedrig — *gefixt* +- [F38](#f38) — mittel — *gefixt* ## Funde @@ -235,6 +236,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6590af2` + +### F38 – java-llama.cpp: `JsonParameters` nimmt einen leeren oder nur aus Leerzeichen bestehenden Wert still an und sendet `"key":null` +- **Zeit:** 2026-10-17 17:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java:181` (`parseOrThrow`) +- **Schwere:** mittel +- **Beschreibung:** `JsonParameters` garantiert laut CLAUDE.md, dass jeder gespeicherte Wert genau ein wohlgeformter JSON-Wert ist, geprüft beim Schreiben. Die Prüfung verließ sich darauf, dass `readTree` bei ungültiger Eingabe wirft. Für eine leere oder nur aus Leerzeichen bestehende Eingabe wirft auch der strikte Reader (`FAIL_ON_TRAILING_TOKENS`) nicht, sondern liefert einen `MissingNode` (per Probe bestätigt), der im Request als `"key":null` landete. `withJsonSchema("")`, `withResponseFormat("")`, `withToolsJson("")` usw. wurden so still angenommen, und die Generierung lief zum Beispiel ohne das Schema, das der Aufrufer zu setzen glaubte. Gefunden über die Erkenntnis aus F37 (`readTree("")` → `MissingNode`). +- **Fix:** `parseOrThrow` weist einen `MissingNode` mit derselben `IllegalArgumentException` ab wie jeden anderen ungültigen Wert; die Meldung baut ein gemeinsamer Helfer `rejected(...)`. +- **Reproduktion:** `JsonParametersTest#withRaw_rejectsEmptyAndBlankValues` +- **Rot-Grün-Nachweis:** ohne Fix rot 1/25 („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün 25/25 (skipped 0); Gegenprobe per `git stash` rot, nach `stash pop` grün. Paket `parameters` plus Server-Mapping- und HTTP-Tests: 775 Tests, 0 Fehler. PIT auf `JsonParameters` (Schwelle 100): alle Mutanten getötet. `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** siehe Git-Log („Reject an empty JSON parameter value instead of sending null“) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java index c26ba4847..1c4c9f7d7 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java @@ -170,15 +170,25 @@ private T withPut(RequestField field, String value) { * value so a large or hostile fragment cannot flood a log through the message */ private static JsonNode parseOrThrow(String key, String value) { + final JsonNode node; try { - return STRICT_JSON_READER.readTree(value); + node = STRICT_JSON_READER.readTree(value); } catch (Exception e) { - String excerpt = value.length() > REJECTED_VALUE_EXCERPT - ? value.substring(0, REJECTED_VALUE_EXCERPT) + "..." - : value; - throw new IllegalArgumentException( - key + " must be exactly one well-formed JSON value, but was: " + excerpt, e); + throw rejected(key, value, e); } + // For empty or blank input readTree returns a MissingNode instead of throwing; it would be + // rendered as "key":null, a value the caller never wrote. + if (node.isMissingNode()) { + throw rejected(key, value, null); + } + return node; + } + + private static IllegalArgumentException rejected(String key, String value, @Nullable Exception cause) { + String excerpt = + value.length() > REJECTED_VALUE_EXCERPT ? value.substring(0, REJECTED_VALUE_EXCERPT) + "..." : value; + return new IllegalArgumentException( + key + " must be exactly one well-formed JSON value, but was: " + excerpt, cause); } /** diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java index 050642779..daf400a6e 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java @@ -207,6 +207,19 @@ public void withRaw_rejectsAValueFollowedByMoreText() { () -> new TestBuilder().withRawPublic(RequestField.JSON_SCHEMA, "1, \"x\": 2")); } + /** + * An empty or blank value is no JSON value at all. Jackson's readTree returns a MissingNode for + * it rather than throwing, which used to slip past the check and render as {@code "key":null}, + * so withJsonSchema("") silently sent no schema. + */ + @Test + public void withRaw_rejectsEmptyAndBlankValues() { + assertThrows( + IllegalArgumentException.class, () -> new TestBuilder().withRawPublic(RequestField.JSON_SCHEMA, "")); + assertThrows( + IllegalArgumentException.class, () -> new TestBuilder().withRawPublic(RequestField.JSON_SCHEMA, " ")); + } + @Test public void withRaw_rejectsMalformedJson() { assertThrows( From 1f7e784a4228605d3a3df8fd87f527eee37f109f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:10:19 +0000 Subject: [PATCH 032/589] Record the commit of F38 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9006f698c..22dc4eb69 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -246,7 +246,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `JsonParametersTest#withRaw_rejectsEmptyAndBlankValues` - **Rot-Grün-Nachweis:** ohne Fix rot 1/25 („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün 25/25 (skipped 0); Gegenprobe per `git stash` rot, nach `stash pop` grün. Paket `parameters` plus Server-Mapping- und HTTP-Tests: 775 Tests, 0 Fehler. PIT auf `JsonParameters` (Schwelle 100): alle Mutanten getötet. `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** siehe Git-Log („Reject an empty JSON parameter value instead of sending null“) +- **Commit:** java-llama.cpp `41b5ad3` ## Offene Verdachte ohne Fund-ID From 6946b772ccd2191090de2beba3ed75e2fe2f1bfd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:15:21 +0000 Subject: [PATCH 033/589] Send empty Anthropic tool_use input as an empty object The same MissingNode trap as the Ollama fix: toAnthropicResponse parsed the OpenAI arguments into tool_use.input and fell back to {} only on an IOException, so a parameterless call with arguments "" produced "input": null, where Anthropic requires an object. Anything that does not parse to an object or array now becomes {}. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 13 +++++++++++++ .../ladenthin/llama/server/AnthropicApiSupport.java | 9 +++++++-- .../llama/server/AnthropicApiSupportTest.java | 12 ++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 436369fd4..7fa1a55f5 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -69,7 +69,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `1a04e66` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiSseFormatter`: SSE-Framing, Fehler-JSON, `ensureUsageCachedTokens` (Schnellpfad per Substring kann nicht von einem im Text maskierten `\"usage\":null` getäuscht werden, unveränderte Ausgabe wenn `cached_tokens` schon da ist) und `/props`/`/v1/models`-Payloads korrekt. - `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `1c4c9f7` · 2026-10-17 · Fund F38 — F38 parseOrThrow weist MissingNode ab; Ein-Wert-Invariante und Excerpt-Grenze geprüft - `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `d978e19` · 2026-10-17 · Fund F29 — java-llama.cpp: Anthropic-Messages-API (`POST /v1/messages`) verwirft `image`-Blöcke → Vision-Modell sieht kein Bild +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `4c59b6c` · 2026-10-17 · Fund F39 — F39 parseToObject: nur Objekt/Array durchreichen, sonst {}; readTree-Durchsicht der übrigen Aufrufer siehe Notiz in BUGHUNT-FOUND - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-17 · Fund F30 — java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · unsicher — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 22dc4eb69..22dc5a416 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -33,6 +33,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F36](#f36) — mittel — *gefixt* - [F37](#f37) — niedrig — *gefixt* - [F38](#f38) — mittel — *gefixt* +- [F39](#f39) — niedrig — *gefixt* ## Funde @@ -248,6 +249,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `41b5ad3` + +### F39 – java-llama.cpp: Anthropic-`tool_use` mit leeren Argumenten bekommt `"input": null` statt `{}` +- **Zeit:** 2026-10-17 17:15 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:331` (`parseToObject`, genutzt für `tool_use.input` in `toAnthropicResponse`) +- **Schwere:** niedrig +- **Beschreibung:** Dasselbe Muster wie F37, gefunden in der `readTree`-Durchsicht nach F37: Die blockierende Antwort von `POST /v1/messages` wandelte die OpenAI-Argumente per `readTree` in `tool_use.input` und fiel nur bei einer `IOException` auf `{}` zurück. Für `"arguments": ""` (parameterloser Aufruf) entstand ein `MissingNode`, also `"input": null`; Anthropic verlangt dort ein Objekt, Clients wie das Anthropic-SDK lesen `input` als Dict. Fix: nur Objekt/Array durchreichen, sonst `{}`. +- **Reproduktion:** `AnthropicApiSupportTest#toAnthropicResponseTurnsEmptyToolCallArgumentsIntoAnEmptyInputObject` +- **Rot-Grün-Nachweis:** ohne Fix rot 1/13 („Expected: is but: was “), mit Fix grün 13/13 (skipped 0); Gegenprobe per `git stash` rot, danach grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Durchsicht der übrigen `readTree`-Aufrufer (2026-10-17):** 35 Aufrufe in 24 Dateien. Betroffen waren nur F37 (Ollama), F38 (`JsonParameters`) und F39. Die übrigen lesen Antworten des eigenen Servers (Completion-/Chat-/Stream-Parser, Rerank, LoRA, Router) und greifen danach über `path(...)` zu, was auf einem `MissingNode` sicher leere/Default-Werte liefert; `ChatRequest.buildToolsJson` liest ein Tool-Schema, das ein leerer String zu `null` macht (bereits als F05-Umfeld bekannt), `StreamingChunkAssembler` wirft bei unparsebarem Chunk bewusst. +- **Status:** gefixt +- **Commit:** siehe Git-Log („Send empty Anthropic tool_use input as an empty object“) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index d978e1993..4c59b6c3a 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -328,9 +328,14 @@ private static JsonNode parseToObject(JsonNode arguments) { } if (arguments.isTextual()) { try { - return OBJECT_MAPPER.readTree(arguments.asText()); + // readTree("") returns a MissingNode, which serializes as null, rather than throwing; + // a parameterless call's empty arguments must still become an input object. + JsonNode parsed = OBJECT_MAPPER.readTree(arguments.asText()); + if (parsed.isObject() || parsed.isArray()) { + return parsed; + } } catch (IOException e) { - return OBJECT_MAPPER.createObjectNode(); + // fall through to an empty object } } return OBJECT_MAPPER.createObjectNode(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index cc65f41df..4d9d16a97 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -163,6 +163,18 @@ public void responseEmitsTextAndToolUseBlocksAndStopReason() throws IOException assertThat(out.path("usage").path("output_tokens").asInt(), is(2)); } + @Test + public void toAnthropicResponseTurnsEmptyToolCallArgumentsIntoAnEmptyInputObject() throws IOException { + // a parameterless call can come back with "arguments": "" -- tool_use.input must be an object + String openAi = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":null," + + "\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"get_time\"," + + "\"arguments\":\"\"}}]},\"finish_reason\":\"tool_calls\"}]}"; + JsonNode out = read(AnthropicApiSupport.toAnthropicResponse(openAi, "m")); + JsonNode input = out.path("content").get(0).path("input"); + assertThat(input.isObject(), is(true)); + assertThat(input.size(), is(0)); + } + @Test public void stopReasonMapping() { assertThat(AnthropicApiSupport.anthropicStopReason("stop"), is("end_turn")); From 908afaa04dfa3b307462235b3c90ba69eaff3b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:15:21 +0000 Subject: [PATCH 034/589] Record the commit of F39 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 22dc5a416..58f9dc805 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -259,7 +259,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot 1/13 („Expected: is but: was “), mit Fix grün 13/13 (skipped 0); Gegenprobe per `git stash` rot, danach grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Durchsicht der übrigen `readTree`-Aufrufer (2026-10-17):** 35 Aufrufe in 24 Dateien. Betroffen waren nur F37 (Ollama), F38 (`JsonParameters`) und F39. Die übrigen lesen Antworten des eigenen Servers (Completion-/Chat-/Stream-Parser, Rerank, LoRA, Router) und greifen danach über `path(...)` zu, was auf einem `MissingNode` sicher leere/Default-Werte liefert; `ChatRequest.buildToolsJson` liest ein Tool-Schema, das ein leerer String zu `null` macht (bereits als F05-Umfeld bekannt), `StreamingChunkAssembler` wirft bei unparsebarem Chunk bewusst. - **Status:** gefixt -- **Commit:** siehe Git-Log („Send empty Anthropic tool_use input as an empty object“) +- **Commit:** java-llama.cpp `6effd44` ## Offene Verdachte ohne Fund-ID From 4f11f6ebf764037f1ae3f1dd65d1a9cd6ca7fe52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:17:31 +0000 Subject: [PATCH 035/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7fa1a55f5..503fa22b7 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -78,3 +78,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `7f002e9` · 2026-10-17 · kein Fund — Stop-Grund-Abbildung (`length`→`max_tokens`, `tool_calls`→`tool_use`, sonst `end_turn`) passt zu den gesendeten `tool_use`-Blöcken (llama.cpp meldet `tool_calls` genau dann, wenn der Stream Tool-Aufrufe enthält); Blockindizes Text vor Tools, Usage ohne Cache-Treffer wie bei Anthropic. Funktionslücke, kein Fehler: `reasoning_content` wird nicht in Thinking-Blöcke übersetzt. - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `3d47cac` · 2026-10-17 · kein Fund — Abbruch bei Sink-Fehler: `stream` reicht die IOException durch, `LlamaModel.streamChatCompletion` gibt die Task im `finally` frei; `streamCompletions` schließt den Iterator per try-with-resources (cancel); finish_reason `length`/`stop` korrekt. Funktionslücke: gestreamte `/v1/completions` beachten `stream_options.include_usage` nicht (kein Usage-Chunk). - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-17 · kein Fund — Gruppierung nach `index` (TreeMap, sortierte Ausgabe), Argument-Deltas werden verkettet, `id`/`name` aus dem ersten Delta; ein Aufruf ohne Argument-Deltas ergibt `arguments: ""`, was erst der Ollama-Konsument falsch abbildete (F37). +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` · `11dd00a` · 2026-10-17 · kein Fund — Durchsicht nach dem F36-Muster: `finished` wird nie wieder auf `false` gesetzt, `close()` gibt die Task genau einmal frei, auch nach dem natürlichen Stopp; kein Versprechen für Nutzung aus mehreren Threads (Felder nicht `volatile`, im Vertrag nicht verlangt). Grep nach `= !…stop` in allen Java-Zielen: kein weiterer Treffer. From dd012914ed5c94fd668f8ba77cf2c3270d992729 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:51:01 +0000 Subject: [PATCH 036/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 503fa22b7..3922d6911 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -79,3 +79,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `3d47cac` · 2026-10-17 · kein Fund — Abbruch bei Sink-Fehler: `stream` reicht die IOException durch, `LlamaModel.streamChatCompletion` gibt die Task im `finally` frei; `streamCompletions` schließt den Iterator per try-with-resources (cancel); finish_reason `length`/`stop` korrekt. Funktionslücke: gestreamte `/v1/completions` beachten `stream_options.include_usage` nicht (kein Usage-Chunk). - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-17 · kein Fund — Gruppierung nach `index` (TreeMap, sortierte Ausgabe), Argument-Deltas werden verkettet, `id`/`name` aus dem ersten Delta; ein Aufruf ohne Argument-Deltas ergibt `arguments: ""`, was erst der Ollama-Konsument falsch abbildete (F37). - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` · `11dd00a` · 2026-10-17 · kein Fund — Durchsicht nach dem F36-Muster: `finished` wird nie wieder auf `false` gesetzt, `close()` gibt die Task genau einmal frei, auch nach dem natürlichen Stopp; kein Versprechen für Nutzung aus mehreren Threads (Felder nicht `volatile`, im Vertrag nicht verlangt). Grep nach `= !…stop` in allen Java-Zielen: kein weiterer Treffer. +- `llama/src/main/java/net/ladenthin/llama/loader/ProcessRunner.java` · `372d6f4` · 2026-10-17 · kein Fund — Liest stdout erst nach `waitFor`, stderr gar nicht: ein Kind mit mehr als einem Pipe-Puffer (~64 KiB) Ausgabe bliebe bis zum Timeout hängen. Nicht erreichbar: einzige Aufrufer sind `OSInfo.isAndroidTermux` (`uname -o`) und `getHardwareName` (`uname -m`), je eine Zeile; der Pfad mit viel Ausgabe (`readelf`) läuft über `runDiscardingOutput`, das vor `waitFor` leert. Timeout → IOException, `destroyForcibly` im finally, Abschluss-Newline bei `startsWith`/`contains` harmlos. Musterdurchsicht nach F41 (abgeschnittene Exception-Meldungen) über alle Ziele: kein weiterer Treffer. From e9c9a02b52bc8d5435922fe3fba5d2bd33282d29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:56:36 +0000 Subject: [PATCH 037/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3922d6911..bf86c90e7 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -80,3 +80,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-17 · kein Fund — Gruppierung nach `index` (TreeMap, sortierte Ausgabe), Argument-Deltas werden verkettet, `id`/`name` aus dem ersten Delta; ein Aufruf ohne Argument-Deltas ergibt `arguments: ""`, was erst der Ollama-Konsument falsch abbildete (F37). - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` · `11dd00a` · 2026-10-17 · kein Fund — Durchsicht nach dem F36-Muster: `finished` wird nie wieder auf `false` gesetzt, `close()` gibt die Task genau einmal frei, auch nach dem natürlichen Stopp; kein Versprechen für Nutzung aus mehreren Threads (Felder nicht `volatile`, im Vertrag nicht verlangt). Grep nach `= !…stop` in allen Java-Zielen: kein weiterer Treffer. - `llama/src/main/java/net/ladenthin/llama/loader/ProcessRunner.java` · `372d6f4` · 2026-10-17 · kein Fund — Liest stdout erst nach `waitFor`, stderr gar nicht: ein Kind mit mehr als einem Pipe-Puffer (~64 KiB) Ausgabe bliebe bis zum Timeout hängen. Nicht erreichbar: einzige Aufrufer sind `OSInfo.isAndroidTermux` (`uname -o`) und `getHardwareName` (`uname -m`), je eine Zeile; der Pfad mit viel Ausgabe (`readelf`) läuft über `runDiscardingOutput`, das vor `waitFor` leert. Timeout → IOException, `destroyForcibly` im finally, Abschluss-Newline bei `startsWith`/`contains` harmlos. Musterdurchsicht nach F41 (abgeschnittene Exception-Meldungen) über alle Ziele: kein weiterer Treffer. +- `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` · `6a637ed` · 2026-10-17 · kein Fund — `readTree("")` liefert `MissingNode`, dessen `isArray()` falsch ist → leere Liste wie dokumentiert (F37-Muster hier unschädlich). `toRequestJson` weist NaN/±∞ ab (würde nicht als JSON-Zahl serialisieren); unbekannte Ids ignoriert die native Seite tatsächlich (`parse_lora_request` füllt eine `std::map`, angewandt wird nur über geladene Adapter), passend zur Javadoc von `LlamaModel.setLoraAdapters`. `null`-Schlüssel/-Werte → NPE, für `Map` Aufruferfehler. From 9df1d0a9c6d1099913167d664053308ee89e9101 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:24:27 +0000 Subject: [PATCH 038/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index bf86c90e7..6d460f168 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -81,3 +81,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` · `11dd00a` · 2026-10-17 · kein Fund — Durchsicht nach dem F36-Muster: `finished` wird nie wieder auf `false` gesetzt, `close()` gibt die Task genau einmal frei, auch nach dem natürlichen Stopp; kein Versprechen für Nutzung aus mehreren Threads (Felder nicht `volatile`, im Vertrag nicht verlangt). Grep nach `= !…stop` in allen Java-Zielen: kein weiterer Treffer. - `llama/src/main/java/net/ladenthin/llama/loader/ProcessRunner.java` · `372d6f4` · 2026-10-17 · kein Fund — Liest stdout erst nach `waitFor`, stderr gar nicht: ein Kind mit mehr als einem Pipe-Puffer (~64 KiB) Ausgabe bliebe bis zum Timeout hängen. Nicht erreichbar: einzige Aufrufer sind `OSInfo.isAndroidTermux` (`uname -o`) und `getHardwareName` (`uname -m`), je eine Zeile; der Pfad mit viel Ausgabe (`readelf`) läuft über `runDiscardingOutput`, das vor `waitFor` leert. Timeout → IOException, `destroyForcibly` im finally, Abschluss-Newline bei `startsWith`/`contains` harmlos. Musterdurchsicht nach F41 (abgeschnittene Exception-Meldungen) über alle Ziele: kein weiterer Treffer. - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` · `6a637ed` · 2026-10-17 · kein Fund — `readTree("")` liefert `MissingNode`, dessen `isArray()` falsch ist → leere Liste wie dokumentiert (F37-Muster hier unschädlich). `toRequestJson` weist NaN/±∞ ab (würde nicht als JSON-Zahl serialisieren); unbekannte Ids ignoriert die native Seite tatsächlich (`parse_lora_request` füllt eine `std::map`, angewandt wird nur über geladene Adapter), passend zur Javadoc von `LlamaModel.setLoraAdapters`. `null`-Schlüssel/-Werte → NPE, für `Map` Aufruferfehler. +- `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` · `8327d64` · 2026-10-17 · kein Fund — Zahlen mit `Locale.ROOT` formatiert (kein Dezimalkomma); Leerlauf-Prüfung `promptN == 0 && predictedN == 0` passt zu `Timings.fromJson`, das bei fehlendem/`null`-Knoten und fehlenden Feldern 0 liefert (upstream lässt `timings` bei `prompt_n < 0` ganz weg). Draft-Segment nur bei `draftN > 0`; Aufrufer `CompletionResponseParser`/`ChatResponseParser` übergeben nie `null`. `isInfoEnabled` vor der String-Bildung. From 34fac0b50e382b7d2ff10a84684703a53a1aa9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:39:43 +0000 Subject: [PATCH 039/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 6d460f168..e55d0cf30 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -82,3 +82,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/ProcessRunner.java` · `372d6f4` · 2026-10-17 · kein Fund — Liest stdout erst nach `waitFor`, stderr gar nicht: ein Kind mit mehr als einem Pipe-Puffer (~64 KiB) Ausgabe bliebe bis zum Timeout hängen. Nicht erreichbar: einzige Aufrufer sind `OSInfo.isAndroidTermux` (`uname -o`) und `getHardwareName` (`uname -m`), je eine Zeile; der Pfad mit viel Ausgabe (`readelf`) läuft über `runDiscardingOutput`, das vor `waitFor` leert. Timeout → IOException, `destroyForcibly` im finally, Abschluss-Newline bei `startsWith`/`contains` harmlos. Musterdurchsicht nach F41 (abgeschnittene Exception-Meldungen) über alle Ziele: kein weiterer Treffer. - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` · `6a637ed` · 2026-10-17 · kein Fund — `readTree("")` liefert `MissingNode`, dessen `isArray()` falsch ist → leere Liste wie dokumentiert (F37-Muster hier unschädlich). `toRequestJson` weist NaN/±∞ ab (würde nicht als JSON-Zahl serialisieren); unbekannte Ids ignoriert die native Seite tatsächlich (`parse_lora_request` füllt eine `std::map`, angewandt wird nur über geladene Adapter), passend zur Javadoc von `LlamaModel.setLoraAdapters`. `null`-Schlüssel/-Werte → NPE, für `Map` Aufruferfehler. - `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` · `8327d64` · 2026-10-17 · kein Fund — Zahlen mit `Locale.ROOT` formatiert (kein Dezimalkomma); Leerlauf-Prüfung `promptN == 0 && predictedN == 0` passt zu `Timings.fromJson`, das bei fehlendem/`null`-Knoten und fehlenden Feldern 0 liefert (upstream lässt `timings` bei `prompt_n < 0` ganz weg). Draft-Segment nur bei `draftN > 0`; Aufrufer `CompletionResponseParser`/`ChatResponseParser` übergeben nie `null`. `isInfoEnabled` vor der String-Bildung. +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` · `0ff46cf` · 2026-10-17 · kein Fund — `withCancellationToken`: Abbruch des Aufrufers lässt `await()` mit `CancellationException` enden, das Token wird vor dem Weiterwerfen abgebrochen, und `coroutineScope` wartet, bis der blockierende Block zurückkehrt – keine weiterlaufende Generierung nach der Rückkehr. Abbruch vor dem Start des `async`: Block läuft gar nicht. Fehler im Block brechen den Scope ab und kommen über `await()` beim Aufrufer an. `chatSuspend`/`chatCompleteTextSuspend`/`embedSuspend` dokumentieren ehrlich, dass sie nicht token-abbrechbar sind. Musterdurchsicht nach F45 (inklusive/exklusive Grenzen, Meldung vs. Prüfung) über alle drei Ziele: kein weiterer Treffer. From 0e6962ef8e4a0a6328ae536d0df6ad2a0f64f544 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 19:56:39 +0000 Subject: [PATCH 040/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index e55d0cf30..22d758b26 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -83,3 +83,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` · `6a637ed` · 2026-10-17 · kein Fund — `readTree("")` liefert `MissingNode`, dessen `isArray()` falsch ist → leere Liste wie dokumentiert (F37-Muster hier unschädlich). `toRequestJson` weist NaN/±∞ ab (würde nicht als JSON-Zahl serialisieren); unbekannte Ids ignoriert die native Seite tatsächlich (`parse_lora_request` füllt eine `std::map`, angewandt wird nur über geladene Adapter), passend zur Javadoc von `LlamaModel.setLoraAdapters`. `null`-Schlüssel/-Werte → NPE, für `Map` Aufruferfehler. - `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` · `8327d64` · 2026-10-17 · kein Fund — Zahlen mit `Locale.ROOT` formatiert (kein Dezimalkomma); Leerlauf-Prüfung `promptN == 0 && predictedN == 0` passt zu `Timings.fromJson`, das bei fehlendem/`null`-Knoten und fehlenden Feldern 0 liefert (upstream lässt `timings` bei `prompt_n < 0` ganz weg). Draft-Segment nur bei `draftN > 0`; Aufrufer `CompletionResponseParser`/`ChatResponseParser` übergeben nie `null`. `isInfoEnabled` vor der String-Bildung. - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` · `0ff46cf` · 2026-10-17 · kein Fund — `withCancellationToken`: Abbruch des Aufrufers lässt `await()` mit `CancellationException` enden, das Token wird vor dem Weiterwerfen abgebrochen, und `coroutineScope` wartet, bis der blockierende Block zurückkehrt – keine weiterlaufende Generierung nach der Rückkehr. Abbruch vor dem Start des `async`: Block läuft gar nicht. Fehler im Block brechen den Scope ab und kommen über `await()` beim Aufrufer an. `chatSuspend`/`chatCompleteTextSuspend`/`embedSuspend` dokumentieren ehrlich, dass sie nicht token-abbrechbar sind. Musterdurchsicht nach F45 (inklusive/exklusive Grenzen, Meldung vs. Prüfung) über alle drei Ziele: kein weiterer Treffer. +- `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` · `2591bb0` · 2026-10-17 · kein Fund — Leere/unparsbare Eingabe: `readTree("")` → `MissingNode`, `isArray()` falsch → leere, veränderbare Liste (F37-Muster hier unschädlich). Fehlende Felder fallen auf `""`/`0.0`; die native Seite (`rerank_results_to_json`) liefert immer `document`, `index` und `score` und wirft bei fehlendem oder ungültigem Index. `LlamaModel.rerank(reRank=true)` sortiert absteigend nach Score; ohne Sortierung verspricht die Javadoc keine Reihenfolge. From 4d19a0459d6516ae7098785ac6802d995a2e7260 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:04:25 +0000 Subject: [PATCH 041/589] Treat an empty streaming envelope as the end of the chat stream ChatStreamChunkParser.feed documents that an unparseable envelope ends the stream so a polling loop cannot spin forever. For non-JSON text that held, but Jackson parses an empty or blank text to MissingNode instead of throwing, so stop came back false. A MissingNode root now ends the stream as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../ladenthin/llama/json/ChatStreamChunkParser.java | 4 ++++ .../llama/json/ChatStreamChunkParserTest.java | 11 +++++++++++ 4 files changed, 29 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 22d758b26..c64405da0 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -84,3 +84,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` · `8327d64` · 2026-10-17 · kein Fund — Zahlen mit `Locale.ROOT` formatiert (kein Dezimalkomma); Leerlauf-Prüfung `promptN == 0 && predictedN == 0` passt zu `Timings.fromJson`, das bei fehlendem/`null`-Knoten und fehlenden Feldern 0 liefert (upstream lässt `timings` bei `prompt_n < 0` ganz weg). Draft-Segment nur bei `draftN > 0`; Aufrufer `CompletionResponseParser`/`ChatResponseParser` übergeben nie `null`. `isInfoEnabled` vor der String-Bildung. - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` · `0ff46cf` · 2026-10-17 · kein Fund — `withCancellationToken`: Abbruch des Aufrufers lässt `await()` mit `CancellationException` enden, das Token wird vor dem Weiterwerfen abgebrochen, und `coroutineScope` wartet, bis der blockierende Block zurückkehrt – keine weiterlaufende Generierung nach der Rückkehr. Abbruch vor dem Start des `async`: Block läuft gar nicht. Fehler im Block brechen den Scope ab und kommen über `await()` beim Aufrufer an. `chatSuspend`/`chatCompleteTextSuspend`/`embedSuspend` dokumentieren ehrlich, dass sie nicht token-abbrechbar sind. Musterdurchsicht nach F45 (inklusive/exklusive Grenzen, Meldung vs. Prüfung) über alle drei Ziele: kein weiterer Treffer. - `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` · `2591bb0` · 2026-10-17 · kein Fund — Leere/unparsbare Eingabe: `readTree("")` → `MissingNode`, `isArray()` falsch → leere, veränderbare Liste (F37-Muster hier unschädlich). Fehlende Felder fallen auf `""`/`0.0`; die native Seite (`rerank_results_to_json`) liefert immer `document`, `index` und `score` und wirft bei fehlendem oder ungültigem Index. `LlamaModel.rerank(reRank=true)` sortiert absteigend nach Score; ohne Sortierung verspricht die Javadoc keine Reihenfolge. +- `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` · `2448442` · 2026-10-17 · Fund F53 — F53 leerer Umschlag gilt als Streamende. `data` als Array → ein Aufruf pro Element in Reihenfolge, als Objekt → einer, sonst keiner; `stop` fehlt → `false`. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 58f9dc805..e16a6ee59 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -34,6 +34,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F37](#f37) — niedrig — *gefixt* - [F38](#f38) — mittel — *gefixt* - [F39](#f39) — niedrig — *gefixt* +- [F53](#f53) — niedrig — *gefixt* ## Funde @@ -261,6 +262,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6effd44` + +### F53 – java-llama.cpp: leerer Streaming-Umschlag beendet den Chat-Stream nicht +- **Zeit:** 2026-10-17 21:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java:58` (`feed`) +- **Schwere:** niedrig +- **Beschreibung:** Die Javadoc verspricht: „An unparseable envelope is treated as end-of-stream (returns true) so a polling loop cannot spin forever on malformed input.“ Für Nicht-JSON stimmt das (`IOException` → `true`), für einen leeren oder nur aus Leerraum bestehenden Umschlag nicht: `readTree` liefert dann `MissingNode` statt zu werfen (dieselbe Falle wie F37–F39), `path("stop")` ergibt `false`, und `LlamaModel`s Pollschleife (`chatStreamParser.feed` bei Zeile 823) läse weiter. +- **Fix:** Nach dem Parsen gilt auch `root.isMissingNode()` als Streamende. +- **Reproduktion:** `ChatStreamChunkParserTest#feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing` (`""`, `" "`, `"\n"`) +- **Rot-Grün-Nachweis:** ohne Fix rot („envelope '' Expected: is but: was “), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatStreamChunkParserTest` 7/7, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** – + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java b/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java index aee8c9838..2448442c7 100644 --- a/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java +++ b/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java @@ -57,6 +57,10 @@ public boolean feed(String envelopeJson, Consumer chunkSink) { } catch (IOException e) { return true; } + // An empty or blank envelope is unparseable too, but Jackson returns MissingNode for it. + if (root.isMissingNode()) { + return true; + } JsonNode data = root.path("data"); if (data.isArray()) { for (JsonNode element : data) { diff --git a/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java b/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java index 7723d673d..01836eb04 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java @@ -64,6 +64,17 @@ public void feed_malformedEnvelope_reportsStop_andEmitsNothing() { assertThat(chunks, is(empty())); } + /** Jackson parses an empty or blank text to MissingNode instead of throwing; it is still unparseable. */ + @Test + public void feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing() { + for (String envelope : new String[] {"", " ", "\n"}) { + List chunks = new ArrayList<>(); + boolean stopped = parser.feed(envelope, chunks::add); + assertThat("envelope '" + envelope + "'", stopped, is(true)); + assertThat(chunks, is(empty())); + } + } + @Test public void feed_nullData_emitsNothing() { List chunks = new ArrayList<>(); From feffadbb556d6cbaa99c7cd25d1259e820aebbf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:04:25 +0000 Subject: [PATCH 042/589] Record the commit of F53 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e16a6ee59..a0b5aa9e7 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -272,7 +272,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ChatStreamChunkParserTest#feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing` (`""`, `" "`, `"\n"`) - **Rot-Grün-Nachweis:** ohne Fix rot („envelope '' Expected: is but: was “), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatStreamChunkParserTest` 7/7, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** – +- **Commit:** java-llama.cpp `27d6477` ## Offene Verdachte ohne Fund-ID From 3eb7a6ecc1d95f386435e4be04308d6dd7323233 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:11:48 +0000 Subject: [PATCH 043/589] Keep an empty chat_template_kwargs value as a string instead of null buildRawValueObject documents that a value which is no JSON is stored as a JSON string. For non-JSON text that held, but Jackson parses an empty or blank text to MissingNode, which an ObjectNode writes as null, so setChatTemplateKwargs and withChatTemplateKwargs passed null to the template. A MissingNode value now falls back to the string as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 14 ++++++++++++++ .../llama/parameters/ParameterJsonSerializer.java | 7 ++++++- .../llama/json/ParameterJsonSerializerTest.java | 10 ++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index c64405da0..74ca3c8be 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -65,7 +65,6 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` · `90d097c` · 2026-10-17 · Fund F13 — java-llama.cpp: `setRopeScaling(UNSPECIFIED | LONGROPE | MAX_VALUE)` macht das Modell unladbar - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` · `386d73f` · 2026-10-17 · Fund F26 — java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert - `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` · `b0428f5` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/CliParameters`: argv-Aufbau (Option + Wert, Flag ohne Wert, `argv[0] = ""`), Floats als `String.valueOf` (`1.0E-5`, von `std::stof` gelesen) korrekt. Randnotiz (niedrig, Sicherheit): `ModelParameters.toString()` gibt den Wert von `--hf-token` (`setHfToken`) im Klartext … -- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `49ca2b5` · 2026-10-17 · Fund F26 — java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `1a04e66` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiSseFormatter`: SSE-Framing, Fehler-JSON, `ensureUsageCachedTokens` (Schnellpfad per Substring kann nicht von einem im Text maskierten `\"usage\":null` getäuscht werden, unveränderte Ausgabe wenn `cached_tokens` schon da ist) und `/props`/`/v1/models`-Payloads korrekt. - `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `1c4c9f7` · 2026-10-17 · Fund F38 — F38 parseOrThrow weist MissingNode ab; Ein-Wert-Invariante und Excerpt-Grenze geprüft - `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. @@ -85,3 +84,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` · `0ff46cf` · 2026-10-17 · kein Fund — `withCancellationToken`: Abbruch des Aufrufers lässt `await()` mit `CancellationException` enden, das Token wird vor dem Weiterwerfen abgebrochen, und `coroutineScope` wartet, bis der blockierende Block zurückkehrt – keine weiterlaufende Generierung nach der Rückkehr. Abbruch vor dem Start des `async`: Block läuft gar nicht. Fehler im Block brechen den Scope ab und kommen über `await()` beim Aufrufer an. `chatSuspend`/`chatCompleteTextSuspend`/`embedSuspend` dokumentieren ehrlich, dass sie nicht token-abbrechbar sind. Musterdurchsicht nach F45 (inklusive/exklusive Grenzen, Meldung vs. Prüfung) über alle drei Ziele: kein weiterer Treffer. - `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` · `2591bb0` · 2026-10-17 · kein Fund — Leere/unparsbare Eingabe: `readTree("")` → `MissingNode`, `isArray()` falsch → leere, veränderbare Liste (F37-Muster hier unschädlich). Fehlende Felder fallen auf `""`/`0.0`; die native Seite (`rerank_results_to_json`) liefert immer `document`, `index` und `score` und wirft bei fehlendem oder ungültigem Index. `LlamaModel.rerank(reRank=true)` sortiert absteigend nach Score; ohne Sortierung verspricht die Javadoc keine Reihenfolge. - `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` · `2448442` · 2026-10-17 · Fund F53 — F53 leerer Umschlag gilt als Streamende. `data` als Array → ein Aufruf pro Element in Reihenfolge, als Objekt → einer, sonst keiner; `stop` fehlt → `false`. +- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `408b4ae` · 2026-10-17 · Fund F54 — F54 leerer Wert bleibt String statt `null`. Frühere Prüfung: java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a0b5aa9e7..3d4da9bab 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -35,6 +35,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F38](#f38) — mittel — *gefixt* - [F39](#f39) — niedrig — *gefixt* - [F53](#f53) — niedrig — *gefixt* +- [F54](#f54) — niedrig — *gefixt* ## Funde @@ -274,6 +275,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `27d6477` + +### F54 – java-llama.cpp: leerer Wert in `chat_template_kwargs` wird zu `null` statt zum Text +- **Zeit:** 2026-10-17 21:15 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java:316` (`buildRawValueObject`) +- **Schwere:** niedrig +- **Beschreibung:** Erneute `readTree`-Durchsicht nach F53, jede Fundstelle mit `""` durchgespielt. Die Javadoc verspricht: „If a value cannot be parsed as JSON, it is stored as a JSON string literal.“ Für `"not json"` stimmt das, für einen leeren oder nur aus Leerraum bestehenden Wert nicht: `readTree` liefert `MissingNode`, und ein `MissingNode` als Kind eines `ObjectNode` wird als `null` geschrieben (in der Sandbox geprüft). `ModelParameters.setChatTemplateKwargs` und `InferenceParameters.withChatTemplateKwargs` gaben so `{"x":null}` an die Jinja-Vorlage weiter statt `{"x":""}`. +- **Fix:** `MissingNode` wird wie ein Parsefehler behandelt und der Text unverändert als String eingetragen. +- **Reproduktion:** `ParameterJsonSerializerTest#testBuildRawValueObject_emptyOrBlankValueFallsBackToString` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "{"empty":"","blank":" "}" but: was "{"empty":null,"blank":null}"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ParameterJsonSerializerTest` 35/35, `ModelParametersTest` 92/92, `InferenceParametersTest` 106/106, `WireNameRegistryTest` 8/8, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** – + ## Offene Verdachte ohne Fund-ID +- **`ChatRequest.buildToolsJson` mit leerem Parameterschema (2026-10-17, readTree-Durchsicht nach F53):** ein `ToolDefinition` mit `parametersSchemaJson = ""` ergibt `"parameters": null` (`MissingNode` als Kind), während ein nicht parsbares Schema als String eingetragen wird. Kandidat für die nächste Runde. Unschädlich in derselben Durchsicht: `OpenAiCompatServer.readBody` (danach `isObject()`), `OpenAiServerCli.parseChatTemplateKwargs` (`isObject()`), langchain4j `JsonSchemaElementSerializer.rawNode` (Cast-Fehler wird zu `IllegalArgumentException`), `ToolCallDeltaAccumulator` (ignoriert Unparsbares wie dokumentiert), die `*ResponseParser` (Ersatzwerte über `path`/`isArray`); langchain4j `StreamingChunkAssembler.accept` wirft bei `""` nicht die dokumentierte `UncheckedIOException`, die native Seite liefert aber nie leere Chunks. - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java index 49ca2b580..408b4aeb2 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java @@ -314,7 +314,12 @@ public ObjectNode buildRawValueObject(Map map) { for (Map.Entry entry : map.entrySet()) { try { JsonNode val = OBJECT_MAPPER.readTree(entry.getValue()); - node.set(entry.getKey(), val); + // An empty or blank value is no JSON either, but Jackson returns MissingNode (written as null). + if (val.isMissingNode()) { + node.put(entry.getKey(), entry.getValue()); + } else { + node.set(entry.getKey(), val); + } } catch (IOException e) { node.put(entry.getKey(), entry.getValue()); } diff --git a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java index 31352a1c2..f923cc6e2 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java @@ -325,6 +325,16 @@ public void testBuildRawValueObject_invalidJsonFallsBackToString() { assertThat(node.path("key").asText(), is("not-valid-json{{{")); } + /** Jackson parses an empty or blank text to MissingNode, which would be written as null. */ + @Test + public void testBuildRawValueObject_emptyOrBlankValueFallsBackToString() { + Map map = new LinkedHashMap<>(); + map.put("empty", ""); + map.put("blank", " "); + ObjectNode node = serializer.buildRawValueObject(map); + assertThat(node.toString(), is("{\"empty\":\"\",\"blank\":\" \"}")); + } + @Test public void testBuildRawValueObject_roundtripsAsJson() throws Exception { Map map = new LinkedHashMap<>(); From 18a24e1299ac8295395191dd92cace951adae3d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:11:48 +0000 Subject: [PATCH 044/589] Record the commit of F54 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3d4da9bab..12a0fac76 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -285,7 +285,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ParameterJsonSerializerTest#testBuildRawValueObject_emptyOrBlankValueFallsBackToString` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "{"empty":"","blank":" "}" but: was "{"empty":null,"blank":null}"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ParameterJsonSerializerTest` 35/35, `ModelParametersTest` 92/92, `InferenceParametersTest` 106/106, `WireNameRegistryTest` 8/8, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** – +- **Commit:** java-llama.cpp `37303d0` ## Offene Verdachte ohne Fund-ID From 8a13aa9b86253625b3358aba6c9dfeda6ece7fda Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:18:03 +0000 Subject: [PATCH 045/589] Keep an empty tool parameter schema as a string instead of null buildToolsJson passes a parameter schema that is no JSON on as a string so it stays visible instead of being changed silently. An empty or blank schema parsed to MissingNode instead and was written as "parameters": null. It now takes the same string fallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 14 +++++++++++++- .../ladenthin/llama/parameters/ChatRequest.java | 9 ++++++++- .../llama/parameters/ChatRequestTest.java | 11 +++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 74ca3c8be..90b4b5d00 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -16,7 +16,6 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche ## Geprüft - `llama/src/main/java/net/ladenthin/llama/parameters/TrainingField.java` · `8a305f9` · 2026-10-17 · kein Fund — gelesen, keine Notiz -- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `c411fa0` · 2026-10-17 · Fund F05 — java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert - `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-17 · kein Fund — `json/RouterModelsResponseParser`, `value/RouterModel`: Feldnamen gegen die Testfixtures des Routers (b11538) konsistent, Null/fehlende Felder abgefangen. - `llama/src/main/java/net/ladenthin/llama/value/ChatChoice.java` · `ad1c75f` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` · `31d8fb7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). @@ -85,3 +84,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` · `2591bb0` · 2026-10-17 · kein Fund — Leere/unparsbare Eingabe: `readTree("")` → `MissingNode`, `isArray()` falsch → leere, veränderbare Liste (F37-Muster hier unschädlich). Fehlende Felder fallen auf `""`/`0.0`; die native Seite (`rerank_results_to_json`) liefert immer `document`, `index` und `score` und wirft bei fehlendem oder ungültigem Index. `LlamaModel.rerank(reRank=true)` sortiert absteigend nach Score; ohne Sortierung verspricht die Javadoc keine Reihenfolge. - `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` · `2448442` · 2026-10-17 · Fund F53 — F53 leerer Umschlag gilt als Streamende. `data` als Array → ein Aufruf pro Element in Reihenfolge, als Objekt → einer, sonst keiner; `stop` fehlt → `false`. - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `408b4ae` · 2026-10-17 · Fund F54 — F54 leerer Wert bleibt String statt `null`. Frühere Prüfung: java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert +- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `7ac6e49` · 2026-10-17 · Fund F55 — F55 leeres Schema bleibt String statt `null`. Übrige `readTree`-Stellen der Durchsicht nach F53 siehe die F54-Notiz im Fundbuch. Frühere Prüfung: java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 12a0fac76..657b122e9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -36,6 +36,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F39](#f39) — niedrig — *gefixt* - [F53](#f53) — niedrig — *gefixt* - [F54](#f54) — niedrig — *gefixt* +- [F55](#f55) — niedrig — *gefixt* ## Funde @@ -287,7 +288,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `37303d0` + +### F55 – java-llama.cpp: leeres Tool-Parameterschema wird zu `"parameters": null` +- **Zeit:** 2026-10-17 21:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java:329` (`buildToolsJson`) +- **Schwere:** niedrig +- **Beschreibung:** Aus der `readTree`-Durchsicht nach F53/F54. Ein nicht parsbares Parameterschema eines `ToolDefinition` wird bewusst als String weitergereicht (Test `buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent`), damit es auffällt statt still verändert zu werden. Ein leeres oder nur aus Leerraum bestehendes Schema ergab dagegen `MissingNode` und damit `"parameters": null` – das Werkzeug ginge ohne Schema an die native Seite. +- **Fix:** `MissingNode` nimmt denselben String-Rückfall wie jedes andere nicht parsbare Schema. +- **Reproduktion:** `ChatRequestTest#buildToolsJsonKeepsAnEmptySchemaAsTheStringFallbackInsteadOfNull` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"parameters\":\"\"" but: was "[{"type":"function","function":{"name":"f","description":"d","parameters":null}}]"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatRequestTest` 25/25, `ToolCallingAgentTest` 6/6, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** – + ## Offene Verdachte ohne Fund-ID -- **`ChatRequest.buildToolsJson` mit leerem Parameterschema (2026-10-17, readTree-Durchsicht nach F53):** ein `ToolDefinition` mit `parametersSchemaJson = ""` ergibt `"parameters": null` (`MissingNode` als Kind), während ein nicht parsbares Schema als String eingetragen wird. Kandidat für die nächste Runde. Unschädlich in derselben Durchsicht: `OpenAiCompatServer.readBody` (danach `isObject()`), `OpenAiServerCli.parseChatTemplateKwargs` (`isObject()`), langchain4j `JsonSchemaElementSerializer.rawNode` (Cast-Fehler wird zu `IllegalArgumentException`), `ToolCallDeltaAccumulator` (ignoriert Unparsbares wie dokumentiert), die `*ResponseParser` (Ersatzwerte über `path`/`isArray`); langchain4j `StreamingChunkAssembler.accept` wirft bei `""` nicht die dokumentierte `UncheckedIOException`, die native Seite liefert aber nie leere Chunks. - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java b/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java index c411fa019..7ac6e49d3 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java @@ -5,6 +5,7 @@ package net.ladenthin.llama.parameters; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -326,7 +327,13 @@ public Optional buildToolsJson() { fn.put("name", t.getName()); fn.put("description", t.getDescription()); try { - fn.set("parameters", SCHEMA_READER.readTree(t.getParametersSchemaJson())); + JsonNode schema = SCHEMA_READER.readTree(t.getParametersSchemaJson()); + // An empty or blank schema is no JSON either, but Jackson returns MissingNode (written as null). + if (schema.isMissingNode()) { + fn.put("parameters", t.getParametersSchemaJson()); + } else { + fn.set("parameters", schema); + } } catch (IOException e) { fn.put("parameters", t.getParametersSchemaJson()); } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java index f34822346..fe9c5c6d5 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ChatRequestTest.java @@ -237,6 +237,17 @@ void buildToolsJsonEmbedsAValidSchemaAsAnObject() { assertThat(json, containsString("\"parameters\":{\"type\":\"object\"}")); } + @Test + void buildToolsJsonKeepsAnEmptySchemaAsTheStringFallbackInsteadOfNull() { + // Jackson parses an empty or blank text to MissingNode, which an ObjectNode writes as null; an + // empty schema is no schema, so it takes the same string fallback as any other unparseable one. + String json = ChatRequest.empty() + .appendTool(new ToolDefinition("f", "d", "")) + .buildToolsJson() + .orElseThrow(IllegalStateException::new); + assertThat(json, containsString("\"parameters\":\"\"")); + } + @Test void buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent() { // A schema string holding more than one JSON value is not a schema; keeping only the first From cd1cdaae845039132ebac47ff6e878b4c894d904 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:18:03 +0000 Subject: [PATCH 046/589] Record the commit of F55 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 657b122e9..8f9a1d053 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -298,7 +298,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ChatRequestTest#buildToolsJsonKeepsAnEmptySchemaAsTheStringFallbackInsteadOfNull` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"parameters\":\"\"" but: was "[{"type":"function","function":{"name":"f","description":"d","parameters":null}}]"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatRequestTest` 25/25, `ToolCallingAgentTest` 6/6, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** – +- **Commit:** java-llama.cpp `ed6236d` ## Offene Verdachte ohne Fund-ID From 04b78cb86b013b7edecf2e2f39406cec7b303def Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:20:20 +0000 Subject: [PATCH 047/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 90b4b5d00..13fd08313 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -85,3 +85,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` · `2448442` · 2026-10-17 · Fund F53 — F53 leerer Umschlag gilt als Streamende. `data` als Array → ein Aufruf pro Element in Reihenfolge, als Objekt → einer, sonst keiner; `stop` fehlt → `false`. - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `408b4ae` · 2026-10-17 · Fund F54 — F54 leerer Wert bleibt String statt `null`. Frühere Prüfung: java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert - `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `7ac6e49` · 2026-10-17 · Fund F55 — F55 leeres Schema bleibt String statt `null`. Übrige `readTree`-Stellen der Durchsicht nach F53 siehe die F54-Notiz im Fundbuch. Frühere Prüfung: java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert +- `llama/src/main/java/net/ladenthin/llama/loader/NativeLibraryPermissionSetter.java` · `e2c6e1b` · 2026-10-17 · kein Fund — Setzt Lesen/Schreiben/Ausführen jeweils nur für den Besitzer (`setReadable(true)`/`setExecutable(true)` sind die Besitzer-Varianten, `setWritable(true, true)` explizit) – für eine extrahierte native Bibliothek die vorsichtigere Wahl. Ein Fehlschlag wird mit allen drei Ergebnissen gemeldet und als `false` zurückgegeben, nicht verschluckt; die Senke ist im Konstruktor auf `null` geprüft. From 0b9482264cdd2f0fef1a766377d62c56d3bad21a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:22:24 +0000 Subject: [PATCH 048/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 13fd08313..93a438740 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -86,3 +86,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `408b4ae` · 2026-10-17 · Fund F54 — F54 leerer Wert bleibt String statt `null`. Frühere Prüfung: java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert - `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `7ac6e49` · 2026-10-17 · Fund F55 — F55 leeres Schema bleibt String statt `null`. Übrige `readTree`-Stellen der Durchsicht nach F53 siehe die F54-Notiz im Fundbuch. Frühere Prüfung: java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert - `llama/src/main/java/net/ladenthin/llama/loader/NativeLibraryPermissionSetter.java` · `e2c6e1b` · 2026-10-17 · kein Fund — Setzt Lesen/Schreiben/Ausführen jeweils nur für den Besitzer (`setReadable(true)`/`setExecutable(true)` sind die Besitzer-Varianten, `setWritable(true, true)` explizit) – für eine extrahierte native Bibliothek die vorsichtigere Wahl. Ein Fehlschlag wird mit allen drei Ergebnissen gemeldet und als `false` zurückgegeben, nicht verschluckt; die Senke ist im Konstruktor auf `null` geprüft. +- `llama/src/main/java/net/ladenthin/llama/loader/LlamaSystemProperties.java` · `ce16589` · 2026-10-17 · kein Fund — Reine Zugriffsschicht auf `System.getProperty`, `@Nullable` korrekt. Die fünf Namen (`lib.path`, `tmpdir`, `osinfo.architecture`, `test.ngl`, `backend`) stimmen mit der Systemeigenschaften-Tabelle der README überein. From 32eda942d6758968c9e9a9d326edd6d280eedaf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:24:29 +0000 Subject: [PATCH 049/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 93a438740..261556138 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -87,3 +87,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `7ac6e49` · 2026-10-17 · Fund F55 — F55 leeres Schema bleibt String statt `null`. Übrige `readTree`-Stellen der Durchsicht nach F53 siehe die F54-Notiz im Fundbuch. Frühere Prüfung: java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert - `llama/src/main/java/net/ladenthin/llama/loader/NativeLibraryPermissionSetter.java` · `e2c6e1b` · 2026-10-17 · kein Fund — Setzt Lesen/Schreiben/Ausführen jeweils nur für den Besitzer (`setReadable(true)`/`setExecutable(true)` sind die Besitzer-Varianten, `setWritable(true, true)` explizit) – für eine extrahierte native Bibliothek die vorsichtigere Wahl. Ein Fehlschlag wird mit allen drei Ergebnissen gemeldet und als `false` zurückgegeben, nicht verschluckt; die Senke ist im Konstruktor auf `null` geprüft. - `llama/src/main/java/net/ladenthin/llama/loader/LlamaSystemProperties.java` · `ce16589` · 2026-10-17 · kein Fund — Reine Zugriffsschicht auf `System.getProperty`, `@Nullable` korrekt. Die fünf Namen (`lib.path`, `tmpdir`, `osinfo.architecture`, `test.ngl`, `backend`) stimmen mit der Systemeigenschaften-Tabelle der README überein. +- `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` · `9d751fc` · 2026-10-17 · kein Fund (Verhalten) — Native Rückgabe `null`/leer = Erfolg, sonst `LlamaException` mit der Meldung; Konfiguration wird vor dem nativen Aufruf über `toJson()` geprüft (F01). Javadoc nennt die `IllegalArgumentException` dieser Prüfung nicht – als Doku-Lücke beim F01-Eintrag notiert. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 8f9a1d053..24bdd54de 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -51,6 +51,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot, 4 von 7 („Expected IllegalArgumentException to be thrown, but nothing was thrown“ bzw. bare NPE ohne Feldnamen); mit Fix grün 7/7 (skipped 0), `WireNameRegistryTest` 8/8; Gegenprobe per `git stash` rot 4/7, nach `stash pop` grün 7/7. `WireNameRegistryTest#everyTrainingFieldIsWrittenByAFullyPopulatedConfiguration` setzte beide Korpus-Felder zugleich und nutzt jetzt zwei Konfigurationen. - **Status:** gefixt - **Commit:** java-llama.cpp `0aa7c94` +- **Doku-Lücke aus diesem Fix (2026-10-17, nur beschrieben):** Seit dem Fix wirft `TrainingParameters.toJson()` – und damit beide `LlamaTrainer.finetune`-Überladungen – bei ungültiger Konfiguration `IllegalArgumentException`; die Javadoc beider `finetune` nennt nur `LlamaException`. Reine Doku, kein Test kann sie rot machen, daher nach der Nutzerregel unverändert. ### F05 – java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert From e958a0e06f3fb769d07302c649f1e4f4480c7a9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:30:15 +0000 Subject: [PATCH 050/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 261556138..3deacb494 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -88,3 +88,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/NativeLibraryPermissionSetter.java` · `e2c6e1b` · 2026-10-17 · kein Fund — Setzt Lesen/Schreiben/Ausführen jeweils nur für den Besitzer (`setReadable(true)`/`setExecutable(true)` sind die Besitzer-Varianten, `setWritable(true, true)` explizit) – für eine extrahierte native Bibliothek die vorsichtigere Wahl. Ein Fehlschlag wird mit allen drei Ergebnissen gemeldet und als `false` zurückgegeben, nicht verschluckt; die Senke ist im Konstruktor auf `null` geprüft. - `llama/src/main/java/net/ladenthin/llama/loader/LlamaSystemProperties.java` · `ce16589` · 2026-10-17 · kein Fund — Reine Zugriffsschicht auf `System.getProperty`, `@Nullable` korrekt. Die fünf Namen (`lib.path`, `tmpdir`, `osinfo.architecture`, `test.ngl`, `backend`) stimmen mit der Systemeigenschaften-Tabelle der README überein. - `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` · `9d751fc` · 2026-10-17 · kein Fund (Verhalten) — Native Rückgabe `null`/leer = Erfolg, sonst `LlamaException` mit der Meldung; Konfiguration wird vor dem nativen Aufruf über `toJson()` geprüft (F01). Javadoc nennt die `IllegalArgumentException` dieser Prüfung nicht – als Doku-Lücke beim F01-Eintrag notiert. +- `llama/src/main/java/net/ladenthin/llama/RpcServerNative.java` · `ef8f53a` · 2026-10-17 · kein Fund — Reine JNI-Delegation hinter der `RpcServer.Backend`-Naht; Signaturen der vier `native`-Methoden und `@Nullable` (Cache-Verzeichnis, Geräteliste bei unbekanntem Namen) passen zum Interface. `LlamaLoader.initialize()` im statischen Block: das in CLAUDE.md beschriebene Wiedereintreten beim Laden vor `LlamaModel` ist in `LlamaLoader.runOnceOnThisThread` behoben. From f3b8aed78513de99e78d17e43d80c8a14b073597 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:42:37 +0000 Subject: [PATCH 051/589] Report an unrepresentable offline model path as missing OfflineModelGuard.check promises a ModelUnavailableException when --offline is set and the configured local model file does not exist. A path the file system cannot represent (a NUL character, or a character Windows forbids) made Paths.get throw an InvalidPathException instead, so the caller got an untyped IllegalArgumentException before the native loader was reached. Such a path cannot name a file, so it now counts as missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/loader/OfflineModelGuard.java | 19 ++++++++++++++++++- .../llama/LlamaModelOfflineTest.java | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java b/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java index 84b86bbba..658e69a0f 100644 --- a/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java +++ b/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java @@ -5,6 +5,7 @@ package net.ladenthin.llama.loader; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Paths; import net.ladenthin.llama.args.ModelFlag; import net.ladenthin.llama.exception.ModelUnavailableException; @@ -59,10 +60,26 @@ public static void check(ModelParameters parameters) { return; } String model = parameters.getModel(); - if (model != null && !model.isEmpty() && !Files.exists(Paths.get(model))) { + if (model != null && !model.isEmpty() && !localFileExists(model)) { throw new ModelUnavailableException( "Model unavailable: --offline is set but the configured model file does not " + "exist (no download attempted): " + model); } } + + /** + * Whether {@code model} names an existing file. A path the file system cannot represent (a NUL + * character, or a character Windows forbids) cannot name a file, so it counts as missing instead + * of escaping as an {@link InvalidPathException}. + * + * @param model the configured local model path + * @return {@code true} if the file exists + */ + private static boolean localFileExists(String model) { + try { + return Files.exists(Paths.get(model)); + } catch (InvalidPathException e) { + return false; + } + } } diff --git a/llama/src/test/java/net/ladenthin/llama/LlamaModelOfflineTest.java b/llama/src/test/java/net/ladenthin/llama/LlamaModelOfflineTest.java index e4198cf5f..30dcc0cba 100644 --- a/llama/src/test/java/net/ladenthin/llama/LlamaModelOfflineTest.java +++ b/llama/src/test/java/net/ladenthin/llama/LlamaModelOfflineTest.java @@ -73,6 +73,20 @@ public void check_offlineAndMissingModel_throws() { "message should mention the --offline flag for caller diagnosis"); } + @Test + @DisplayName("check: offline + a path the file system cannot represent -> ModelUnavailableException") + public void check_offlineAndUnrepresentablePath_throwsModelUnavailable() { + // A NUL character is invalid in a path on every platform, so Paths.get rejects it with an + // InvalidPathException. Such a file cannot exist, which is exactly the case the guard + // promises to report as the typed exception. + ModelParameters p = new ModelParameters().setOffline(true).setModel("models/bad\u0000name.gguf"); + + ModelUnavailableException ex = assertThrows(ModelUnavailableException.class, () -> OfflineModelGuard.check(p)); + assertTrue( + ex.getMessage() != null && ex.getMessage().contains("--offline"), + "message should mention the --offline flag for caller diagnosis"); + } + @Test @DisplayName("check: offline + present local model -> no exception") public void check_offlineAndPresentModel_doesNotThrow(@TempDir Path tempDir) throws IOException { From 69e67e9e03cb7ad42609884bc6034b1cefea5189 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:42:52 +0000 Subject: [PATCH 052/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ BUGHUNT-FOUND.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3deacb494..997010e27 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -89,3 +89,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/LlamaSystemProperties.java` · `ce16589` · 2026-10-17 · kein Fund — Reine Zugriffsschicht auf `System.getProperty`, `@Nullable` korrekt. Die fünf Namen (`lib.path`, `tmpdir`, `osinfo.architecture`, `test.ngl`, `backend`) stimmen mit der Systemeigenschaften-Tabelle der README überein. - `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` · `9d751fc` · 2026-10-17 · kein Fund (Verhalten) — Native Rückgabe `null`/leer = Erfolg, sonst `LlamaException` mit der Meldung; Konfiguration wird vor dem nativen Aufruf über `toJson()` geprüft (F01). Javadoc nennt die `IllegalArgumentException` dieser Prüfung nicht – als Doku-Lücke beim F01-Eintrag notiert. - `llama/src/main/java/net/ladenthin/llama/RpcServerNative.java` · `ef8f53a` · 2026-10-17 · kein Fund — Reine JNI-Delegation hinter der `RpcServer.Backend`-Naht; Signaturen der vier `native`-Methoden und `@Nullable` (Cache-Verzeichnis, Geräteliste bei unbekanntem Namen) passen zum Interface. `LlamaLoader.initialize()` im statischen Block: das in CLAUDE.md beschriebene Wiedereintreten beim Laden vor `LlamaModel` ist in `LlamaLoader.runOnceOnThisThread` behoben. +- `llama/src/main/java/net/ladenthin/llama/callback/CancellationToken.java` · `70365a6` · 2026-10-17 · kein Fund (Verhalten) — `volatile`-Flag, `cancel()` idempotent. `LlamaModel.complete(params, token)` setzt den Token bewusst erst im `finally` zurück, nicht beim Eintritt, sodass ein vorab gesetztes `cancel()` wirkt. Doku-Abweichung: Javadoc von `reset()` sagt „Package-private.“, die Methode ist `public` – nur beschrieben (kein Test, Regel Schritt 4). +- `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` · `658e69a` · 2026-10-17 · Fund F56 — nicht darstellbarer Pfad ergab `InvalidPathException`. Durchsicht der übrigen `Paths.get`-Stellen: `RpcServer.Options.parse` (`--cache`) wirft dieselbe Ausnahme, die dort als `IllegalArgumentException` der Argumentprüfung passt; `OpenAiServerCli.getModelId` nur bei unsinnigem Modellpfad, den der native Lader ohnehin ablehnt – kein Fund. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 24bdd54de..48b0d468e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -37,6 +37,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F53](#f53) — niedrig — *gefixt* - [F54](#f54) — niedrig — *gefixt* - [F55](#f55) — niedrig — *gefixt* +- [F56](#f56) — niedrig — *gefixt* ## Funde @@ -301,6 +302,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ed6236d` +### F56 – java-llama.cpp: nicht darstellbarer Offline-Modellpfad ergibt `InvalidPathException` statt `ModelUnavailableException` +- **Zeit:** 2026-10-17 22:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java:62` (`check`) +- **Schwere:** niedrig +- **Beschreibung:** Die Javadoc verspricht eine `ModelUnavailableException`, wenn `--offline` gesetzt ist und die konfigurierte lokale Modelldatei fehlt. Ein Pfad, den das Dateisystem nicht darstellen kann (NUL-Zeichen; unter Windows auch `?`, `*`, `<` …), ließ `Paths.get` eine `InvalidPathException` werfen – eine untypisierte `IllegalArgumentException`, noch bevor der native Lader erreicht wird. Ein solcher Pfad kann keine Datei bezeichnen, ist also genau der Fall „Datei fehlt“. +- **Fix:** `localFileExists` behandelt eine `InvalidPathException` als „existiert nicht“. +- **Reproduktion:** `LlamaModelOfflineTest#check_offlineAndUnrepresentablePath_throwsModelUnavailable` +- **Rot-Grün-Nachweis:** ohne Fix rot („expected: but was: “, Nul character not allowed), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaModelOfflineTest` 9/9, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `4f610bd` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 0106db628771da972de9e3f0e8cc39061b63e1b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:44:44 +0000 Subject: [PATCH 053/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 997010e27..dbe1ba594 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -91,3 +91,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/RpcServerNative.java` · `ef8f53a` · 2026-10-17 · kein Fund — Reine JNI-Delegation hinter der `RpcServer.Backend`-Naht; Signaturen der vier `native`-Methoden und `@Nullable` (Cache-Verzeichnis, Geräteliste bei unbekanntem Namen) passen zum Interface. `LlamaLoader.initialize()` im statischen Block: das in CLAUDE.md beschriebene Wiedereintreten beim Laden vor `LlamaModel` ist in `LlamaLoader.runOnceOnThisThread` behoben. - `llama/src/main/java/net/ladenthin/llama/callback/CancellationToken.java` · `70365a6` · 2026-10-17 · kein Fund (Verhalten) — `volatile`-Flag, `cancel()` idempotent. `LlamaModel.complete(params, token)` setzt den Token bewusst erst im `finally` zurück, nicht beim Eintritt, sodass ein vorab gesetztes `cancel()` wirkt. Doku-Abweichung: Javadoc von `reset()` sagt „Package-private.“, die Methode ist `public` – nur beschrieben (kein Test, Regel Schritt 4). - `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` · `658e69a` · 2026-10-17 · Fund F56 — nicht darstellbarer Pfad ergab `InvalidPathException`. Durchsicht der übrigen `Paths.get`-Stellen: `RpcServer.Options.parse` (`--cache`) wirft dieselbe Ausnahme, die dort als `IllegalArgumentException` der Argumentprüfung passt; `OpenAiServerCli.getModelId` nur bei unsinnigem Modellpfad, den der native Lader ohnehin ablehnt – kein Fund. +- `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` · `2c10c02` · 2026-10-17 · kein Fund — Jeder Getter liest über `path()` mit Standardwert, also kein NPE bei fehlenden Schlüsseln; `getDraftAcceptanceRate` und die beiden Durchsatzberechnungen sind gegen Division durch 0 geschützt; Argumentreihenfolge an den `Timings`-Konstruktor (cacheN, promptN, promptMs, promptPerSec, predictedN, predictedMs, predictedPerSec, draftN, draftNAccepted) stimmt; Listen werden unveränderlich zurückgegeben. Schlüsselnamen passen zu `server_metrics_to_json` laut CLAUDE.md. From 3d6b24db69574c77301a3d23df7c1d508199fb5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:47:06 +0000 Subject: [PATCH 054/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index dbe1ba594..65f0850e6 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -92,3 +92,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/callback/CancellationToken.java` · `70365a6` · 2026-10-17 · kein Fund (Verhalten) — `volatile`-Flag, `cancel()` idempotent. `LlamaModel.complete(params, token)` setzt den Token bewusst erst im `finally` zurück, nicht beim Eintritt, sodass ein vorab gesetztes `cancel()` wirkt. Doku-Abweichung: Javadoc von `reset()` sagt „Package-private.“, die Methode ist `public` – nur beschrieben (kein Test, Regel Schritt 4). - `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` · `658e69a` · 2026-10-17 · Fund F56 — nicht darstellbarer Pfad ergab `InvalidPathException`. Durchsicht der übrigen `Paths.get`-Stellen: `RpcServer.Options.parse` (`--cache`) wirft dieselbe Ausnahme, die dort als `IllegalArgumentException` der Argumentprüfung passt; `OpenAiServerCli.getModelId` nur bei unsinnigem Modellpfad, den der native Lader ohnehin ablehnt – kein Fund. - `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` · `2c10c02` · 2026-10-17 · kein Fund — Jeder Getter liest über `path()` mit Standardwert, also kein NPE bei fehlenden Schlüsseln; `getDraftAcceptanceRate` und die beiden Durchsatzberechnungen sind gegen Division durch 0 geschützt; Argumentreihenfolge an den `Timings`-Konstruktor (cacheN, promptN, promptMs, promptPerSec, predictedN, predictedMs, predictedPerSec, draftN, draftNAccepted) stimmt; Listen werden unveränderlich zurückgegeben. Schlüsselnamen passen zu `server_metrics_to_json` laut CLAUDE.md. +- `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-17 · kein Fund — `Status.fromValue` deckt alle sechs Namen von upstreams `server_model_status_to_string` (b11639, `server-models.h`) ab, `unknown`/`null`/Fremdes → `UNKNOWN`. Gegen `get_router_models` (b11639) abgeglichen: `id`, `status.value`/`failed`/`exit_code` (nur bei Fehlschlag gesendet, Standardwerte im Parser passen), `architecture.input_/output_modalities`, `context_length` (nur wenn > 0). Modalitätslisten werden kopiert und unveränderlich gemacht; `equals`/`hashCode` über alle Felder wie dokumentiert. From 10a8237add7d60e7d3766537d402a22d43bc6d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:49:50 +0000 Subject: [PATCH 055/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 65f0850e6..694ec0b0c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -93,3 +93,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` · `658e69a` · 2026-10-17 · Fund F56 — nicht darstellbarer Pfad ergab `InvalidPathException`. Durchsicht der übrigen `Paths.get`-Stellen: `RpcServer.Options.parse` (`--cache`) wirft dieselbe Ausnahme, die dort als `IllegalArgumentException` der Argumentprüfung passt; `OpenAiServerCli.getModelId` nur bei unsinnigem Modellpfad, den der native Lader ohnehin ablehnt – kein Fund. - `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` · `2c10c02` · 2026-10-17 · kein Fund — Jeder Getter liest über `path()` mit Standardwert, also kein NPE bei fehlenden Schlüsseln; `getDraftAcceptanceRate` und die beiden Durchsatzberechnungen sind gegen Division durch 0 geschützt; Argumentreihenfolge an den `Timings`-Konstruktor (cacheN, promptN, promptMs, promptPerSec, predictedN, predictedMs, predictedPerSec, draftN, draftNAccepted) stimmt; Listen werden unveränderlich zurückgegeben. Schlüsselnamen passen zu `server_metrics_to_json` laut CLAUDE.md. - `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-17 · kein Fund — `Status.fromValue` deckt alle sechs Namen von upstreams `server_model_status_to_string` (b11639, `server-models.h`) ab, `unknown`/`null`/Fremdes → `UNKNOWN`. Gegen `get_router_models` (b11639) abgeglichen: `id`, `status.value`/`failed`/`exit_code` (nur bei Fehlschlag gesendet, Standardwerte im Parser passen), `architecture.input_/output_modalities`, `context_length` (nur wenn > 0). Modalitätslisten werden kopiert und unveränderlich gemacht; `equals`/`hashCode` über alle Felder wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` · `453a951` · 2026-10-17 · kein Fund — `parts` null/leer → IAE wie dokumentiert, Kopie unveränderlich; `toolCalls` null → leere Liste. Javadoc-Versprechen „Parts haben beim Serialisieren Vorrang“ in der einzigen Serialisierung (`ParameterJsonSerializer.buildMessages(List)`, genutzt von `ChatRequest.buildMessagesJson` und `InferenceParameters.withMessages`) erfüllt, `tool_call_id`/`tool_calls` werden mitgeschrieben. `Session`/`ChatTranscript` bauen nur Textnachrichten. Ein `null`-Element in `parts` ergäbe eine NPE statt IAE – Javadoc schweigt dazu, kein Fund. From 8b6b18538473582584e19bed82c28df660996f71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:51:30 +0000 Subject: [PATCH 056/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 694ec0b0c..d6a42ae49 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -94,3 +94,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` · `2c10c02` · 2026-10-17 · kein Fund — Jeder Getter liest über `path()` mit Standardwert, also kein NPE bei fehlenden Schlüsseln; `getDraftAcceptanceRate` und die beiden Durchsatzberechnungen sind gegen Division durch 0 geschützt; Argumentreihenfolge an den `Timings`-Konstruktor (cacheN, promptN, promptMs, promptPerSec, predictedN, predictedMs, predictedPerSec, draftN, draftNAccepted) stimmt; Listen werden unveränderlich zurückgegeben. Schlüsselnamen passen zu `server_metrics_to_json` laut CLAUDE.md. - `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-17 · kein Fund — `Status.fromValue` deckt alle sechs Namen von upstreams `server_model_status_to_string` (b11639, `server-models.h`) ab, `unknown`/`null`/Fremdes → `UNKNOWN`. Gegen `get_router_models` (b11639) abgeglichen: `id`, `status.value`/`failed`/`exit_code` (nur bei Fehlschlag gesendet, Standardwerte im Parser passen), `architecture.input_/output_modalities`, `context_length` (nur wenn > 0). Modalitätslisten werden kopiert und unveränderlich gemacht; `equals`/`hashCode` über alle Felder wie dokumentiert. - `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` · `453a951` · 2026-10-17 · kein Fund — `parts` null/leer → IAE wie dokumentiert, Kopie unveränderlich; `toolCalls` null → leere Liste. Javadoc-Versprechen „Parts haben beim Serialisieren Vorrang“ in der einzigen Serialisierung (`ParameterJsonSerializer.buildMessages(List)`, genutzt von `ChatRequest.buildMessagesJson` und `InferenceParameters.withMessages`) erfüllt, `tool_call_id`/`tool_calls` werden mitgeschrieben. `Session`/`ChatTranscript` bauen nur Textnachrichten. Ein `null`-Element in `parts` ergäbe eine NPE statt IAE – Javadoc schweigt dazu, kein Fund. +- `llama/src/main/java/net/ladenthin/llama/value/Timings.java` · `e187e37` · 2026-10-17 · kein Fund — Schlüssel von `fromJson` gegen upstreams `server_slot_stats::to_json()` (b11639, `server-common.cpp`) abgeglichen: `cache_n`, `prompt_n/_ms/_per_second`, `predicted_n/_ms/_per_second` stimmen; `draft_n`/`draft_n_accepted` sendet upstream nur bei Draft-Tokens > 0, der Standardwert 0 passt. `null`/Missing/JSON-null → Nullobjekt. `*_per_token_ms` wird nicht gelesen (kein Getter versprochen). From 3c3a093c6b3e01277752eeb21aa53f7c2debf04e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 20:53:24 +0000 Subject: [PATCH 057/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index d6a42ae49..4807de95f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -95,3 +95,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-17 · kein Fund — `Status.fromValue` deckt alle sechs Namen von upstreams `server_model_status_to_string` (b11639, `server-models.h`) ab, `unknown`/`null`/Fremdes → `UNKNOWN`. Gegen `get_router_models` (b11639) abgeglichen: `id`, `status.value`/`failed`/`exit_code` (nur bei Fehlschlag gesendet, Standardwerte im Parser passen), `architecture.input_/output_modalities`, `context_length` (nur wenn > 0). Modalitätslisten werden kopiert und unveränderlich gemacht; `equals`/`hashCode` über alle Felder wie dokumentiert. - `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` · `453a951` · 2026-10-17 · kein Fund — `parts` null/leer → IAE wie dokumentiert, Kopie unveränderlich; `toolCalls` null → leere Liste. Javadoc-Versprechen „Parts haben beim Serialisieren Vorrang“ in der einzigen Serialisierung (`ParameterJsonSerializer.buildMessages(List)`, genutzt von `ChatRequest.buildMessagesJson` und `InferenceParameters.withMessages`) erfüllt, `tool_call_id`/`tool_calls` werden mitgeschrieben. `Session`/`ChatTranscript` bauen nur Textnachrichten. Ein `null`-Element in `parts` ergäbe eine NPE statt IAE – Javadoc schweigt dazu, kein Fund. - `llama/src/main/java/net/ladenthin/llama/value/Timings.java` · `e187e37` · 2026-10-17 · kein Fund — Schlüssel von `fromJson` gegen upstreams `server_slot_stats::to_json()` (b11639, `server-common.cpp`) abgeglichen: `cache_n`, `prompt_n/_ms/_per_second`, `predicted_n/_ms/_per_second` stimmen; `draft_n`/`draft_n_accepted` sendet upstream nur bei Draft-Tokens > 0, der Standardwert 0 passt. `null`/Missing/JSON-null → Nullobjekt. `*_per_token_ms` wird nicht gelesen (kein Getter versprochen). +- `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` · `cf96a79` · 2026-10-17 · kein Fund — Zweiphasen-Commit wie dokumentiert: `messagesWithPendingUserTurn` kopiert, mutiert nicht; `removePendingUserTurn` entfernt nur eine abschließende `user`-Runde. Leere/`null`-Systemnachricht wird in `snapshot()` und in `ParameterJsonSerializer.buildMessages(String, List)` gleich behandelt (weggelassen). Nicht synchronisiert, wie die Javadoc sagt; `Session` hält das Lock (bereits geprüft). `turnsSnapshot`/`resetTurns` kopieren, `Pair` ist unveränderlich. From 4b206dc2e18e8c083f32387e0ded4f74e03e342f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:01:25 +0000 Subject: [PATCH 058/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 4807de95f..1db76e820 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -96,3 +96,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` · `453a951` · 2026-10-17 · kein Fund — `parts` null/leer → IAE wie dokumentiert, Kopie unveränderlich; `toolCalls` null → leere Liste. Javadoc-Versprechen „Parts haben beim Serialisieren Vorrang“ in der einzigen Serialisierung (`ParameterJsonSerializer.buildMessages(List)`, genutzt von `ChatRequest.buildMessagesJson` und `InferenceParameters.withMessages`) erfüllt, `tool_call_id`/`tool_calls` werden mitgeschrieben. `Session`/`ChatTranscript` bauen nur Textnachrichten. Ein `null`-Element in `parts` ergäbe eine NPE statt IAE – Javadoc schweigt dazu, kein Fund. - `llama/src/main/java/net/ladenthin/llama/value/Timings.java` · `e187e37` · 2026-10-17 · kein Fund — Schlüssel von `fromJson` gegen upstreams `server_slot_stats::to_json()` (b11639, `server-common.cpp`) abgeglichen: `cache_n`, `prompt_n/_ms/_per_second`, `predicted_n/_ms/_per_second` stimmen; `draft_n`/`draft_n_accepted` sendet upstream nur bei Draft-Tokens > 0, der Standardwert 0 passt. `null`/Missing/JSON-null → Nullobjekt. `*_per_token_ms` wird nicht gelesen (kein Getter versprochen). - `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` · `cf96a79` · 2026-10-17 · kein Fund — Zweiphasen-Commit wie dokumentiert: `messagesWithPendingUserTurn` kopiert, mutiert nicht; `removePendingUserTurn` entfernt nur eine abschließende `user`-Runde. Leere/`null`-Systemnachricht wird in `snapshot()` und in `ParameterJsonSerializer.buildMessages(String, List)` gleich behandelt (weggelassen). Nicht synchronisiert, wie die Javadoc sagt; `Session` hält das Lock (bereits geprüft). `turnsSnapshot`/`resetTurns` kopieren, `Pair` ist unveränderlich. +- `llama/src/main/java/net/ladenthin/llama/LlamaQuantizer.java` · `8fca432` · 2026-10-17 · kein Fund — Null-Prüfung aller drei Referenzen vor dem nativen Aufruf; die Kurzform reicht `threads=0` (`<= 0` = alle Hardware-Threads laut Javadoc und `llama_model_quantize_params.nthread`) und `allowRequantize=false` durch. `LlamaLoader.initialize()` im statischen Block wie bei den übrigen JNI-Klassen. +- `llama/src/main/java/net/ladenthin/llama/args/QuantizationType.java` · `d5ccc9d` · 2026-10-17 · kein Fund — Alle 36 `ftype`-Werte gegen `enum llama_ftype` in `include/llama.h` (b11639) abgeglichen; Namen und Zahlen stimmen. Die upstream entfernten 4/5/6 und 33–35 (`Q4_0_4_4` …) fehlen hier zu Recht, ebenso `GUESSED` (1024, kein Ziel). From b5dd5c93e9bacb80d24e456b22fff3c24b1be111 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:03:51 +0000 Subject: [PATCH 059/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 4 ++++ BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 1db76e820..c4e3f008a 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -98,3 +98,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` · `cf96a79` · 2026-10-17 · kein Fund — Zweiphasen-Commit wie dokumentiert: `messagesWithPendingUserTurn` kopiert, mutiert nicht; `removePendingUserTurn` entfernt nur eine abschließende `user`-Runde. Leere/`null`-Systemnachricht wird in `snapshot()` und in `ParameterJsonSerializer.buildMessages(String, List)` gleich behandelt (weggelassen). Nicht synchronisiert, wie die Javadoc sagt; `Session` hält das Lock (bereits geprüft). `turnsSnapshot`/`resetTurns` kopieren, `Pair` ist unveränderlich. - `llama/src/main/java/net/ladenthin/llama/LlamaQuantizer.java` · `8fca432` · 2026-10-17 · kein Fund — Null-Prüfung aller drei Referenzen vor dem nativen Aufruf; die Kurzform reicht `threads=0` (`<= 0` = alle Hardware-Threads laut Javadoc und `llama_model_quantize_params.nthread`) und `allowRequantize=false` durch. `LlamaLoader.initialize()` im statischen Block wie bei den übrigen JNI-Klassen. - `llama/src/main/java/net/ladenthin/llama/args/QuantizationType.java` · `d5ccc9d` · 2026-10-17 · kein Fund — Alle 36 `ftype`-Werte gegen `enum llama_ftype` in `include/llama.h` (b11639) abgeglichen; Namen und Zahlen stimmen. Die upstream entfernten 4/5/6 und 33–35 (`Q4_0_4_4` …) fehlen hier zu Recht, ebenso `GUESSED` (1024, kein Ziel). +- `llama/src/main/java/net/ladenthin/llama/value/Usage.java` · `ce0b9a7` · 2026-10-17 · Fund F57 (beim Aufrufer `ServerMetrics`) — Klasse selbst korrekt: `getProcessedPromptTokens` auf ≥ 0 begrenzt, `getTotalTokens` = prompt + completion wie bei OAI. +- `llama/src/main/java/net/ladenthin/llama/value/ChatResponse.java` · `7b68d5f` · 2026-10-17 · kein Fund — `choices` null → leere Liste; `getFirstMessage`/`getFirstContent` behandeln leere Antworten. +- `llama/src/main/java/net/ladenthin/llama/value/LlamaOutput.java` · `e928537` · 2026-10-17 · kein Fund — reiner Träger, `toString` gibt den Text wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` · `cb51433` · 2026-10-17 · kein Fund — Namen gegen `stop_type_to_str` (b11639, `server-task.cpp`) abgeglichen: `eos`/`word`/`limit`, `none` und Unbekanntes → `NONE`. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 48b0d468e..6b2806f6e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -38,6 +38,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F54](#f54) — niedrig — *gefixt* - [F55](#f55) — niedrig — *gefixt* - [F56](#f56) — niedrig — *gefixt* +- [F57](#f57) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) ## Funde @@ -313,6 +314,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4f610bd` + +### F57 – java-llama.cpp: `ServerMetrics.getCumulativeUsage()` liefert ein `Usage` gegen dessen eigenen Vertrag +- **Zeit:** 2026-10-17 21:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java:203` (`getCumulativeUsage`) +- **Schwere:** niedrig +- **Beschreibung:** `Usage.getPromptTokens()` ist dokumentiert als „logical prompt token count, including cached tokens“, `getCachedTokens()` als dessen gecachte Teilmenge. `getCumulativeUsage()` füllt `promptTokens` aber mit `n_prompt_tokens_processed_total` (nur ausgewertete Tokens, ohne Cache-Treffer) und `cachedTokens` mit 0. Die Javadoc der Methode sagt das zwar, begründet es aber mit „upstream does not currently expose a cumulative logical or cached prompt total“ – das stimmt nicht mehr: `n_prompt_tokens_cached_total` kommt seit der `getMetrics()`-Zusammenführung mit und hat einen eigenen Getter (`getCumulativeCachedPromptTokens`). Ein Aufrufer, der `Usage` wie bei einer Chat-Antwort liest (`getPromptTokens()` gesamt, `getProcessedPromptTokens()` ausgewertet), bekommt hier zu kleine Gesamtzahlen. +- **Fix (Vorschlag, nicht umgesetzt):** `new Usage(processed + cached, predicted, cached)`; dann stimmen `getPromptTokens()`, `getCachedTokens()` und `getProcessedPromptTokens()` mit dem `Usage`-Vertrag überein. Das ändert dokumentiertes Verhalten (und `ServerMetricsTest`), daher Entscheidung des Nutzers. `getWindowUsage()` ist nicht betroffen: für das Fenster sendet upstream keinen Cache-Zähler. +- **Reproduktion:** ein Test mit `n_prompt_tokens_processed_total=10`, `n_prompt_tokens_cached_total=4` erwartet `getPromptTokens()==14` und `getCachedTokens()==4` – heute 10 und 0. Nicht geschrieben, solange die Richtung offen ist. +- **Rot-Grün-Nachweis:** keiner (kein Fix ohne Entscheidung). +- **Status:** nur beschrieben +- **Commit:** — + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 6c0dd2c914be09993f04ca818cb64b03daf623d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:05:16 +0000 Subject: [PATCH 060/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index c4e3f008a..a52b2b623 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -102,3 +102,6 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ChatResponse.java` · `7b68d5f` · 2026-10-17 · kein Fund — `choices` null → leere Liste; `getFirstMessage`/`getFirstContent` behandeln leere Antworten. - `llama/src/main/java/net/ladenthin/llama/value/LlamaOutput.java` · `e928537` · 2026-10-17 · kein Fund — reiner Träger, `toString` gibt den Text wie dokumentiert. - `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` · `cb51433` · 2026-10-17 · kein Fund — Namen gegen `stop_type_to_str` (b11639, `server-task.cpp`) abgeglichen: `eos`/`word`/`limit`, `none` und Unbekanntes → `NONE`. +- `llama/src/main/java/net/ladenthin/llama/value/ToolCall.java` · `acce5a3` · 2026-10-17 · kein Fund — unveränderlicher Träger (id, name, Argumente als JSON-Text); `equals`/`hashCode` über alle drei Felder. +- `llama/src/main/java/net/ladenthin/llama/value/ToolDefinition.java` · `1b8732b` · 2026-10-17 · kein Fund — unveränderlicher Träger; das Schema bleibt Text, die Prüfung leerer/ungültiger Schemata liegt in `ChatRequest.buildToolsJson` (F55). +- `llama/src/main/java/net/ladenthin/llama/value/SessionCheckpoint.java` · `15a5811` · 2026-10-17 · kein Fund — Runden werden kopiert und unveränderlich gespeichert, `Pair` ist unveränderlich; `toString` nennt Pfad und Rundenzahl. From ce6f1b49aad2b9300ae7ef820dec86cd50d6d189 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:17:40 +0000 Subject: [PATCH 061/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a52b2b623..6e27ea741 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -105,3 +105,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ToolCall.java` · `acce5a3` · 2026-10-17 · kein Fund — unveränderlicher Träger (id, name, Argumente als JSON-Text); `equals`/`hashCode` über alle drei Felder. - `llama/src/main/java/net/ladenthin/llama/value/ToolDefinition.java` · `1b8732b` · 2026-10-17 · kein Fund — unveränderlicher Träger; das Schema bleibt Text, die Prüfung leerer/ungültiger Schemata liegt in `ChatRequest.buildToolsJson` (F55). - `llama/src/main/java/net/ladenthin/llama/value/SessionCheckpoint.java` · `15a5811` · 2026-10-17 · kein Fund — Runden werden kopiert und unveränderlich gespeichert, `Pair` ist unveränderlich; `toString` nennt Pfad und Rundenzahl. +- `llama/src/main/java/net/ladenthin/llama/LlamaIterable.java` · `c7f2128` · 2026-10-17 · kein Fund — `close()` delegiert an `LlamaIterator.close()` (bricht nur ab, solange `hasNext`; mehrfach sicher, F36). `iterator()` liefert stets denselben Iterator, also ist das Iterable einmalig; die Javadoc verspricht nichts anderes. +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-17 · kein Fund — `closeableIterableFlow` öffnet die Quelle je Sammlung und schließt sie über `use` bei Ende, Fehler und Abbruch (ein `emit` nach Abbruch wirft `CancellationException`, `use` schließt). Abbruch wirkt erst nach Rückkehr des blockierenden `next()`, wie die KDoc mit dem Hinweis auf `flowOn(Dispatchers.IO)` sagt. From 66b35ba28a5a4477320a31a1b77828dc44401901 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:20:23 +0000 Subject: [PATCH 062/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ BUGHUNT-FOUND.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 6e27ea741..524890538 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -107,3 +107,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/SessionCheckpoint.java` · `15a5811` · 2026-10-17 · kein Fund — Runden werden kopiert und unveränderlich gespeichert, `Pair` ist unveränderlich; `toString` nennt Pfad und Rundenzahl. - `llama/src/main/java/net/ladenthin/llama/LlamaIterable.java` · `c7f2128` · 2026-10-17 · kein Fund — `close()` delegiert an `LlamaIterator.close()` (bricht nur ab, solange `hasNext`; mehrfach sicher, F36). `iterator()` liefert stets denselben Iterator, also ist das Iterable einmalig; die Javadoc verspricht nichts anderes. - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-17 · kein Fund — `closeableIterableFlow` öffnet die Quelle je Sammlung und schließt sie über `use` bei Ende, Fehler und Abbruch (ein `emit` nach Abbruch wirft `CancellationException`, `use` schließt). Abbruch wirkt erst nach Rückkehr des blockierenden `next()`, wie die KDoc mit dem Hinweis auf `flowOn(Dispatchers.IO)` sagt. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` · `21145a1` · 2026-10-17 · Fund F59 — Race `isRunning`/`close`. Sonst stimmen die Bindungen mit `jllama_api.h` überein: `start(argc, argv, out)`, `start_attached(model, argc, argv, out)`, `stop`, `is_running` mit `JAVA_BOOLEAN`, `set_worker_command` mit `""` für „löschen“ wie dokumentiert; `argv[0]` = Programmname. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` · `7b5e31f` · 2026-10-17 · kein Fund — Signaturen passen zu `jllama_rpc_server_serve/stop/listening/devices`; `cacheDir` darf null sein (C-Vertrag), `devices` null → `""`. Das bekannte `takeStrings`-Leck bei Anzahl 0 steht als offener Verdacht im Fundbuch. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6b2806f6e..5b8ebdf1c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -39,6 +39,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F55](#f55) — niedrig — *gefixt* - [F56](#f56) — niedrig — *gefixt* - [F57](#f57) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) +- [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) ## Funde @@ -326,6 +327,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** nur beschrieben - **Commit:** — + +### F59 – java-llama.cpp: `FfmNativeServer.isRunning()` kann nach einem parallelen `close()` freigegebenen Speicher lesen +- **Zeit:** 2026-10-17 21:25 UTC +- **Datei:** `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` (`isRunning`, `close`) +- **Schwere:** niedrig +- **Beschreibung:** `isRunning()` prüft `closed` und ruft dann `jllama_server_is_running(handle)`. `close()` setzt `closed` und ruft `jllama_server_stop`, das laut `jllama_api.h` den Handle freigibt. Läuft `close()` (etwa aus einem Shutdown-Hook) zwischen der Prüfung und dem nativen Aufruf eines anderen Threads, liest `jllama_server_is_running` einen freigegebenen `jllama_server` – undefiniertes Verhalten im nativen Code, schlimmstenfalls ein JVM-Absturz. Der JNI-Zwilling `server.NativeServer` hält genau deshalb `isRunning()` und `close()` unter einem gemeinsamen `lifecycleLock`; die FFM-Klasse dokumentiert keine Einschränkung. (`FfmLlamaModel` hat dasselbe Muster, schreibt aber ausdrücklich, dass `close()` keinen neuen Aufruf überholen darf – dort ein dokumentierter Vertrag, kein Fund.) +- **Fix (Vorschlag, nicht umgesetzt):** ein privates Lock-Objekt um den Rumpf von `isRunning()` und `close()`, wie in `NativeServer`; `close()` blockiert ohnehin bis zum Ende des Workers. +- **Reproduktion:** nicht geschrieben – braucht einen Fake hinter den Method-Handles oder eine echte Bibliothek mit JDK 22+. +- **Rot-Grün-Nachweis:** keiner; in dieser Sandbox gibt es kein JDK 22+ (siehe Erkenntnisse). +- **CI-Bedarf:** Test im Job `test-java-llama-ffm` (z. B. paketinterne Naht für die beiden Method-Handles, `isRunning` im Latch festhalten, `close()` parallel, Erwartung: `close()` wartet). +- **Status:** nur beschrieben +- **Commit:** — + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 62ffc91ba75768d1ff330b182eb525c1e9c782d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:22:49 +0000 Subject: [PATCH 063/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 524890538..01e94105a 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -109,3 +109,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-17 · kein Fund — `closeableIterableFlow` öffnet die Quelle je Sammlung und schließt sie über `use` bei Ende, Fehler und Abbruch (ein `emit` nach Abbruch wirft `CancellationException`, `use` schließt). Abbruch wirkt erst nach Rückkehr des blockierenden `next()`, wie die KDoc mit dem Hinweis auf `flowOn(Dispatchers.IO)` sagt. - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` · `21145a1` · 2026-10-17 · Fund F59 — Race `isRunning`/`close`. Sonst stimmen die Bindungen mit `jllama_api.h` überein: `start(argc, argv, out)`, `start_attached(model, argc, argv, out)`, `stop`, `is_running` mit `JAVA_BOOLEAN`, `set_worker_command` mit `""` für „löschen“ wie dokumentiert; `argv[0]` = Programmname. - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` · `7b5e31f` · 2026-10-17 · kein Fund — Signaturen passen zu `jllama_rpc_server_serve/stop/listening/devices`; `cacheDir` darf null sein (C-Vertrag), `devices` null → `""`. Das bekannte `takeStrings`-Leck bei Anzahl 0 steht als offener Verdacht im Fundbuch. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java` · `3353536` · 2026-10-17 · kein Fund — Naht ohne Logik; der Standard von `streamCompletions` wirft `UnsupportedOperationException`, die `OpenAiCompatServer.streamCompletions` als Server-Fehler-Ereignis im SSE-Strom meldet (Kopfzeilen sind dann schon gesendet), statt die Verbindung abzubrechen. Der einzige Produktions-Backend `LlamaModelBackend` überschreibt sie. From 8e3fe3ffd616d37bbf7f52a7ff7da5effb84634d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:24:34 +0000 Subject: [PATCH 064/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 01e94105a..bf5e545f2 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -110,3 +110,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` · `21145a1` · 2026-10-17 · Fund F59 — Race `isRunning`/`close`. Sonst stimmen die Bindungen mit `jllama_api.h` überein: `start(argc, argv, out)`, `start_attached(model, argc, argv, out)`, `stop`, `is_running` mit `JAVA_BOOLEAN`, `set_worker_command` mit `""` für „löschen“ wie dokumentiert; `argv[0]` = Programmname. - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` · `7b5e31f` · 2026-10-17 · kein Fund — Signaturen passen zu `jllama_rpc_server_serve/stop/listening/devices`; `cacheDir` darf null sein (C-Vertrag), `devices` null → `""`. Das bekannte `takeStrings`-Leck bei Anzahl 0 steht als offener Verdacht im Fundbuch. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java` · `3353536` · 2026-10-17 · kein Fund — Naht ohne Logik; der Standard von `streamCompletions` wirft `UnsupportedOperationException`, die `OpenAiCompatServer.streamCompletions` als Server-Fehler-Ereignis im SSE-Strom meldet (Kopfzeilen sind dann schon gesendet), statt die Verbindung abzubrechen. Der einzige Produktions-Backend `LlamaModelBackend` überschreibt sie. +- `llama/src/main/java/net/ladenthin/llama/args/ModelOption.java` · `729cbc6` · 2026-10-17 · kein Fund — 110 Konstanten, 110 verschiedene Namen, keiner doppelt mit `ModelFlag`; alle 110 Namen kommen in `common/arg.cpp` von b11639 vor (die Server-Parser-Zuordnung prüft `test_model_flags.cpp`). Randnotiz: die Javadoc von `ModelFlag.getCliFlag()` nennt `--flash-attn` als Beispiel, das ist seit der Umstellung ein `ModelOption` – Stil, kein Fund. From 4e137b0399b88ee869d46f463323caaec9d0c229 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:27:12 +0000 Subject: [PATCH 065/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index bf5e545f2..57fa0f274 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -111,3 +111,14 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` · `7b5e31f` · 2026-10-17 · kein Fund — Signaturen passen zu `jllama_rpc_server_serve/stop/listening/devices`; `cacheDir` darf null sein (C-Vertrag), `devices` null → `""`. Das bekannte `takeStrings`-Leck bei Anzahl 0 steht als offener Verdacht im Fundbuch. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java` · `3353536` · 2026-10-17 · kein Fund — Naht ohne Logik; der Standard von `streamCompletions` wirft `UnsupportedOperationException`, die `OpenAiCompatServer.streamCompletions` als Server-Fehler-Ereignis im SSE-Strom meldet (Kopfzeilen sind dann schon gesendet), statt die Verbindung abzubrechen. Der einzige Produktions-Backend `LlamaModelBackend` überschreibt sie. - `llama/src/main/java/net/ladenthin/llama/args/ModelOption.java` · `729cbc6` · 2026-10-17 · kein Fund — 110 Konstanten, 110 verschiedene Namen, keiner doppelt mit `ModelFlag`; alle 110 Namen kommen in `common/arg.cpp` von b11639 vor (die Server-Parser-Zuordnung prüft `test_model_flags.cpp`). Randnotiz: die Javadoc von `ModelFlag.getCliFlag()` nennt `--flash-attn` als Beispiel, das ist seit der Umstellung ein `ModelOption` – Stil, kein Fund. +- `llama/src/main/java/net/ladenthin/llama/args/Sampler.java` · `3d1661d` · 2026-10-17 · kein Fund — Alle neun Namen in `common_sampler_types_from_names` (b11639, `sampling.cpp`); upstream kennt zusätzlich `top_n_sigma` und `adaptive_p`, die hier fehlen – Vollständigkeit, kein Fund. `setSamplers` verbindet mit `;` wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/args/CacheType.java` · `e38c636` · 2026-10-17 · kein Fund — Genau die neun Typen von `kv_cache_types` in `common/arg.cpp` (b11639), gleiche Namen. +- `llama/src/main/java/net/ladenthin/llama/args/RopeScalingType.java` · `12ebd19` · 2026-10-17 · kein Fund — `--rope-scaling` nimmt nur none/linear/yarn; `ModelParameters.setRopeScaling` entfernt die Option bei `UNSPECIFIED` und wirft bei `LONGROPE`/`MAX_VALUE`, wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/args/LoadMode.java` · `8f8b7be` · 2026-10-17 · kein Fund — Werte auto/none/mmap/mlock/mmap+mlock/dio entsprechen `--load-mode` in `common/arg.cpp`. +- `llama/src/main/java/net/ladenthin/llama/args/ReasoningFormat.java` · `ea87677` · 2026-10-17 · kein Fund — none/auto/deepseek/deepseek-legacy entsprechen `common_reasoning_format_from_name` (b11639, `chat.cpp`). +- `llama/src/main/java/net/ladenthin/llama/args/GpuSplitMode.java` · `93b37e4` · 2026-10-17 · kein Fund — none/layer/row/tensor entsprechen `--split-mode`. +- `llama/src/main/java/net/ladenthin/llama/args/NumaStrategy.java` · `47fece9` · 2026-10-17 · kein Fund — distribute/isolate/numactl entsprechen `--numa`. +- `llama/src/main/java/net/ladenthin/llama/args/MiroStat.java` · `691102a` · 2026-10-17 · kein Fund — 0/1/2 entsprechen `--mirostat`. +- `llama/src/main/java/net/ladenthin/llama/args/LazyMode.java` · `d5225f0` · 2026-10-17 · kein Fund — off/auto/on entsprechen `--lazy-mode`. +- `llama/src/main/java/net/ladenthin/llama/args/FlashAttn.java` · `310dcc5` · 2026-10-17 · kein Fund — on/off/auto entsprechen `--flash-attn`. +- `llama/src/main/java/net/ladenthin/llama/args/DraftSampling.java` · `9c1a37f` · 2026-10-17 · kein Fund — greedy/probabilistic entsprechen der Draft-Sampling-Option in `common/arg.cpp`. From 81fad63145a51d9dd1e1bbdcc353a23c2e1746d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:39:33 +0000 Subject: [PATCH 066/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 57fa0f274..7e25c1aff 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -122,3 +122,11 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/args/LazyMode.java` · `d5225f0` · 2026-10-17 · kein Fund — off/auto/on entsprechen `--lazy-mode`. - `llama/src/main/java/net/ladenthin/llama/args/FlashAttn.java` · `310dcc5` · 2026-10-17 · kein Fund — on/off/auto entsprechen `--flash-attn`. - `llama/src/main/java/net/ladenthin/llama/args/DraftSampling.java` · `9c1a37f` · 2026-10-17 · kein Fund — greedy/probabilistic entsprechen der Draft-Sampling-Option in `common/arg.cpp`. +- `llama/src/main/java/net/ladenthin/llama/value/Pair.java` · `a209738` · 2026-10-17 · kein Fund — Unveränderlicher Träger, `equals`/`hashCode` über beide Felder. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/SlotOp.java` · `f6fdfd1` · 2026-10-17 · kein Fund — Codes 1/2/3 stimmen mit `jllama_slot_op` in `jllama_api.h` überein. +- `llama/src/main/java/net/ladenthin/llama/args/Optimizer.java` · `5d3e2a1` · 2026-10-17 · kein Fund — 0/1 entsprechen `GGML_OPT_OPTIMIZER_TYPE_ADAMW`/`SGD` (b11639, `ggml-opt.h`), die `train_engine.cpp` direkt übergibt. +- `llama/src/main/java/net/ladenthin/llama/args/ContinuationMode.java` · `b01f540` · 2026-10-17 · kein Fund — Zwei Feldnamen der Antwort, als Wert durchgereicht. +- `llama/src/main/java/net/ladenthin/llama/exception/ModelUnavailableException.java` · `9eebb86` · 2026-10-17 · kein Fund — Unterklasse von `LlamaException`, nur Konstruktoren; F56 sorgt dafür, dass `OfflineModelGuard` sie auch bei nicht darstellbaren Pfaden wirft. +- `llama/src/main/java/net/ladenthin/llama/exception/LlamaException.java` · `5af534c` · 2026-10-17 · kein Fund — Basisklasse, nur Konstruktoren. +- `llama/src/main/java/net/ladenthin/llama/exception/InvalidRequestException.java` · `971de14` · 2026-10-17 · kein Fund — Unterklasse von `LlamaException` für upstream-400; Zuordnung in `jllama.cpp`/`JllamaNative.check`. +- `llama/src/main/java/net/ladenthin/llama/value/LogLevel.java` · `6662b54` · 2026-10-17 · kein Fund — Vier Stufen, Abbildung der ggml-Stufen liegt im JNI-Code (außerhalb des Ziels). From 10ea06da896ea83512695a8d88766a188dc820cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:43:38 +0000 Subject: [PATCH 067/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7e25c1aff..a020a2dd0 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -130,3 +130,11 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/exception/LlamaException.java` · `5af534c` · 2026-10-17 · kein Fund — Basisklasse, nur Konstruktoren. - `llama/src/main/java/net/ladenthin/llama/exception/InvalidRequestException.java` · `971de14` · 2026-10-17 · kein Fund — Unterklasse von `LlamaException` für upstream-400; Zuordnung in `jllama.cpp`/`JllamaNative.check`. - `llama/src/main/java/net/ladenthin/llama/value/LogLevel.java` · `6662b54` · 2026-10-17 · kein Fund — Vier Stufen, Abbildung der ggml-Stufen liegt im JNI-Code (außerhalb des Ziels). +- `llama/src/main/java/net/ladenthin/llama/server/ChunkSink.java` · `02cb145` · 2026-10-17 · kein Fund — Funktionale Schnittstelle, `IOException` signalisiert einen getrennten Client. +- `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` · `9546b9a` · 2026-10-17 · kein Fund — Funktionale Schnittstelle für Werkzeugaufrufe. +- `llama/src/main/java/net/ladenthin/llama/callback/LoadProgressCallback.java` · `8eae161` · 2026-10-17 · kein Fund — `false` bricht das Laden ab (siehe Patch 0002 / C-API). +- `llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java` · `519fc3a` · 2026-10-17 · kein Fund — `b11639` stimmt mit `JLLAMA_GIT_TAG` in `llama/CMakeLists.txt` überein. +- `llama/src/main/java/net/ladenthin/llama/parameters/RequestContract.java` · `b216881` · 2026-10-17 · kein Fund — Zwei Vertragsarten der Request-Registry. +- `llama/src/main/java/net/ladenthin/llama/args/LogFormat.java` · `f3f742b` · 2026-10-17 · kein Fund — Zwei Formate. +- `llama/src/main/java/net/ladenthin/llama/args/CliContract.java` · `a288676` · 2026-10-17 · kein Fund — Zwei Vertragsarten der CLI-Registry. +- `llama/src/main/java/net/ladenthin/llama/args/CliArg.java` · `8516cfd` · 2026-10-17 · kein Fund — Schnittstelle für Enum-Werte auf der Kommandozeile. From 33119ba53318ff2347f21d3315ebfda88c796e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:45:37 +0000 Subject: [PATCH 068/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a020a2dd0..589accf5c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -138,3 +138,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/args/LogFormat.java` · `f3f742b` · 2026-10-17 · kein Fund — Zwei Formate. - `llama/src/main/java/net/ladenthin/llama/args/CliContract.java` · `a288676` · 2026-10-17 · kein Fund — Zwei Vertragsarten der CLI-Registry. - `llama/src/main/java/net/ladenthin/llama/args/CliArg.java` · `8516cfd` · 2026-10-17 · kein Fund — Schnittstelle für Enum-Werte auf der Kommandozeile. +- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Schnittstelle) · `b578b98`/`3d47cac`/`6bdd3ff` · 2026-10-17 · kein Fund — Rerank-Kette: `readDocuments` nimmt Strings oder `{text}`-Objekte, leere Liste → 400; die native Antwort aus `rerank_results_to_json` (`index`, `score`, `document`) wird zu `results`/`data` mit `relevance_score`, absteigend sortiert, `top_n ≤ 0` oder fehlend = alle. `requirePostJson`: Content-Length-Vorprüfung plus `BoundedInputStream`, leerer Body → `MissingNode` → 400; Bearer- und `x-api-key`-Vergleich in konstanter Zeit. From e93d99786cee7eb05e2c80c6d4b425c2506cc6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:53:28 +0000 Subject: [PATCH 069/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 589accf5c..7e14633d9 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -139,3 +139,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/args/CliContract.java` · `a288676` · 2026-10-17 · kein Fund — Zwei Vertragsarten der CLI-Registry. - `llama/src/main/java/net/ladenthin/llama/args/CliArg.java` · `8516cfd` · 2026-10-17 · kein Fund — Schnittstelle für Enum-Werte auf der Kommandozeile. - `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Schnittstelle) · `b578b98`/`3d47cac`/`6bdd3ff` · 2026-10-17 · kein Fund — Rerank-Kette: `readDocuments` nimmt Strings oder `{text}`-Objekte, leere Liste → 400; die native Antwort aus `rerank_results_to_json` (`index`, `score`, `document`) wird zu `results`/`data` mit `relevance_score`, absteigend sortiert, `top_n ≤ 0` oder fehlend = alle. `requirePostJson`: Content-Length-Vorprüfung plus `BoundedInputStream`, leerer Body → `MissingNode` → 400; Bearer- und `x-api-key`-Vergleich in konstanter Zeit. +- `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` + `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug) · `b69653a`/`9546b9a` · 2026-10-17 · kein Fund — `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. From a6939f5d7565d4f26576b79e9754ea6eae1adaee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:55:30 +0000 Subject: [PATCH 070/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7e14633d9..1cfd434b9 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -140,3 +140,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/args/CliArg.java` · `8516cfd` · 2026-10-17 · kein Fund — Schnittstelle für Enum-Werte auf der Kommandozeile. - `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Schnittstelle) · `b578b98`/`3d47cac`/`6bdd3ff` · 2026-10-17 · kein Fund — Rerank-Kette: `readDocuments` nimmt Strings oder `{text}`-Objekte, leere Liste → 400; die native Antwort aus `rerank_results_to_json` (`index`, `score`, `document`) wird zu `results`/`data` mit `relevance_score`, absteigend sortiert, `top_n ≤ 0` oder fehlend = alle. `requirePostJson`: Content-Length-Vorprüfung plus `BoundedInputStream`, leerer Body → `MissingNode` → 400; Bearer- und `x-api-key`-Vergleich in konstanter Zeit. - `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` + `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug) · `b69653a`/`9546b9a` · 2026-10-17 · kein Fund — `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Schnittstelle OAI-Request ↔ `InferenceParameters`) · `5bddfea` · 2026-10-17 · kein Fund — `messages` leer → 400; `max_completion_tokens` vor `max_tokens`, nur > 0 übernommen; `stop` als String oder String-Array; `tool_choice` nur als String, wie upstreams `oaicompat_chat_params_parse` (`json_value(body, "tool_choice", "auto")`) ihn liest. `seed` über `asInt()`: Werte bis 2^32 kommen bitgleich an, weil upstreams `field_num("seed", …)` in `uint32_t` liest (b11639, `server-schema.cpp`). From 35af747acdb9eb15ab0d1faabe5eee0ca935eca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 21:57:45 +0000 Subject: [PATCH 071/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 1cfd434b9..af68e9ce8 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -141,3 +141,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Schnittstelle) · `b578b98`/`3d47cac`/`6bdd3ff` · 2026-10-17 · kein Fund — Rerank-Kette: `readDocuments` nimmt Strings oder `{text}`-Objekte, leere Liste → 400; die native Antwort aus `rerank_results_to_json` (`index`, `score`, `document`) wird zu `results`/`data` mit `relevance_score`, absteigend sortiert, `top_n ≤ 0` oder fehlend = alle. `requirePostJson`: Content-Length-Vorprüfung plus `BoundedInputStream`, leerer Body → `MissingNode` → 400; Bearer- und `x-api-key`-Vergleich in konstanter Zeit. - `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` + `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug) · `b69653a`/`9546b9a` · 2026-10-17 · kein Fund — `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Schnittstelle OAI-Request ↔ `InferenceParameters`) · `5bddfea` · 2026-10-17 · kein Fund — `messages` leer → 400; `max_completion_tokens` vor `max_tokens`, nur > 0 übernommen; `stop` als String oder String-Array; `tool_choice` nur als String, wie upstreams `oaicompat_chat_params_parse` (`json_value(body, "tool_choice", "auto")`) ihn liest. `seed` über `asInt()`: Werte bis 2^32 kommen bitgleich an, weil upstreams `field_num("seed", …)` in `uint32_t` liest (b11639, `server-schema.cpp`). +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Schnittstelle Ollama ↔ OpenAI-Kern) · `c1840dc` · 2026-10-17 · kein Fund (Verdacht notiert) — `options`-Abbildung: `temperature`/`top_p`/`top_k`/`seed`, `num_predict` → `max_tokens` (Ollamas -1/-2 fallen in `readMaxTokens` unter „nur > 0“ und lassen upstreams Standard -1 stehen), `stop` als Array. `done_reason` siehe offener Verdacht im Fundbuch. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5b8ebdf1c..f9fe8f89d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -343,3 +343,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. +- *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. From 29ba25c1a280ce077b687fb9161e94af73c1a617 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:04:54 +0000 Subject: [PATCH 072/589] Keep the text blocks of an Anthropic message apart with a newline The /v1/messages translation concatenated a message's text blocks with no separator, so blocks such as a reminder followed by the user's own text reached the model as one run-together word at the seam ('first' + 'second' became 'firstsecond'). llama.cpp's own /v1/messages keeps the blocks as content parts and joins them with a newline (concat_content_parts in common/chat.cpp); the text path now does the same. The system blocks are left as they are: upstream concatenates those without a separator too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/AnthropicApiSupport.java | 4 ++++ .../llama/server/AnthropicApiSupportTest.java | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 4c59b6c3a..b59c4eec1 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -143,6 +143,10 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag String type = block.path("type").asText(""); switch (type) { case "text": + // Joined with a newline, as llama.cpp's concat_content_parts joins text parts. + if (text.length() > 0) { + text.append('\n'); + } text.append(block.path("text").asText("")); parts.addObject() .put("type", "text") diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 4d9d16a97..3afc1b022 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -45,6 +45,21 @@ public void requestTurnsImageBlocksIntoImageUrlParts() throws IOException { assertThat(content.get(1).path("image_url").path("url").asText(), is("data:image/png;base64,iVBORw0K")); } + /** + * Separate text blocks of one message must stay separate words. Anthropic clients split a turn into + * several blocks (a reminder block, then the user's own text); llama.cpp's own {@code /v1/messages} + * keeps them as content parts and joins them with a newline ({@code concat_content_parts} in + * {@code common/chat.cpp}). Concatenated without a separator, {@code "first"} and {@code "second"} + * reached the model as {@code "firstsecond"}. + */ + @Test + public void requestJoinsTheTextBlocksOfOneMessageWithANewline() throws IOException { + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[{\"role\":\"user\"," + + "\"content\":[{\"type\":\"text\",\"text\":\"first\"},{\"type\":\"text\",\"text\":\"second\"}]}]}")); + + assertThat(openAi.path("messages").path(0).path("content").asText(), is("first\nsecond")); + } + @Test public void isStreamingDefaultsFalse() throws IOException { assertThat(AnthropicApiSupport.isStreaming(read("{}")), is(false)); From d3adb8627ac033e00bc5586d62015e8c1fe17cd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:05:05 +0000 Subject: [PATCH 073/589] Record the commit of F60 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index af68e9ce8..64563c1ce 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -142,3 +142,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` + `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug) · `b69653a`/`9546b9a` · 2026-10-17 · kein Fund — `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Schnittstelle OAI-Request ↔ `InferenceParameters`) · `5bddfea` · 2026-10-17 · kein Fund — `messages` leer → 400; `max_completion_tokens` vor `max_tokens`, nur > 0 übernommen; `stop` als String oder String-Array; `tool_choice` nur als String, wie upstreams `oaicompat_chat_params_parse` (`json_value(body, "tool_choice", "auto")`) ihn liest. `seed` über `asInt()`: Werte bis 2^32 kommen bitgleich an, weil upstreams `field_num("seed", …)` in `uint32_t` liest (b11639, `server-schema.cpp`). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Schnittstelle Ollama ↔ OpenAI-Kern) · `c1840dc` · 2026-10-17 · kein Fund (Verdacht notiert) — `options`-Abbildung: `temperature`/`top_p`/`top_k`/`seed`, `num_predict` → `max_tokens` (Ollamas -1/-2 fallen in `readMaxTokens` unter „nur > 0“ und lassen upstreams Standard -1 stehen), `stop` als Array. `done_reason` siehe offener Verdacht im Fundbuch. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `b59c4ee` · 2026-10-17 · Fund F60 — Textblöcke ohne Trenner. Gegen upstreams `server_chat_convert_anthropic_to_oai` (b11639) abgeglichen: `tool_use` → `tool_calls` mit `input` als JSON-Text, `tool_result` → eigene `role:tool`-Nachricht vor dem Text, Bilder als `image_url` (base64 → Data-URL), System-Blöcke ohne Trenner wie upstream; upstream liest zusätzlich `thinking`-Blöcke in `reasoning_content`, die hier wegfallen – fehlende Funktion, kein Fehler. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f9fe8f89d..20c5eeb82 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -40,6 +40,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F56](#f56) — niedrig — *gefixt* - [F57](#f57) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) - [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) +- [F60](#f60) — niedrig — *gefixt* ## Funde @@ -340,6 +341,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** nur beschrieben - **Commit:** — + +### F60 – java-llama.cpp: Textblöcke einer Anthropic-Nachricht werden ohne Trenner zusammengeklebt +- **Zeit:** 2026-10-17 22:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:145` (`appendOpenAiMessages`, Fall `text`) +- **Schwere:** niedrig +- **Beschreibung:** `/v1/messages` übersetzt eine Nachricht mit mehreren `text`-Blöcken in einen OpenAI-String, hängte die Blöcke aber ohne Trenner aneinander. Anthropic-Clients teilen einen Zug regelmäßig in mehrere Blöcke (Hinweisblock, dann der eigentliche Text); an der Nahtstelle verschmolzen Wörter (`first` + `second` → `firstsecond`). llama.cpps eigenes `/v1/messages` behält die Blöcke als Content-Parts und verbindet sie beim Rendern mit einem Zeilenumbruch (`concat_content_parts` in `common/chat.cpp`, b11639). Der Bildpfad derselben Methode reicht bereits Parts weiter und war nicht betroffen; die System-Blöcke verbindet upstream ebenfalls ohne Trenner, sie bleiben unverändert. +- **Fix:** Vor jedem weiteren Textblock ein `\n`, wie upstream. +- **Reproduktion:** `AnthropicApiSupportTest#requestJoinsTheTextBlocksOfOneMessageWithANewline` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, 14 Tests, 1 Fehlschlag), mit Fix grün 14/14; Gegenprobe per `git stash` rot, nach `stash pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `76a3370` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 196e76f0c49a69c91911e2569e0d66c9ae1cdb0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:12:03 +0000 Subject: [PATCH 074/589] Keep the text parts of a Responses input item apart with a newline The /v1/responses translation concatenated the text parts of an input item with no separator, so 'first' and 'second' reached the model as 'firstsecond'. llama.cpp's own /v1/responses keeps them as content parts, which are joined with a newline (concat_content_parts in common/chat.cpp); the text path now does the same, like the /v1/messages fix before it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/ResponsesApiSupport.java | 4 ++++ .../llama/server/ResponsesApiSupportTest.java | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index ac50ae451..1365c84d2 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -188,6 +188,10 @@ private static String inputContentText(JsonNode content) { StringBuilder sb = new StringBuilder(); for (JsonNode part : content) { if (part.path("text").isTextual()) { + // Joined with a newline, as llama.cpp's concat_content_parts joins text parts. + if (sb.length() > 0) { + sb.append('\n'); + } sb.append(part.path("text").asText()); } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 101a8b96a..8272ecbe8 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -24,6 +24,21 @@ private static JsonNode read(String json) throws IOException { return MAPPER.readTree(json); } + /** + * The text parts of one input item must stay separate words. llama.cpp's own {@code /v1/responses} + * keeps them as content parts, which are joined with a newline ({@code concat_content_parts} in + * {@code common/chat.cpp}); concatenated without a separator, {@code "first"} and {@code "second"} + * reached the model as {@code "firstsecond"}. + */ + @Test + public void requestJoinsTheTextPartsOfOneInputItemWithANewline() throws IOException { + JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[{\"type\":\"message\"," + + "\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"first\"}," + + "{\"type\":\"input_text\",\"text\":\"second\"}]}]}")); + + assertThat(openAi.path("messages").path(0).path("content").asText(), is("first\nsecond")); + } + @Test public void isStreamingDefaultsFalse() throws IOException { assertThat(ResponsesApiSupport.isStreaming(read("{}")), is(false)); From 17c36eb978d8c936b14b6a01e4da8dda724220f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:12:03 +0000 Subject: [PATCH 075/589] Record the commit of F61 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 64563c1ce..ebfd05911 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -143,3 +143,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Schnittstelle OAI-Request ↔ `InferenceParameters`) · `5bddfea` · 2026-10-17 · kein Fund — `messages` leer → 400; `max_completion_tokens` vor `max_tokens`, nur > 0 übernommen; `stop` als String oder String-Array; `tool_choice` nur als String, wie upstreams `oaicompat_chat_params_parse` (`json_value(body, "tool_choice", "auto")`) ihn liest. `seed` über `asInt()`: Werte bis 2^32 kommen bitgleich an, weil upstreams `field_num("seed", …)` in `uint32_t` liest (b11639, `server-schema.cpp`). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Schnittstelle Ollama ↔ OpenAI-Kern) · `c1840dc` · 2026-10-17 · kein Fund (Verdacht notiert) — `options`-Abbildung: `temperature`/`top_p`/`top_k`/`seed`, `num_predict` → `max_tokens` (Ollamas -1/-2 fallen in `readMaxTokens` unter „nur > 0“ und lassen upstreams Standard -1 stehen), `stop` als Array. `done_reason` siehe offener Verdacht im Fundbuch. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `b59c4ee` · 2026-10-17 · Fund F60 — Textblöcke ohne Trenner. Gegen upstreams `server_chat_convert_anthropic_to_oai` (b11639) abgeglichen: `tool_use` → `tool_calls` mit `input` als JSON-Text, `tool_result` → eigene `role:tool`-Nachricht vor dem Text, Bilder als `image_url` (base64 → Data-URL), System-Blöcke ohne Trenner wie upstream; upstream liest zusätzlich `thinking`-Blöcke in `reasoning_content`, die hier wegfallen – fehlende Funktion, kein Fehler. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1365c84` · 2026-10-17 · Fund F61 — Textteile ohne Trenner. Musterdurchsicht nach F60 über alle drei Übersetzungsschichten: Anthropic (F60) und Responses (F61) betroffen; Ollama reicht `content` als einen String durch, kein Zusammenfügen. `instructions` → System-Nachricht, `function_call`/`function_call_output` → `tool_calls`/`role:tool` wie upstream. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 20c5eeb82..e90708273 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -41,6 +41,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F57](#f57) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) - [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) - [F60](#f60) — niedrig — *gefixt* +- [F61](#f61) — niedrig — *gefixt* ## Funde @@ -353,6 +354,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `76a3370` + +### F61 – java-llama.cpp: Textteile eines Responses-Eingabeelements werden ohne Trenner zusammengeklebt +- **Zeit:** 2026-10-17 22:15 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:183` (`inputContentText`) +- **Schwere:** niedrig +- **Beschreibung:** Musterdurchsicht nach F60. `/v1/responses` verband die `input_text`-Teile eines Eingabeelements ohne Trenner, sodass an der Naht Wörter verschmolzen (`first` + `second` → `firstsecond`). Upstreams `server_chat_convert_responses_to_chatcmpl` (b11639, `server-chat.cpp`) behält die Teile als Content-Parts, die `concat_content_parts` in `common/chat.cpp` mit einem Zeilenumbruch verbindet. Der Bildpfad (`inputContentWithImages`) reicht bereits Parts weiter und war nicht betroffen. +- **Fix:** Vor jedem weiteren Textteil ein `\n`, wie upstream und wie F60. +- **Reproduktion:** `ResponsesApiSupportTest#requestJoinsTheTextPartsOfOneInputItemWithANewline` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, 11 Tests, 1 Fehlschlag), mit Fix grün 11/11; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `745fed5` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 311070b082307e7f810385e528ac65472fac2701 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:12:52 +0000 Subject: [PATCH 076/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ebfd05911..713445a94 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -144,3 +144,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Schnittstelle Ollama ↔ OpenAI-Kern) · `c1840dc` · 2026-10-17 · kein Fund (Verdacht notiert) — `options`-Abbildung: `temperature`/`top_p`/`top_k`/`seed`, `num_predict` → `max_tokens` (Ollamas -1/-2 fallen in `readMaxTokens` unter „nur > 0“ und lassen upstreams Standard -1 stehen), `stop` als Array. `done_reason` siehe offener Verdacht im Fundbuch. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `b59c4ee` · 2026-10-17 · Fund F60 — Textblöcke ohne Trenner. Gegen upstreams `server_chat_convert_anthropic_to_oai` (b11639) abgeglichen: `tool_use` → `tool_calls` mit `input` als JSON-Text, `tool_result` → eigene `role:tool`-Nachricht vor dem Text, Bilder als `image_url` (base64 → Data-URL), System-Blöcke ohne Trenner wie upstream; upstream liest zusätzlich `thinking`-Blöcke in `reasoning_content`, die hier wegfallen – fehlende Funktion, kein Fehler. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1365c84` · 2026-10-17 · Fund F61 — Textteile ohne Trenner. Musterdurchsicht nach F60 über alle drei Übersetzungsschichten: Anthropic (F60) und Responses (F61) betroffen; Ollama reicht `content` als einen String durch, kein Zusammenfügen. `instructions` → System-Nachricht, `function_call`/`function_call_output` → `tool_calls`/`role:tool` wie upstream. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · Fund F62 (nur beschrieben) — Musterdurchsicht nach F60/F61: mehrere `TextContent` ohne Trenner, von einem Test festgeschrieben; Entscheidung des Nutzers. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e90708273..01f5bbcf5 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -42,6 +42,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) - [F60](#f60) — niedrig — *gefixt* - [F61](#f61) — niedrig — *gefixt* +- [F62](#f62) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) ## Funde @@ -366,6 +367,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `745fed5` + +### F62 – java-llama.cpp: LangChain4j-Adapter klebt mehrere `TextContent` ohne Trenner zusammen +- **Zeit:** 2026-10-17 22:20 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:261` (`toJllamaUserMessage`) +- **Schwere:** niedrig +- **Beschreibung:** Musterdurchsicht nach F60/F61. Eine `UserMessage` aus mehreren `TextContent` ohne Medien wird zu einem String ohne Trenner (`"Hello"` + `"world"` → `"Helloworld"`). Enthält dieselbe Nachricht zusätzlich ein Bild, bleiben die Teile Content-Parts, die llama.cpp mit `\n` verbindet (`concat_content_parts`) – ein Bild ändert also, wie die Texte zusammenkommen. Anders als bei F60/F61 gibt es kein upstream-Gegenstück als Maßstab, und der Test `LangChain4jMappingTest#flattensMultiTextUserMessageToText` schreibt das heutige Verhalten mit `"Hello "` (Leerzeichen am Ende) fest. +- **Fix (Vorschlag, nicht umgesetzt):** wie F60/F61 mit `\n` verbinden (oder die Teile als Parts weiterreichen); dabei den festschreibenden Test anpassen. Entscheidung des Nutzers, weil der Test das Verhalten bewusst zu wählen scheint. +- **Reproduktion:** `UserMessage.from(TextContent.from("Hello"), TextContent.from("world"))` → `getContent()` = `"Helloworld"`. +- **Rot-Grün-Nachweis:** keiner (kein Fix ohne Entscheidung). +- **Status:** nur beschrieben +- **Commit:** — + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 6b2ce7bb74d7dc54e3a2eaa1a7238fc0cb09679a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:14:33 +0000 Subject: [PATCH 077/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 713445a94..76919cc96 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -145,3 +145,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `b59c4ee` · 2026-10-17 · Fund F60 — Textblöcke ohne Trenner. Gegen upstreams `server_chat_convert_anthropic_to_oai` (b11639) abgeglichen: `tool_use` → `tool_calls` mit `input` als JSON-Text, `tool_result` → eigene `role:tool`-Nachricht vor dem Text, Bilder als `image_url` (base64 → Data-URL), System-Blöcke ohne Trenner wie upstream; upstream liest zusätzlich `thinking`-Blöcke in `reasoning_content`, die hier wegfallen – fehlende Funktion, kein Fehler. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1365c84` · 2026-10-17 · Fund F61 — Textteile ohne Trenner. Musterdurchsicht nach F60 über alle drei Übersetzungsschichten: Anthropic (F60) und Responses (F61) betroffen; Ollama reicht `content` als einen String durch, kein Zusammenfügen. `instructions` → System-Nachricht, `function_call`/`function_call_output` → `tool_calls`/`role:tool` wie upstream. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · Fund F62 (nur beschrieben) — Musterdurchsicht nach F60/F61: mehrere `TextContent` ohne Trenner, von einem Test festgeschrieben; Entscheidung des Nutzers. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Antwortseite gegen upstream `to_json_anthropic`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — `stop_reason` (`length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn`) und `usage` (`input_tokens` = prompt − cached, `cache_read_input_tokens`, `output_tokens`) entsprechen upstream; `tool_use.input` fällt bei unparsbaren Argumenten auf `{}` zurück wie upstream. Abweichungen ohne Fehlerfolge: upstream setzt `stop_sequence` auf das auslösende Stoppwort und gibt `reasoning_content` als `thinking`-Block vor dem Text aus; beides fehlt hier, weil die OpenAI-Antwort das Stoppwort nicht trägt bzw. die Abbildung `reasoning_content` nicht liest – fehlende Funktion. From fffb318d04eee7409dbd4deec8bbf92f6e44e3f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:21:54 +0000 Subject: [PATCH 078/589] Stamp Responses API objects with the current time instead of 0 created_at is the Unix time a response was created, in the Responses API and in llama.cpp's own /v1/responses. The shell every /v1/responses object and event is built from set it to the constant 0, so clients showed each response as created on 1 January 1970. The other surfaces of the server already use the current time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/ResponsesApiSupport.java | 2 +- .../llama/server/ResponsesApiSupportTest.java | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 1365c84d2..b755abd17 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -283,7 +283,7 @@ static ObjectNode newResponseShell(String model, String responseId, String statu ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("id", responseId); root.put("object", "response"); - root.put("created_at", 0); + root.put("created_at", System.currentTimeMillis() / 1000L); root.put("status", status); root.put("model", model); return root; diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 8272ecbe8..cdbb417ef 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -5,7 +5,10 @@ package net.ladenthin.llama.server; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.both; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThanOrEqualTo; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -39,6 +42,22 @@ public void requestJoinsTheTextPartsOfOneInputItemWithANewline() throws IOExcept assertThat(openAi.path("messages").path(0).path("content").asText(), is("first\nsecond")); } + /** + * {@code created_at} is the Unix time the response was created, as in the Responses API and in + * llama.cpp's own {@code /v1/responses} ({@code to_json_oaicompat_resp}). It was the constant + * {@code 0}, so clients showed every response as created on 1 January 1970. + */ + @Test + public void responseCarriesTheCurrentUnixTimeAsCreatedAt() throws IOException { + long before = System.currentTimeMillis() / 1000L; + + JsonNode response = read(ResponsesApiSupport.toResponsesResponse("{}", "m", "resp_1")); + + long createdAt = response.path("created_at").asLong(); + long after = System.currentTimeMillis() / 1000L; + assertThat(createdAt, both(greaterThanOrEqualTo(before)).and(lessThanOrEqualTo(after))); + } + @Test public void isStreamingDefaultsFalse() throws IOException { assertThat(ResponsesApiSupport.isStreaming(read("{}")), is(false)); From 9244d9d341cc0f9be55e70dbe55e5596814e5fc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:21:54 +0000 Subject: [PATCH 079/589] Record the commit of F63 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 76919cc96..812447465 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -146,3 +146,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1365c84` · 2026-10-17 · Fund F61 — Textteile ohne Trenner. Musterdurchsicht nach F60 über alle drei Übersetzungsschichten: Anthropic (F60) und Responses (F61) betroffen; Ollama reicht `content` als einen String durch, kein Zusammenfügen. `instructions` → System-Nachricht, `function_call`/`function_call_output` → `tool_calls`/`role:tool` wie upstream. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · Fund F62 (nur beschrieben) — Musterdurchsicht nach F60/F61: mehrere `TextContent` ohne Trenner, von einem Test festgeschrieben; Entscheidung des Nutzers. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Antwortseite gegen upstream `to_json_anthropic`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — `stop_reason` (`length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn`) und `usage` (`input_tokens` = prompt − cached, `cache_read_input_tokens`, `output_tokens`) entsprechen upstream; `tool_use.input` fällt bei unparsbaren Argumenten auf `{}` zurück wie upstream. Abweichungen ohne Fehlerfolge: upstream setzt `stop_sequence` auf das auslösende Stoppwort und gibt `reasoning_content` als `thinking`-Block vor dem Text aus; beides fehlt hier, weil die OpenAI-Antwort das Stoppwort nicht trägt bzw. die Abbildung `reasoning_content` nicht liest – fehlende Funktion. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `b755abd` · 2026-10-17 · Fund F63 — Antwortseite gegen upstreams `to_json_oaicompat_resp`: `created_at` war 0. `status` immer `completed` und `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `input_tokens_details.cached_tokens`) wie upstream; upstream gibt zusätzlich ein `reasoning`-Ausgabeelement aus `reasoning_content` aus, das hier fehlt – fehlende Funktion. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 01f5bbcf5..ddb7330c3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -43,6 +43,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F60](#f60) — niedrig — *gefixt* - [F61](#f61) — niedrig — *gefixt* - [F62](#f62) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) +- [F63](#f63) — niedrig — *gefixt* ## Funde @@ -379,6 +380,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** nur beschrieben - **Commit:** — + +### F63 – java-llama.cpp: Responses-API-Objekte tragen `created_at: 0` +- **Zeit:** 2026-10-17 22:25 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:286` (`newResponseShell`) +- **Schwere:** niedrig +- **Beschreibung:** `created_at` ist in der Responses-API und in llama.cpps eigenem `/v1/responses` (`to_json_oaicompat_resp`, b11639: `{"created_at", t}`) die Unix-Zeit der Erstellung. Die Schale, aus der jede Antwort und jedes Stream-Ereignis von `/v1/responses` entsteht, setzte die Konstante `0`; Clients zeigten jede Antwort als am 1. Januar 1970 erstellt. Die übrigen Oberflächen des Servers (`LlamaModelBackend`, Ollama) verwenden die aktuelle Zeit. +- **Fix:** `System.currentTimeMillis() / 1000L`. +- **Reproduktion:** `ResponsesApiSupportTest#responseCarriesTheCurrentUnixTimeAsCreatedAt` +- **Rot-Grün-Nachweis:** ohne Fix rot („a value equal to or greater than <1792275470L> <0L> was less than <1792275470L>“, 12 Tests, 1 Fehlschlag), mit Fix grün 12/12; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `aba327e` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 13d11b6d452d20904dbd65171a9599ba2cb6cccc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:24:36 +0000 Subject: [PATCH 080/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 812447465..78d3d25f4 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -147,3 +147,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · Fund F62 (nur beschrieben) — Musterdurchsicht nach F60/F61: mehrere `TextContent` ohne Trenner, von einem Test festgeschrieben; Entscheidung des Nutzers. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Antwortseite gegen upstream `to_json_anthropic`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — `stop_reason` (`length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn`) und `usage` (`input_tokens` = prompt − cached, `cache_read_input_tokens`, `output_tokens`) entsprechen upstream; `tool_use.input` fällt bei unparsbaren Argumenten auf `{}` zurück wie upstream. Abweichungen ohne Fehlerfolge: upstream setzt `stop_sequence` auf das auslösende Stoppwort und gibt `reasoning_content` als `thinking`-Block vor dem Text aus; beides fehlt hier, weil die OpenAI-Antwort das Stoppwort nicht trägt bzw. die Abbildung `reasoning_content` nicht liest – fehlende Funktion. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `b755abd` · 2026-10-17 · Fund F63 — Antwortseite gegen upstreams `to_json_oaicompat_resp`: `created_at` war 0. `status` immer `completed` und `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `input_tokens_details.cached_tokens`) wie upstream; upstream gibt zusätzlich ein `reasoning`-Ausgabeelement aus `reasoning_content` aus, das hier fehlt – fehlende Funktion. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Schnittstelle Stream-Chunks → Responses-Ereignisse) · `51665a1` · 2026-10-17 · kein Fund — Ereignisfolge `response.created` → bei erstem Text `output_item.added` + `content_part.added` → `output_text.delta` → am Ende `output_text.done`, `content_part.done`, `output_item.done`, je Werkzeugaufruf `output_item.added`/`function_call_arguments.done`/`output_item.done`, dann `response.completed` mit gesammeltem `output` und `usage`; `output_index` und `sequence_number` streng aufsteigend. Die Nutzungsdaten kommen an, weil `OpenAiCompatServer.withUsageChunk` für die übersetzten Routen `include_usage` erzwingt. Keine `function_call_arguments.delta`-Ereignisse (Argumente erst am Ende) – zulässig, weil `.done` die vollständigen Argumente trägt. From 3b59d1bff49f7c6fbbdf9a079f78cdfcd40d9ab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:26:26 +0000 Subject: [PATCH 081/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 78d3d25f4..cec1b5917 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -148,3 +148,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Antwortseite gegen upstream `to_json_anthropic`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — `stop_reason` (`length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn`) und `usage` (`input_tokens` = prompt − cached, `cache_read_input_tokens`, `output_tokens`) entsprechen upstream; `tool_use.input` fällt bei unparsbaren Argumenten auf `{}` zurück wie upstream. Abweichungen ohne Fehlerfolge: upstream setzt `stop_sequence` auf das auslösende Stoppwort und gibt `reasoning_content` als `thinking`-Block vor dem Text aus; beides fehlt hier, weil die OpenAI-Antwort das Stoppwort nicht trägt bzw. die Abbildung `reasoning_content` nicht liest – fehlende Funktion. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `b755abd` · 2026-10-17 · Fund F63 — Antwortseite gegen upstreams `to_json_oaicompat_resp`: `created_at` war 0. `status` immer `completed` und `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `input_tokens_details.cached_tokens`) wie upstream; upstream gibt zusätzlich ein `reasoning`-Ausgabeelement aus `reasoning_content` aus, das hier fehlt – fehlende Funktion. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Schnittstelle Stream-Chunks → Responses-Ereignisse) · `51665a1` · 2026-10-17 · kein Fund — Ereignisfolge `response.created` → bei erstem Text `output_item.added` + `content_part.added` → `output_text.delta` → am Ende `output_text.done`, `content_part.done`, `output_item.done`, je Werkzeugaufruf `output_item.added`/`function_call_arguments.done`/`output_item.done`, dann `response.completed` mit gesammeltem `output` und `usage`; `output_index` und `sequence_number` streng aufsteigend. Die Nutzungsdaten kommen an, weil `OpenAiCompatServer.withUsageChunk` für die übersetzten Routen `include_usage` erzwingt. Keine `function_call_arguments.delta`-Ereignisse (Argumente erst am Ende) – zulässig, weil `.done` die vollständigen Argumente trägt. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Schnittstelle Stream-Chunks → Anthropic-SSE) · `7f002e9` · 2026-10-17 · kein Fund — Folge `message_start` → Textblock (`content_block_start`/`text_delta`) → je Werkzeugaufruf `tool_use`-Block mit `input: {}`, einem `input_json_delta` mit den vollständigen Argumenten und `content_block_stop` → `message_delta` (`stop_reason` wie die Nicht-Stream-Antwort, Nutzung aus dem erzwungenen Usage-Chunk, `input_tokens` ohne Cache) → `message_stop`; Blockindizes fortlaufend. Leere Argumente ergeben ein leeres `partial_json`, das die Clients mit dem Start-`input` `{}` überbrücken. From 618c6623665e69f8ffc84eebd61e501dff823856 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:28:44 +0000 Subject: [PATCH 082/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index cec1b5917..3e2fbc4fd 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -149,3 +149,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `b755abd` · 2026-10-17 · Fund F63 — Antwortseite gegen upstreams `to_json_oaicompat_resp`: `created_at` war 0. `status` immer `completed` und `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `input_tokens_details.cached_tokens`) wie upstream; upstream gibt zusätzlich ein `reasoning`-Ausgabeelement aus `reasoning_content` aus, das hier fehlt – fehlende Funktion. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Schnittstelle Stream-Chunks → Responses-Ereignisse) · `51665a1` · 2026-10-17 · kein Fund — Ereignisfolge `response.created` → bei erstem Text `output_item.added` + `content_part.added` → `output_text.delta` → am Ende `output_text.done`, `content_part.done`, `output_item.done`, je Werkzeugaufruf `output_item.added`/`function_call_arguments.done`/`output_item.done`, dann `response.completed` mit gesammeltem `output` und `usage`; `output_index` und `sequence_number` streng aufsteigend. Die Nutzungsdaten kommen an, weil `OpenAiCompatServer.withUsageChunk` für die übersetzten Routen `include_usage` erzwingt. Keine `function_call_arguments.delta`-Ereignisse (Argumente erst am Ende) – zulässig, weil `.done` die vollständigen Argumente trägt. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Schnittstelle Stream-Chunks → Anthropic-SSE) · `7f002e9` · 2026-10-17 · kein Fund — Folge `message_start` → Textblock (`content_block_start`/`text_delta`) → je Werkzeugaufruf `tool_use`-Block mit `input: {}`, einem `input_json_delta` mit den vollständigen Argumenten und `content_block_stop` → `message_delta` (`stop_reason` wie die Nicht-Stream-Antwort, Nutzung aus dem erzwungenen Usage-Chunk, `input_tokens` ohne Cache) → `message_stop`; Blockindizes fortlaufend. Leere Argumente ergeben ein leeres `partial_json`, das die Clients mit dem Start-`input` `{}` überbrücken. +- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `58a9bab` · 2026-10-17 · kein Fund — Upstream (`common/chat.cpp` Diff, `server-chat.cpp` Ausgabe) sendet bei geändertem Namen oder geänderter ID den **vollständigen** neuen Wert (ein wachsender Name kommt also ganz, nicht als Anhang), die Argumente dagegen als Differenz, und leere Felder gar nicht. Genau so verarbeitet der Akkumulator: `id`/`name` überschreiben, `arguments` anhängen, je `index` getrennt, in Indexreihenfolge ausgegeben (`TreeMap`). From e42a67412346692f06b330eb92dbfc831cccd595 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:30:15 +0000 Subject: [PATCH 083/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3e2fbc4fd..92fb33605 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -150,3 +150,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Schnittstelle Stream-Chunks → Responses-Ereignisse) · `51665a1` · 2026-10-17 · kein Fund — Ereignisfolge `response.created` → bei erstem Text `output_item.added` + `content_part.added` → `output_text.delta` → am Ende `output_text.done`, `content_part.done`, `output_item.done`, je Werkzeugaufruf `output_item.added`/`function_call_arguments.done`/`output_item.done`, dann `response.completed` mit gesammeltem `output` und `usage`; `output_index` und `sequence_number` streng aufsteigend. Die Nutzungsdaten kommen an, weil `OpenAiCompatServer.withUsageChunk` für die übersetzten Routen `include_usage` erzwingt. Keine `function_call_arguments.delta`-Ereignisse (Argumente erst am Ende) – zulässig, weil `.done` die vollständigen Argumente trägt. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Schnittstelle Stream-Chunks → Anthropic-SSE) · `7f002e9` · 2026-10-17 · kein Fund — Folge `message_start` → Textblock (`content_block_start`/`text_delta`) → je Werkzeugaufruf `tool_use`-Block mit `input: {}`, einem `input_json_delta` mit den vollständigen Argumenten und `content_block_stop` → `message_delta` (`stop_reason` wie die Nicht-Stream-Antwort, Nutzung aus dem erzwungenen Usage-Chunk, `input_tokens` ohne Cache) → `message_stop`; Blockindizes fortlaufend. Leere Argumente ergeben ein leeres `partial_json`, das die Clients mit dem Start-`input` `{}` überbrücken. - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `58a9bab` · 2026-10-17 · kein Fund — Upstream (`common/chat.cpp` Diff, `server-chat.cpp` Ausgabe) sendet bei geändertem Namen oder geänderter ID den **vollständigen** neuen Wert (ein wachsender Name kommt also ganz, nicht als Anhang), die Argumente dagegen als Differenz, und leere Felder gar nicht. Genau so verarbeitet der Akkumulator: `id`/`name` überschreiben, `arguments` anhängen, je `index` getrennt, in Indexreihenfolge ausgegeben (`TreeMap`). +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `b224a28` · 2026-10-17 · kein Fund — Gleiche Semantik wie `ToolCallDeltaAccumulator`: `id`/`name` überschreiben (nur nicht-leer), `arguments` anhängen, je `index` getrennt; Startwerte `""`, also nie `null` an `PartialToolCall`. `reasoning_content`-Deltas werden zu `onPartialThinking` und `AiMessage.thinking`, Nutzung aus dem Usage-Chunk. From 39b008b1d65d8c610f99ec7f57dee6c3b96fcce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:37:12 +0000 Subject: [PATCH 084/589] Give Ollama's assistant tool calls the function type llama.cpp requires Ollama sends an assistant turn's tool calls as {function: {name, arguments}} with no type. llama.cpp's OpenAI message parser rejects any tool call without one ('Missing tool call type' in common/chat.cpp), so an /api/chat request that replays an earlier tool call in its history - every second turn of a tool-calling conversation - failed with a 400 before reaching the model. The conversion now adds type 'function' where it is missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/OllamaApiSupport.java | 4 ++++ .../llama/server/OllamaApiSupportTest.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index c1840dc49..339b3e0bb 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -186,6 +186,10 @@ private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { JsonNode toolCalls = message.path("tool_calls"); if (toolCalls.isArray()) { for (JsonNode toolCall : toolCalls) { + // Ollama's tool calls carry no type; llama.cpp rejects a tool call without one. + if (toolCall.isObject() && !toolCall.has("type")) { + ((ObjectNode) toolCall).put("type", "function"); + } JsonNode arguments = toolCall.path("function").path("arguments"); if (arguments.isObject() || arguments.isArray()) { ((ObjectNode) toolCall.path("function")).put("arguments", arguments.toString()); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index def9a9fc6..d04aab764 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -72,6 +72,28 @@ public void toOpenAiChatRequestMapsMessagesToolsAndOptions() throws IOException assertThat(openAi.path("max_tokens").asInt(), is(64)); } + /** + * Ollama's assistant tool calls carry no {@code type}; llama.cpp's OpenAI message parser rejects a + * tool call without one ({@code "Missing tool call type"} in {@code common/chat.cpp}), so a chat + * that replays an earlier tool call in its history failed with a 400 before reaching the model. + */ + @Test + public void toOpenAiChatRequestAddsTheFunctionTypeToAssistantToolCalls() throws IOException { + JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest( + read( + "{\"messages\":[" + + "{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"name\":\"f\",\"arguments\":{\"a\":1}}}]}]}")); + + assertThat( + openAi.path("messages") + .get(0) + .path("tool_calls") + .get(0) + .path("type") + .asText(), + is("function")); + } + @Test public void toOpenAiChatRequestStringifiesAssistantToolCallArguments() throws IOException { JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest( From 1de3a69a8124b728b782164bb1e48b15addaf1cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:37:12 +0000 Subject: [PATCH 085/589] Record the commit of F64 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 92fb33605..fe814d452 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -151,3 +151,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Schnittstelle Stream-Chunks → Anthropic-SSE) · `7f002e9` · 2026-10-17 · kein Fund — Folge `message_start` → Textblock (`content_block_start`/`text_delta`) → je Werkzeugaufruf `tool_use`-Block mit `input: {}`, einem `input_json_delta` mit den vollständigen Argumenten und `content_block_stop` → `message_delta` (`stop_reason` wie die Nicht-Stream-Antwort, Nutzung aus dem erzwungenen Usage-Chunk, `input_tokens` ohne Cache) → `message_stop`; Blockindizes fortlaufend. Leere Argumente ergeben ein leeres `partial_json`, das die Clients mit dem Start-`input` `{}` überbrücken. - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `58a9bab` · 2026-10-17 · kein Fund — Upstream (`common/chat.cpp` Diff, `server-chat.cpp` Ausgabe) sendet bei geändertem Namen oder geänderter ID den **vollständigen** neuen Wert (ein wachsender Name kommt also ganz, nicht als Anhang), die Argumente dagegen als Differenz, und leere Felder gar nicht. Genau so verarbeitet der Akkumulator: `id`/`name` überschreiben, `arguments` anhängen, je `index` getrennt, in Indexreihenfolge ausgegeben (`TreeMap`). - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `b224a28` · 2026-10-17 · kein Fund — Gleiche Semantik wie `ToolCallDeltaAccumulator`: `id`/`name` überschreiben (nur nicht-leer), `arguments` anhängen, je `index` getrennt; Startwerte `""`, also nie `null` an `PartialToolCall`. `reasoning_content`-Deltas werden zu `onPartialThinking` und `AiMessage.thinking`, Nutzung aus dem Usage-Chunk. +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `339b3e0` · 2026-10-17 · Fund F64 — Werkzeugaufrufe ohne `type`. Gegen upstreams Nachrichtenparser (`common/chat.cpp`) geprüft: verlangt `type == "function"`, `function.name` und `function.arguments` (String oder Objekt), `id` optional; Bilder (`images`) werden zu `image_url`-Parts. Werkzeug-Ergebnisse (`role: tool`) ohne `tool_call_id` nimmt upstream an. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ddb7330c3..c44a0ecf6 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -44,6 +44,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F61](#f61) — niedrig — *gefixt* - [F62](#f62) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) - [F63](#f63) — niedrig — *gefixt* +- [F64](#f64) — mittel — *gefixt* ## Funde @@ -392,6 +393,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `aba327e` + +### F64 – java-llama.cpp: Ollama-Werkzeugaufrufe im Verlauf ohne `type` – llama.cpp lehnt die Anfrage ab +- **Zeit:** 2026-10-17 22:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:186` (`toOpenAiMessage`) +- **Schwere:** mittel +- **Beschreibung:** Ollama schickt die Werkzeugaufrufe einer Assistenten-Runde als `{"function": {"name", "arguments"}}` ohne `type`. Die Übersetzung nach OpenAI wandelte nur die Argumente in einen String und ließ `type` weg. llama.cpps OpenAI-Nachrichtenparser verlangt ihn aber für jeden Werkzeugaufruf (`throw std::invalid_argument("Missing tool call type: …")` in `common/chat.cpp`, b11639). Jede `/api/chat`-Anfrage, die einen früheren Werkzeugaufruf im Verlauf mitschickt – also jede zweite Runde eines Gesprächs mit Werkzeugen –, scheiterte deshalb mit 400, bevor sie das Modell erreichte. Der bestehende Test zur Argumentumwandlung prüfte genau diese Form, aber nur die Argumente. +- **Fix:** Fehlt `type`, wird `"function"` gesetzt (der einzige Typ, den upstream annimmt). +- **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestAddsTheFunctionTypeToAssistantToolCalls` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "function" but: was """, 22 Tests, 1 Fehlschlag), mit Fix grün 22/22; Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **CI-Bedarf:** der Ende-zu-Ende-Beweis (die 400 von upstream) braucht ein Modell; der modellgebundene `OpenAiCompatServerIntegrationTest` lief hier übersprungen. +- **Status:** gefixt +- **Commit:** java-llama.cpp `f4c3ed6` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From aa803a82cb4be6f258f0c8d82fc76fe1c5ce9b46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:39:23 +0000 Subject: [PATCH 086/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index fe814d452..a33463b85 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -152,3 +152,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `58a9bab` · 2026-10-17 · kein Fund — Upstream (`common/chat.cpp` Diff, `server-chat.cpp` Ausgabe) sendet bei geändertem Namen oder geänderter ID den **vollständigen** neuen Wert (ein wachsender Name kommt also ganz, nicht als Anhang), die Argumente dagegen als Differenz, und leere Felder gar nicht. Genau so verarbeitet der Akkumulator: `id`/`name` überschreiben, `arguments` anhängen, je `index` getrennt, in Indexreihenfolge ausgegeben (`TreeMap`). - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `b224a28` · 2026-10-17 · kein Fund — Gleiche Semantik wie `ToolCallDeltaAccumulator`: `id`/`name` überschreiben (nur nicht-leer), `arguments` anhängen, je `index` getrennt; Startwerte `""`, also nie `null` an `PartialToolCall`. `reasoning_content`-Deltas werden zu `onPartialThinking` und `AiMessage.thinking`, Nutzung aus dem Usage-Chunk. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `339b3e0` · 2026-10-17 · Fund F64 — Werkzeugaufrufe ohne `type`. Gegen upstreams Nachrichtenparser (`common/chat.cpp`) geprüft: verlangt `type == "function"`, `function.name` und `function.arguments` (String oder Objekt), `id` optional; Bilder (`images`) werden zu `image_url`-Parts. Werkzeug-Ergebnisse (`role: tool`) ohne `tool_call_id` nimmt upstream an. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Durchsicht nach F64: Pflichtfelder des upstream-Parsers) · `b755abd`/`b59c4ee` · 2026-10-17 · kein Fund — Beide setzen `type: "function"` für jeden erzeugten Werkzeugaufruf, `function.name` und `function.arguments` (String) sind immer vorhanden; Assistenten-Runden mit Werkzeugaufrufen tragen `content: null`, was `common/chat.cpp` annimmt (nur ein Nicht-String/Nicht-Array/Nicht-null wird abgelehnt); jede erzeugte Nachricht hat `content` oder `tool_calls`; Werkzeugergebnisse sind Strings. Der Ollama-Pfad war der einzige ohne `type` (F64). From ea222b49cffffe781ca0d15f5d086d42ce3c68e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:41:27 +0000 Subject: [PATCH 087/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a33463b85..484ea6f52 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -153,3 +153,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `b224a28` · 2026-10-17 · kein Fund — Gleiche Semantik wie `ToolCallDeltaAccumulator`: `id`/`name` überschreiben (nur nicht-leer), `arguments` anhängen, je `index` getrennt; Startwerte `""`, also nie `null` an `PartialToolCall`. `reasoning_content`-Deltas werden zu `onPartialThinking` und `AiMessage.thinking`, Nutzung aus dem Usage-Chunk. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `339b3e0` · 2026-10-17 · Fund F64 — Werkzeugaufrufe ohne `type`. Gegen upstreams Nachrichtenparser (`common/chat.cpp`) geprüft: verlangt `type == "function"`, `function.name` und `function.arguments` (String oder Objekt), `id` optional; Bilder (`images`) werden zu `image_url`-Parts. Werkzeug-Ergebnisse (`role: tool`) ohne `tool_call_id` nimmt upstream an. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Durchsicht nach F64: Pflichtfelder des upstream-Parsers) · `b755abd`/`b59c4ee` · 2026-10-17 · kein Fund — Beide setzen `type: "function"` für jeden erzeugten Werkzeugaufruf, `function.name` und `function.arguments` (String) sind immer vorhanden; Assistenten-Runden mit Werkzeugaufrufen tragen `content: null`, was `common/chat.cpp` annimmt (nur ein Nicht-String/Nicht-Array/Nicht-null wird abgelehnt); jede erzeugte Nachricht hat `content` oder `tool_calls`; Werkzeugergebnisse sind Strings. Der Ollama-Pfad war der einzige ohne `type` (F64). +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Werkzeugdefinitionen gegen `common_chat_tools_parse_oaicompat`, b11639) · `b59c4ee`/`b755abd` · 2026-10-17 · kein Fund — upstream verlangt je Werkzeug `type == "function"` und `function.name`, `parameters` optional. Anthropic setzt beides (auch für eingebaute Werkzeugtypen ohne `input_schema`, die dann leere Parameter bekommen); Responses überspringt Nicht-Funktionswerkzeuge, eine dadurch leere Liste lässt `OpenAiRequestMapper` weg; Ollama reicht seine `tools` durch, die das OpenAI-Format mit `type` schon haben. From 761252212a977471e3f27c21499cb09d0122ddf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:43:26 +0000 Subject: [PATCH 088/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 484ea6f52..3ecb51080 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -154,3 +154,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `339b3e0` · 2026-10-17 · Fund F64 — Werkzeugaufrufe ohne `type`. Gegen upstreams Nachrichtenparser (`common/chat.cpp`) geprüft: verlangt `type == "function"`, `function.name` und `function.arguments` (String oder Objekt), `id` optional; Bilder (`images`) werden zu `image_url`-Parts. Werkzeug-Ergebnisse (`role: tool`) ohne `tool_call_id` nimmt upstream an. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Durchsicht nach F64: Pflichtfelder des upstream-Parsers) · `b755abd`/`b59c4ee` · 2026-10-17 · kein Fund — Beide setzen `type: "function"` für jeden erzeugten Werkzeugaufruf, `function.name` und `function.arguments` (String) sind immer vorhanden; Assistenten-Runden mit Werkzeugaufrufen tragen `content: null`, was `common/chat.cpp` annimmt (nur ein Nicht-String/Nicht-Array/Nicht-null wird abgelehnt); jede erzeugte Nachricht hat `content` oder `tool_calls`; Werkzeugergebnisse sind Strings. Der Ollama-Pfad war der einzige ohne `type` (F64). - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Werkzeugdefinitionen gegen `common_chat_tools_parse_oaicompat`, b11639) · `b59c4ee`/`b755abd` · 2026-10-17 · kein Fund — upstream verlangt je Werkzeug `type == "function"` und `function.name`, `parameters` optional. Anthropic setzt beides (auch für eingebaute Werkzeugtypen ohne `input_schema`, die dann leere Parameter bekommen); Responses überspringt Nicht-Funktionswerkzeuge, eine dadurch leere Liste lässt `OpenAiRequestMapper` weg; Ollama reicht seine `tools` durch, die das OpenAI-Format mit `type` schon haben. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toolResultText`, `toOpenAiToolChoice` gegen upstreams `server_chat_convert_anthropic_to_oai`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — Anders als die Nachrichtentexte (F60) verbindet upstream auch die Textblöcke eines `tool_result` ohne Trenner (`result_text += text`); die Übersetzung tut dasselbe, daher bewusst unverändert. `tool_choice`: `auto` → `auto`, `any`/`tool` → `required`, `none` und Unbekanntes entfallen – identisch mit upstream. Abweichung ohne Fehlerfolge: Bilder in einem `tool_result` reicht upstream als Content-Parts weiter, hier fallen sie weg (nur Text). From 0555c7729abc942e065b0569fec1013c7a004806 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:45:27 +0000 Subject: [PATCH 089/589] Make the F60 and F61 ledger entries precise about when llama.cpp joins text parts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c44a0ecf6..57bd531a3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -356,6 +356,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, 14 Tests, 1 Fehlschlag), mit Fix grün 14/14; Gegenprobe per `git stash` rot, nach `stash pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `76a3370` +- **Präzisierung (2026-10-17, 22:45 UTC):** Upstream verbindet Text-Parts nur dann mit `\n`, wenn die Chat-Vorlage ausschließlich String-Inhalt annimmt (`messages_inp_normalizer`, Zweig `only_string`, in `common/chat.cpp`); eine Vorlage mit typisiertem Inhalt bekommt die Parts als Array und rendert sie selbst, unter Umständen ohne Trenner. Die Übersetzung reicht aber keine Parts weiter, sondern einen fertigen String – sie muss sich also für einen Trenner entscheiden, und `\n` ist der, den upstream wählt, wenn es selbst verbindet. Der Fix bleibt damit sinnvoll; die frühere Aussage „upstream verbindet mit Zeilenumbruch“ galt nur für String-Vorlagen. Alternative für den Nutzer: Text-Blöcke wie Bilder als Content-Parts weiterreichen und die Entscheidung der Vorlage überlassen. ### F61 – java-llama.cpp: Textteile eines Responses-Eingabeelements werden ohne Trenner zusammengeklebt @@ -368,6 +369,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, 11 Tests, 1 Fehlschlag), mit Fix grün 11/11; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `745fed5` +- **Präzisierung (2026-10-17, 22:45 UTC):** Upstream verbindet Text-Parts nur dann mit `\n`, wenn die Chat-Vorlage ausschließlich String-Inhalt annimmt (`messages_inp_normalizer`, Zweig `only_string`, in `common/chat.cpp`); eine Vorlage mit typisiertem Inhalt bekommt die Parts als Array und rendert sie selbst, unter Umständen ohne Trenner. Die Übersetzung reicht aber keine Parts weiter, sondern einen fertigen String – sie muss sich also für einen Trenner entscheiden, und `\n` ist der, den upstream wählt, wenn es selbst verbindet. Der Fix bleibt damit sinnvoll; die frühere Aussage „upstream verbindet mit Zeilenumbruch“ galt nur für String-Vorlagen. Alternative für den Nutzer: Text-Blöcke wie Bilder als Content-Parts weiterreichen und die Entscheidung der Vorlage überlassen. ### F62 – java-llama.cpp: LangChain4j-Adapter klebt mehrere `TextContent` ohne Trenner zusammen From d9e50b9511912df1f89c8a780b8f2c4822c6a90e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:51:20 +0000 Subject: [PATCH 090/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3ecb51080..8b3c15907 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -155,3 +155,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Durchsicht nach F64: Pflichtfelder des upstream-Parsers) · `b755abd`/`b59c4ee` · 2026-10-17 · kein Fund — Beide setzen `type: "function"` für jeden erzeugten Werkzeugaufruf, `function.name` und `function.arguments` (String) sind immer vorhanden; Assistenten-Runden mit Werkzeugaufrufen tragen `content: null`, was `common/chat.cpp` annimmt (nur ein Nicht-String/Nicht-Array/Nicht-null wird abgelehnt); jede erzeugte Nachricht hat `content` oder `tool_calls`; Werkzeugergebnisse sind Strings. Der Ollama-Pfad war der einzige ohne `type` (F64). - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Werkzeugdefinitionen gegen `common_chat_tools_parse_oaicompat`, b11639) · `b59c4ee`/`b755abd` · 2026-10-17 · kein Fund — upstream verlangt je Werkzeug `type == "function"` und `function.name`, `parameters` optional. Anthropic setzt beides (auch für eingebaute Werkzeugtypen ohne `input_schema`, die dann leere Parameter bekommen); Responses überspringt Nicht-Funktionswerkzeuge, eine dadurch leere Liste lässt `OpenAiRequestMapper` weg; Ollama reicht seine `tools` durch, die das OpenAI-Format mit `type` schon haben. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toolResultText`, `toOpenAiToolChoice` gegen upstreams `server_chat_convert_anthropic_to_oai`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — Anders als die Nachrichtentexte (F60) verbindet upstream auch die Textblöcke eines `tool_result` ohne Trenner (`result_text += text`); die Übersetzung tut dasselbe, daher bewusst unverändert. `tool_choice`: `auto` → `auto`, `any`/`tool` → `required`, `none` und Unbekanntes entfallen – identisch mit upstream. Abweichung ohne Fehlerfolge: Bilder in einem `tool_result` reicht upstream als Content-Parts weiter, hier fallen sie weg (nur Text). +- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`) · `31d8fb7` · 2026-10-17 · kein Fund — `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. From 9958f313e571dd0f56187544303096b802f549d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:58:17 +0000 Subject: [PATCH 091/589] Let CORS preflights allow the headers browser SDKs send The preflight answered Access-Control-Allow-Headers with only "Content-Type, Authorization", so a browser dropped any request that also carried Anthropic's x-api-key / anthropic-version or the x-stainless-* headers of the official SDKs. The filter now echoes the Access-Control-Request-Headers of the preflight (the origin still gates access) and falls back to the headers the routes read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/server/OpenAiCompatServer.java | 18 ++++++++-- .../server/OpenAiCompatServerHttpTest.java | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 6bdd3ff37..09c96406c 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -144,6 +144,10 @@ public final class OpenAiCompatServer implements AutoCloseable { /** Header the Anthropic Messages API authenticates with. */ private static final String API_KEY_HEADER = "x-api-key"; + /** Headers a preflight allows when the browser names none: the ones the routes read. */ + private static final String DEFAULT_CORS_ALLOW_HEADERS = + "Content-Type, Authorization, x-api-key, anthropic-version"; + private static final String ERROR_TYPE_REQUEST = "invalid_request_error"; private static final String ERROR_TYPE_SERVER = "server_error"; private static final String HEALTH_BODY = "{\"status\":\"ok\"}"; @@ -258,7 +262,8 @@ private void register(String path, HttpHandler handler) { /** * Build a CORS filter that stamps {@code Access-Control-Allow-Origin} on every response and answers * {@code OPTIONS} preflights with {@code 204} + the allowed methods/headers — so browser- and - * webview-based clients (which preflight an {@code Authorization} header) are not blocked. + * webview-based clients (which preflight {@code Authorization}, {@code x-api-key} and their SDK's own + * headers) are not blocked. */ private static Filter buildCorsFilter(String allowOrigin) { return new Filter() { @@ -267,7 +272,16 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException { exchange.getResponseHeaders().set("Access-Control-Allow-Origin", allowOrigin); if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + // Allow what the browser asks for: browser SDKs send their own headers (Anthropic's + // x-api-key and anthropic-version, the x-stainless-* family), and a preflight that + // lists fewer makes the browser drop the request. The origin still gates access. + String requested = exchange.getRequestHeaders().getFirst("Access-Control-Request-Headers"); + exchange.getResponseHeaders() + .set( + "Access-Control-Allow-Headers", + requested != null && !requested.trim().isEmpty() + ? requested + : DEFAULT_CORS_ALLOW_HEADERS); exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400"); exchange.sendResponseHeaders(204, -1); exchange.close(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 8c4748ab7..958483304 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -10,6 +10,10 @@ import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; import net.ladenthin.llama.exception.InvalidRequestException; import net.ladenthin.llama.exception.LlamaException; import org.junit.jupiter.api.Test; @@ -470,6 +474,37 @@ public void optionsPreflightReturns204WithCorsHeaders() throws IOException { } } + /** + * A preflight must allow the headers the browser asks for. The server authenticates Anthropic clients + * with {@code x-api-key}, and browser SDKs also send {@code anthropic-version} and {@code x-stainless-*} + * headers; with only {@code Content-Type, Authorization} allowed, the browser refused to send such a + * request at all. The preflight is written over a raw socket because {@code HttpURLConnection} + * silently drops the restricted {@code Access-Control-Request-Headers} header. + */ + @Test + public void optionsPreflightAllowsTheRequestedHeaders() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start(); + Socket socket = new Socket("127.0.0.1", server.getPort())) { + OutputStream out = socket.getOutputStream(); + out.write(("OPTIONS /v1/messages HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Origin: https://app.example\r\n" + + "Access-Control-Request-Method: POST\r\n" + + "Access-Control-Request-Headers: content-type, x-api-key, anthropic-version, x-stainless-os\r\n" + + "Connection: close\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + InputStream in = socket.getInputStream(); + String response = + new String(in.readAllBytes(), StandardCharsets.ISO_8859_1).toLowerCase(java.util.Locale.ROOT); + + assertThat( + response, + containsString( + "access-control-allow-headers: content-type, x-api-key, anthropic-version, x-stainless-os")); + } + } + @Test public void normalResponsesCarryCorsAllowOrigin() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { From b8e48905ff67f1710dea7bfe8dae821d12d8eb35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:58:35 +0000 Subject: [PATCH 092/589] Record F65 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 8b3c15907..a0ae39a7f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -156,3 +156,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Werkzeugdefinitionen gegen `common_chat_tools_parse_oaicompat`, b11639) · `b59c4ee`/`b755abd` · 2026-10-17 · kein Fund — upstream verlangt je Werkzeug `type == "function"` und `function.name`, `parameters` optional. Anthropic setzt beides (auch für eingebaute Werkzeugtypen ohne `input_schema`, die dann leere Parameter bekommen); Responses überspringt Nicht-Funktionswerkzeuge, eine dadurch leere Liste lässt `OpenAiRequestMapper` weg; Ollama reicht seine `tools` durch, die das OpenAI-Format mit `type` schon haben. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toolResultText`, `toOpenAiToolChoice` gegen upstreams `server_chat_convert_anthropic_to_oai`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — Anders als die Nachrichtentexte (F60) verbindet upstream auch die Textblöcke eines `tool_result` ohne Trenner (`result_text += text`); die Übersetzung tut dasselbe, daher bewusst unverändert. `tool_choice`: `auto` → `auto`, `any`/`tool` → `required`, `none` und Unbekanntes entfallen – identisch mit upstream. Abweichung ohne Fehlerfolge: Bilder in einem `tool_result` reicht upstream als Content-Parts weiter, hier fallen sie weg (nur Text). - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`) · `31d8fb7` · 2026-10-17 · kein Fund — `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; Origin-Prüfung, `OPTIONS`-Antwort 204 und `Vary: Origin` unverändert geprüft. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 57bd531a3..031847dd9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -45,6 +45,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F62](#f62) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) - [F63](#f63) — niedrig — *gefixt* - [F64](#f64) — mittel — *gefixt* +- [F65](#f65) — niedrig — *gefixt* ## Funde @@ -408,6 +409,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f4c3ed6` + +### F65 – java-llama.cpp: CORS-Preflight erlaubt nur `Content-Type, Authorization` – Browser-SDKs werden abgewiesen +- **Zeit:** 2026-10-17 23:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`buildCorsFilter`) +- **Schwere:** niedrig +- **Beschreibung:** Bei gesetztem `corsAllowOrigin` beantwortete der Filter jeden Preflight mit `Access-Control-Allow-Headers: Content-Type, Authorization`. Der Server liest aber selbst `x-api-key` (F25) und bedient `/v1/messages`, dessen Browser-Clients `x-api-key`, `anthropic-version` und die `x-stainless-*`-Header der offiziellen SDKs schicken. Fehlt ein angefragter Header in der Antwort, verwirft der Browser die eigentliche Anfrage – ein im Browser laufender Anthropic-Client erreichte den Server also nie, obwohl CORS eingeschaltet war. +- **Fix:** Der Filter spiegelt die `Access-Control-Request-Headers` des Preflights (der Origin bleibt das Tor); ohne angefragte Header gilt `Content-Type, Authorization, x-api-key, anthropic-version`. +- **Reproduktion:** `OpenAiCompatServerHttpTest#optionsPreflightAllowsTheRequestedHeaders` (roher Socket, `OPTIONS /v1/messages` mit `content-type, x-api-key, anthropic-version, x-stainless-os`) +- **Rot-Grün-Nachweis:** ohne Fix rot (Antwort `access-control-allow-headers: content-type, authorization`, 45 Tests, 1 Fehlschlag), mit Fix grün 45/45; Gegenprobe per `git stash` rot, nach `stash pop` grün. `OllamaApiSupportTest` 22/22, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `f782c7a` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From d51f5dea2df71e1e1301df2d89a82ec3cd5fa3fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:58:50 +0000 Subject: [PATCH 093/589] Correct the F65 check note in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a0ae39a7f..69c7e5766 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -156,4 +156,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Werkzeugdefinitionen gegen `common_chat_tools_parse_oaicompat`, b11639) · `b59c4ee`/`b755abd` · 2026-10-17 · kein Fund — upstream verlangt je Werkzeug `type == "function"` und `function.name`, `parameters` optional. Anthropic setzt beides (auch für eingebaute Werkzeugtypen ohne `input_schema`, die dann leere Parameter bekommen); Responses überspringt Nicht-Funktionswerkzeuge, eine dadurch leere Liste lässt `OpenAiRequestMapper` weg; Ollama reicht seine `tools` durch, die das OpenAI-Format mit `type` schon haben. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toolResultText`, `toOpenAiToolChoice` gegen upstreams `server_chat_convert_anthropic_to_oai`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — Anders als die Nachrichtentexte (F60) verbindet upstream auch die Textblöcke eines `tool_result` ohne Trenner (`result_text += text`); die Übersetzung tut dasselbe, daher bewusst unverändert. `tool_choice`: `auto` → `auto`, `any`/`tool` → `required`, `none` und Unbekanntes entfallen – identisch mit upstream. Abweichung ohne Fehlerfolge: Bilder in einem `tool_result` reicht upstream als Content-Parts weiter, hier fallen sie weg (nur Text). - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`) · `31d8fb7` · 2026-10-17 · kein Fund — `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; Origin-Prüfung, `OPTIONS`-Antwort 204 und `Vary: Origin` unverändert geprüft. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; fester `Access-Control-Allow-Origin` auf jeder Antwort und `OPTIONS` → 204 unverändert; kein `Vary` nötig, da der Origin konfiguriert und nicht gespiegelt wird. From c4ab327602eb0be57646f3782f8045ba5b5f7b1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:21:52 +0000 Subject: [PATCH 094/589] Record F67 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 69c7e5766..fda185c9d 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -157,3 +157,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toolResultText`, `toOpenAiToolChoice` gegen upstreams `server_chat_convert_anthropic_to_oai`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — Anders als die Nachrichtentexte (F60) verbindet upstream auch die Textblöcke eines `tool_result` ohne Trenner (`result_text += text`); die Übersetzung tut dasselbe, daher bewusst unverändert. `tool_choice`: `auto` → `auto`, `any`/`tool` → `required`, `none` und Unbekanntes entfallen – identisch mit upstream. Abweichung ohne Fehlerfolge: Bilder in einem `tool_result` reicht upstream als Content-Parts weiter, hier fallen sie weg (nur Text). - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`) · `31d8fb7` · 2026-10-17 · kein Fund — `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; fester `Access-Control-Allow-Origin` auf jeder Antwort und `OPTIONS` → 204 unverändert; kein `Vary` nötig, da der Origin konfiguriert und nicht gespiegelt wird. +- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` ↔ `llama/src/main/cpp/jllama_api.h` (Schnittstelle Slot-Aktionen und LoRA) · `96eafab` · 2026-10-17 · Fund F67 — `saveSlot`/`restoreSlot`/`eraseSlot` übergeben 1/2/3 = `JLLAMA_SLOT_SAVE/RESTORE/ERASE`; `setLoraAdapters` schickt `[{id, scale}]` an `parse_lora_request`, unbekannte Ids ignoriert und alle übrigen auf 0 (`construct_lora_list`) wie dokumentiert; das versprochene Leeren der KV-Caches findet auf diesem Weg nicht statt (F67). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 031847dd9..1d598c8e0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -46,6 +46,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F63](#f63) — niedrig — *gefixt* - [F64](#f64) — mittel — *gefixt* - [F65](#f65) — niedrig — *gefixt* +- [F67](#f67) — niedrig — *nur beschrieben* (CI-Bedarf: Modell + LoRA-Adapter) ## Funde @@ -421,6 +422,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f782c7a` + +### F67 – java-llama.cpp: `setLoraAdapters` verspricht ein Leeren der KV-Caches, das upstream auf diesem Weg nicht macht +- **Zeit:** 2026-10-17 23:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java:1124` (Javadoc `setLoraAdapters`), ebenso `README.md:833` +- **Schwere:** niedrig (Dokumentation; das Verhalten selbst ist upstreams) +- **Beschreibung:** Javadoc und README sagen: „The native side clears affected KV caches when the effective adapter set changes.“ `setLoraAdapters` geht über `jllama_set_lora_adapters` → `engine::set_lora_adapters` → `SERVER_TASK_TYPE_SET_LORA`; upstream (`server-context.cpp`, b11639) setzt dort nur `params_base.lora_adapters = construct_lora_list(...)`. Eine spätere Anfrage ohne eigene `lora`-Angabe übernimmt in `launch_slot_with_task` im `else`-Zweig `slot.lora = params_base.lora_adapters`, **ohne** `slot.prompt.clear()` – den Cache leert upstream nur im Zweig für LoRA pro Anfrage (`lora_should_clear_cache`). Eine Anfrage, deren Prompt-Präfix noch im Slot liegt, rechnet also mit KV-Einträgen weiter, die unter den alten Adapter-Skalen entstanden sind, und liefert stillschweigend eine Mischung aus altem und neuem Adapter. Wer sich auf die Javadoc verlässt, rechnet damit nicht. +- **Fix:** keiner (Nutzerregel: kein Fix ohne roten Test). Möglich wäre, die Doku zu korrigieren (Hinweis auf `eraseSlot`/`cache_prompt=false` nach einem Wechsel) oder in `engine::set_lora_adapters` nach dem Task die Slots zu leeren (C++, außerhalb des Ziels). +- **Reproduktion:** keine lauffähige: es braucht ein Modell und einen dazu passenden LoRA-Adapter (nicht in `models.csv`), und in dieser Sandbox gibt es keine native Bibliothek und kein `gguf-py`. Ein Test würde denselben Prompt vor und nach `setLoraAdapter(0, 0f)` mit Greedy-Sampling schicken und mit einem frisch geladenen Modell ohne Adapter vergleichen. +- **CI-Bedarf:** ein kleines Modell mit LoRA-Adapter im Modellsatz (oder ein zur Laufzeit erzeugtes Zufallsmodell samt Adapter, wie `test_c_api.cpp` es für das Modell macht). +- **Status:** nur beschrieben +- **Commit:** – + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 08f11f5408b7711b3ed872f4323bf5106159d257 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:25:19 +0000 Subject: [PATCH 095/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index fda185c9d..b67825321 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -158,3 +158,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`) · `31d8fb7` · 2026-10-17 · kein Fund — `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; fester `Access-Control-Allow-Origin` auf jeder Antwort und `OPTIONS` → 204 unverändert; kein `Vary` nötig, da der Origin konfiguriert und nicht gespiegelt wird. - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` ↔ `llama/src/main/cpp/jllama_api.h` (Schnittstelle Slot-Aktionen und LoRA) · `96eafab` · 2026-10-17 · Fund F67 — `saveSlot`/`restoreSlot`/`eraseSlot` übergeben 1/2/3 = `JLLAMA_SLOT_SAVE/RESTORE/ERASE`; `setLoraAdapters` schickt `[{id, scale}]` an `parse_lora_request`, unbekannte Ids ignoriert und alle übrigen auf 0 (`construct_lora_list`) wie dokumentiert; das versprochene Leeren der KV-Caches findet auf diesem Weg nicht statt (F67). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` ↔ `jllama_api.h` (Schnittstelle Streaming-Abbruch und Fehler) · `09c9640`/`3d47cac`/`f7963f2` · 2026-10-17 · kein Fund (Designabweichung notiert) — Client-Abbruch: `IOException` der Senke wird über `UncheckedIOException` aus `streamChatCompletion` getragen, dessen `finally` `cancelCompletion` = `jllama_task_release` ruft (laut Header „cancels or releases“, unbekannte Id ignoriert); nach `stop` gibt der Empfang die Aufgabe selbst frei, `LlamaIterator.next` ruft dann `releaseTask`, `close()` danach ist ein No-op. `streamCompletions` bricht per `break` ab, `LlamaIterable.close()` gibt frei. Fehler nach den Headern als SSE-Ereignis (siehe offene Verdachte). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 1d598c8e0..8e364bfdd 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -438,3 +438,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. +- *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. From 812f44699ff375142999a379c9a130d50facef78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:27:59 +0000 Subject: [PATCH 096/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index b67825321..518da5ba1 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -159,3 +159,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; fester `Access-Control-Allow-Origin` auf jeder Antwort und `OPTIONS` → 204 unverändert; kein `Vary` nötig, da der Origin konfiguriert und nicht gespiegelt wird. - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` ↔ `llama/src/main/cpp/jllama_api.h` (Schnittstelle Slot-Aktionen und LoRA) · `96eafab` · 2026-10-17 · Fund F67 — `saveSlot`/`restoreSlot`/`eraseSlot` übergeben 1/2/3 = `JLLAMA_SLOT_SAVE/RESTORE/ERASE`; `setLoraAdapters` schickt `[{id, scale}]` an `parse_lora_request`, unbekannte Ids ignoriert und alle übrigen auf 0 (`construct_lora_list`) wie dokumentiert; das versprochene Leeren der KV-Caches findet auf diesem Weg nicht statt (F67). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` ↔ `jllama_api.h` (Schnittstelle Streaming-Abbruch und Fehler) · `09c9640`/`3d47cac`/`f7963f2` · 2026-10-17 · kein Fund (Designabweichung notiert) — Client-Abbruch: `IOException` der Senke wird über `UncheckedIOException` aus `streamChatCompletion` getragen, dessen `finally` `cancelCompletion` = `jllama_task_release` ruft (laut Header „cancels or releases“, unbekannte Id ignoriert); nach `stop` gibt der Empfang die Aufgabe selbst frei, `LlamaIterator.next` ruft dann `releaseTask`, `close()` danach ist ein No-op. `streamCompletions` bricht per `break` ab, `LlamaIterable.close()` gibt frei. Fehler nach den Headern als SSE-Ereignis (siehe offene Verdachte). +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` + `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe) · `0ff46cf`/`45f5f14` · 2026-10-17 · kein Fund (Doku-Abweichung notiert) — Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). From 14979c80edc2e44a15ae12f89e7d76ae505682c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:32:47 +0000 Subject: [PATCH 097/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 518da5ba1..a107e81df 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -160,3 +160,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` ↔ `llama/src/main/cpp/jllama_api.h` (Schnittstelle Slot-Aktionen und LoRA) · `96eafab` · 2026-10-17 · Fund F67 — `saveSlot`/`restoreSlot`/`eraseSlot` übergeben 1/2/3 = `JLLAMA_SLOT_SAVE/RESTORE/ERASE`; `setLoraAdapters` schickt `[{id, scale}]` an `parse_lora_request`, unbekannte Ids ignoriert und alle übrigen auf 0 (`construct_lora_list`) wie dokumentiert; das versprochene Leeren der KV-Caches findet auf diesem Weg nicht statt (F67). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` ↔ `jllama_api.h` (Schnittstelle Streaming-Abbruch und Fehler) · `09c9640`/`3d47cac`/`f7963f2` · 2026-10-17 · kein Fund (Designabweichung notiert) — Client-Abbruch: `IOException` der Senke wird über `UncheckedIOException` aus `streamChatCompletion` getragen, dessen `finally` `cancelCompletion` = `jllama_task_release` ruft (laut Header „cancels or releases“, unbekannte Id ignoriert); nach `stop` gibt der Empfang die Aufgabe selbst frei, `LlamaIterator.next` ruft dann `releaseTask`, `close()` danach ist ein No-op. `streamCompletions` bricht per `break` ab, `LlamaIterable.close()` gibt frei. Fehler nach den Headern als SSE-Ereignis (siehe offene Verdachte). - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` + `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe) · `0ff46cf`/`45f5f14` · 2026-10-17 · kein Fund (Doku-Abweichung notiert) — Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`) · `550a6c2`/`62e0ac1`/`8cecaa4` · 2026-10-17 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). From 70833e5b701d1ab5717d761e39cab39d3f4c51a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:41:48 +0000 Subject: [PATCH 098/589] Treat a sleeping router model as loaded in awaitModelLoaded With --sleep-idle-seconds a loaded worker goes to sleep when idle and wakes on the next request. upstream's own wait for a model treats the "sleeping" status like "loaded", but RouterClient.awaitModelLoaded only accepted LOADED, so awaiting an idle, ready model ran into the timeout and threw. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- README.md | 2 +- .../net/ladenthin/llama/server/RouterClient.java | 11 +++++++---- .../net/ladenthin/llama/server/RouterClientTest.java | 12 ++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7b8124987..3ddf3be8a 100644 --- a/README.md +++ b/README.md @@ -1170,7 +1170,7 @@ embedded router above or any external `llama-server` router): RouterClient client = new RouterClient(8080); List models = client.listModels(); // GET /models, typed status per entry client.loadModel("Qwen3-0.6B-Q4_K_M"); // POST /models/load (non-blocking) -client.awaitModelLoaded("Qwen3-0.6B-Q4_K_M", 240_000L); // poll until LOADED; fails fast if the +client.awaitModelLoaded("Qwen3-0.6B-Q4_K_M", 240_000L); // poll until LOADED (or SLEEPING); fails fast if the // worker died (exit code in the message) client.unloadModel("Qwen3-0.6B-Q4_K_M"); // POST /models/unload ``` diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index 3f14aec77..5396c0a71 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -179,8 +179,9 @@ public void unloadModel(String modelId) throws IOException { /** * Poll {@link #listModels()} until {@code modelId} reaches - * {@link RouterModel.Status#LOADED}, its worker is flagged as failed, or the timeout - * elapses. Fails fast (instead of running out the timeout) when the router does not list + * {@link RouterModel.Status#LOADED} (or {@link RouterModel.Status#SLEEPING}: an idle worker + * that wakes on the next request, which upstream also counts as ready), its worker is flagged + * as failed, or the timeout elapses. Fails fast (instead of running out the timeout) when the router does not list * the model or marks it failed. * *

Hidden models cannot be awaited here. Since llama.cpp b10505 (upstream @@ -194,7 +195,7 @@ public void unloadModel(String modelId) throws IOException { * * @param modelId the model identifier to wait for * @param timeoutMillis how long to keep polling - * @return the entry in its {@code LOADED} state + * @return the entry in its {@code LOADED} or {@code SLEEPING} state * @throws IOException if a poll request fails * @throws InterruptedException if the calling thread is interrupted while waiting * @throws IllegalStateException if the model is not listed by the router, its worker @@ -219,7 +220,9 @@ public RouterModel awaitModelLoaded(String modelId, long timeoutMillis) throws I throw new IllegalStateException( "Router worker for model '" + modelId + "' failed with exit code " + last.getExitCode()); } - if (last.getStatus() == RouterModel.Status.LOADED) { + // A sleeping worker (--sleep-idle-seconds) is still running and wakes on the next request; + // upstream's own wait for a model treats it as ready too. + if (last.getStatus() == RouterModel.Status.LOADED || last.getStatus() == RouterModel.Status.SLEEPING) { return last; } // Interruptible pause between polls; a latch that is never counted down is the diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java index 50ca8cd9c..cb690bc21 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java @@ -231,6 +231,18 @@ public void awaitModelLoaded_failsFastOnUnknownModel() { assertThat(thrown.getMessage(), containsString("does not list model 'qwen'")); } + @Test + public void awaitModelLoaded_sleepingModelIsReady() throws Exception { + // With --sleep-idle-seconds a loaded worker goes to sleep when idle and wakes on the next + // request; upstream's own wait for a model (server_models, b11639) treats "sleeping" like + // "loaded" for exactly that reason. Awaiting such a model ran into the timeout instead. + modelsBody.set("{\"data\":[" + entry("qwen", "sleeping") + "]}"); + + RouterModel model = client.awaitModelLoaded("qwen", 2_000L); + + assertThat(model.getStatus(), is(RouterModel.Status.SLEEPING)); + } + @Test public void awaitModelLoaded_timesOutWithLastStatusInMessage() { modelsBody.set("{\"data\":[" + entry("qwen", "loading") + "]}"); From b9373581f36956826fea10e67f39c022fb8b6624 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:41:48 +0000 Subject: [PATCH 099/589] Record F68 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a107e81df..da44c39be 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -161,3 +161,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` ↔ `jllama_api.h` (Schnittstelle Streaming-Abbruch und Fehler) · `09c9640`/`3d47cac`/`f7963f2` · 2026-10-17 · kein Fund (Designabweichung notiert) — Client-Abbruch: `IOException` der Senke wird über `UncheckedIOException` aus `streamChatCompletion` getragen, dessen `finally` `cancelCompletion` = `jllama_task_release` ruft (laut Header „cancels or releases“, unbekannte Id ignoriert); nach `stop` gibt der Empfang die Aufgabe selbst frei, `LlamaIterator.next` ruft dann `releaseTask`, `close()` danach ist ein No-op. `streamCompletions` bricht per `break` ab, `LlamaIterable.close()` gibt frei. Fehler nach den Headern als SSE-Ereignis (siehe offene Verdachte). - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` + `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe) · `0ff46cf`/`45f5f14` · 2026-10-17 · kein Fund (Doku-Abweichung notiert) — Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`) · `550a6c2`/`62e0ac1`/`8cecaa4` · 2026-10-17 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). +- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` + `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` + `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` ↔ upstream `get_router_models` (Schnittstelle `/models`-Antwort, b11639) · `5396c0a` · 2026-10-17 · Fund F68 — Schlüssel `data[].id`, `status.value`/`failed`/`exit_code`, `architecture.input_modalities`/`output_modalities`, `context_length` stimmen mit upstream überein, ebenso die sechs Statusnamen; `sleeping` wurde nicht als bereit gewertet (F68), Aliase werden nicht aufgelöst (offener Verdacht). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 8e364bfdd..0f85f3a9d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -47,6 +47,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F64](#f64) — mittel — *gefixt* - [F65](#f65) — niedrig — *gefixt* - [F67](#f67) — niedrig — *nur beschrieben* (CI-Bedarf: Modell + LoRA-Adapter) +- [F68](#f68) — niedrig — *gefixt* ## Funde @@ -434,8 +435,21 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** nur beschrieben - **Commit:** – + +### F68 – java-llama.cpp: `RouterClient.awaitModelLoaded` läuft bei einem schlafenden Modell in den Timeout +- **Zeit:** 2026-10-17 23:55 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`) +- **Schwere:** niedrig +- **Beschreibung:** Mit `--sleep-idle-seconds` schläft ein geladener Worker im Leerlauf und wacht bei der nächsten Anfrage auf; `GET /models` meldet ihn dann als `sleeping`. Upstreams eigenes Warten auf ein Modell (`server_models`, `server-models.cpp:1501`/`1550`, b11639) behandelt `sleeping` ausdrücklich wie `loaded` („child is sleeping but still running; new request will wake it up“). `awaitModelLoaded` nahm nur `LOADED` an: wer ein bereits geladenes, aber leerlaufendes Modell abwartete, bekam nach der vollen Wartezeit `IllegalStateException` („did not reach LOADED … last status: sleeping“), obwohl das Modell bereit war. +- **Fix:** `SLEEPING` zählt wie `LOADED` als bereit; Javadoc und README-Kommentar angepasst. +- **Reproduktion:** `RouterClientTest#awaitModelLoaded_sleepingModelIsReady` (Stub-Server liefert `sleeping`) +- **Rot-Grün-Nachweis:** ohne Fix rot (`IllegalStateException: Model 'qwen' did not reach LOADED within 2000 ms (last status: qwen [sleeping])`), mit Fix grün, `RouterClientTest` 19/19; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `352057b` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. +- *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. From de54178855ee182712a46d217fa30783fa066372 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:43:46 +0000 Subject: [PATCH 100/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index da44c39be..1b8ac2ce6 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -162,3 +162,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` + `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe) · `0ff46cf`/`45f5f14` · 2026-10-17 · kein Fund (Doku-Abweichung notiert) — Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`) · `550a6c2`/`62e0ac1`/`8cecaa4` · 2026-10-17 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` + `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` + `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` ↔ upstream `get_router_models` (Schnittstelle `/models`-Antwort, b11639) · `5396c0a` · 2026-10-17 · Fund F68 — Schlüssel `data[].id`, `status.value`/`failed`/`exit_code`, `architecture.input_modalities`/`output_modalities`, `context_length` stimmen mit upstream überein, ebenso die sechs Statusnamen; `sleeping` wurde nicht als bereit gewertet (F68), Aliase werden nicht aufgelöst (offener Verdacht). +- `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`) · `35d2521` · 2026-10-17 · kein Fund — Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. From a9604b2f4726525a62bc51ad0ed7cbf6be9aba65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:48:03 +0000 Subject: [PATCH 101/589] Record F69 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 1b8ac2ce6..304509ecc 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -163,3 +163,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`) · `550a6c2`/`62e0ac1`/`8cecaa4` · 2026-10-17 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` + `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` + `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` ↔ upstream `get_router_models` (Schnittstelle `/models`-Antwort, b11639) · `5396c0a` · 2026-10-17 · Fund F68 — Schlüssel `data[].id`, `status.value`/`failed`/`exit_code`, `architecture.input_modalities`/`output_modalities`, `context_length` stimmen mit upstream überein, ebenso die sechs Statusnamen; `sleeping` wurde nicht als bereit gewertet (F68), Aliase werden nicht aufgelöst (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`) · `35d2521` · 2026-10-17 · kein Fund — Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. +- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` ↔ upstream `server-schema.cpp` (Schnittstelle Java-Setter ↔ Feldgrenzen, b11639) · `386d73f` · 2026-10-18 · Fund F69 — Java prüft `cache_reuse`, `slot_id`, `repeat_last_n` und `dry_penalty_last_n` auf ≥ 0 wie upstreams harte Grenzen; `n_keep` (−1 = alle), `sse_ping_interval` (−1 = aus) und `reasoning_budget_tokens` (−1 = unbegrenzt) stimmen mit den harten Untergrenzen −1 überein; weiche Grenzen (`top_k`, `top_p`, `min_p`, `temperature`, `mirostat`) klemmt upstream, Java lässt sie durch. Abweichung: `withNPredict` verspricht `-2` (F69). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0f85f3a9d..aacc28ef9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -48,6 +48,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F65](#f65) — niedrig — *gefixt* - [F67](#f67) — niedrig — *nur beschrieben* (CI-Bedarf: Modell + LoRA-Adapter) - [F68](#f68) — niedrig — *gefixt* +- [F69](#f69) — niedrig — *nur beschrieben* (CI-Bedarf: modellgestützter Test) ## Funde @@ -447,6 +448,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `352057b` + +### F69 – java-llama.cpp: `withNPredict` dokumentiert `-2 = until context filled`, der Server lehnt `-2` ab +- **Zeit:** 2026-10-18 00:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java:197` (Javadoc `withNPredict`) +- **Schwere:** niedrig (Dokumentation; der Aufrufer bekommt eine Ausnahme, kein stilles Fehlverhalten) +- **Beschreibung:** Die Javadoc nennt für den Anfragewert `n_predict` „-1 = infinity, -2 = until context filled“. Upstream (`tools/server/server-schema.cpp:44`, b11639) deklariert das Feld mit `set_hard_limits(-1, INT32_MAX)`, und `is_hard_limit` heißt laut `server-schema.h:52` „throw error if the value is invalid“: `withNPredict(-2)` endet also in einer 400 bzw. `InvalidRequestException`, statt bis zum Kontextende zu generieren. `-2` gibt es nur für die Startoption `-n` (`ModelParameters.setNPredict`, dort korrekt dokumentiert), nicht pro Anfrage. +- **Fix:** keiner (Nutzerregel: kein Fix ohne roten Test; der Test braucht ein geladenes Modell). Möglich: Javadoc auf „-1 = unbegrenzt, 0 = nur Prompt auswerten“ (upstreams `set_desc`) korrigieren oder `-2` in Java mit klarer Meldung abweisen. +- **Reproduktion:** keine lauffähige hier (kein Modell, keine native Bibliothek). Ein Test in einer modellgestützten Klasse: `model.complete(InferenceParameters.empty().withPrompt("x").withNPredict(-2))` → `InvalidRequestException` mit `n_predict` in der Meldung. +- **CI-Bedarf:** ein modellgestützter Java-Test (jedes Modell aus `models.csv` genügt). +- **Status:** nur beschrieben +- **Commit:** – + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 3b385bf96510a5a11bfdd3f4ff7c6d2fca3dab68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:50:44 +0000 Subject: [PATCH 102/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 304509ecc..07b5b4769 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -164,3 +164,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` + `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` + `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` ↔ upstream `get_router_models` (Schnittstelle `/models`-Antwort, b11639) · `5396c0a` · 2026-10-17 · Fund F68 — Schlüssel `data[].id`, `status.value`/`failed`/`exit_code`, `architecture.input_modalities`/`output_modalities`, `context_length` stimmen mit upstream überein, ebenso die sechs Statusnamen; `sleeping` wurde nicht als bereit gewertet (F68), Aliase werden nicht aufgelöst (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`) · `35d2521` · 2026-10-17 · kein Fund — Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` ↔ upstream `server-schema.cpp` (Schnittstelle Java-Setter ↔ Feldgrenzen, b11639) · `386d73f` · 2026-10-18 · Fund F69 — Java prüft `cache_reuse`, `slot_id`, `repeat_last_n` und `dry_penalty_last_n` auf ≥ 0 wie upstreams harte Grenzen; `n_keep` (−1 = alle), `sse_ping_interval` (−1 = aus) und `reasoning_budget_tokens` (−1 = unbegrenzt) stimmen mit den harten Untergrenzen −1 überein; weiche Grenzen (`top_k`, `top_p`, `min_p`, `temperature`, `mirostat`) klemmt upstream, Java lässt sie durch. Abweichung: `withNPredict` verspricht `-2` (F69). +- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` + `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Musterdurchsicht nach F69: dokumentierte Sonderwerte ↔ `server-schema.cpp`/`common/arg.cpp`, b11639) · `386d73f`/`90d097c` · 2026-10-18 · kein weiterer Fund (zwei Doku-Notizen) — Anfrageseite: `top_n_sigma` (< 0 = aus), `t_max_predict_ms` (−1), `n_keep` (−1), `reasoning_budget_tokens` (−1), `sse_ping_interval` (−1) liegen in upstreams Grenzen, `dry_base` < 1 wird upstream ersetzt; einzig `n_predict = −2` fällt heraus (F69). Startoptionen: `-n −2` ist dort gültig. Reine Doku, nicht geändert: `setMirostat` sagt wörtlich „default: PLACEHOLDER“ (upstream 0), `setKeep` nennt „default: -1 = all“, upstreams Vorgabe ist `n_keep = 0`. From 4cad6b698a4e451913d875d1706a096a3c5783ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:56:44 +0000 Subject: [PATCH 103/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 07b5b4769..8ed8e6300 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -165,3 +165,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`) · `35d2521` · 2026-10-17 · kein Fund — Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` ↔ upstream `server-schema.cpp` (Schnittstelle Java-Setter ↔ Feldgrenzen, b11639) · `386d73f` · 2026-10-18 · Fund F69 — Java prüft `cache_reuse`, `slot_id`, `repeat_last_n` und `dry_penalty_last_n` auf ≥ 0 wie upstreams harte Grenzen; `n_keep` (−1 = alle), `sse_ping_interval` (−1 = aus) und `reasoning_budget_tokens` (−1 = unbegrenzt) stimmen mit den harten Untergrenzen −1 überein; weiche Grenzen (`top_k`, `top_p`, `min_p`, `temperature`, `mirostat`) klemmt upstream, Java lässt sie durch. Abweichung: `withNPredict` verspricht `-2` (F69). - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` + `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Musterdurchsicht nach F69: dokumentierte Sonderwerte ↔ `server-schema.cpp`/`common/arg.cpp`, b11639) · `386d73f`/`90d097c` · 2026-10-18 · kein weiterer Fund (zwei Doku-Notizen) — Anfrageseite: `top_n_sigma` (< 0 = aus), `t_max_predict_ms` (−1), `n_keep` (−1), `reasoning_budget_tokens` (−1), `sse_ping_interval` (−1) liegen in upstreams Grenzen, `dry_base` < 1 wird upstream ersetzt; einzig `n_predict = −2` fällt heraus (F69). Startoptionen: `-n −2` ist dort gültig. Reine Doku, nicht geändert: `setMirostat` sagt wörtlich „default: PLACEHOLDER“ (upstream 0), `setKeep` nennt „default: -1 = all“, upstreams Vorgabe ist `n_keep = 0`. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, nicht wie bei `llama-server` `--prompt` (eigene CLI, dokumentiert). From 10dea85d0038fde68beb75e33c90c93b79972c6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 23:57:00 +0000 Subject: [PATCH 104/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 8ed8e6300..26defb2eb 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -165,4 +165,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`) · `35d2521` · 2026-10-17 · kein Fund — Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` ↔ upstream `server-schema.cpp` (Schnittstelle Java-Setter ↔ Feldgrenzen, b11639) · `386d73f` · 2026-10-18 · Fund F69 — Java prüft `cache_reuse`, `slot_id`, `repeat_last_n` und `dry_penalty_last_n` auf ≥ 0 wie upstreams harte Grenzen; `n_keep` (−1 = alle), `sse_ping_interval` (−1 = aus) und `reasoning_budget_tokens` (−1 = unbegrenzt) stimmen mit den harten Untergrenzen −1 überein; weiche Grenzen (`top_k`, `top_p`, `min_p`, `temperature`, `mirostat`) klemmt upstream, Java lässt sie durch. Abweichung: `withNPredict` verspricht `-2` (F69). - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` + `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Musterdurchsicht nach F69: dokumentierte Sonderwerte ↔ `server-schema.cpp`/`common/arg.cpp`, b11639) · `386d73f`/`90d097c` · 2026-10-18 · kein weiterer Fund (zwei Doku-Notizen) — Anfrageseite: `top_n_sigma` (< 0 = aus), `t_max_predict_ms` (−1), `n_keep` (−1), `reasoning_budget_tokens` (−1), `sse_ping_interval` (−1) liegen in upstreams Grenzen, `dry_base` < 1 wird upstream ersetzt; einzig `n_predict = −2` fällt heraus (F69). Startoptionen: `-n −2` ist dort gültig. Reine Doku, nicht geändert: `setMirostat` sagt wörtlich „default: PLACEHOLDER“ (upstream 0), `setKeep` nennt „default: -1 = all“, upstreams Vorgabe ist `n_keep = 0`. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, nicht wie bei `llama-server` `--prompt` (eigene CLI, dokumentiert). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, upstream ist `-p` = `--prompt` (eigene CLI, dokumentiert). From 657e792497edfad212906756f98f21b0b089f195 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:04:27 +0000 Subject: [PATCH 105/589] Advertise the per-slot context when --parallel splits it With an explicit --parallel N llama.cpp does not unify the KV cache (it does so only when the slot count is "auto"), so each request gets n_ctx / N, and upstream's /props reports that per-slot size. OpenAiServerCli derived the advertised token budgets (maxInputTokens, maxOutputTokens, the context length of /api/show and /props) from the whole -c, so a client sized its prompts for four times the window a request can hold with -c 8192 --parallel 4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/server/OpenAiServerCli.java | 10 +++++++--- .../ladenthin/llama/server/OpenAiServerCliTest.java | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index fea260b78..200384056 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -673,7 +673,8 @@ public ModelParameters toModelParameters() { /** * Build the {@link OpenAiServerConfig} describing the server side of these options. When a - * context size is given, the advertised input/output token budgets are derived from it. + * context size is given, the advertised input/output token budgets are derived from it (per slot + * when {@code --parallel} is greater than one). * * @return the server configuration */ @@ -713,12 +714,15 @@ public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFty builder.apiKey(apiKey); } if (ctxSize > 0) { + // An explicit --parallel N splits the context: llama.cpp unifies the KV cache only when + // the slot count is "auto", so each request gets n_ctx / N (what upstream's /props reports). + final int slotCtx = parallel > 1 ? Math.max(1, ctxSize / parallel) : ctxSize; final int maxOutputTokens = - Math.min(OpenAiServerConfig.DEFAULT_MAX_OUTPUT_TOKENS, Math.max(1, ctxSize / 2)); + Math.min(OpenAiServerConfig.DEFAULT_MAX_OUTPUT_TOKENS, Math.max(1, slotCtx / 2)); builder.maxOutputTokens(maxOutputTokens); // The rest of the window, not ctx minus the output default: below twice that default // the output budget is ctx/2, and the subtraction of the default left 1 input token. - builder.maxInputTokens(Math.max(1, ctxSize - maxOutputTokens)); + builder.maxInputTokens(Math.max(1, slotCtx - maxOutputTokens)); } return builder.build(); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index 03f209045..6416cb08c 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -285,6 +285,16 @@ public void smallContextSizeSplitsTheWholeWindowBetweenInputAndOutput() { } } + @Test + public void parallelSlotsShareTheContextSoTheBudgetIsPerSlot() { + // With an explicit --parallel N (and no unified KV cache, which llama.cpp enables only when the + // slot count is "auto") every slot gets n_ctx / N, and upstream's /props reports that per-slot + // size. Advertising the whole -c made clients size prompts for 8192 tokens a request cannot hold. + OpenAiServerConfig config = OpenAiServerCli.parse("-m", "m.gguf", "-c", "8192", "--parallel", "4") + .toServerConfig(); + assertThat(config.getMaxInputTokens() + config.getMaxOutputTokens(), is(2048)); + } + @Test public void modelParametersIncludeModelPath() { String json = From f225a93dd0eed62a890987e2427d5aa104d8a31e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:04:28 +0000 Subject: [PATCH 106/589] Record F70 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 26defb2eb..e0ad4463d 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -166,3 +166,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` ↔ upstream `server-schema.cpp` (Schnittstelle Java-Setter ↔ Feldgrenzen, b11639) · `386d73f` · 2026-10-18 · Fund F69 — Java prüft `cache_reuse`, `slot_id`, `repeat_last_n` und `dry_penalty_last_n` auf ≥ 0 wie upstreams harte Grenzen; `n_keep` (−1 = alle), `sse_ping_interval` (−1 = aus) und `reasoning_budget_tokens` (−1 = unbegrenzt) stimmen mit den harten Untergrenzen −1 überein; weiche Grenzen (`top_k`, `top_p`, `min_p`, `temperature`, `mirostat`) klemmt upstream, Java lässt sie durch. Abweichung: `withNPredict` verspricht `-2` (F69). - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` + `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Musterdurchsicht nach F69: dokumentierte Sonderwerte ↔ `server-schema.cpp`/`common/arg.cpp`, b11639) · `386d73f`/`90d097c` · 2026-10-18 · kein weiterer Fund (zwei Doku-Notizen) — Anfrageseite: `top_n_sigma` (< 0 = aus), `t_max_predict_ms` (−1), `n_keep` (−1), `reasoning_budget_tokens` (−1), `sse_ping_interval` (−1) liegen in upstreams Grenzen, `dry_base` < 1 wird upstream ersetzt; einzig `n_predict = −2` fällt heraus (F69). Startoptionen: `-n −2` ist dort gültig. Reine Doku, nicht geändert: `setMirostat` sagt wörtlich „default: PLACEHOLDER“ (upstream 0), `setKeep` nennt „default: -1 = all“, upstreams Vorgabe ist `n_keep = 0`. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, upstream ist `-p` = `--prompt` (eigene CLI, dokumentiert). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`) + `OpenAiServerConfig` + `OpenAiCompatServer` (`/api/show`, `/props`) ↔ upstream Slot-Kontext (Schnittstelle beworbene Budgets ↔ tatsächliches Fenster, b11639) · `2003840` · 2026-10-18 · Fund F70 — Kontextlänge in `/api/show` und `/props` = `maxInputTokens + maxOutputTokens`; ohne `-c` gelten die Vorgaben; mit `--parallel N` war es das ganze `-c` statt des Fensters je Slot (F70). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index aacc28ef9..52a526a09 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -49,6 +49,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F67](#f67) — niedrig — *nur beschrieben* (CI-Bedarf: Modell + LoRA-Adapter) - [F68](#f68) — niedrig — *gefixt* - [F69](#f69) — niedrig — *nur beschrieben* (CI-Bedarf: modellgestützter Test) +- [F70](#f70) — mittel — *gefixt* ## Funde @@ -460,6 +461,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** nur beschrieben - **Commit:** – + +### F70 – java-llama.cpp: `OpenAiServerCli` bewirbt mit `--parallel N` das ganze `-c` statt des Kontexts je Slot +- **Zeit:** 2026-10-18 00:15 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`Options.toServerConfig`) +- **Schwere:** mittel +- **Beschreibung:** Aus `-c` leitet die CLI die beworbenen Budgets ab: `maxInputTokens`/`maxOutputTokens` (die Copilot & Co. zum Zuschneiden des Prompts nutzen) und daraus die Kontextlänge in `/api/show` und `/props`. Bei expliziter `--parallel N` vereinheitlicht llama.cpp den KV-Cache nicht (`-kvu`: „default: enabled if number of slots is auto“, `common/arg.cpp`, b11639), jeder Slot und damit jede Anfrage bekommt `n_ctx / N`, und upstreams eigenes `/props` meldet genau diese Slot-Größe (`slot_n_ctx`, `server-context.cpp:5083`). Mit `-c 8192 --parallel 4` warb der Server aber 8192 Token: Clients füllten Prompts auf das Vierfache dessen, was eine Anfrage fassen kann, und liefen in „exceeds the available context size“. +- **Fix:** Bei `parallel > 1` wird das Fenster je Slot (`ctx / parallel`) als Grundlage der Budgets genommen; ohne `--parallel` (Slotzahl „auto“, vereinheitlichter Cache) bleibt es das ganze `-c`. +- **Reproduktion:** `OpenAiServerCliTest#parallelSlotsShareTheContextSoTheBudgetIsPerSlot` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2048> but: was <8192>“), mit Fix grün, `OpenAiServerCliTest` 39/39; Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `c1eaa50` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 9ff3908547fb74ef81132185be12bc71674df963 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:06:39 +0000 Subject: [PATCH 107/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index e0ad4463d..2db7e217c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -167,3 +167,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` + `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Musterdurchsicht nach F69: dokumentierte Sonderwerte ↔ `server-schema.cpp`/`common/arg.cpp`, b11639) · `386d73f`/`90d097c` · 2026-10-18 · kein weiterer Fund (zwei Doku-Notizen) — Anfrageseite: `top_n_sigma` (< 0 = aus), `t_max_predict_ms` (−1), `n_keep` (−1), `reasoning_budget_tokens` (−1), `sse_ping_interval` (−1) liegen in upstreams Grenzen, `dry_base` < 1 wird upstream ersetzt; einzig `n_predict = −2` fällt heraus (F69). Startoptionen: `-n −2` ist dort gültig. Reine Doku, nicht geändert: `setMirostat` sagt wörtlich „default: PLACEHOLDER“ (upstream 0), `setKeep` nennt „default: -1 = all“, upstreams Vorgabe ist `n_keep = 0`. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, upstream ist `-p` = `--prompt` (eigene CLI, dokumentiert). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`) + `OpenAiServerConfig` + `OpenAiCompatServer` (`/api/show`, `/props`) ↔ upstream Slot-Kontext (Schnittstelle beworbene Budgets ↔ tatsächliches Fenster, b11639) · `2003840` · 2026-10-18 · Fund F70 — Kontextlänge in `/api/show` und `/props` = `maxInputTokens + maxOutputTokens`; ohne `-c` gelten die Vorgaben; mit `--parallel N` war es das ganze `-c` statt des Fensters je Slot (F70). +- Musterdurchsicht nach F70 (Kontextlänge aus `n_ctx` abgeleitet) über `llama/src/main/java`, `llama-langchain4j`, `llama-kotlin`, `llama-ffm` · – · 2026-10-18 · kein weiterer Fund (Verdacht notiert) — Einzige Ableitung ist `OpenAiServerCli.toServerConfig`; `OpenAiCompatServer` meldet in `/api/show` und `/props` nur `maxInputTokens + maxOutputTokens`; `RouterModel.contextLength` und `ModelMeta` geben upstreams Werte unverändert weiter. Ohne `-c` werden feste Vorgaben beworben (offener Verdacht). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 52a526a09..ce00e15b3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -479,3 +479,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. +- *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. From b28efbc090bfb05465bb2cae87b510d8e5621735 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:11:39 +0000 Subject: [PATCH 108/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2db7e217c..ad7e4e82e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -168,3 +168,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, upstream ist `-p` = `--prompt` (eigene CLI, dokumentiert). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`) + `OpenAiServerConfig` + `OpenAiCompatServer` (`/api/show`, `/props`) ↔ upstream Slot-Kontext (Schnittstelle beworbene Budgets ↔ tatsächliches Fenster, b11639) · `2003840` · 2026-10-18 · Fund F70 — Kontextlänge in `/api/show` und `/props` = `maxInputTokens + maxOutputTokens`; ohne `-c` gelten die Vorgaben; mit `--parallel N` war es das ganze `-c` statt des Fensters je Slot (F70). - Musterdurchsicht nach F70 (Kontextlänge aus `n_ctx` abgeleitet) über `llama/src/main/java`, `llama-langchain4j`, `llama-kotlin`, `llama-ffm` · – · 2026-10-18 · kein weiterer Fund (Verdacht notiert) — Einzige Ableitung ist `OpenAiServerCli.toServerConfig`; `OpenAiCompatServer` meldet in `/api/show` und `/props` nur `maxInputTokens + maxOutputTokens`; `RouterModel.contextLength` und `ModelMeta` geben upstreams Werte unverändert weiter. Ohne `-c` werden feste Vorgaben beworben (offener Verdacht). +- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` ↔ `llama/src/main/cpp/jllama_api.h` (`jllama_tts_*`) / `tts_engine.cpp` (Schnittstelle Lebenszyklus und Nebenläufigkeit) · `7b6b88d` · 2026-10-18 · kein Fund — Der C-Vertrag erlaubt „one synthesis at a time per handle“, Java nimmt für `synthesize` nur die Lese-Sperre; das ist sicher, weil `tts_engine.cpp` jede Synthese unter dem `synthesize_mutex` der Engine ausführt. `close()` wartet über die Schreib-Sperre auf laufende Synthesen, gibt einmal frei und setzt den Handle auf 0, danach meldet `synthesize` `IllegalStateException`. `seed` (Java `int`) wird zu `unsigned int`; `gpuLayers` 0 im Kurzkonstruktor heißt CPU, −1 (auto) ist explizit übergebbar. From 486718983b08cc5c97468f768da236b973fee77e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:28:22 +0000 Subject: [PATCH 109/589] Escape non-ASCII in the training config so it survives JNI The native trainer takes the configuration JSON through GetStringUTFChars, which yields Modified UTF-8: a character outside the BMP (an emoji in the training text or in a path) becomes two 3-byte surrogates, and nlohmann rejects that as "ill-formed UTF-8 byte", so the whole run failed with "invalid training config". TrainingParameters now writes every non-ASCII character as a \u escape; ASCII is identical in both encodings and nlohmann decodes the escapes, surrogate pairs included, into proper UTF-8. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../llama/parameters/TrainingParameters.java | 20 ++++++++++++++++++- .../parameters/TrainingParametersTest.java | 19 ++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java index 8447e351a..12ecdbff5 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java @@ -115,7 +115,25 @@ public String toJson() { node.put(TrainingField.VAL_SPLIT.getKey(), valSplit); node.put(TrainingField.N_BATCH.getKey(), nBatch); node.put(TrainingField.N_UBATCH.getKey(), nUbatch); - return node.toString(); + return escapeNonAscii(node.toString()); + } + + // The native trainer takes the JSON through GetStringUTFChars, which yields Modified UTF-8: a + // character outside the BMP becomes two 3-byte surrogates, which its JSON parser rejects as + // ill-formed UTF-8. Only ASCII is the same in both, so everything else travels as a backslash-u escape + // (outside a string literal JSON has no non-ASCII, so escaping every such char is always valid). + static String escapeNonAscii(String json) { + final int length = json.length(); + StringBuilder escaped = new StringBuilder(length); + for (int i = 0; i < length; i++) { + char c = json.charAt(i); + if (c < 0x80) { + escaped.append(c); + } else { + escaped.append(String.format(java.util.Locale.ROOT, "\\u%04x", (int) c)); + } + } + return escaped.toString(); } // The native trainer reads the JSON with defaults and checks none of these: epochs below 1 wrote the diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java index 9df4d3f7b..5f8048331 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/TrainingParametersTest.java @@ -45,6 +45,25 @@ void defaultsSerializeWithExpectedValues() throws Exception { assertThat(node.has("training_file"), is(false)); } + @Test + void nonAsciiTextIsEscapedSoTheJsonCrossesJniIntact() throws Exception { + // The native trainer takes the JSON through GetStringUTFChars, which yields Modified UTF-8: a + // character outside the BMP (an emoji) becomes two 3-byte surrogates, which nlohmann rejects + // as "ill-formed UTF-8" -- the whole run failed with "invalid training config". Only ASCII + // survives Modified UTF-8 unchanged, so the JSON must carry everything else as \\u escapes. + String text = "Grüße \uD83D\uDE00 und 中文"; + TrainingParameters parameters = TrainingParameters.builder() + .modelPath(Paths.get("base.gguf")) + .trainingText(text) + .outputPath(Paths.get("tuned.gguf")) + .build(); + + String jsonText = parameters.toJson(); + + assertThat(jsonText.chars().filter(c -> c > 0x7F).count(), is(0L)); + assertThat(MAPPER.readTree(jsonText).get("training_text").asText(), is(text)); + } + @Test void customValuesSerialize() throws Exception { TrainingParameters parameters = TrainingParameters.builder() From 3bf221f1c9044bea9a974742c78c10bb81a053b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:28:22 +0000 Subject: [PATCH 110/589] Record F71 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ad7e4e82e..57a911811 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -169,3 +169,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`) + `OpenAiServerConfig` + `OpenAiCompatServer` (`/api/show`, `/props`) ↔ upstream Slot-Kontext (Schnittstelle beworbene Budgets ↔ tatsächliches Fenster, b11639) · `2003840` · 2026-10-18 · Fund F70 — Kontextlänge in `/api/show` und `/props` = `maxInputTokens + maxOutputTokens`; ohne `-c` gelten die Vorgaben; mit `--parallel N` war es das ganze `-c` statt des Fensters je Slot (F70). - Musterdurchsicht nach F70 (Kontextlänge aus `n_ctx` abgeleitet) über `llama/src/main/java`, `llama-langchain4j`, `llama-kotlin`, `llama-ffm` · – · 2026-10-18 · kein weiterer Fund (Verdacht notiert) — Einzige Ableitung ist `OpenAiServerCli.toServerConfig`; `OpenAiCompatServer` meldet in `/api/show` und `/props` nur `maxInputTokens + maxOutputTokens`; `RouterModel.contextLength` und `ModelMeta` geben upstreams Werte unverändert weiter. Ohne `-c` werden feste Vorgaben beworben (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` ↔ `llama/src/main/cpp/jllama_api.h` (`jllama_tts_*`) / `tts_engine.cpp` (Schnittstelle Lebenszyklus und Nebenläufigkeit) · `7b6b88d` · 2026-10-18 · kein Fund — Der C-Vertrag erlaubt „one synthesis at a time per handle“, Java nimmt für `synthesize` nur die Lese-Sperre; das ist sicher, weil `tts_engine.cpp` jede Synthese unter dem `synthesize_mutex` der Engine ausführt. `close()` wartet über die Schreib-Sperre auf laufende Synthesen, gibt einmal frei und setzt den Handle auf 0, danach meldet `synthesize` `IllegalStateException`. `seed` (Java `int`) wird zu `unsigned int`; `gpuLayers` 0 im Kurzkonstruktor heißt CPU, −1 (auto) ist explizit übergebbar. +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` + `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` ↔ `train_engine.cpp`/`train_params.hpp` (Schnittstelle Trainingskonfiguration über JNI) · `12ecdbf` · 2026-10-18 · Fund F71 — Schlüssel und Vorgaben stimmen (`epochs` 2, `learning_rate` 1e-5, `val_split` 0.05, `n_gpu_layers` −1, `n_batch`/`n_ubatch` 0 = Vorgabe); Java prüft, was die Engine nicht prüft (genau ein Korpus, `epochs` ≥ 1, `val_split` in [0, 1)). Die Übergabe per `GetStringUTFChars` zerbrach Zeichen außerhalb der BMP (F71). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ce00e15b3..ff32861ea 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -50,6 +50,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F68](#f68) — niedrig — *gefixt* - [F69](#f69) — niedrig — *nur beschrieben* (CI-Bedarf: modellgestützter Test) - [F70](#f70) — mittel — *gefixt* +- [F71](#f71) — niedrig — *gefixt* ## Funde @@ -473,6 +474,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `c1eaa50` + +### F71 – java-llama.cpp: Feintuning scheitert an jedem Zeichen außerhalb der BMP (Emoji) im Trainingstext +- **Zeit:** 2026-10-18 00:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` (`toJson`), Gegenstück `llama/src/main/cpp/train_engine.cpp:141` +- **Schwere:** niedrig +- **Beschreibung:** `LlamaTrainer.finetune` übergibt `TrainingParameters.toJson()`, und die JNI-Schicht liest den String mit `GetStringUTFChars` – Modified UTF-8. Jackson schreibt Nicht-ASCII roh in die JSON, ein Zeichen außerhalb der BMP (Emoji, seltene CJK-Zeichen) kommt deshalb als zwei 3-Byte-Surrogate (CESU-8) beim Parser an. Das vendorte nlohmann am Pin lehnt das ab – gemessen mit `vendor/nlohmann/json.hpp` von b11639: echtes UTF-8 `F0 9F 98 80` wird gelesen, die CESU-8-Form `ED A0 BD ED B8 80` endet in `parse_error.101 … invalid string: ill-formed UTF-8 byte`. Der ganze Lauf scheiterte also mit „invalid training config“, sobald Trainingstext oder ein Pfad ein solches Zeichen enthielt. Die beiden anderen JNI-Stellen, die Text übernehmen (`jllama.cpp:248`, `native_server.cpp:33`), umgehen `GetStringUTFChars` ausdrücklich; nur der Trainer nicht. +- **Fix:** `toJson` maskiert jedes Zeichen > 0x7F als `\uXXXX` (`escapeNonAscii`): ASCII ist in beiden Kodierungen gleich, und nlohmann setzt die Maskierungen samt Surrogatpaaren wieder zu richtigem UTF-8 zusammen. Bewusst nicht über `ESCAPE_NON_ASCII` am Mapper: `ObjectNode.toString()` nutzt Jacksons eigenen Mapper, und `writeValueAsString` hätte eine geprüfte Ausnahme eingeführt (SpotBugs `EXS_EXCEPTION_SOFTENING_NO_CONSTRAINTS`). +- **Reproduktion:** `TrainingParametersTest#nonAsciiTextIsEscapedSoTheJsonCrossesJniIntact` (nur ASCII in der JSON, Rücklesen ergibt den Originaltext); der native Fehler selbst ist mit dem nlohmann-Probelauf oben belegt. +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <0L> but: was <6L>“ Nicht-ASCII-Zeichen), mit Fix grün, `TrainingParametersTest` 8/8; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **CI-Bedarf:** ein Ende-zu-Ende-Beweis bräuchte `LlamaTrainerIntegrationTest` mit einem Emoji im Trainingstext (Modell `stories260K.gguf` liegt in CI vor). +- **Status:** gefixt +- **Commit:** java-llama.cpp `ca6ccdf` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 46bd10a3df46e63bf71a5e095b94e966c35e5c19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:30:28 +0000 Subject: [PATCH 111/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 57a911811..f9ab5d02b 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -170,3 +170,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - Musterdurchsicht nach F70 (Kontextlänge aus `n_ctx` abgeleitet) über `llama/src/main/java`, `llama-langchain4j`, `llama-kotlin`, `llama-ffm` · – · 2026-10-18 · kein weiterer Fund (Verdacht notiert) — Einzige Ableitung ist `OpenAiServerCli.toServerConfig`; `OpenAiCompatServer` meldet in `/api/show` und `/props` nur `maxInputTokens + maxOutputTokens`; `RouterModel.contextLength` und `ModelMeta` geben upstreams Werte unverändert weiter. Ohne `-c` werden feste Vorgaben beworben (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` ↔ `llama/src/main/cpp/jllama_api.h` (`jllama_tts_*`) / `tts_engine.cpp` (Schnittstelle Lebenszyklus und Nebenläufigkeit) · `7b6b88d` · 2026-10-18 · kein Fund — Der C-Vertrag erlaubt „one synthesis at a time per handle“, Java nimmt für `synthesize` nur die Lese-Sperre; das ist sicher, weil `tts_engine.cpp` jede Synthese unter dem `synthesize_mutex` der Engine ausführt. `close()` wartet über die Schreib-Sperre auf laufende Synthesen, gibt einmal frei und setzt den Handle auf 0, danach meldet `synthesize` `IllegalStateException`. `seed` (Java `int`) wird zu `unsigned int`; `gpuLayers` 0 im Kurzkonstruktor heißt CPU, −1 (auto) ist explizit übergebbar. - `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` + `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` ↔ `train_engine.cpp`/`train_params.hpp` (Schnittstelle Trainingskonfiguration über JNI) · `12ecdbf` · 2026-10-18 · Fund F71 — Schlüssel und Vorgaben stimmen (`epochs` 2, `learning_rate` 1e-5, `val_split` 0.05, `n_gpu_layers` −1, `n_batch`/`n_ubatch` 0 = Vorgabe); Java prüft, was die Engine nicht prüft (genau ein Korpus, `epochs` ≥ 1, `val_split` in [0, 1)). Die Übergabe per `GetStringUTFChars` zerbrach Zeichen außerhalb der BMP (F71). +- Musterdurchsicht nach F71 (Modified UTF-8 an der JNI-Grenze) über `llama/src/main/cpp/*` als Vertragsreferenz · – · 2026-10-18 · kein Fund im Ziel (Verdacht außerhalb notiert) — Eingehend nutzt nur `train_engine.cpp` `GetStringUTFChars` (behoben über F71 auf der Java-Seite); `jllama.cpp` und `native_server.cpp` gehen ausdrücklich über `String.getBytes("UTF-8")`. Ausgehend gehen Nutzdaten über `utf8_to_jstring_impl`; nur `jllama_build_info` (ASCII), Gerätenamen in `rpc_bridge.cpp` und die Fehlermeldungen nutzen `NewStringUTF`/`ThrowNew` (offener Verdacht, C++). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ff32861ea..4fd76ce2e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -494,3 +494,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. +- *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. From 663cef05f8cc14ecd465721cce495078cdb3f784 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:35:00 +0000 Subject: [PATCH 112/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f9ab5d02b..3a6e539f6 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -171,3 +171,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` ↔ `llama/src/main/cpp/jllama_api.h` (`jllama_tts_*`) / `tts_engine.cpp` (Schnittstelle Lebenszyklus und Nebenläufigkeit) · `7b6b88d` · 2026-10-18 · kein Fund — Der C-Vertrag erlaubt „one synthesis at a time per handle“, Java nimmt für `synthesize` nur die Lese-Sperre; das ist sicher, weil `tts_engine.cpp` jede Synthese unter dem `synthesize_mutex` der Engine ausführt. `close()` wartet über die Schreib-Sperre auf laufende Synthesen, gibt einmal frei und setzt den Handle auf 0, danach meldet `synthesize` `IllegalStateException`. `seed` (Java `int`) wird zu `unsigned int`; `gpuLayers` 0 im Kurzkonstruktor heißt CPU, −1 (auto) ist explizit übergebbar. - `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` + `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` ↔ `train_engine.cpp`/`train_params.hpp` (Schnittstelle Trainingskonfiguration über JNI) · `12ecdbf` · 2026-10-18 · Fund F71 — Schlüssel und Vorgaben stimmen (`epochs` 2, `learning_rate` 1e-5, `val_split` 0.05, `n_gpu_layers` −1, `n_batch`/`n_ubatch` 0 = Vorgabe); Java prüft, was die Engine nicht prüft (genau ein Korpus, `epochs` ≥ 1, `val_split` in [0, 1)). Die Übergabe per `GetStringUTFChars` zerbrach Zeichen außerhalb der BMP (F71). - Musterdurchsicht nach F71 (Modified UTF-8 an der JNI-Grenze) über `llama/src/main/cpp/*` als Vertragsreferenz · – · 2026-10-18 · kein Fund im Ziel (Verdacht außerhalb notiert) — Eingehend nutzt nur `train_engine.cpp` `GetStringUTFChars` (behoben über F71 auf der Java-Seite); `jllama.cpp` und `native_server.cpp` gehen ausdrücklich über `String.getBytes("UTF-8")`. Ausgehend gehen Nutzdaten über `utf8_to_jstring_impl`; nur `jllama_build_info` (ASCII), Gerätenamen in `rpc_bridge.cpp` und die Fehlermeldungen nutzen `NewStringUTF`/`ThrowNew` (offener Verdacht, C++). +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM) · `11dd00a`/`41c9e8b` · 2026-10-18 · kein Fund (nur gelesen, kein JDK 22 hier) — Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. From a7c22eaeb446c21803d04e9f86c765f035fd2cb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:40:49 +0000 Subject: [PATCH 113/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3a6e539f6..af7af8544 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -172,3 +172,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` + `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` ↔ `train_engine.cpp`/`train_params.hpp` (Schnittstelle Trainingskonfiguration über JNI) · `12ecdbf` · 2026-10-18 · Fund F71 — Schlüssel und Vorgaben stimmen (`epochs` 2, `learning_rate` 1e-5, `val_split` 0.05, `n_gpu_layers` −1, `n_batch`/`n_ubatch` 0 = Vorgabe); Java prüft, was die Engine nicht prüft (genau ein Korpus, `epochs` ≥ 1, `val_split` in [0, 1)). Die Übergabe per `GetStringUTFChars` zerbrach Zeichen außerhalb der BMP (F71). - Musterdurchsicht nach F71 (Modified UTF-8 an der JNI-Grenze) über `llama/src/main/cpp/*` als Vertragsreferenz · – · 2026-10-18 · kein Fund im Ziel (Verdacht außerhalb notiert) — Eingehend nutzt nur `train_engine.cpp` `GetStringUTFChars` (behoben über F71 auf der Java-Seite); `jllama.cpp` und `native_server.cpp` gehen ausdrücklich über `String.getBytes("UTF-8")`. Ausgehend gehen Nutzdaten über `utf8_to_jstring_impl`; nur `jllama_build_info` (ASCII), Gerätenamen in `rpc_bridge.cpp` und die Fehlermeldungen nutzen `NewStringUTF`/`ThrowNew` (offener Verdacht, C++). - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM) · `11dd00a`/`41c9e8b` · 2026-10-18 · kein Fund (nur gelesen, kein JDK 22 hier) — Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. +- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages`) ↔ upstream `common_chat_msgs_parse_oaicompat` (`common/chat.cpp:342`, b11639; Schnittstelle Nachrichten-Array ↔ Pflichtfelder) · `408b4ae` · 2026-10-18 · kein Fund — Jede Nachricht trägt `role` und `content` (String oder Teile-Array), Werkzeugaufrufe `id`, `type: "function"`, `function.name` und `function.arguments` als String, Werkzeugantworten `tool_call_id`: keine der `throw std::invalid_argument`-Stellen (fehlende Rolle, fehlender Typ/Name, weder `content` noch `tool_calls`, falscher `content`-Typ) ist erreichbar. Teile vom Typ `image_url`/`input_audio` wandelt upstreams Serververarbeitung vorher in Medienmarker; `reasoning_content` früherer Antworten und `name` werden nicht mitgeschickt (beide optional). From 345041723273054438d2400568f23e6bf09cfb50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:43:14 +0000 Subject: [PATCH 114/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 4fd76ce2e..b37fde73d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -495,3 +495,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. +- *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. From 987a55f203f029154a47f7bfda26e353f3405eb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:45:35 +0000 Subject: [PATCH 115/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index af7af8544..543ca037c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -173,3 +173,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - Musterdurchsicht nach F71 (Modified UTF-8 an der JNI-Grenze) über `llama/src/main/cpp/*` als Vertragsreferenz · – · 2026-10-18 · kein Fund im Ziel (Verdacht außerhalb notiert) — Eingehend nutzt nur `train_engine.cpp` `GetStringUTFChars` (behoben über F71 auf der Java-Seite); `jllama.cpp` und `native_server.cpp` gehen ausdrücklich über `String.getBytes("UTF-8")`. Ausgehend gehen Nutzdaten über `utf8_to_jstring_impl`; nur `jllama_build_info` (ASCII), Gerätenamen in `rpc_bridge.cpp` und die Fehlermeldungen nutzen `NewStringUTF`/`ThrowNew` (offener Verdacht, C++). - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM) · `11dd00a`/`41c9e8b` · 2026-10-18 · kein Fund (nur gelesen, kein JDK 22 hier) — Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages`) ↔ upstream `common_chat_msgs_parse_oaicompat` (`common/chat.cpp:342`, b11639; Schnittstelle Nachrichten-Array ↔ Pflichtfelder) · `408b4ae` · 2026-10-18 · kein Fund — Jede Nachricht trägt `role` und `content` (String oder Teile-Array), Werkzeugaufrufe `id`, `type: "function"`, `function.name` und `function.arguments` als String, Werkzeugantworten `tool_call_id`: keine der `throw std::invalid_argument`-Stellen (fehlende Rolle, fehlender Typ/Name, weder `content` noch `tool_calls`, falscher `content`-Typ) ist erreichbar. Teile vom Typ `image_url`/`input_audio` wandelt upstreams Serververarbeitung vorher in Medienmarker; `reasoning_content` früherer Antworten und `name` werden nicht mitgeschickt (beide optional). +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` + `AnthropicApiSupport.java` + `AnthropicStreamTranslator.java` + `ResponsesApiSupport.java` (Musterdurchsicht leere Werkzeug-Argumente) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — Rückweg OpenAI → Ollama/Anthropic wandelt `""` und Unparsbares in ein leeres Objekt (F37/F38); Hinweg Responses/Anthropic → OpenAI kann `""` weitergeben (offener Verdacht „Leere Werkzeug-Argumente“). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b37fde73d..230f0ce3c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -495,4 +495,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. -- *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. +- *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. From e362ea635c8ed65efa502ad50f6a84847f5c4040 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:47:50 +0000 Subject: [PATCH 116/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 543ca037c..b33c9075b 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -174,3 +174,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM) · `11dd00a`/`41c9e8b` · 2026-10-18 · kein Fund (nur gelesen, kein JDK 22 hier) — Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages`) ↔ upstream `common_chat_msgs_parse_oaicompat` (`common/chat.cpp:342`, b11639; Schnittstelle Nachrichten-Array ↔ Pflichtfelder) · `408b4ae` · 2026-10-18 · kein Fund — Jede Nachricht trägt `role` und `content` (String oder Teile-Array), Werkzeugaufrufe `id`, `type: "function"`, `function.name` und `function.arguments` als String, Werkzeugantworten `tool_call_id`: keine der `throw std::invalid_argument`-Stellen (fehlende Rolle, fehlender Typ/Name, weder `content` noch `tool_calls`, falscher `content`-Typ) ist erreichbar. Teile vom Typ `image_url`/`input_audio` wandelt upstreams Serververarbeitung vorher in Medienmarker; `reasoning_content` früherer Antworten und `name` werden nicht mitgeschickt (beide optional). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` + `AnthropicApiSupport.java` + `AnthropicStreamTranslator.java` + `ResponsesApiSupport.java` (Musterdurchsicht leere Werkzeug-Argumente) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — Rückweg OpenAI → Ollama/Anthropic wandelt `""` und Unparsbares in ein leeres Objekt (F37/F38); Hinweg Responses/Anthropic → OpenAI kann `""` weitergeben (offener Verdacht „Leere Werkzeug-Argumente“). +- `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` + `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639) · `cb51433`/`707296e` · 2026-10-18 · kein Fund (Randnotiz) — Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. From 8fdce48b01e97b975cfb696b243427e104d539fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:50:55 +0000 Subject: [PATCH 117/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index b33c9075b..a78ad193a 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -175,3 +175,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages`) ↔ upstream `common_chat_msgs_parse_oaicompat` (`common/chat.cpp:342`, b11639; Schnittstelle Nachrichten-Array ↔ Pflichtfelder) · `408b4ae` · 2026-10-18 · kein Fund — Jede Nachricht trägt `role` und `content` (String oder Teile-Array), Werkzeugaufrufe `id`, `type: "function"`, `function.name` und `function.arguments` als String, Werkzeugantworten `tool_call_id`: keine der `throw std::invalid_argument`-Stellen (fehlende Rolle, fehlender Typ/Name, weder `content` noch `tool_calls`, falscher `content`-Typ) ist erreichbar. Teile vom Typ `image_url`/`input_audio` wandelt upstreams Serververarbeitung vorher in Medienmarker; `reasoning_content` früherer Antworten und `name` werden nicht mitgeschickt (beide optional). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` + `AnthropicApiSupport.java` + `AnthropicStreamTranslator.java` + `ResponsesApiSupport.java` (Musterdurchsicht leere Werkzeug-Argumente) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — Rückweg OpenAI → Ollama/Anthropic wandelt `""` und Unparsbares in ein leeres Objekt (F37/F38); Hinweg Responses/Anthropic → OpenAI kann `""` weitergeben (offener Verdacht „Leere Werkzeug-Argumente“). - `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` + `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639) · `cb51433`/`707296e` · 2026-10-18 · kein Fund (Randnotiz) — Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`anthropicStopReason`, Antwortgerüst) + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Status) ↔ upstream `to_json_anthropic`/`to_json_oaicompat_resp` (`server-task.cpp:723`, `:541`, b11639) · `b59c4ee`/`b755abd` · 2026-10-18 · kein Fund (Randnotiz) — `length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn` wie upstream; `input_tokens` = Prompt minus Cache, `cache_read_input_tokens` wie upstream; Responses meldet wie upstream immer `completed`. Abweichung ohne Fund: upstream füllt `stop_sequence` mit dem ausgelösten Stoppwort, die Übersetzung schreibt immer `null`, weil die OpenAI-Antwort, aus der sie übersetzt, kein `stopping_word` trägt. From 754127d49aca785e70a2e969a309d312f2a7704d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:54:21 +0000 Subject: [PATCH 118/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a78ad193a..02baf8f1e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -176,3 +176,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` + `AnthropicApiSupport.java` + `AnthropicStreamTranslator.java` + `ResponsesApiSupport.java` (Musterdurchsicht leere Werkzeug-Argumente) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — Rückweg OpenAI → Ollama/Anthropic wandelt `""` und Unparsbares in ein leeres Objekt (F37/F38); Hinweg Responses/Anthropic → OpenAI kann `""` weitergeben (offener Verdacht „Leere Werkzeug-Argumente“). - `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` + `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639) · `cb51433`/`707296e` · 2026-10-18 · kein Fund (Randnotiz) — Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`anthropicStopReason`, Antwortgerüst) + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Status) ↔ upstream `to_json_anthropic`/`to_json_oaicompat_resp` (`server-task.cpp:723`, `:541`, b11639) · `b59c4ee`/`b755abd` · 2026-10-18 · kein Fund (Randnotiz) — `length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn` wie upstream; `input_tokens` = Prompt minus Cache, `cache_read_input_tokens` wie upstream; Responses meldet wie upstream immer `completed`. Abweichung ohne Fund: upstream füllt `stop_sequence` mit dem ausgelösten Stoppwort, die Übersetzung schreibt immer `null`, weil die OpenAI-Antwort, aus der sie übersetzt, kein `stopping_word` trägt. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `ResponsesStreamTranslator.java` + `OpenAiCompatServer.java` (`withUsageChunk`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:576`, b11639; Schnittstelle Usage) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `input_tokens` = alle Prompt-Token inkl. Cache, `output_tokens`, `total_tokens` = Summe, `input_tokens_details.cached_tokens` wie upstream, blockierend und gestreamt; die Anthropic- und Responses-Streams fordern `include_usage` an. Der Ollama-Stream nicht (Nachtrag zum `done_reason`-Verdacht). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 230f0ce3c..6169d9658 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -490,7 +490,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. -- *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. +- *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18: Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. From 5a0b83489eea0e4152d032c825ade3d57d5fc0cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:13:32 +0000 Subject: [PATCH 119/589] Reject nested GGUF arrays in GgufInspector like ggml does ggml's gguf reader rejects an array whose element type is an array ("invalid GGUF type"), so llama.cpp cannot load such a file. The pure-Java inspector accepted them and read them recursively: a crafted file with deep nesting (12 bytes per level, one stack frame each) ended the read in a StackOverflowError instead of the IOException the method declares for an invalid file, and a shallow nesting was reported as valid metadata. readArray now throws an IOException for an array element type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/GgufInspector.java | 6 ++++ .../ladenthin/llama/GgufInspectorTest.java | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/llama/src/main/java/net/ladenthin/llama/GgufInspector.java b/llama/src/main/java/net/ladenthin/llama/GgufInspector.java index eda2bb692..3b4fafceb 100644 --- a/llama/src/main/java/net/ladenthin/llama/GgufInspector.java +++ b/llama/src/main/java/net/ladenthin/llama/GgufInspector.java @@ -176,6 +176,12 @@ private static Object readValue(Reader reader, int type) throws IOException { private static Object readArray(Reader reader) throws IOException { int elementType = (int) reader.u32(); + // ggml's gguf reader rejects arrays of arrays ("invalid GGUF type"), so llama.cpp cannot load such + // a file; accepting them here also let a crafted nesting recurse into a StackOverflowError. + if (elementType == TYPE_ARRAY) { + throw new IOException( + "Nested GGUF arrays are not supported (llama.cpp rejects them): element type id " + elementType); + } long count = reader.u64(); if (count < 0 || count > MAX_ARRAY_ELEMENTS) { throw new IOException("Implausible GGUF array element count: " + count); diff --git a/llama/src/test/java/net/ladenthin/llama/GgufInspectorTest.java b/llama/src/test/java/net/ladenthin/llama/GgufInspectorTest.java index bad79f740..2ebe380fd 100644 --- a/llama/src/test/java/net/ladenthin/llama/GgufInspectorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/GgufInspectorTest.java @@ -225,6 +225,36 @@ public void failsLoudOnTruncatedFile() { assertThrows(IOException.class, () -> GgufInspector.read(new ByteArrayInputStream(truncated))); } + /** A file with one key whose value is {@code depth} arrays nested in each other (one element each). */ + private static byte[] nestedArrayGguf(int depth) { + GgufWriter writer = new GgufWriter(ByteOrder.LITTLE_ENDIAN); + writer.magic().u32(3).u64(0).u64(1); + writer.str("nested").u32(9); // the value is an array ... + for (int level = 1; level < depth; level++) { + writer.u32(9).u64(1); // ... of one array ... + } + writer.u32(5).u64(1).u32(42); // ... of one int32 + return writer.bytes(); + } + + @Test + public void nestedArrayIsRejectedLikeUpstream() { + // ggml's gguf reader rejects an array whose element type is an array ("invalid GGUF type"), + // so llama.cpp cannot load such a file; the inspector must not report it as valid metadata. + byte[] nested = nestedArrayGguf(2); + + assertThrows(IOException.class, () -> GgufInspector.read(new ByteArrayInputStream(nested))); + } + + @Test + public void deeplyNestedArraysFailWithIOExceptionNotStackOverflow() { + // Each nesting level costs 12 bytes but one recursion frame; a crafted file ended the read in a + // StackOverflowError instead of the IOException the method declares for an invalid file. + byte[] nested = nestedArrayGguf(100_000); + + assertThrows(IOException.class, () -> GgufInspector.read(new ByteArrayInputStream(nested))); + } + @Test public void readsRealModelFileWhenPresent() throws IOException { // Real-file sanity (CI: the shared GGUF cache; locally self-skips without models/). From 087b8b89a1877106bbf28faaae0ac9bf73597d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:13:32 +0000 Subject: [PATCH 120/589] Record F72 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 02baf8f1e..f53c23a6e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -177,3 +177,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` + `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639) · `cb51433`/`707296e` · 2026-10-18 · kein Fund (Randnotiz) — Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`anthropicStopReason`, Antwortgerüst) + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Status) ↔ upstream `to_json_anthropic`/`to_json_oaicompat_resp` (`server-task.cpp:723`, `:541`, b11639) · `b59c4ee`/`b755abd` · 2026-10-18 · kein Fund (Randnotiz) — `length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn` wie upstream; `input_tokens` = Prompt minus Cache, `cache_read_input_tokens` wie upstream; Responses meldet wie upstream immer `completed`. Abweichung ohne Fund: upstream füllt `stop_sequence` mit dem ausgelösten Stoppwort, die Übersetzung schreibt immer `null`, weil die OpenAI-Antwort, aus der sie übersetzt, kein `stopping_word` trägt. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `ResponsesStreamTranslator.java` + `OpenAiCompatServer.java` (`withUsageChunk`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:576`, b11639; Schnittstelle Usage) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `input_tokens` = alle Prompt-Token inkl. Cache, `output_tokens`, `total_tokens` = Summe, `input_tokens_details.cached_tokens` wie upstream, blockierend und gestreamt; die Anthropic- und Responses-Streams fordern `include_usage` an. Der Ollama-Stream nicht (Nachtrag zum `done_reason`-Verdacht). +- `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` ↔ `ggml/src/gguf.cpp` (Schnittstelle GGUF-Metadatenleser ↔ upstream-Leser, b11639) · `3b4fafc` · 2026-10-18 · Fund F72 — Typ-Ids, Kopf (Magie, Version mit Byte-Order-Erkennung, Tensor- und KV-Anzahl) und Grenzen vor Allokation wie zuvor geprüft; Abweichung: Arrays von Arrays, die upstream ablehnt, wurden rekursiv gelesen (F72). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6169d9658..39e978b39 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -51,6 +51,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F69](#f69) — niedrig — *nur beschrieben* (CI-Bedarf: modellgestützter Test) - [F70](#f70) — mittel — *gefixt* - [F71](#f71) — niedrig — *gefixt* +- [F72](#f72) — niedrig — *gefixt* ## Funde @@ -487,6 +488,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ca6ccdf` + +### F72 – java-llama.cpp: `GgufInspector` liest verschachtelte Arrays rekursiv – `StackOverflowError` statt `IOException` +- **Zeit:** 2026-10-18 01:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` (`readArray`) +- **Schwere:** niedrig +- **Beschreibung:** `readArray` liest den Elementtyp und ruft für jedes Element `readValue` – auch für den Typ `ARRAY`, also rekursiv und ohne Tiefengrenze. Upstreams Leser lehnt ein Array mit Elementtyp `ARRAY` dagegen ab (`ggml/src/gguf.cpp:606`, b11639: „key … has invalid GGUF type“), llama.cpp kann eine solche Datei also nicht laden. Folgen: (1) eine präparierte Datei mit tiefer Verschachtelung (12 Byte und ein Stapelrahmen je Ebene) beendet `GgufInspector.read` mit `StackOverflowError`, obwohl die Methode für eine ungültige Datei `IOException` deklariert – wer den Inspektor vor dem Laden als Prüfung nutzt (`catch (IOException)`), fängt das nicht; (2) schon zwei Ebenen meldet der Inspektor als gültige Metadaten. +- **Fix:** `readArray` wirft bei Elementtyp `ARRAY` eine `IOException` mit dem Typ in der Meldung, wie upstream. +- **Reproduktion:** `GgufInspectorTest#nestedArrayIsRejectedLikeUpstream` (2 Ebenen) und `#deeplyNestedArraysFailWithIOExceptionNotStackOverflow` (100 000 Ebenen) +- **Rot-Grün-Nachweis:** ohne Fix rot („expected: but was: “ bzw. „Expected java.io.IOException to be thrown, but nothing was thrown“), mit Fix grün, `GgufInspectorTest` 14 (1 übersprungen: der bestehende Test mit echtem Modell); Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `79f3ee9` + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. From 7d38ec7e5aacdf1acd3928f1afb4de160206614a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:15:52 +0000 Subject: [PATCH 121/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f53c23a6e..7f4d06549 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -178,3 +178,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`anthropicStopReason`, Antwortgerüst) + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Status) ↔ upstream `to_json_anthropic`/`to_json_oaicompat_resp` (`server-task.cpp:723`, `:541`, b11639) · `b59c4ee`/`b755abd` · 2026-10-18 · kein Fund (Randnotiz) — `length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn` wie upstream; `input_tokens` = Prompt minus Cache, `cache_read_input_tokens` wie upstream; Responses meldet wie upstream immer `completed`. Abweichung ohne Fund: upstream füllt `stop_sequence` mit dem ausgelösten Stoppwort, die Übersetzung schreibt immer `null`, weil die OpenAI-Antwort, aus der sie übersetzt, kein `stopping_word` trägt. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `ResponsesStreamTranslator.java` + `OpenAiCompatServer.java` (`withUsageChunk`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:576`, b11639; Schnittstelle Usage) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `input_tokens` = alle Prompt-Token inkl. Cache, `output_tokens`, `total_tokens` = Summe, `input_tokens_details.cached_tokens` wie upstream, blockierend und gestreamt; die Anthropic- und Responses-Streams fordern `include_usage` an. Der Ollama-Stream nicht (Nachtrag zum `done_reason`-Verdacht). - `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` ↔ `ggml/src/gguf.cpp` (Schnittstelle GGUF-Metadatenleser ↔ upstream-Leser, b11639) · `3b4fafc` · 2026-10-18 · Fund F72 — Typ-Ids, Kopf (Magie, Version mit Byte-Order-Erkennung, Tensor- und KV-Anzahl) und Grenzen vor Allokation wie zuvor geprüft; Abweichung: Arrays von Arrays, die upstream ablehnt, wurden rekursiv gelesen (F72). +- Musterdurchsicht nach F72 (Rekursion auf Fremdeingabe ohne Tiefengrenze) über alle Ziele der drei Repos · – · 2026-10-18 · kein weiterer Fund — Heuristik: Methoden, die ihren eigenen Namen im Rumpf aufrufen; fast alles Delegation an gleichnamige Methoden anderer Objekte. Echte Rekursion auf Fremdeingabe nur in BAF `SeparatorFormat.splitRecursive` (Tiefe = Anzahl der Trennzeichen, fest) und eben `GgufInspector.readArray` (F72). Jackson begrenzt die Schachtelungstiefe beim Parsen selbst (`StreamReadConstraints`), die Fehler landen dort als `JsonProcessingException`. From b641834f48154424d96d97570b36f2d33f688bd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:47:42 +0000 Subject: [PATCH 122/589] Keep the Ollama sampling options on a fill-in-the-middle generate An /api/generate with a suffix is translated into a native /infill request, which copied only temperature and num_predict. Upstream's /infill reads stop, top_p, top_k and seed through the same completion schema as /completions, and FIM clients bound their completions with options.stop, so every completion ran to num_predict. Copy the same options as the plain generate path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++++++++ .../ladenthin/llama/server/OllamaApiSupport.java | 6 ++++++ .../llama/server/OllamaApiSupportTest.java | 12 ++++++++++++ 4 files changed, 33 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7f4d06549..7581fdd80 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -179,3 +179,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `ResponsesStreamTranslator.java` + `OpenAiCompatServer.java` (`withUsageChunk`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:576`, b11639; Schnittstelle Usage) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `input_tokens` = alle Prompt-Token inkl. Cache, `output_tokens`, `total_tokens` = Summe, `input_tokens_details.cached_tokens` wie upstream, blockierend und gestreamt; die Anthropic- und Responses-Streams fordern `include_usage` an. Der Ollama-Stream nicht (Nachtrag zum `done_reason`-Verdacht). - `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` ↔ `ggml/src/gguf.cpp` (Schnittstelle GGUF-Metadatenleser ↔ upstream-Leser, b11639) · `3b4fafc` · 2026-10-18 · Fund F72 — Typ-Ids, Kopf (Magie, Version mit Byte-Order-Erkennung, Tensor- und KV-Anzahl) und Grenzen vor Allokation wie zuvor geprüft; Abweichung: Arrays von Arrays, die upstream ablehnt, wurden rekursiv gelesen (F72). - Musterdurchsicht nach F72 (Rekursion auf Fremdeingabe ohne Tiefengrenze) über alle Ziele der drei Repos · – · 2026-10-18 · kein weiterer Fund — Heuristik: Methoden, die ihren eigenen Namen im Rumpf aufrufen; fast alles Delegation an gleichnamige Methoden anderer Objekte. Echte Rekursion auf Fremdeingabe nur in BAF `SeparatorFormat.splitRecursive` (Tiefe = Anzahl der Trennzeichen, fest) und eben `GgufInspector.readArray` (F72). Jackson begrenzt die Schachtelungstiefe beim Parsen selbst (`StreamReadConstraints`), die Fehler landen dort als `JsonProcessingException`. +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate`, `toOpenAiCompletionRequest`/`toInfillRequest`) ↔ upstream `post_infill`/`handle_completions_impl` (Schnittstelle Ollama-FIM → `/infill`, b11639) · `122da1d` · 2026-10-18 · Fund F74 — upstream verlangt `input_prefix` und `input_suffix` (beide immer gesetzt, leer bei fehlendem Feld), `prompt` optional, `input_extra` optional; Sampling-Optionen liest derselbe Schema-Parser wie bei `/completions`, der FIM-Pfad übernahm aber nur zwei davon (F74). Dazu Rerank (`OaiRerankSupport`) gegen `format_response_rerank` verglichen: Sortierung und Felder gleich, `top_n: 0` und `usage` weichen ab (offener Verdacht). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 39e978b39..59bc03c91 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -52,6 +52,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F70](#f70) — mittel — *gefixt* - [F71](#f71) — niedrig — *gefixt* - [F72](#f72) — niedrig — *gefixt* +- [F74](#f74) — mittel — *gefixt* ## Funde @@ -500,6 +501,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `79f3ee9` + +### F74 – java-llama.cpp: Ollama-`/api/generate` mit `suffix` (FIM) verwirft `stop`, `top_p`, `top_k` und `seed` +- **Zeit:** 2026-10-18 01:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:403` (`toInfillRequest`) +- **Schwere:** mittel +- **Beschreibung:** Ein `/api/generate` ohne `suffix` übernimmt aus `options` `temperature`, `top_p`, `top_k`, `seed`, `num_predict` und `stop`. Mit `suffix`, also als Fill-in-the-Middle-Anfrage, übernahm `toInfillRequest` nur `temperature` und `num_predict`. Gerade FIM-Clients (Autovervollständigung über den Ollama-Provider) begrenzen ihre Vervollständigung mit `options.stop` (etwa `"\n\n"`); ohne sie lief jede Vervollständigung bis `num_predict` weiter. Upstreams `/infill` (`server-context.cpp`, `post_infill`, b11639) gibt den ganzen Body an `handle_completions_impl` und damit an denselben Schema-Parser wie `/completions`, liest also `stop`, `top_p`, `top_k` und `seed` genauso. +- **Fix:** `toInfillRequest` übernimmt dieselben Optionen wie `toOpenAiCompletionRequest` (`num_predict` weiter als `n_predict`, dem Namen des nativen Infill-Bodys). +- **Reproduktion:** `OllamaApiSupportTest#generateWithSuffixKeepsTheSamplingOptionsOfAPlainGenerate` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "\n\n" but: was ""“), mit Fix grün (23/23 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -509,3 +522,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. +- **Rerank gegen upstream (2026-10-18):** `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 339b3e0bb..122da1d3b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -406,7 +406,13 @@ static ObjectNode toInfillRequest(JsonNode request) { infill.put("input_suffix", request.path("suffix").asText("")); JsonNode options = request.path("options"); copyNumber(options, "temperature", infill, "temperature"); + copyNumber(options, "top_p", infill, "top_p"); + copyNumber(options, "top_k", infill, "top_k"); + copyNumber(options, "seed", infill, "seed"); copyNumber(options, "num_predict", infill, "n_predict"); + if (options.path("stop").isArray()) { + infill.set("stop", options.path("stop").deepCopy()); + } return infill; } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index d04aab764..655ceba93 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -236,6 +236,18 @@ public void generateWithSuffixMapsToInfillRequest() throws IOException { assertThat(infill.path("n_predict").asInt(), is(16)); } + @Test + public void generateWithSuffixKeepsTheSamplingOptionsOfAPlainGenerate() throws IOException { + // FIM clients stop a completion with options.stop; upstream's /infill reads stop, top_p, top_k + // and seed through the same completion schema as /completions. + JsonNode infill = OllamaApiSupport.toInfillRequest(read("{\"prompt\":\"pre\",\"suffix\":\"suf\"," + + "\"options\":{\"stop\":[\"\\n\\n\"],\"top_p\":0.9,\"top_k\":40,\"seed\":7}}")); + assertThat(infill.path("stop").path(0).asText(), is("\n\n")); + assertThat(infill.path("top_p").asDouble(), is(0.9)); + assertThat(infill.path("top_k").asInt(), is(40)); + assertThat(infill.path("seed").asInt(), is(7)); + } + @Test public void generateTextExtractors() { assertThat(OllamaApiSupport.extractCompletionText("{\"choices\":[{\"text\":\"hi\"}]}"), is("hi")); From 651e517b2dc61b59aec38bb70e1eb3bdae1c4b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:47:42 +0000 Subject: [PATCH 123/589] Record the commit of F74 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 59bc03c91..897cde7c8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -511,7 +511,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#generateWithSuffixKeepsTheSamplingOptionsOfAPlainGenerate` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "\n\n" but: was ""“), mit Fix grün (23/23 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `367592f` ## Offene Verdachte ohne Fund-ID From 33ea16a4bd10b8b907914a9431f5db5d3ffcdbc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:47:53 +0000 Subject: [PATCH 124/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 897cde7c8..c5da06992 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -522,4 +522,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. -- **Rerank gegen upstream (2026-10-18):** `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. +- *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. From eeba74dbc78eccf8c07adb7da92106a4def838b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:55:38 +0000 Subject: [PATCH 125/589] Map the Responses reasoning effort like upstream ResponsesApiSupport built the chat request from a fixed set of fields and dropped reasoning.effort. Upstream's Responses converter maps it to reasoning_effort, which the chat parser reads: "none" switches the template's thinking off. A client asking for no reasoning got the full thinking block. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++++++++ .../llama/server/ResponsesApiSupport.java | 6 ++++++ .../llama/server/ResponsesApiSupportTest.java | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7581fdd80..7b4d7b470 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -180,3 +180,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` ↔ `ggml/src/gguf.cpp` (Schnittstelle GGUF-Metadatenleser ↔ upstream-Leser, b11639) · `3b4fafc` · 2026-10-18 · Fund F72 — Typ-Ids, Kopf (Magie, Version mit Byte-Order-Erkennung, Tensor- und KV-Anzahl) und Grenzen vor Allokation wie zuvor geprüft; Abweichung: Arrays von Arrays, die upstream ablehnt, wurden rekursiv gelesen (F72). - Musterdurchsicht nach F72 (Rekursion auf Fremdeingabe ohne Tiefengrenze) über alle Ziele der drei Repos · – · 2026-10-18 · kein weiterer Fund — Heuristik: Methoden, die ihren eigenen Namen im Rumpf aufrufen; fast alles Delegation an gleichnamige Methoden anderer Objekte. Echte Rekursion auf Fremdeingabe nur in BAF `SeparatorFormat.splitRecursive` (Tiefe = Anzahl der Trennzeichen, fest) und eben `GgufInspector.readArray` (F72). Jackson begrenzt die Schachtelungstiefe beim Parsen selbst (`StreamReadConstraints`), die Fehler landen dort als `JsonProcessingException`. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate`, `toOpenAiCompletionRequest`/`toInfillRequest`) ↔ upstream `post_infill`/`handle_completions_impl` (Schnittstelle Ollama-FIM → `/infill`, b11639) · `122da1d` · 2026-10-18 · Fund F74 — upstream verlangt `input_prefix` und `input_suffix` (beide immer gesetzt, leer bei fehlendem Feld), `prompt` optional, `input_extra` optional; Sampling-Optionen liest derselbe Schema-Parser wie bei `/completions`, der FIM-Pfad übernahm aber nur zwei davon (F74). Dazu Rerank (`OaiRerankSupport`) gegen `format_response_rerank` verglichen: Sortierung und Felder gleich, `top_n: 0` und `usage` weichen ab (offener Verdacht). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `AnthropicApiSupport.java` + `OllamaApiSupport.java` (Musterdurchsicht nach F74: Optionsmengen der Übersetzer gegen upstreams `convert_responses_to_chatcmpl`/`convert_anthropic_to_oai`, b11639) · `446fa5f` · 2026-10-18 · Fund F75 — Ollama-Chat und -Generate übernehmen jetzt dieselben sechs Optionen; Anthropic übernimmt fünf, verwirft aber `thinking` (offener Verdacht); Responses verwarf `reasoning.effort` (F75). upstreams Responses-Umsetzer kopiert den ganzen Body, wir nur bekannte Felder; `top_k`/`seed`/`stop` kennt das Responses-Protokoll nicht, daher keine weitere Abweichung von Gewicht. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c5da06992..dbbff401c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -53,6 +53,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F71](#f71) — niedrig — *gefixt* - [F72](#f72) — niedrig — *gefixt* - [F74](#f74) — mittel — *gefixt* +- [F75](#f75) — mittel — *gefixt* ## Funde @@ -513,6 +514,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `367592f` + +### F75 – java-llama.cpp: Responses-API verwirft `reasoning.effort` – `"none"` schaltet das Denken nicht ab +- **Zeit:** 2026-10-18 02:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:51` (`toOpenAiChatRequest`) +- **Schwere:** mittel +- **Beschreibung:** `toOpenAiChatRequest` baut einen neuen Chat-Request und übernimmt nur Nachrichten, Werkzeuge, `temperature`, `top_p` und `max_output_tokens`. Das Feld `reasoning: {"effort": …}` des Responses-Protokolls fiel weg. upstreams eigener Responses-Umsetzer (`server-chat.cpp`, `convert_responses_to_chatcmpl`, b11639) bildet es auf `reasoning_effort` ab, und der Chat-Parser (`server-common.cpp:1420`) liest es: `"none"` schaltet das Denken der Vorlage ab, ein anderer Wert geht als `reasoning_effort` an die Vorlage. Ein Responses-Client, der `effort: "none"` (oder `"low"`) schickt, bekam bei uns trotzdem den vollen Denkblock samt der Token, die er kostet. +- **Fix:** ein textuelles `reasoning.effort` wird als `reasoning_effort` übernommen, wie upstream. +- **Reproduktion:** `ResponsesApiSupportTest#requestMapsTheReasoningEffortLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -523,3 +536,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. +- *(mittel, nur beschrieben, 2026-10-18, nächstes Ziel)* Anthropic-`thinking` wird verworfen: `AnthropicApiSupport.toOpenAiChatRequest` (`AnthropicApiSupport.java:96`) übernimmt `max_tokens`, `temperature`, `top_p`, `top_k` und `stop_sequences`, aber nicht `thinking: {"type": "enabled", "budget_tokens": N}`. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:600`) setzt daraus `thinking_budget_tokens`, das der Chat-Parser (`server-common.cpp:1441`) als Reasoning-Budget an den Sampler gibt; bei uns denkt das Modell unbegrenzt. Außerdem setzt upstream `max_tokens` 4096, wenn es fehlt (bei uns: unbegrenzt), und reicht `chat_template_kwargs` durch. Mit einem Rot-Test auf die Abbildung belegbar. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index b755abd17..446fa5ff5 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -96,6 +96,12 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { copyNumber(request, "temperature", openAi, "temperature"); copyNumber(request, "top_p", openAi, "top_p"); copyNumber(request, "max_output_tokens", openAi, "max_tokens"); + // Upstream's converter maps reasoning.effort to the chat field reasoning_effort ("none" turns + // the model's thinking off); the other reasoning settings have no chat equivalent there either. + if (request.path("reasoning").path("effort").isTextual()) { + openAi.put( + "reasoning_effort", request.path("reasoning").path("effort").asText()); + } return openAi; } diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index cdbb417ef..1254f856a 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -47,6 +47,15 @@ public void requestJoinsTheTextPartsOfOneInputItemWithANewline() throws IOExcept * llama.cpp's own {@code /v1/responses} ({@code to_json_oaicompat_resp}). It was the constant * {@code 0}, so clients showed every response as created on 1 January 1970. */ + @Test + public void requestMapsTheReasoningEffortLikeUpstream() throws IOException { + // Upstream's Responses converter maps reasoning.effort to reasoning_effort, which the chat + // parser reads ("none" switches the model's thinking off). + JsonNode openAi = + ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":\"hi\",\"reasoning\":{\"effort\":\"none\"}}")); + assertThat(openAi.path("reasoning_effort").asText(), is("none")); + } + @Test public void responseCarriesTheCurrentUnixTimeAsCreatedAt() throws IOException { long before = System.currentTimeMillis() / 1000L; From b9876013ba55a63e3c2268191c2f733656330f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:55:38 +0000 Subject: [PATCH 126/589] Record the commit of F75 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index dbbff401c..4f75e4e79 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -524,7 +524,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#requestMapsTheReasoningEffortLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `485f153` ## Offene Verdachte ohne Fund-ID From 262b41d85918a5e91beb6fc240286746e4fbf6ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:01:35 +0000 Subject: [PATCH 127/589] Map an enabled Anthropic thinking budget like upstream AnthropicApiSupport dropped thinking.budget_tokens. Upstream's convert_anthropic_to_oai turns an enabled thinking block into thinking_budget_tokens (default 10000), which the chat parser passes to the sampler as the reasoning budget; without it the model reasoned without bound. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 16 +++++++++++++++- .../llama/server/AnthropicApiSupport.java | 9 +++++++++ .../llama/server/AnthropicApiSupportTest.java | 10 ++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7b4d7b470..53e241929 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -181,3 +181,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - Musterdurchsicht nach F72 (Rekursion auf Fremdeingabe ohne Tiefengrenze) über alle Ziele der drei Repos · – · 2026-10-18 · kein weiterer Fund — Heuristik: Methoden, die ihren eigenen Namen im Rumpf aufrufen; fast alles Delegation an gleichnamige Methoden anderer Objekte. Echte Rekursion auf Fremdeingabe nur in BAF `SeparatorFormat.splitRecursive` (Tiefe = Anzahl der Trennzeichen, fest) und eben `GgufInspector.readArray` (F72). Jackson begrenzt die Schachtelungstiefe beim Parsen selbst (`StreamReadConstraints`), die Fehler landen dort als `JsonProcessingException`. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate`, `toOpenAiCompletionRequest`/`toInfillRequest`) ↔ upstream `post_infill`/`handle_completions_impl` (Schnittstelle Ollama-FIM → `/infill`, b11639) · `122da1d` · 2026-10-18 · Fund F74 — upstream verlangt `input_prefix` und `input_suffix` (beide immer gesetzt, leer bei fehlendem Feld), `prompt` optional, `input_extra` optional; Sampling-Optionen liest derselbe Schema-Parser wie bei `/completions`, der FIM-Pfad übernahm aber nur zwei davon (F74). Dazu Rerank (`OaiRerankSupport`) gegen `format_response_rerank` verglichen: Sortierung und Felder gleich, `top_n: 0` und `usage` weichen ab (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `AnthropicApiSupport.java` + `OllamaApiSupport.java` (Musterdurchsicht nach F74: Optionsmengen der Übersetzer gegen upstreams `convert_responses_to_chatcmpl`/`convert_anthropic_to_oai`, b11639) · `446fa5f` · 2026-10-18 · Fund F75 — Ollama-Chat und -Generate übernehmen jetzt dieselben sechs Optionen; Anthropic übernimmt fünf, verwirft aber `thinking` (offener Verdacht); Responses verwarf `reasoning.effort` (F75). upstreams Responses-Umsetzer kopiert den ganzen Body, wir nur bekannte Felder; `top_k`/`seed`/`stop` kennt das Responses-Protokoll nicht, daher keine weitere Abweichung von Gewicht. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:560`–`620`, b11639; Schnittstelle Anthropic-Optionen) · `8a426d9` · 2026-10-18 · Fund F76 — `stop_sequences`, `temperature`/`top_p`/`top_k`, `max_tokens` wie upstream; `thinking` fehlte (F76); `metadata.user_id` setzt upstream nur intern (`__metadata_user_id`), ohne Wirkung auf die Generierung; `max_tokens`-Vorgabe und `chat_template_kwargs` als Verdacht. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 4f75e4e79..6b31f30dd 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -54,6 +54,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F72](#f72) — niedrig — *gefixt* - [F74](#f74) — mittel — *gefixt* - [F75](#f75) — mittel — *gefixt* +- [F76](#f76) — mittel — *gefixt* ## Funde @@ -526,6 +527,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `485f153` + +### F76 – java-llama.cpp: Anthropic-`/v1/messages` verwirft `thinking.budget_tokens` – das Modell denkt unbegrenzt +- **Zeit:** 2026-10-18 02:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:108` (`toOpenAiChatRequest`) +- **Schwere:** mittel +- **Beschreibung:** Ein Anthropic-Client begrenzt das Denken mit `thinking: {"type": "enabled", "budget_tokens": N}`. `toOpenAiChatRequest` übernahm `max_tokens`, `temperature`, `top_p`, `top_k` und `stop_sequences`, aber nicht `thinking`. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:600`, b11639) setzt daraus `thinking_budget_tokens` (Vorgabe 10000 ohne `budget_tokens`), das der Chat-Parser (`server-common.cpp:1441`) als `reasoning_budget_tokens` an den Sampler gibt, der den Denkblock nach N Token schließt. Bei uns galt das Budget des Servers (Vorgabe unbegrenzt): ein Denkblock konnte die ganze Ausgabe aufbrauchen. +- **Fix:** ein `thinking` mit `type: "enabled"` wird zu `thinking_budget_tokens` (`budget_tokens` oder upstreams Vorgabe 10000). Andere Typen (`disabled`) bleiben wie bei upstream ohne Wirkung. +- **Reproduktion:** `AnthropicApiSupportTest#requestMapsAnEnabledThinkingBudgetLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <1024> but: was <0>“), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Nicht übernommen:** upstreams Vorgabe `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Anthropic-Protokoll verlangt das Feld), und das Durchreichen von `chat_template_kwargs` – beide bleiben als offener Verdacht stehen. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -536,4 +550,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. -- *(mittel, nur beschrieben, 2026-10-18, nächstes Ziel)* Anthropic-`thinking` wird verworfen: `AnthropicApiSupport.toOpenAiChatRequest` (`AnthropicApiSupport.java:96`) übernimmt `max_tokens`, `temperature`, `top_p`, `top_k` und `stop_sequences`, aber nicht `thinking: {"type": "enabled", "budget_tokens": N}`. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:600`) setzt daraus `thinking_budget_tokens`, das der Chat-Parser (`server-common.cpp:1441`) als Reasoning-Budget an den Sampler gibt; bei uns denkt das Modell unbegrenzt. Außerdem setzt upstream `max_tokens` 4096, wenn es fehlt (bei uns: unbegrenzt), und reicht `chat_template_kwargs` durch. Mit einem Rot-Test auf die Abbildung belegbar. +- *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index b59c4eec1..8a426d945 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -31,6 +31,9 @@ final class AnthropicApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** Reasoning budget of an enabled {@code thinking} block without {@code budget_tokens} (upstream's default). */ + private static final int DEFAULT_THINKING_BUDGET_TOKENS = 10_000; + private AnthropicApiSupport() {} /** @@ -100,6 +103,12 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { if (request.path("stop_sequences").isArray()) { openAi.set("stop", request.path("stop_sequences").deepCopy()); } + // As upstream's convert_anthropic_to_oai: an enabled thinking block becomes the reasoning budget + // the chat parser hands to the sampler, with upstream's default when budget_tokens is absent. + JsonNode thinking = request.path("thinking"); + if ("enabled".equals(thinking.path("type").asText(""))) { + openAi.put("thinking_budget_tokens", thinking.path("budget_tokens").asInt(DEFAULT_THINKING_BUDGET_TOKENS)); + } return openAi; } diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 3afc1b022..28a4262fa 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -45,6 +45,16 @@ public void requestTurnsImageBlocksIntoImageUrlParts() throws IOException { assertThat(content.get(1).path("image_url").path("url").asText(), is("data:image/png;base64,iVBORw0K")); } + @Test + public void requestMapsAnEnabledThinkingBudgetLikeUpstream() throws IOException { + // Upstream's convert_anthropic_to_oai turns thinking.budget_tokens into thinking_budget_tokens, + // which the chat parser hands to the sampler as the reasoning budget. + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest(read("{\"max_tokens\":64," + + "\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")); + assertThat(openAi.path("thinking_budget_tokens").asInt(), is(1024)); + } + /** * Separate text blocks of one message must stay separate words. Anthropic clients split a turn into * several blocks (a reminder block, then the user's own text); llama.cpp's own {@code /v1/messages} From bf58cd721294c16c689c23cb8705ae4c5899b9df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:01:35 +0000 Subject: [PATCH 128/589] Record the commit of F76 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6b31f30dd..342f0eff8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -538,7 +538,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <1024> but: was <0>“), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Nicht übernommen:** upstreams Vorgabe `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Anthropic-Protokoll verlangt das Feld), und das Durchreichen von `chat_template_kwargs` – beide bleiben als offener Verdacht stehen. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `5117413` ## Offene Verdachte ohne Fund-ID From 444d4d555b84e1286a31d57da33ea416ea8a4c13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:06:43 +0000 Subject: [PATCH 129/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 53e241929..5fdd3fe97 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -182,3 +182,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate`, `toOpenAiCompletionRequest`/`toInfillRequest`) ↔ upstream `post_infill`/`handle_completions_impl` (Schnittstelle Ollama-FIM → `/infill`, b11639) · `122da1d` · 2026-10-18 · Fund F74 — upstream verlangt `input_prefix` und `input_suffix` (beide immer gesetzt, leer bei fehlendem Feld), `prompt` optional, `input_extra` optional; Sampling-Optionen liest derselbe Schema-Parser wie bei `/completions`, der FIM-Pfad übernahm aber nur zwei davon (F74). Dazu Rerank (`OaiRerankSupport`) gegen `format_response_rerank` verglichen: Sortierung und Felder gleich, `top_n: 0` und `usage` weichen ab (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `AnthropicApiSupport.java` + `OllamaApiSupport.java` (Musterdurchsicht nach F74: Optionsmengen der Übersetzer gegen upstreams `convert_responses_to_chatcmpl`/`convert_anthropic_to_oai`, b11639) · `446fa5f` · 2026-10-18 · Fund F75 — Ollama-Chat und -Generate übernehmen jetzt dieselben sechs Optionen; Anthropic übernimmt fünf, verwirft aber `thinking` (offener Verdacht); Responses verwarf `reasoning.effort` (F75). upstreams Responses-Umsetzer kopiert den ganzen Body, wir nur bekannte Felder; `top_k`/`seed`/`stop` kennt das Responses-Protokoll nicht, daher keine weitere Abweichung von Gewicht. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:560`–`620`, b11639; Schnittstelle Anthropic-Optionen) · `8a426d9` · 2026-10-18 · Fund F76 — `stop_sequences`, `temperature`/`top_p`/`top_k`, `max_tokens` wie upstream; `thinking` fehlte (F76); `metadata.user_id` setzt upstream nur intern (`__metadata_user_id`), ohne Wirkung auf die Generierung; `max_tokens`-Vorgabe und `chat_template_kwargs` als Verdacht. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`) + `OpenAiCompatServer.handleProps` ↔ upstream `get_res_props` (`server-context.cpp:5126`, b11639; Schnittstelle `/props`) · `1a04e66` · 2026-10-18 · kein Fund (Verdacht notiert) — `default_generation_settings.n_ctx` = Fenster je Slot (seit F70, wie upstreams `slot_n_ctx`), `model_alias`, `modalities.vision`/`audio`/`video` mit denselben Schlüsseln; `chat_template` leer und `params`, `build_info` usw. fehlen (bewusste Teilmenge). `total_slots` fest 1 statt `n_parallel` (offener Verdacht). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 342f0eff8..602f526d1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -551,3 +551,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. +- *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. From f6057ad4f1f204fdafd71e5ce002fa60270d2812 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:14:01 +0000 Subject: [PATCH 130/589] Carry the reasoning controls through the OpenAI chat mapper OpenAiRequestMapper builds the chat request from a fixed field list and dropped chat_template_kwargs, reasoning_effort and the reasoning budget, which upstream's chat parser reads. Every chat request lost them, including the fields the Anthropic and Responses translations now produce. Apply them with upstream's rules: "none" switches thinking off, any other effort becomes a template variable, and the budget is reasoning_budget_tokens or else thinking_budget_tokens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 16 +++++ .../llama/server/OpenAiRequestMapper.java | 49 +++++++++++++++- .../llama/server/OpenAiRequestMapperTest.java | 58 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 5fdd3fe97..5e4e0ee38 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -183,3 +183,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `AnthropicApiSupport.java` + `OllamaApiSupport.java` (Musterdurchsicht nach F74: Optionsmengen der Übersetzer gegen upstreams `convert_responses_to_chatcmpl`/`convert_anthropic_to_oai`, b11639) · `446fa5f` · 2026-10-18 · Fund F75 — Ollama-Chat und -Generate übernehmen jetzt dieselben sechs Optionen; Anthropic übernimmt fünf, verwirft aber `thinking` (offener Verdacht); Responses verwarf `reasoning.effort` (F75). upstreams Responses-Umsetzer kopiert den ganzen Body, wir nur bekannte Felder; `top_k`/`seed`/`stop` kennt das Responses-Protokoll nicht, daher keine weitere Abweichung von Gewicht. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:560`–`620`, b11639; Schnittstelle Anthropic-Optionen) · `8a426d9` · 2026-10-18 · Fund F76 — `stop_sequences`, `temperature`/`top_p`/`top_k`, `max_tokens` wie upstream; `thinking` fehlte (F76); `metadata.user_id` setzt upstream nur intern (`__metadata_user_id`), ohne Wirkung auf die Generierung; `max_tokens`-Vorgabe und `chat_template_kwargs` als Verdacht. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`) + `OpenAiCompatServer.handleProps` ↔ upstream `get_res_props` (`server-context.cpp:5126`, b11639; Schnittstelle `/props`) · `1a04e66` · 2026-10-18 · kein Fund (Verdacht notiert) — `default_generation_settings.n_ctx` = Fenster je Slot (seit F70, wie upstreams `slot_n_ctx`), `model_alias`, `modalities.vision`/`audio`/`video` mit denselben Schlüsseln; `chat_template` leer und `params`, `build_info` usw. fehlen (bewusste Teilmenge). `total_slots` fest 1 statt `n_parallel` (offener Verdacht). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` + `LlamaModelBackend.java` ↔ upstream `oaicompat_chat_params_parse` (Schnittstelle übersetzter Chat-Request → `InferenceParameters` → native Seite, b11639) · `9eee221` · 2026-10-18 · Fund F77 — Folgefrage zu F75/F76: der Chat-Pfad reicht den Body nicht durch, sondern baut `InferenceParameters` aus einer festen Liste (Sampling, Cache, `stop`, `stream_options`, `response_format`, Werkzeuge); die drei Reasoning-Steuerungen fehlten (F77). `/v1/completions` geht dagegen unverändert an `handleCompletionsOai`. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 602f526d1..4f69ec687 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -55,6 +55,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F74](#f74) — mittel — *gefixt* - [F75](#f75) — mittel — *gefixt* - [F76](#f76) — mittel — *gefixt* +- [F77](#f77) — mittel — *gefixt* ## Funde @@ -524,6 +525,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** ein textuelles `reasoning.effort` wird als `reasoning_effort` übernommen, wie upstream. - **Reproduktion:** `ResponsesApiSupportTest#requestMapsTheReasoningEffortLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Nachtrag (2026-10-18):** wirkt erst zusammen mit [F77](#f77): der Chat-Mapper, durch den jede übersetzte Anfrage läuft, verwarf das erzeugte Feld bis dahin. - **Status:** gefixt - **Commit:** java-llama.cpp `485f153` @@ -537,9 +539,23 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#requestMapsAnEnabledThinkingBudgetLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <1024> but: was <0>“), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Nicht übernommen:** upstreams Vorgabe `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Anthropic-Protokoll verlangt das Feld), und das Durchreichen von `chat_template_kwargs` – beide bleiben als offener Verdacht stehen. +- **Nachtrag (2026-10-18):** wirkt erst zusammen mit [F77](#f77): der Chat-Mapper, durch den jede übersetzte Anfrage läuft, verwarf das erzeugte Feld bis dahin. - **Status:** gefixt - **Commit:** java-llama.cpp `5117413` + +### F77 – java-llama.cpp: `OpenAiRequestMapper` verwirft `chat_template_kwargs`, `reasoning_effort` und das Reasoning-Budget jedes Chat-Requests +- **Zeit:** 2026-10-18 02:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:222` (`applyReasoningControls`, neu; Aufruf in `toInferenceParameters`) +- **Schwere:** mittel +- **Beschreibung:** Jede Chat-Anfrage an `OpenAiCompatServer` – OpenAI direkt sowie Ollama, Anthropic und Responses nach ihrer Übersetzung – läuft durch `OpenAiRequestMapper.toInferenceParameters`, das `InferenceParameters` aus einer festen Feldliste baut. Die Reasoning-Steuerungen, die upstreams Chat-Parser liest (`oaicompat_chat_params_parse`, `server-common.cpp:1403`–`1453`, b11639), fehlten darin: `chat_template_kwargs` (etwa `{"enable_thinking": false}`, mit dem Qwen3-Clients das Denken abschalten), `reasoning_effort` (`"none"` → Denken aus, sonst Template-Variable) und `reasoning_budget_tokens`/`thinking_budget_tokens`. Alle drei wurden still verworfen; `InferenceParameters` hat die passenden Setter (`withChatTemplateKwargs`, `withReasoningBudgetTokens`). Folge: die Fixes F75 (Responses `reasoning.effort`) und F76 (Anthropic `thinking.budget_tokens`) erzeugten das richtige Feld, das danach verloren ging. +- **Fix:** `applyReasoningControls` übernimmt die Template-Variablen des Requests, setzt bei `reasoning_effort: "none"` `enable_thinking` auf `false` (und entfernt `reasoning_effort`), legt jeden anderen Wert als Template-Variable `reasoning_effort` ab und übernimmt das Budget (`reasoning_budget_tokens`, sonst `thinking_budget_tokens`) – die Regeln von upstreams Parser. Class-Javadoc nachgezogen. +- **Reproduktion:** `OpenAiRequestMapperTest#chatTemplateKwargsAreForwarded`, `#reasoningEffortNoneSwitchesThinkingOffLikeUpstream`, `#reasoningEffortLevelBecomesATemplateVariableLikeUpstream`, `#reasoningBudgetIsForwardedUnderEitherUpstreamName`, `#anthropicAndResponsesReasoningControlsReachTheInferenceParameters` (die Kette Übersetzer → Mapper) +- **Rot-Grün-Nachweis:** ohne Fix alle fünf rot (u. a. „Expected: is <512> but: was <0>“, „Expected: is "low" but: was ""“, „Expected: is <1024> but: was <0>“), mit Fix 21/21 der Klasse grün, skipped 0; Gegenprobe per `git stash` wieder 5 rot, nach `pop` grün. `AnthropicApiSupportTest` 15/15, `ResponsesApiSupportTest` 13/13, `OllamaApiSupportTest` 23/23, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **CI-Bedarf:** ein modellgestützter Lauf (`OpenAiCompatServerIntegrationTest` mit dem Reasoning-Modell) würde zeigen, dass `reasoning_effort: "none"` den Denkblock wirklich unterdrückt; hier gibt es kein Modell. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 5bddfea81..9eee221ca 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -6,7 +6,9 @@ import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import net.ladenthin.llama.parameters.InferenceParameters; /** @@ -17,7 +19,9 @@ * verbatim as raw JSON so the full OpenAI shape (assistant {@code tool_calls}, * {@code role:"tool"} results with {@code tool_call_id}, and vision {@code image_url} content parts) * round-trips untouched into the native chat-template parser. Sampling fields are translated to the - * matching {@code InferenceParameters.with*} setter; unknown fields are ignored. + * matching {@code InferenceParameters.with*} setter, and so are the reasoning controls upstream's chat + * parser reads ({@code chat_template_kwargs}, {@code reasoning_effort}, the reasoning budget); unknown + * fields are ignored. * *

The {@code stream} flag is intentionally not mapped here — streaming is selected by the caller * ({@link net.ladenthin.llama.LlamaModel#chatComplete} forces it off, @@ -49,6 +53,7 @@ InferenceParameters toInferenceParameters(JsonNode request) { .withCachePrompt(true); params = applyCommonFields(params, request); + params = applyReasoningControls(params, request); // Tools are chat-only. JsonNode tools = request.path("tools"); @@ -201,4 +206,46 @@ private String[] readStops(JsonNode request) { } return stops.toArray(new String[0]); } + + /** + * Apply the reasoning controls that upstream's chat parser ({@code oaicompat_chat_params_parse}, + * b11639) reads from a chat request: the request's {@code chat_template_kwargs}, {@code + * reasoning_effort} ({@code "none"} switches thinking off, any other value becomes the template + * variable {@code reasoning_effort}) and the reasoning budget ({@code reasoning_budget_tokens}, + * else {@code thinking_budget_tokens}). The Anthropic and Responses translations produce the + * latter two. + * + * @param params the parameters built so far + * @param request the parsed chat request + * @return a new instance with the reasoning controls applied + */ + private InferenceParameters applyReasoningControls(InferenceParameters params, JsonNode request) { + Map kwargs = new LinkedHashMap<>(); + JsonNode requestKwargs = request.path("chat_template_kwargs"); + if (requestKwargs.isObject()) { + for (Map.Entry entry : requestKwargs.properties()) { + kwargs.put(entry.getKey(), entry.getValue().toString()); + } + } + JsonNode effort = request.path("reasoning_effort"); + if (effort.isTextual()) { + if ("none".equals(effort.asText())) { + kwargs.remove("reasoning_effort"); + kwargs.put("enable_thinking", "false"); + } else if (!effort.asText().isEmpty()) { + kwargs.put("reasoning_effort", effort.toString()); + } + } + if (!kwargs.isEmpty()) { + params = params.withChatTemplateKwargs(kwargs); + } + JsonNode budget = request.path("reasoning_budget_tokens"); + if (!budget.isInt()) { + budget = request.path("thinking_budget_tokens"); + } + if (budget.isInt()) { + params = params.withReasoningBudgetTokens(budget.asInt()); + } + return params; + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index fe79ebcd7..406270338 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -31,6 +31,64 @@ private JsonNode mapAndSerialize(String requestJson) throws IOException { return MAPPER.readTree(params.toJson()); } + @Test + public void chatTemplateKwargsAreForwarded() throws IOException { + // Qwen3 clients switch thinking off with chat_template_kwargs; upstream's chat parser merges the + // request's kwargs into the template variables. + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]," + + "\"chat_template_kwargs\":{\"enable_thinking\":false}}"); + assertThat(out.path("chat_template_kwargs").path("enable_thinking").isBoolean(), is(true)); + assertThat(out.path("chat_template_kwargs").path("enable_thinking").asBoolean(), is(false)); + } + + @Test + public void reasoningEffortNoneSwitchesThinkingOffLikeUpstream() throws IOException { + JsonNode out = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"none\"}"); + assertThat(out.path("chat_template_kwargs").path("enable_thinking").isBoolean(), is(true)); + assertThat(out.path("chat_template_kwargs").path("enable_thinking").asBoolean(), is(false)); + assertThat(out.path("chat_template_kwargs").has("reasoning_effort"), is(false)); + } + + @Test + public void reasoningEffortLevelBecomesATemplateVariableLikeUpstream() throws IOException { + JsonNode out = + mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_effort\":\"low\"}"); + assertThat(out.path("chat_template_kwargs").path("reasoning_effort").asText(), is("low")); + } + + @Test + public void reasoningBudgetIsForwardedUnderEitherUpstreamName() throws IOException { + JsonNode thinking = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"thinking_budget_tokens\":512}"); + assertThat(thinking.path("reasoning_budget_tokens").asInt(), is(512)); + JsonNode reasoning = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"reasoning_budget_tokens\":256}"); + assertThat(reasoning.path("reasoning_budget_tokens").asInt(), is(256)); + } + + @Test + public void anthropicAndResponsesReasoningControlsReachTheInferenceParameters() throws IOException { + // The translation layers produce these fields (F75, F76); they only take effect if the chat + // mapper carries them on into the request the native side parses. + InferenceParameters anthropic = mapper.toInferenceParameters(AnthropicApiSupport.toOpenAiChatRequest( + MAPPER.readTree("{\"max_tokens\":64,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024}," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"))); + assertThat( + MAPPER.readTree(anthropic.toJson()) + .path("reasoning_budget_tokens") + .asInt(), + is(1024)); + InferenceParameters responses = mapper.toInferenceParameters(ResponsesApiSupport.toOpenAiChatRequest( + MAPPER.readTree("{\"input\":\"hi\",\"reasoning\":{\"effort\":\"none\"}}"))); + assertThat( + MAPPER.readTree(responses.toJson()) + .path("chat_template_kwargs") + .path("enable_thinking") + .asBoolean(true), + is(false)); + } + @Test public void messagesForwardedVerbatim() throws IOException { JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"); From d6e61db1ea732723925e8b530b0ec8e4d8d2d31d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:14:01 +0000 Subject: [PATCH 131/589] Record the commit of F77 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 4f69ec687..3fd73bd27 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -554,7 +554,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix alle fünf rot (u. a. „Expected: is <512> but: was <0>“, „Expected: is "low" but: was ""“, „Expected: is <1024> but: was <0>“), mit Fix 21/21 der Klasse grün, skipped 0; Gegenprobe per `git stash` wieder 5 rot, nach `pop` grün. `AnthropicApiSupportTest` 15/15, `ResponsesApiSupportTest` 13/13, `OllamaApiSupportTest` 23/23, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **CI-Bedarf:** ein modellgestützter Lauf (`OpenAiCompatServerIntegrationTest` mit dem Reasoning-Modell) würde zeigen, dass `reasoning_effort: "none"` den Denkblock wirklich unterdrückt; hier gibt es kein Modell. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `60c2b9f` ## Offene Verdachte ohne Fund-ID From 6374dc897f83f21f7e745225dd429582e9554f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:20:48 +0000 Subject: [PATCH 132/589] Honour the Ollama think field on /api/chat OllamaApiSupport ignored Ollama's top-level think field, so a client asking for no thinking still got the full reasoning block. Map think=false to reasoning_effort "none" and a level string to the same effort; the chat mapper applies reasoning_effort as upstream does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++++++++ .../ladenthin/llama/server/OllamaApiSupport.java | 9 +++++++++ .../llama/server/OllamaApiSupportTest.java | 15 +++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 5e4e0ee38..8ef0dca52 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -184,3 +184,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:560`–`620`, b11639; Schnittstelle Anthropic-Optionen) · `8a426d9` · 2026-10-18 · Fund F76 — `stop_sequences`, `temperature`/`top_p`/`top_k`, `max_tokens` wie upstream; `thinking` fehlte (F76); `metadata.user_id` setzt upstream nur intern (`__metadata_user_id`), ohne Wirkung auf die Generierung; `max_tokens`-Vorgabe und `chat_template_kwargs` als Verdacht. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`) + `OpenAiCompatServer.handleProps` ↔ upstream `get_res_props` (`server-context.cpp:5126`, b11639; Schnittstelle `/props`) · `1a04e66` · 2026-10-18 · kein Fund (Verdacht notiert) — `default_generation_settings.n_ctx` = Fenster je Slot (seit F70, wie upstreams `slot_n_ctx`), `model_alias`, `modalities.vision`/`audio`/`video` mit denselben Schlüsseln; `chat_template` leer und `params`, `build_info` usw. fehlen (bewusste Teilmenge). `total_slots` fest 1 statt `n_parallel` (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` + `LlamaModelBackend.java` ↔ upstream `oaicompat_chat_params_parse` (Schnittstelle übersetzter Chat-Request → `InferenceParameters` → native Seite, b11639) · `9eee221` · 2026-10-18 · Fund F77 — Folgefrage zu F75/F76: der Chat-Pfad reicht den Body nicht durch, sondern baut `InferenceParameters` aus einer festen Liste (Sampling, Cache, `stop`, `stream_options`, `response_format`, Werkzeuge); die drei Reasoning-Steuerungen fehlten (F77). `/v1/completions` geht dagegen unverändert an `handleCompletionsOai`. +- Musterdurchsicht nach F77 (Felder, die ein Übersetzer oder der Mapper still verwirft) über `OllamaApiSupport`, `AnthropicApiSupport`, `ResponsesApiSupport`, `OpenAiRequestMapper` · `ed9aff1` · 2026-10-18 · Fund F78 — Ollama `think` fehlte (F78); Antwortseite ohne `message.thinking` (Verdacht). Weiter offen: der gestreamte `/v1/completions`-Pfad baut `InferenceParameters` über den Mapper, der nicht gestreamte reicht den Body unverändert durch – dieselbe Anfrage verliert gestreamt Felder wie `suffix`, `min_p`, `grammar`, `logprobs` (nächstes Ziel). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3fd73bd27..2c61e3d89 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -56,6 +56,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F75](#f75) — mittel — *gefixt* - [F76](#f76) — mittel — *gefixt* - [F77](#f77) — mittel — *gefixt* +- [F78](#f78) — mittel — *gefixt* ## Funde @@ -556,6 +557,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `60c2b9f` + +### F78 – java-llama.cpp: Ollama-`/api/chat` ignoriert `think` – `"think": false` schaltet das Denken nicht ab +- **Zeit:** 2026-10-18 02:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:143` (`toOpenAiChatRequest`) +- **Schwere:** mittel +- **Beschreibung:** Ollamas Chat-API steuert das Denken eines Modells mit dem Top-Level-Feld `think`: `false` schaltet es ab, `true` an, ein String wählt bei gpt-oss eine Stufe (`"low"`, `"medium"`, `"high"`). `OllamaApiSupport.toOpenAiChatRequest` las das Feld nicht. Ein Ollama-Client, der für schnelle Antworten `think: false` schickt (Open WebUI, ollama-python), bekam trotzdem einen vollen Denkblock samt seiner Token und Latenz. Gefunden in der Musterdurchsicht nach F77. +- **Fix:** `think: false` wird zu `reasoning_effort: "none"`, ein nicht leerer String zu `reasoning_effort: `; `true` bleibt bei der Vorgabe der Vorlage. Der Chat-Mapper wendet `reasoning_effort` seit F77 nach upstreams Regeln an (`enable_thinking: false` bzw. Template-Variable). +- **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestHonoursThinkLikeOllama` (Kette Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`) +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ – `enable_thinking` fehlte), mit Fix grün (24/24 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiRequestMapperTest` 21/21, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -568,3 +581,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. +- *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 122da1d3b..ed9aff1f1 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -138,6 +138,15 @@ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { openAi.set("tools", tools.deepCopy()); } + // Ollama's "think": false switches a model's thinking off, a string picks a level (gpt-oss); + // true leaves the template's default. The chat mapper applies reasoning_effort as upstream does. + JsonNode think = ollamaRequest.path("think"); + if (think.isBoolean() && !think.asBoolean()) { + openAi.put("reasoning_effort", "none"); + } else if (think.isTextual() && !think.asText().isEmpty()) { + openAi.put("reasoning_effort", think.asText()); + } + // Ollama nests sampling under "options"; map the common knobs onto OpenAI top-level fields. JsonNode options = ollamaRequest.path("options"); copyNumber(options, "temperature", openAi, "temperature"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 655ceba93..c0f91ca30 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -77,6 +77,21 @@ public void toOpenAiChatRequestMapsMessagesToolsAndOptions() throws IOException * tool call without one ({@code "Missing tool call type"} in {@code common/chat.cpp}), so a chat * that replays an earlier tool call in its history failed with a 400 before reaching the model. */ + @Test + public void toOpenAiChatRequestHonoursThinkLikeOllama() throws IOException { + // Ollama clients switch a model's thinking off with "think": false and pick a level with a + // string (gpt-oss); the chat mapper turns reasoning_effort into the template switch upstream reads. + OpenAiRequestMapper mapper = new OpenAiRequestMapper(); + JsonNode off = read(mapper.toInferenceParameters(OllamaApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"think\":false}"))) + .toJson()); + assertThat(off.path("chat_template_kwargs").path("enable_thinking").asBoolean(true), is(false)); + JsonNode low = read(mapper.toInferenceParameters(OllamaApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"think\":\"low\"}"))) + .toJson()); + assertThat(low.path("chat_template_kwargs").path("reasoning_effort").asText(), is("low")); + } + @Test public void toOpenAiChatRequestAddsTheFunctionTypeToAssistantToolCalls() throws IOException { JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest( From 0f6fea4b5b2693145a114722a0783f956f75db07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:20:48 +0000 Subject: [PATCH 133/589] Record the commit of F78 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2c61e3d89..ac6bca29c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -567,7 +567,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestHonoursThinkLikeOllama` (Kette Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ – `enable_thinking` fehlte), mit Fix grün (24/24 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiRequestMapperTest` 21/21, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `d252f84` ## Offene Verdachte ohne Fund-ID From 4a40a1d9c1ae57fcc69f05a943e19298278940c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:27:14 +0000 Subject: [PATCH 134/589] Reject suffix, best_of and echo on the streamed completions path The non-streamed /v1/completions forwards the body to upstream's oaicompat_completion_params_parse, which rejects echo=true, suffix and best_of. The streamed path maps the body itself and dropped them, so a streamed FIM request with a suffix silently continued the prefix. Reject them with upstream's messages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 +++++++++++ .../llama/server/OpenAiRequestMapper.java | 16 +++++++++++++ .../llama/server/OpenAiRequestMapperTest.java | 23 +++++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 8ef0dca52..2b23d296b 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -185,3 +185,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`) + `OpenAiCompatServer.handleProps` ↔ upstream `get_res_props` (`server-context.cpp:5126`, b11639; Schnittstelle `/props`) · `1a04e66` · 2026-10-18 · kein Fund (Verdacht notiert) — `default_generation_settings.n_ctx` = Fenster je Slot (seit F70, wie upstreams `slot_n_ctx`), `model_alias`, `modalities.vision`/`audio`/`video` mit denselben Schlüsseln; `chat_template` leer und `params`, `build_info` usw. fehlen (bewusste Teilmenge). `total_slots` fest 1 statt `n_parallel` (offener Verdacht). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` + `LlamaModelBackend.java` ↔ upstream `oaicompat_chat_params_parse` (Schnittstelle übersetzter Chat-Request → `InferenceParameters` → native Seite, b11639) · `9eee221` · 2026-10-18 · Fund F77 — Folgefrage zu F75/F76: der Chat-Pfad reicht den Body nicht durch, sondern baut `InferenceParameters` aus einer festen Liste (Sampling, Cache, `stop`, `stream_options`, `response_format`, Werkzeuge); die drei Reasoning-Steuerungen fehlten (F77). `/v1/completions` geht dagegen unverändert an `handleCompletionsOai`. - Musterdurchsicht nach F77 (Felder, die ein Übersetzer oder der Mapper still verwirft) über `OllamaApiSupport`, `AnthropicApiSupport`, `ResponsesApiSupport`, `OpenAiRequestMapper` · `ed9aff1` · 2026-10-18 · Fund F78 — Ollama `think` fehlte (F78); Antwortseite ohne `message.thinking` (Verdacht). Weiter offen: der gestreamte `/v1/completions`-Pfad baut `InferenceParameters` über den Mapper, der nicht gestreamte reicht den Body unverändert durch – dieselbe Anfrage verliert gestreamt Felder wie `suffix`, `min_p`, `grammar`, `logprobs` (nächstes Ziel). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters`) + `LlamaModelBackend.completions`/`streamCompletions` ↔ upstream `oaicompat_completion_params_parse` (Schnittstelle gestreamt ↔ nicht gestreamt, b11639) · `d89f722` · 2026-10-18 · Fund F79 — `stop` als String wird wie upstream zur Liste; die Abweisungen von upstream fehlten gestreamt (F79); der übrige Feldverlust beim Streaming ist als Verdacht notiert (Umbau nötig). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ac6bca29c..db03f1e23 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -57,6 +57,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F76](#f76) — mittel — *gefixt* - [F77](#f77) — mittel — *gefixt* - [F78](#f78) — mittel — *gefixt* +- [F79](#f79) — niedrig — *gefixt* ## Funde @@ -569,6 +570,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d252f84` + +### F79 – java-llama.cpp: gestreamtes `/v1/completions` ignoriert `suffix`, `best_of` und `echo`, die der nicht gestreamte Weg abweist +- **Zeit:** 2026-10-18 02:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:89` (`toCompletionParameters`) +- **Schwere:** niedrig +- **Beschreibung:** Nicht gestreamt geht `/v1/completions` unverändert an `handleCompletionsOai`, also an upstreams `oaicompat_completion_params_parse` (`server-common.cpp:1082`, b11639), das `echo: true` („Only no echo is supported“) sowie `suffix` und `best_of` („Unsupported param: …“) mit 400 abweist. Gestreamt baut `toCompletionParameters` die Anfrage aus einer Teilmenge und überging diese Felder still. Ein gestreamter FIM-Request mit `suffix` lieferte so eine bloße Fortsetzung des Präfixes, die über den Suffix hinausläuft und ihn im Editor dupliziert, statt eines Fehlers; dieselbe Anfrage verhielt sich gestreamt und nicht gestreamt verschieden. +- **Fix:** `toCompletionParameters` weist `echo: true`, `suffix` und `best_of` mit upstreams Meldungen ab (`IllegalArgumentException`, vom Server als Anfragefehler gemeldet). +- **Reproduktion:** `OpenAiRequestMapperTest#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, die drei Übersetzer-Tests, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -582,3 +595,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. +- *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 9eee221ca..d89f722d2 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.JsonNode; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -30,6 +32,10 @@ */ final class OpenAiRequestMapper { + /** Completion parameters OpenAI defines that upstream rejects ({@code oaicompat_completion_params_parse}). */ + private static final List UNSUPPORTED_COMPLETION_PARAMS = + Collections.unmodifiableList(Arrays.asList("best_of", "suffix")); + OpenAiRequestMapper() {} /** @@ -85,6 +91,16 @@ InferenceParameters toCompletionParameters(JsonNode request) { if (!prompt.isTextual()) { throw new IllegalArgumentException("'prompt' must be a string"); } + // The non-streamed /v1/completions forwards the body to upstream's oaicompat_completion_params_parse, + // which rejects these; reject them here as well instead of silently generating something else. + if (request.path("echo").asBoolean(false)) { + throw new IllegalArgumentException("Only no echo is supported"); + } + for (String unsupported : UNSUPPORTED_COMPLETION_PARAMS) { + if (request.has(unsupported)) { + throw new IllegalArgumentException("Unsupported param: " + unsupported); + } + } InferenceParameters params = InferenceParameters.empty().withPrompt(prompt.asText()).withCachePrompt(true); return applyCommonFields(params, request); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 406270338..f5be4957a 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -31,6 +31,29 @@ private JsonNode mapAndSerialize(String requestJson) throws IOException { return MAPPER.readTree(params.toJson()); } + @Test + public void completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo() throws IOException { + // Upstream's oaicompat_completion_params_parse answers 400 for suffix, best_of and echo=true, and + // the non-streamed /v1/completions forwards the body to it; the streamed path maps the body here + // and must not silently drop them (a streamed FIM request with suffix continued the prefix). + assertThrows( + IllegalArgumentException.class, + () -> mapper.toCompletionParameters( + MAPPER.readTree("{\"prompt\":\"def f(\",\"suffix\":\"return x\"}"))); + assertThrows( + IllegalArgumentException.class, + () -> mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"best_of\":2}"))); + assertThrows( + IllegalArgumentException.class, + () -> mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"echo\":true}"))); + assertThat( + MAPPER.readTree(mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"echo\":false}")) + .toJson()) + .path("prompt") + .asText(), + is("p")); + } + @Test public void chatTemplateKwargsAreForwarded() throws IOException { // Qwen3 clients switch thinking off with chat_template_kwargs; upstream's chat parser merges the From e56abf31f5e7d549d4cc5ad90c21a71a1a6422c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:27:14 +0000 Subject: [PATCH 135/589] Record the commit of F79 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index db03f1e23..15e291b14 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -580,7 +580,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, die drei Übersetzer-Tests, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `7545586` ## Offene Verdachte ohne Fund-ID From fdf21f15837933d4a5a5835326fa2f0d6474bad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:35:49 +0000 Subject: [PATCH 136/589] Return the reasoning as a thinking block in the Anthropic response toAnthropicResponse kept the content and the tool calls of the native OpenAI answer and dropped message.reasoning_content. Upstream's to_json_anthropic emits it first as a thinking block with an empty signature, so a client that enabled thinking never saw it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 +++++++++++++++ .../llama/server/AnthropicApiSupport.java | 9 +++++++++ .../llama/server/AnthropicApiSupportTest.java | 17 +++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2b23d296b..b1208bdef 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -186,3 +186,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` + `LlamaModelBackend.java` ↔ upstream `oaicompat_chat_params_parse` (Schnittstelle übersetzter Chat-Request → `InferenceParameters` → native Seite, b11639) · `9eee221` · 2026-10-18 · Fund F77 — Folgefrage zu F75/F76: der Chat-Pfad reicht den Body nicht durch, sondern baut `InferenceParameters` aus einer festen Liste (Sampling, Cache, `stop`, `stream_options`, `response_format`, Werkzeuge); die drei Reasoning-Steuerungen fehlten (F77). `/v1/completions` geht dagegen unverändert an `handleCompletionsOai`. - Musterdurchsicht nach F77 (Felder, die ein Übersetzer oder der Mapper still verwirft) über `OllamaApiSupport`, `AnthropicApiSupport`, `ResponsesApiSupport`, `OpenAiRequestMapper` · `ed9aff1` · 2026-10-18 · Fund F78 — Ollama `think` fehlte (F78); Antwortseite ohne `message.thinking` (Verdacht). Weiter offen: der gestreamte `/v1/completions`-Pfad baut `InferenceParameters` über den Mapper, der nicht gestreamte reicht den Body unverändert durch – dieselbe Anfrage verliert gestreamt Felder wie `suffix`, `min_p`, `grammar`, `logprobs` (nächstes Ziel). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters`) + `LlamaModelBackend.completions`/`streamCompletions` ↔ upstream `oaicompat_completion_params_parse` (Schnittstelle gestreamt ↔ nicht gestreamt, b11639) · `d89f722` · 2026-10-18 · Fund F79 — `stop` als String wird wie upstream zur Liste; die Abweisungen von upstream fehlten gestreamt (F79); der übrige Feldverlust beim Streaming ist als Verdacht notiert (Umbau nötig). +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`) ↔ upstream `to_json_anthropic` (`server-task.cpp:723`, b11639; Schnittstelle Denkinhalt in der Antwort) · `5682409` · 2026-10-18 · Fund F80 — Reihenfolge der Blöcke wie upstream (thinking, text, tool_use); fehlender Denkblock (F80); Stream- und Responses-Weg als Verdacht. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 15e291b14..c28c4d902 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -58,6 +58,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F77](#f77) — mittel — *gefixt* - [F78](#f78) — mittel — *gefixt* - [F79](#f79) — niedrig — *gefixt* +- [F80](#f80) — niedrig — *gefixt* ## Funde @@ -582,6 +583,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `7545586` + +### F80 – java-llama.cpp: Anthropic-Antwort verwirft den Denkinhalt – kein `thinking`-Block +- **Zeit:** 2026-10-18 02:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:297` (`toAnthropicResponse`) +- **Schwere:** niedrig +- **Beschreibung:** Die native OpenAI-Antwort trägt den Denkinhalt eines Reasoning-Modells in `message.reasoning_content`. `toAnthropicResponse` übernahm nur `content` und `tool_calls`, der Denkinhalt ging verloren. upstreams `to_json_anthropic` (`server-task.cpp:738`, b11639) setzt ihn als ersten Inhaltsblock `{"type": "thinking", "thinking": …, "signature": ""}`. Ein Anthropic-Client, der das Denken eingeschaltet hat (seit F76 auch wirksam), bekam es nie zu sehen; ein Client, der die Gedanken in der nächsten Runde zurückschickt, konnte das nicht. +- **Fix:** ein nicht leeres `reasoning_content` wird wie upstream als erster Block `thinking` mit leerer Signatur ausgegeben. +- **Reproduktion:** `AnthropicApiSupportTest#responseCarriesTheReasoningAsAThinkingBlockFirstLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2> but: was <1>“), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Nicht im Fix:** der gestreamte Pfad (`AnthropicStreamTranslator`, upstream `thinking_delta`) und die Responses-Antwort (upstream ein `reasoning`-Item mit `reasoning_text`) – offener Verdacht. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -596,3 +610,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt in den übrigen Antwortwegen: `AnthropicStreamTranslator` gibt keine `thinking`-Blöcke mit `thinking_delta` aus (upstream `server-task.cpp:819`/`833`), `ResponsesApiSupport`/`ResponsesStreamTranslator` kein `reasoning`-Item mit `reasoning_text` (upstream `:535`, `:1208`, Ereignis `response.reasoning_text.delta`). Auf der Anfrageseite verwirft `AnthropicApiSupport` `thinking`-Blöcke im Verlauf, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 8a426d945..568240967 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -292,6 +292,15 @@ static String toAnthropicResponse(String openAiCompletionJson, String model) { JsonNode completion = OBJECT_MAPPER.readTree(openAiCompletionJson); JsonNode choice = completion.path("choices").path(0); JsonNode message = choice.path("message"); + // As upstream's to_json_anthropic: the reasoning comes first as a thinking block, with an + // empty signature (a local model has nothing to sign). + String reasoning = message.path("reasoning_content").asText(""); + if (!reasoning.isEmpty()) { + ObjectNode thinkingBlock = content.addObject(); + thinkingBlock.put("type", "thinking"); + thinkingBlock.put("thinking", reasoning); + thinkingBlock.put("signature", ""); + } String text = message.path("content").asText(""); if (!text.isEmpty()) { ObjectNode textBlock = content.addObject(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 28a4262fa..bb03a6198 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -45,6 +45,23 @@ public void requestTurnsImageBlocksIntoImageUrlParts() throws IOException { assertThat(content.get(1).path("image_url").path("url").asText(), is("data:image/png;base64,iVBORw0K")); } + @Test + public void responseCarriesTheReasoningAsAThinkingBlockFirstLikeUpstream() throws IOException { + // Upstream's to_json_anthropic puts the reasoning first, as {"type":"thinking","thinking":..., + // "signature":""}; a client that enabled thinking (F76) otherwise never sees it. + JsonNode response = read(AnthropicApiSupport.toAnthropicResponse( + "{\"choices\":[{\"message\":{\"content\":\"42\",\"reasoning_content\":\"6 times 7\"}," + + "\"finish_reason\":\"stop\"}]}", + "m")); + JsonNode content = response.path("content"); + assertThat(content.size(), is(2)); + assertThat(content.get(0).path("type").asText(), is("thinking")); + assertThat(content.get(0).path("thinking").asText(), is("6 times 7")); + assertThat(content.get(0).path("signature").isTextual(), is(true)); + assertThat(content.get(1).path("type").asText(), is("text")); + assertThat(content.get(1).path("text").asText(), is("42")); + } + @Test public void requestMapsAnEnabledThinkingBudgetLikeUpstream() throws IOException { // Upstream's convert_anthropic_to_oai turns thinking.budget_tokens into thinking_budget_tokens, From 8220af9e9ccabaa8ba7f0266d8193cf8d375a3b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:35:49 +0000 Subject: [PATCH 137/589] Record the commit of F80 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c28c4d902..58a416fb3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -594,7 +594,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2> but: was <1>“), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Nicht im Fix:** der gestreamte Pfad (`AnthropicStreamTranslator`, upstream `thinking_delta`) und die Responses-Antwort (upstream ein `reasoning`-Item mit `reasoning_text`) – offener Verdacht. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `e709e5f` ## Offene Verdachte ohne Fund-ID From 7fdc68414c5fb32a9483abfd681d8491eeaea6df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:42:11 +0000 Subject: [PATCH 138/589] Stream the reasoning as an Anthropic thinking block AnthropicStreamTranslator translated delta.content and tool calls and dropped delta.reasoning_content. Upstream's to_json_anthropic_stream streams it as a thinking block of thinking_delta events, closed with the empty signature_delta the protocol requires. Open such a block on the first reasoning delta and close it when the text begins or the stream ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 +++++++- .../llama/server/AnthropicApiSupport.java | 36 +++++++++++++++++++ .../server/AnthropicStreamTranslator.java | 26 +++++++++++++- .../server/AnthropicStreamTranslatorTest.java | 20 +++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index b1208bdef..b9cc7c77b 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -187,3 +187,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - Musterdurchsicht nach F77 (Felder, die ein Übersetzer oder der Mapper still verwirft) über `OllamaApiSupport`, `AnthropicApiSupport`, `ResponsesApiSupport`, `OpenAiRequestMapper` · `ed9aff1` · 2026-10-18 · Fund F78 — Ollama `think` fehlte (F78); Antwortseite ohne `message.thinking` (Verdacht). Weiter offen: der gestreamte `/v1/completions`-Pfad baut `InferenceParameters` über den Mapper, der nicht gestreamte reicht den Body unverändert durch – dieselbe Anfrage verliert gestreamt Felder wie `suffix`, `min_p`, `grammar`, `logprobs` (nächstes Ziel). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters`) + `LlamaModelBackend.completions`/`streamCompletions` ↔ upstream `oaicompat_completion_params_parse` (Schnittstelle gestreamt ↔ nicht gestreamt, b11639) · `d89f722` · 2026-10-18 · Fund F79 — `stop` als String wird wie upstream zur Liste; die Abweisungen von upstream fehlten gestreamt (F79); der übrige Feldverlust beim Streaming ist als Verdacht notiert (Umbau nötig). - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`) ↔ upstream `to_json_anthropic` (`server-task.cpp:723`, b11639; Schnittstelle Denkinhalt in der Antwort) · `5682409` · 2026-10-18 · Fund F80 — Reihenfolge der Blöcke wie upstream (thinking, text, tool_use); fehlender Denkblock (F80); Stream- und Responses-Weg als Verdacht. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` + `AnthropicApiSupport.java` (Ereignisse) ↔ upstream `to_json_anthropic_stream` (`server-task.cpp:1340`–`1420`, `:905`–`930`, b11639; Schnittstelle Denkinhalt im Stream) · `bd6ac4e`/`edd2c9a` · 2026-10-18 · Fund F81 — Text- und Werkzeugblöcke wie zuvor; `reasoning_content` fehlte (F81). Indizes fortlaufend in der Reihenfolge des Öffnens (thinking, text, tool_use). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 58a416fb3..88c5d117b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -59,6 +59,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F78](#f78) — mittel — *gefixt* - [F79](#f79) — niedrig — *gefixt* - [F80](#f80) — niedrig — *gefixt* +- [F81](#f81) — niedrig — *gefixt* ## Funde @@ -596,6 +597,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `e709e5f` + +### F81 – java-llama.cpp: gestreamte Anthropic-Antwort verwirft den Denkinhalt – keine `thinking_delta`s +- **Zeit:** 2026-10-18 03:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java:80` (`onChunk`, `end`), Ereignisse in `AnthropicApiSupport` +- **Schwere:** niedrig +- **Beschreibung:** Das Gegenstück zu F80 im Stream. Die nativen OpenAI-Chunks tragen den Denkinhalt als `delta.reasoning_content`; `AnthropicStreamTranslator` übersetzte nur `delta.content` und Werkzeugaufrufe. upstreams `to_json_anthropic_stream` (`server-task.cpp:1356`, `:907`, b11639) öffnet dafür einen Block `{"type": "thinking", "thinking": ""}`, schickt `thinking_delta`s und schließt ihn mit einem leeren `signature_delta`, das das Protokoll vor dem Schließen eines thinking-Blocks verlangt. +- **Fix:** Der Übersetzer öffnet beim ersten `reasoning_content` einen thinking-Block, schickt die Teile als `thinking_delta` und schließt ihn mit `signature_delta` + `content_block_stop`, sobald Text beginnt oder der Stream endet; die Blöcke folgen einander, statt wie bei upstream bis zum Ende offen zu bleiben. Drei neue Ereignis-Bausteine in `AnthropicApiSupport`. +- **Reproduktion:** `AnthropicStreamTranslatorTest#reasoningDeltasStreamAsAThinkingBlockClosedWithASignatureBeforeTheText` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}" but: was ""“), mit Fix grün (5/5 der Klasse, skipped 0); Gegenprobe per `git stash` beider Dateien rot, nach `pop` grün. `AnthropicApiSupportTest` 16/16, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -610,4 +623,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt in den übrigen Antwortwegen: `AnthropicStreamTranslator` gibt keine `thinking`-Blöcke mit `thinking_delta` aus (upstream `server-task.cpp:819`/`833`), `ResponsesApiSupport`/`ResponsesStreamTranslator` kein `reasoning`-Item mit `reasoning_text` (upstream `:535`, `:1208`, Ereignis `response.reasoning_text.delta`). Auf der Anfrageseite verwirft `AnthropicApiSupport` `thinking`-Blöcke im Verlauf, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt in den übrigen Antwortwegen (Anthropic-Stream seit F81 erledigt): `ResponsesApiSupport`/`ResponsesStreamTranslator` kein `reasoning`-Item mit `reasoning_text` (upstream `:535`, `:1208`, Ereignis `response.reasoning_text.delta`). Auf der Anfrageseite verwirft `AnthropicApiSupport` `thinking`-Blöcke im Verlauf, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 568240967..edd2c9aef 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -430,6 +430,42 @@ static String textDeltaEvent(int index, String text) { return sseEvent("content_block_delta", data.toString()); } + /** {@code content_block_start} event opening an empty {@code thinking} block at {@code index}. */ + static String thinkingBlockStartEvent(int index) { + ObjectNode data = OBJECT_MAPPER.createObjectNode(); + data.put("type", "content_block_start"); + data.put("index", index); + ObjectNode block = data.putObject("content_block"); + block.put("type", "thinking"); + block.put("thinking", ""); + return sseEvent("content_block_start", data.toString()); + } + + /** {@code content_block_delta} event appending reasoning text to the thinking block at {@code index}. */ + static String thinkingDeltaEvent(int index, String thinking) { + ObjectNode data = OBJECT_MAPPER.createObjectNode(); + data.put("type", "content_block_delta"); + data.put("index", index); + ObjectNode delta = data.putObject("delta"); + delta.put("type", "thinking_delta"); + delta.put("thinking", thinking); + return sseEvent("content_block_delta", data.toString()); + } + + /** + * {@code content_block_delta} event carrying the (empty) signature the protocol requires before a + * thinking block closes; a local model has nothing to sign, as in upstream's stream. + */ + static String signatureDeltaEvent(int index) { + ObjectNode data = OBJECT_MAPPER.createObjectNode(); + data.put("type", "content_block_delta"); + data.put("index", index); + ObjectNode delta = data.putObject("delta"); + delta.put("type", "signature_delta"); + delta.put("signature", ""); + return sseEvent("content_block_delta", data.toString()); + } + /** {@code content_block_start} event opening a {@code tool_use} block at {@code index}. */ static String toolUseBlockStartEvent(int index, String id, String name) { ObjectNode data = OBJECT_MAPPER.createObjectNode(); diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java index 7f002e9d3..bd6ac4e70 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java @@ -11,7 +11,8 @@ /** * Stateful translator that turns the OpenAI streaming chat chunks into the Anthropic Messages SSE event - * sequence: {@code message_start} → (a {@code text} content block with {@code content_block_start} + + * sequence: {@code message_start} → (a {@code thinking} block for {@code reasoning_content}, closed with an + * empty {@code signature_delta}) → (a {@code text} content block with {@code content_block_start} + * {@code content_block_delta}s + {@code content_block_stop}) → one {@code tool_use} block per tool call * (start + {@code input_json_delta} + stop) → {@code message_delta} (stop reason) → {@code message_stop}. * @@ -28,6 +29,8 @@ final class AnthropicStreamTranslator { private final String model; private final ToolCallDeltaAccumulator accumulator = new ToolCallDeltaAccumulator(); + private boolean thinkingBlockOpen; + private int thinkingBlockIndex = -1; private boolean textBlockOpen; private int textBlockIndex = -1; private int nextIndex; @@ -73,8 +76,19 @@ String onChunk(String openAiChunkJson) { outputTokens = usage.path("completion_tokens").asLong(0); } JsonNode choice = chunk.path("choices").path(0); + // Reasoning streams as a thinking block, as in upstream's to_json_anthropic_stream. + JsonNode reasoning = choice.path("delta").path("reasoning_content"); + if (reasoning.isTextual() && !reasoning.asText().isEmpty()) { + if (!thinkingBlockOpen) { + thinkingBlockIndex = nextIndex++; + out.append(AnthropicApiSupport.thinkingBlockStartEvent(thinkingBlockIndex)); + thinkingBlockOpen = true; + } + out.append(AnthropicApiSupport.thinkingDeltaEvent(thinkingBlockIndex, reasoning.asText())); + } JsonNode content = choice.path("delta").path("content"); if (content.isTextual() && !content.asText().isEmpty()) { + closeThinkingBlock(out); if (!textBlockOpen) { textBlockIndex = nextIndex++; out.append(AnthropicApiSupport.textBlockStartEvent(textBlockIndex)); @@ -99,6 +113,7 @@ String onChunk(String openAiChunkJson) { */ String end() { StringBuilder out = new StringBuilder(); + closeThinkingBlock(out); if (textBlockOpen) { out.append(AnthropicApiSupport.blockStopEvent(textBlockIndex)); textBlockOpen = false; @@ -119,4 +134,13 @@ String end() { out.append(AnthropicApiSupport.messageStopEvent()); return out.toString(); } + + /** Close an open thinking block: the empty signature the protocol requires, then the block stop. */ + private void closeThinkingBlock(StringBuilder out) { + if (thinkingBlockOpen) { + out.append(AnthropicApiSupport.signatureDeltaEvent(thinkingBlockIndex)); + out.append(AnthropicApiSupport.blockStopEvent(thinkingBlockIndex)); + thinkingBlockOpen = false; + } + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java index 288464679..5514f23ca 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java @@ -51,6 +51,26 @@ public void endClosesTextBlockAndEmitsStopReasonAndMessageStop() { assertThat(end, containsString("\"cache_read_input_tokens\":8")); } + @Test + public void reasoningDeltasStreamAsAThinkingBlockClosedWithASignatureBeforeTheText() { + // Upstream's to_json_anthropic_stream streams reasoning_content as a thinking block + // (thinking_delta) and closes it with an empty signature_delta, which the protocol requires. + AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); + String first = translator.onChunk("{\"choices\":[{\"delta\":{\"reasoning_content\":\"6 times\"}}]}"); + String second = translator.onChunk("{\"choices\":[{\"delta\":{\"reasoning_content\":\" 7\"}}]}"); + String text = translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"42\"}}]}"); + String events = first + second + text + translator.end(); + + assertThat(first, containsString("\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}")); + assertThat(first, containsString("\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"6 times\"}")); + assertThat(second, containsString("\"thinking\":\" 7\"")); + int signature = events.indexOf("\"type\":\"signature_delta\""); + int thinkingStop = events.indexOf("\"type\":\"content_block_stop\",\"index\":0"); + int textStart = events.indexOf("\"content_block\":{\"type\":\"text\""); + assertThat(signature >= 0 && signature < thinkingStop && thinkingStop < textStart, is(true)); + assertThat(events, containsString("\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"42\"}")); + } + @Test public void accumulatedToolCallsBecomeToolUseBlocksAtEnd() { AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); From 6c78aa3db1e3ad9e066813ee503a0aa78f827cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:42:11 +0000 Subject: [PATCH 139/589] Record the commit of F81 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 88c5d117b..f0366dde4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -607,7 +607,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicStreamTranslatorTest#reasoningDeltasStreamAsAThinkingBlockClosedWithASignatureBeforeTheText` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}" but: was ""“), mit Fix grün (5/5 der Klasse, skipped 0); Gegenprobe per `git stash` beider Dateien rot, nach `pop` grün. `AnthropicApiSupportTest` 16/16, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `d2afd35` ## Offene Verdachte ohne Fund-ID From 935342cb5dc7ae68b4496cae08f6966840adab9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:48:32 +0000 Subject: [PATCH 140/589] Return the reasoning as a reasoning item in the Responses output toResponsesResponse kept the content and the tool calls of the native answer and dropped message.reasoning_content. Upstream's to_json_oaicompat_resp emits it first as a reasoning item with a reasoning_text part, so a Responses client never saw the reasoning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 ++++++++++++++- .../llama/server/ResponsesApiSupport.java | 13 +++++++++++++ .../llama/server/ResponsesApiSupportTest.java | 17 +++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index b9cc7c77b..7dcb98dcf 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -188,3 +188,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters`) + `LlamaModelBackend.completions`/`streamCompletions` ↔ upstream `oaicompat_completion_params_parse` (Schnittstelle gestreamt ↔ nicht gestreamt, b11639) · `d89f722` · 2026-10-18 · Fund F79 — `stop` als String wird wie upstream zur Liste; die Abweisungen von upstream fehlten gestreamt (F79); der übrige Feldverlust beim Streaming ist als Verdacht notiert (Umbau nötig). - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`) ↔ upstream `to_json_anthropic` (`server-task.cpp:723`, b11639; Schnittstelle Denkinhalt in der Antwort) · `5682409` · 2026-10-18 · Fund F80 — Reihenfolge der Blöcke wie upstream (thinking, text, tool_use); fehlender Denkblock (F80); Stream- und Responses-Weg als Verdacht. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` + `AnthropicApiSupport.java` (Ereignisse) ↔ upstream `to_json_anthropic_stream` (`server-task.cpp:1340`–`1420`, `:905`–`930`, b11639; Schnittstelle Denkinhalt im Stream) · `bd6ac4e`/`edd2c9a` · 2026-10-18 · Fund F81 — Text- und Werkzeugblöcke wie zuvor; `reasoning_content` fehlte (F81). Indizes fortlaufend in der Reihenfolge des Öffnens (thinking, text, tool_use). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toResponsesResponse`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:510`–`580`, b11639; Schnittstelle Denkinhalt in der Antwort) · `2a6035a` · 2026-10-18 · Fund F82 — Reihenfolge reasoning, message, function_call wie upstream; fehlendes reasoning-Item (F82). Abweichung ohne Wirkung: wir geben das `message`-Item auch bei leerem Text aus, upstream nur bei Inhalt. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f0366dde4..b0affa215 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -60,6 +60,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F79](#f79) — niedrig — *gefixt* - [F80](#f80) — niedrig — *gefixt* - [F81](#f81) — niedrig — *gefixt* +- [F82](#f82) — niedrig — *gefixt* ## Funde @@ -609,6 +610,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d2afd35` + +### F82 – java-llama.cpp: Responses-Antwort verwirft den Denkinhalt – kein `reasoning`-Item +- **Zeit:** 2026-10-18 03:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:237` (`toResponsesResponse`) +- **Schwere:** niedrig +- **Beschreibung:** Gegenstück zu F80 für die Responses-API. `toResponsesResponse` übernahm aus der nativen OpenAI-Antwort `content` und `tool_calls`; `message.reasoning_content` ging verloren. upstreams `to_json_oaicompat_resp` (`server-task.cpp:531`, b11639) gibt ihn als erstes `output`-Item aus: `{"type": "reasoning", "summary": [], "content": [{"type": "reasoning_text", "text": …}], "encrypted_content": "", "status": "completed"}`. Ein Responses-Client sah die Gedanken nie, auch nicht, wenn er sie (seit F75/F77 wirksam) mit `reasoning.effort` angefordert hatte. +- **Fix:** ein nicht leeres `reasoning_content` wird wie upstream als erstes `reasoning`-Item ausgegeben (Id `rs_`). +- **Reproduktion:** `ResponsesApiSupportTest#responseCarriesTheReasoningAsAReasoningItemFirstLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2> but: was <1>“), mit Fix grün (14/14 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -623,4 +636,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt in den übrigen Antwortwegen (Anthropic-Stream seit F81 erledigt): `ResponsesApiSupport`/`ResponsesStreamTranslator` kein `reasoning`-Item mit `reasoning_text` (upstream `:535`, `:1208`, Ereignis `response.reasoning_text.delta`). Auf der Anfrageseite verwirft `AnthropicApiSupport` `thinking`-Blöcke im Verlauf, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt in den übrigen Antwortwegen (Anthropic-Stream seit F81, Responses blockierend seit F82 erledigt): `ResponsesStreamTranslator` kein `reasoning`-Item mit `reasoning_text` (upstream `:535`, `:1208`, Ereignis `response.reasoning_text.delta`). Auf der Anfrageseite verwirft `AnthropicApiSupport` `thinking`-Blöcke im Verlauf, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 446fa5ff5..2a6035a8b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -233,6 +233,19 @@ static String toResponsesResponse(String openAiCompletionJson, String model, Str try { JsonNode completion = OBJECT_MAPPER.readTree(openAiCompletionJson); JsonNode message = completion.path("choices").path(0).path("message"); + // As upstream's to_json_oaicompat_resp: the reasoning comes first, as a reasoning item. + String reasoning = message.path("reasoning_content").asText(""); + if (!reasoning.isEmpty()) { + ObjectNode reasoningItem = output.addObject(); + reasoningItem.put("type", "reasoning"); + reasoningItem.put("id", "rs_" + responseId); + reasoningItem.putArray("summary"); + ObjectNode reasoningText = reasoningItem.putArray("content").addObject(); + reasoningText.put("type", "reasoning_text"); + reasoningText.put("text", reasoning); + reasoningItem.put("encrypted_content", ""); + reasoningItem.put("status", "completed"); + } String text = message.path("content").asText(""); ObjectNode messageItem = output.addObject(); messageItem.put("type", "message"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 1254f856a..9352df368 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -56,6 +56,23 @@ public void requestMapsTheReasoningEffortLikeUpstream() throws IOException { assertThat(openAi.path("reasoning_effort").asText(), is("none")); } + @Test + public void responseCarriesTheReasoningAsAReasoningItemFirstLikeUpstream() throws IOException { + // Upstream's to_json_oaicompat_resp emits the reasoning as the first output item, + // {"type":"reasoning","content":[{"type":"reasoning_text","text":...}],"summary":[]}. + JsonNode output = read(ResponsesApiSupport.toResponsesResponse( + "{\"choices\":[{\"message\":{\"content\":\"42\",\"reasoning_content\":\"6 times 7\"}}]}", + "m", + "resp_1")) + .path("output"); + assertThat(output.size(), is(2)); + assertThat(output.get(0).path("type").asText(), is("reasoning")); + assertThat(output.get(0).path("content").get(0).path("type").asText(), is("reasoning_text")); + assertThat(output.get(0).path("content").get(0).path("text").asText(), is("6 times 7")); + assertThat(output.get(0).path("summary").isArray(), is(true)); + assertThat(output.get(1).path("type").asText(), is("message")); + } + @Test public void responseCarriesTheCurrentUnixTimeAsCreatedAt() throws IOException { long before = System.currentTimeMillis() / 1000L; From 272bf1427ac80035abec35ae53241629e81597c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:48:32 +0000 Subject: [PATCH 141/589] Record the commit of F82 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b0affa215..cad71c456 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -620,7 +620,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#responseCarriesTheReasoningAsAReasoningItemFirstLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2> but: was <1>“), mit Fix grün (14/14 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `070dbce` ## Offene Verdachte ohne Fund-ID From f32715658d527bd7149e968836a5ef9a19492f46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:02:25 +0000 Subject: [PATCH 142/589] Stream the reasoning as a Responses reasoning item ResponsesStreamTranslator dropped delta.reasoning_content. Upstream's to_json_oaicompat_resp_stream opens a reasoning output item, streams response.reasoning_text.delta and puts the item first in the completed output. Do the same, closing it with reasoning_text.done and output_item.done. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 +++- .../server/ResponsesStreamTranslator.java | 73 +++++++++++++++++-- .../server/ResponsesStreamTranslatorTest.java | 21 ++++++ 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7dcb98dcf..a48020c45 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -189,3 +189,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`) ↔ upstream `to_json_anthropic` (`server-task.cpp:723`, b11639; Schnittstelle Denkinhalt in der Antwort) · `5682409` · 2026-10-18 · Fund F80 — Reihenfolge der Blöcke wie upstream (thinking, text, tool_use); fehlender Denkblock (F80); Stream- und Responses-Weg als Verdacht. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` + `AnthropicApiSupport.java` (Ereignisse) ↔ upstream `to_json_anthropic_stream` (`server-task.cpp:1340`–`1420`, `:905`–`930`, b11639; Schnittstelle Denkinhalt im Stream) · `bd6ac4e`/`edd2c9a` · 2026-10-18 · Fund F81 — Text- und Werkzeugblöcke wie zuvor; `reasoning_content` fehlte (F81). Indizes fortlaufend in der Reihenfolge des Öffnens (thinking, text, tool_use). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toResponsesResponse`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:510`–`580`, b11639; Schnittstelle Denkinhalt in der Antwort) · `2a6035a` · 2026-10-18 · Fund F82 — Reihenfolge reasoning, message, function_call wie upstream; fehlendes reasoning-Item (F82). Abweichung ohne Wirkung: wir geben das `message`-Item auch bei leerem Text aus, upstream nur bei Inhalt. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` ↔ upstream `to_json_oaicompat_resp_stream` (`server-task.cpp:591`, `:1198`, b11639; Schnittstelle Denkinhalt im Stream) · `007523f` · 2026-10-18 · Fund F83 — Folgenummern lückenlos, `output_index` in der Reihenfolge des Öffnens; der Denkinhalt fehlte (F83). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cad71c456..b4e805ecb 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -61,6 +61,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F80](#f80) — niedrig — *gefixt* - [F81](#f81) — niedrig — *gefixt* - [F82](#f82) — niedrig — *gefixt* +- [F83](#f83) — niedrig — *gefixt* ## Funde @@ -622,6 +623,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `070dbce` + +### F83 – java-llama.cpp: gestreamte Responses-Antwort verwirft den Denkinhalt – keine `response.reasoning_text.delta`s +- **Zeit:** 2026-10-18 03:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java:87` (`onChunk`, `end`) +- **Schwere:** niedrig +- **Beschreibung:** Gegenstück zu F82 im Stream. `ResponsesStreamTranslator` übersetzte `delta.content` und Werkzeugaufrufe, `delta.reasoning_content` ging verloren. upstreams `to_json_oaicompat_resp_stream` (`server-task.cpp:1198`, `:591`, b11639) öffnet ein `reasoning`-Item (`response.output_item.added`), streamt `response.reasoning_text.delta` und setzt das Item mit dem ganzen Text an den Anfang des abschließenden `output`. +- **Fix:** Beim ersten Denkteil öffnet der Übersetzer ein `reasoning`-Item (`rs_`), streamt `response.reasoning_text.delta` mit `item_id`/`output_index`/`content_index` wie die Textdeltas, schließt mit `response.reasoning_text.done` und `response.output_item.done` und setzt das Item vorn in `response.completed.output`. Die beiden Delta-Ereignisse teilen sich einen Helfer (SpotBugs PRMC). +- **Reproduktion:** `ResponsesStreamTranslatorTest#reasoningDeltasStreamAsAReasoningItemLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "event: response.output_item.added" but: was ""“), mit Fix grün (5/5 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesApiSupportTest` 14/14, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (erst nach dem Helfer). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -636,4 +649,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt in den übrigen Antwortwegen (Anthropic-Stream seit F81, Responses blockierend seit F82 erledigt): `ResponsesStreamTranslator` kein `reasoning`-Item mit `reasoning_text` (upstream `:535`, `:1208`, Ereignis `response.reasoning_text.delta`). Auf der Anfrageseite verwirft `AnthropicApiSupport` `thinking`-Blöcke im Verlauf, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt im Gesprächsverlauf (die Antwortwege sind seit F80–F83 erledigt): `AnthropicApiSupport` verwirft `thinking`-Blöcke früherer Assistenten-Runden, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java index 51665a1b3..007523f1a 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java @@ -10,10 +10,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import lombok.ToString; +import org.jspecify.annotations.Nullable; /** * Stateful translator that turns the OpenAI streaming chat chunks into the OpenAI Responses SSE event - * sequence: {@code response.created} → (for a text message) {@code response.output_item.added} + + * sequence: {@code response.created} → (for {@code reasoning_content}) a {@code reasoning} item with + * {@code response.reasoning_text.delta}* + {@code response.reasoning_text.done} → (for a text message) + * {@code response.output_item.added} + * {@code response.content_part.added} + {@code response.output_text.delta}* + * {@code response.output_text.done} + {@code response.content_part.done} + * {@code response.output_item.done} → (per tool call) a {@code function_call} item with @@ -32,10 +35,14 @@ final class ResponsesStreamTranslator { private final String model; private final String responseId; private final String messageItemId; + private final String reasoningItemId; private final ToolCallDeltaAccumulator accumulator = new ToolCallDeltaAccumulator(); private final StringBuilder text = new StringBuilder(); + private final StringBuilder reasoning = new StringBuilder(); private int sequence; + private boolean reasoningOpen; + private int reasoningOutputIndex = -1; private boolean messageOpen; private int nextOutputIndex; private int messageOutputIndex = -1; @@ -47,6 +54,7 @@ final class ResponsesStreamTranslator { this.model = model; this.responseId = responseId; this.messageItemId = "msg_" + responseId; + this.reasoningItemId = "rs_" + responseId; } /** @@ -74,7 +82,21 @@ String onChunk(String openAiChunkJson) { JsonNode chunk = OBJECT_MAPPER.readTree(openAiChunkJson); accumulator.accept(chunk); captureUsage(chunk.path("usage")); - JsonNode content = chunk.path("choices").path(0).path("delta").path("content"); + // Reasoning streams as a reasoning output item, as in upstream's to_json_oaicompat_resp_stream. + JsonNode deltaNode = chunk.path("choices").path(0).path("delta"); + JsonNode reasoningDelta = deltaNode.path("reasoning_content"); + String reasoningText = reasoningDelta.isTextual() ? reasoningDelta.asText() : ""; + if (!reasoningText.isEmpty()) { + if (!reasoningOpen) { + reasoningOutputIndex = nextOutputIndex++; + out.append(outputItemAdded(reasoningOutputIndex, reasoningItem("in_progress", null))); + reasoningOpen = true; + } + reasoning.append(reasoningText); + out.append(deltaEvent( + "response.reasoning_text.delta", reasoningItemId, reasoningOutputIndex, reasoningText)); + } + JsonNode content = deltaNode.path("content"); if (content.isTextual() && !content.asText().isEmpty()) { if (!messageOpen) { messageOutputIndex = nextOutputIndex++; @@ -84,12 +106,7 @@ String onChunk(String openAiChunkJson) { } String delta = content.asText(); text.append(delta); - ObjectNode data = ResponsesApiSupport.dataObject(); - data.put("item_id", messageItemId); - data.put("output_index", messageOutputIndex); - data.put("content_index", 0); - data.put("delta", delta); - out.append(ResponsesApiSupport.sseEvent("response.output_text.delta", sequence++, data)); + out.append(deltaEvent("response.output_text.delta", messageItemId, messageOutputIndex, delta)); } } catch (IOException e) { // A malformed chunk produces no events. @@ -107,6 +124,19 @@ String end() { StringBuilder out = new StringBuilder(); ArrayNode output = OBJECT_MAPPER.createArrayNode(); + if (reasoningOpen) { + ObjectNode reasoningDone = ResponsesApiSupport.dataObject(); + reasoningDone.put("item_id", reasoningItemId); + reasoningDone.put("output_index", reasoningOutputIndex); + reasoningDone.put("content_index", 0); + reasoningDone.put("text", reasoning.toString()); + out.append(ResponsesApiSupport.sseEvent("response.reasoning_text.done", sequence++, reasoningDone)); + + ObjectNode item = reasoningItem("completed", reasoning.toString()); + output.add(item); + out.append(outputItemDone(reasoningOutputIndex, item)); + } + if (messageOpen) { ObjectNode textDone = ResponsesApiSupport.dataObject(); textDone.put("item_id", messageItemId); @@ -165,6 +195,33 @@ private void captureUsage(JsonNode usage) { cachedTokens = usage.path("prompt_tokens_details").path("cached_tokens").asLong(0); } + /** A {@code *.delta} event appending {@code delta} to content part 0 of the item {@code itemId}. */ + private String deltaEvent(String event, String itemId, int outputIndex, String delta) { + ObjectNode data = ResponsesApiSupport.dataObject(); + data.put("item_id", itemId); + data.put("output_index", outputIndex); + data.put("content_index", 0); + data.put("delta", delta); + return ResponsesApiSupport.sseEvent(event, sequence++, data); + } + + /** A reasoning output item; {@code reasoningText} {@code null} gives the empty in-progress shell. */ + private ObjectNode reasoningItem(String status, @Nullable String reasoningText) { + ObjectNode item = OBJECT_MAPPER.createObjectNode(); + item.put("type", "reasoning"); + item.put("id", reasoningItemId); + item.putArray("summary"); + ArrayNode content = item.putArray("content"); + if (reasoningText != null) { + ObjectNode part = content.addObject(); + part.put("type", "reasoning_text"); + part.put("text", reasoningText); + } + item.put("encrypted_content", ""); + item.put("status", status); + return item; + } + private ObjectNode messageItemShell() { ObjectNode item = OBJECT_MAPPER.createObjectNode(); item.put("type", "message"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java index b104f56f8..775f30efb 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java @@ -55,6 +55,27 @@ public void endEmitsDoneEventsAndCompleted() { assertThat(end, containsString("\"cached_tokens\":8")); } + @Test + public void reasoningDeltasStreamAsAReasoningItemLikeUpstream() { + // Upstream's to_json_oaicompat_resp_stream opens a reasoning output item, streams + // response.reasoning_text.delta and puts the item first in the completed output. + ResponsesStreamTranslator translator = new ResponsesStreamTranslator("m", "resp_1"); + String first = translator.onChunk("{\"choices\":[{\"delta\":{\"reasoning_content\":\"6 times 7\"}}]}"); + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"42\"}}]}"); + String end = translator.end(); + + assertThat(first, containsString("event: response.output_item.added")); + assertThat(first, containsString("\"type\":\"reasoning\"")); + assertThat(first, containsString("event: response.reasoning_text.delta")); + assertThat(first, containsString("\"delta\":\"6 times 7\"")); + String completed = end.substring(end.indexOf("event: response.completed")); + assertThat( + completed.indexOf("\"type\":\"reasoning\"") >= 0 + && completed.indexOf("\"type\":\"reasoning\"") < completed.indexOf("\"type\":\"message\""), + is(true)); + assertThat(completed, containsString("\"type\":\"reasoning_text\",\"text\":\"6 times 7\"")); + } + @Test public void toolCallsBecomeFunctionCallItemsBeforeCompleted() { ResponsesStreamTranslator translator = new ResponsesStreamTranslator("m", "resp_1"); From 01dd94d457f1c19fa0963a314b5c413408068676 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:02:25 +0000 Subject: [PATCH 143/589] Record the commit of F83 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b4e805ecb..6a95d3766 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -633,7 +633,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesStreamTranslatorTest#reasoningDeltasStreamAsAReasoningItemLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "event: response.output_item.added" but: was ""“), mit Fix grün (5/5 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesApiSupportTest` 14/14, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (erst nach dem Helfer). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `17ad313` ## Offene Verdachte ohne Fund-ID From 638036fb963f11f034df92666a442a40e6708f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:09:08 +0000 Subject: [PATCH 144/589] Keep the thinking blocks of the Anthropic history as reasoning content appendOpenAiMessages ignored thinking blocks, so earlier assistant turns lost their reasoning. Upstream's convert_anthropic_to_oai collects them into reasoning_content, which the chat parser passes to templates that render earlier reasoning. Do the same; a turn of reasoning only becomes an empty content with reasoning_content. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 +++++++++++++- .../llama/server/AnthropicApiSupport.java | 15 ++++++++++++++- .../llama/server/AnthropicApiSupportTest.java | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a48020c45..0b91f3af8 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -190,3 +190,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` + `AnthropicApiSupport.java` (Ereignisse) ↔ upstream `to_json_anthropic_stream` (`server-task.cpp:1340`–`1420`, `:905`–`930`, b11639; Schnittstelle Denkinhalt im Stream) · `bd6ac4e`/`edd2c9a` · 2026-10-18 · Fund F81 — Text- und Werkzeugblöcke wie zuvor; `reasoning_content` fehlte (F81). Indizes fortlaufend in der Reihenfolge des Öffnens (thinking, text, tool_use). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toResponsesResponse`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:510`–`580`, b11639; Schnittstelle Denkinhalt in der Antwort) · `2a6035a` · 2026-10-18 · Fund F82 — Reihenfolge reasoning, message, function_call wie upstream; fehlendes reasoning-Item (F82). Abweichung ohne Wirkung: wir geben das `message`-Item auch bei leerem Text aus, upstream nur bei Inhalt. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` ↔ upstream `to_json_oaicompat_resp_stream` (`server-task.cpp:591`, `:1198`, b11639; Schnittstelle Denkinhalt im Stream) · `007523f` · 2026-10-18 · Fund F83 — Folgenummern lückenlos, `output_index` in der Reihenfolge des Öffnens; der Denkinhalt fehlte (F83). +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`appendOpenAiMessages`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:395`–`540`, b11639; Schnittstelle Verlauf) · `fc5f49b` · 2026-10-18 · Fund F84 — `text` (mit `\n` verbunden, F60), `image` (base64/url → `image_url`), `tool_use` (`input` → `arguments`), `tool_result` (eigene `role: tool`-Nachricht) wie upstream; `thinking` fehlte (F84). Abweichung ohne Wirkung: Werkzeug-Runde ohne Text trägt `content: null`, upstream `""` (beides vom Parser angenommen). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6a95d3766..140343364 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -62,6 +62,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F81](#f81) — niedrig — *gefixt* - [F82](#f82) — niedrig — *gefixt* - [F83](#f83) — niedrig — *gefixt* +- [F84](#f84) — niedrig — *gefixt* ## Funde @@ -635,6 +636,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `17ad313` + +### F84 – java-llama.cpp: Anthropic-Verlauf verwirft `thinking`-Blöcke früherer Runden +- **Zeit:** 2026-10-18 03:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:166` (`appendOpenAiMessages`) +- **Schwere:** niedrig +- **Beschreibung:** Ein Anthropic-Client schickt die `thinking`-Blöcke früherer Assistenten-Runden im Verlauf mit (Pflicht bei Werkzeugschleifen mit Denken). `appendOpenAiMessages` ignorierte den Blocktyp; eine Runde nur aus Denkinhalt wurde zu `content: ""` ohne Gedanken. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:415`, `:524`–`536`, b11639) sammelt die Blöcke als `reasoning_content` der Nachricht, das `common_chat_msgs_parse_oaicompat` liest und Vorlagen, die frühere Gedanken rendern (etwa bei verschachteltem Denken mit Werkzeugen), in den Prompt setzen. Bei uns sah das Modell einen anderen Verlauf als bei upstream. +- **Fix:** `thinking`-Blöcke werden gesammelt und als `reasoning_content` an die Nachricht gehängt; eine Nachricht nur mit Denkinhalt wird wie upstream mit `content: ""` und `reasoning_content` übernommen. Der Chat-Mapper reicht `messages` unverändert weiter, das Feld erreicht also die native Seite. +- **Reproduktion:** `AnthropicApiSupportTest#requestKeepsThinkingBlocksOfTheHistoryAsReasoningContentLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7" but: was ""“), mit Fix grün (17/17 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -649,4 +662,3 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Denkinhalt im Gesprächsverlauf (die Antwortwege sind seit F80–F83 erledigt): `AnthropicApiSupport` verwirft `thinking`-Blöcke früherer Assistenten-Runden, die upstream (`server-chat.cpp:416`) als `reasoning_content` übernimmt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index edd2c9aef..fc5f49b32 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -148,6 +148,8 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag boolean hadImage = false; ArrayNode toolCalls = OBJECT_MAPPER.createArrayNode(); boolean hadToolResult = false; + // Thinking blocks of earlier turns, collected as reasoning_content like upstream's converter. + StringBuilder reasoning = new StringBuilder(); for (JsonNode block : content) { String type = block.path("type").asText(""); switch (type) { @@ -161,6 +163,9 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag .put("type", "text") .put("text", block.path("text").asText("")); break; + case "thinking": + reasoning.append(block.path("thinking").asText("")); + break; case "image": String url = imageUrl(block.path("source")); if (url != null) { @@ -199,7 +204,8 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag if (toolCalls.size() > 0) { message.set("tool_calls", toolCalls); } - } else if (text.length() > 0 || toolCalls.size() > 0) { + putReasoning(message, reasoning); + } else if (text.length() > 0 || toolCalls.size() > 0 || reasoning.length() > 0) { ObjectNode message = out.addObject(); message.put("role", role); if (toolCalls.size() > 0 && text.length() == 0) { @@ -210,6 +216,7 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag if (toolCalls.size() > 0) { message.set("tool_calls", toolCalls); } + putReasoning(message, reasoning); } else if (!hadToolResult) { // Genuinely empty/plain content (no text, no tool calls, no tool_result) — keep a slot. // A content array of only tool_result blocks emits no extra message (they became tool messages). @@ -219,6 +226,12 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag } } + private static void putReasoning(ObjectNode message, CharSequence reasoning) { + if (reasoning.length() > 0) { + message.put("reasoning_content", reasoning.toString()); + } + } + /** * The OpenAI {@code image_url} URL for an Anthropic image {@code source}: a data URL for a base64 * source, the URL itself for a url source, {@code null} for anything else. diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index bb03a6198..a2f619302 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -62,6 +62,22 @@ public void responseCarriesTheReasoningAsAThinkingBlockFirstLikeUpstream() throw assertThat(content.get(1).path("text").asText(), is("42")); } + @Test + public void requestKeepsThinkingBlocksOfTheHistoryAsReasoningContentLikeUpstream() throws IOException { + // Upstream's convert_anthropic_to_oai collects an assistant turn's thinking blocks into + // reasoning_content, which the chat parser hands to templates that render earlier reasoning. + JsonNode messages = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[" + + "{\"role\":\"user\",\"content\":\"q\"}," + + "{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"6 times 7\"," + + "\"signature\":\"\"},{\"type\":\"text\",\"text\":\"42\"}]}," + + "{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"only\"}]}]}")) + .path("messages"); + assertThat(messages.get(1).path("reasoning_content").asText(), is("6 times 7")); + assertThat(messages.get(1).path("content").asText(), is("42")); + assertThat(messages.get(2).path("reasoning_content").asText(), is("only")); + assertThat(messages.get(2).path("content").asText(), is("")); + } + @Test public void requestMapsAnEnabledThinkingBudgetLikeUpstream() throws IOException { // Upstream's convert_anthropic_to_oai turns thinking.budget_tokens into thinking_budget_tokens, From c36a969fd86324f103e4bb56b110703987e0e8b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:09:08 +0000 Subject: [PATCH 145/589] Record the commit of F84 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 140343364..72f2bd773 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -646,7 +646,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#requestKeepsThinkingBlocksOfTheHistoryAsReasoningContentLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7" but: was ""“), mit Fix grün (17/17 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `fb226b3` ## Offene Verdachte ohne Fund-ID From 71354fdb47ed69810e5adc8cf193106b7dc19c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:09:20 +0000 Subject: [PATCH 146/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 72f2bd773..97464ccd7 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -662,3 +662,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Responses-Eingabe: `reasoning`-Items im `input` (die eine frühere Antwort zurückgibt) übernimmt upstream (`convert_responses_to_chatcmpl`, `server-chat.cpp:248`/`253`) als `reasoning_content` der folgenden bzw. einer neuen Assistenten-Nachricht; zu prüfen, ob `ResponsesApiSupport.appendInput` sie verwirft (Gegenstück zu F84). From ad15d1408723f8848598f834eaff959988a62047 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:19:44 +0000 Subject: [PATCH 147/589] Keep Responses reasoning input items out of the user turns appendInput did not know the reasoning item type; it fell into the message branch and, having no role, became a user message carrying the model's own earlier reasoning. Upstream's convert_responses_to_chatcmpl attaches the text as reasoning_content to the preceding assistant message or to a new one. Do the same, and skip an item without readable text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 +++++++++- .../llama/server/ResponsesApiSupport.java | 28 +++++++++++++++++++ .../llama/server/ResponsesApiSupportTest.java | 24 ++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 0b91f3af8..06b401459 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -191,3 +191,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toResponsesResponse`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:510`–`580`, b11639; Schnittstelle Denkinhalt in der Antwort) · `2a6035a` · 2026-10-18 · Fund F82 — Reihenfolge reasoning, message, function_call wie upstream; fehlendes reasoning-Item (F82). Abweichung ohne Wirkung: wir geben das `message`-Item auch bei leerem Text aus, upstream nur bei Inhalt. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` ↔ upstream `to_json_oaicompat_resp_stream` (`server-task.cpp:591`, `:1198`, b11639; Schnittstelle Denkinhalt im Stream) · `007523f` · 2026-10-18 · Fund F83 — Folgenummern lückenlos, `output_index` in der Reihenfolge des Öffnens; der Denkinhalt fehlte (F83). - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`appendOpenAiMessages`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:395`–`540`, b11639; Schnittstelle Verlauf) · `fc5f49b` · 2026-10-18 · Fund F84 — `text` (mit `\n` verbunden, F60), `image` (base64/url → `image_url`), `tool_use` (`input` → `arguments`), `tool_result` (eigene `role: tool`-Nachricht) wie upstream; `thinking` fehlte (F84). Abweichung ohne Wirkung: Werkzeug-Runde ohne Text trägt `content: null`, upstream `""` (beides vom Parser angenommen). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:40`–`260`, b11639; Schnittstelle Responses-Eingabe-Items) · `a2333b5` · 2026-10-18 · Fund F85 — `message` (String/Teile, Bilder), `function_call`, `function_call_output` (String) wie upstream; `reasoning` wurde zur Benutzernachricht (F85); Array-`output` und das Zusammenführen paralleler Aufrufe als Verdacht. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 97464ccd7..5547299b1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -63,6 +63,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F82](#f82) — niedrig — *gefixt* - [F83](#f83) — niedrig — *gefixt* - [F84](#f84) — niedrig — *gefixt* +- [F85](#f85) — mittel — *gefixt* ## Funde @@ -648,6 +649,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `fb226b3` + +### F85 – java-llama.cpp: Responses-`reasoning`-Eingabe wird zur Benutzernachricht +- **Zeit:** 2026-10-18 03:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:199` (`appendReasoningItem`, neu; Aufruf in `appendInput`) +- **Schwere:** mittel +- **Beschreibung:** Ein Responses-Client schickt die `reasoning`-Items früherer Antworten im `input` zurück (Codex & Co. tun das in jeder Werkzeugschleife). `appendInput` kannte den Typ nicht; er fiel in den `default`-Zweig für Nachrichten, und weil ein reasoning-Item keine `role` hat, wurde daraus `role: "user"` mit dem Text seiner Inhaltsteile. Der frühere Gedankengang des Modells stand damit als Benutzereingabe im Prompt – das Modell sah den Nutzer seine eigenen Überlegungen sagen. upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:226`–`255`, b11639) hängt den Text als `reasoning_content` an die vorherige Assistenten-Nachricht (`merge_prev`) oder legt eine neue Assistenten-Nachricht an. Gefunden als Gegenstück zu F84. +- **Fix:** `appendReasoningItem` übernimmt den Text von `content[0].text` als `reasoning_content` der letzten Assistenten-Nachricht, sonst einer neuen mit leerem `content`. Ein Item ohne lesbaren Text (nur `summary`/`encrypted_content`) wird übersprungen; upstream weist es mit 400 ab, hier wäre das für Clients mit verschlüsselten Gedanken eine unnötige Ablehnung. +- **Reproduktion:** `ResponsesApiSupportTest#reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream` (neue Assistenten-Nachricht nach einem Nutzer, Zusammenführung mit einem `function_call`, verschlüsseltes Item übersprungen) +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "q" but: was "plan"“ – der Denktext als Benutzernachricht), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (nach Herausziehen zweier doppelter Aufrufe, PRMC). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -662,4 +675,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Responses-Eingabe: `reasoning`-Items im `input` (die eine frühere Antwort zurückgibt) übernimmt upstream (`convert_responses_to_chatcmpl`, `server-chat.cpp:248`/`253`) als `reasoning_content` der folgenden bzw. einer neuen Assistenten-Nachricht; zu prüfen, ob `ResponsesApiSupport.appendInput` sie verwirft (Gegenstück zu F84). +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Responses-Eingabe `function_call_output` mit einem `output`-**Array** (`input_text`/`input_image`-Teile): `appendInput` liest `output.asText("")` und gibt dem Werkzeugergebnis damit leeren Inhalt; upstream (`server-chat.cpp:189`–`220`) übernimmt die Teile als `text`/`image_url`. Außerdem legt Java für jeden `function_call` eine eigene Assistenten-Nachricht an, upstream hängt aufeinanderfolgende Aufrufe an die vorherige (`merge_prev`); parallele Werkzeugaufrufe erscheinen bei uns als mehrere Runden. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 2a6035a8b..a2333b5da 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -137,6 +137,9 @@ private static void appendInput(ArrayNode messages, JsonNode input) { toolMessage.put("tool_call_id", item.path("call_id").asText("")); toolMessage.put("content", item.path("output").asText("")); break; + case "reasoning": + appendReasoningItem(messages, item); + break; case "message": default: ObjectNode message = messages.addObject(); @@ -186,6 +189,31 @@ private static void appendInput(ArrayNode messages, JsonNode input) { return hasImage ? parts : null; } + /** + * A {@code reasoning} input item (an earlier answer's reasoning, sent back by the client) becomes + * {@code reasoning_content} of the preceding assistant message, or of a new one, as in upstream's + * {@code convert_responses_to_chatcmpl}. The item has no role, so reading it as a message made the + * model's own reasoning a user turn. An item without readable text (only {@code summary} or + * {@code encrypted_content}) is skipped; upstream rejects it. + */ + private static void appendReasoningItem(ArrayNode messages, JsonNode item) { + JsonNode text = item.path("content").path(0).path("text"); + if (!text.isTextual()) { + return; + } + String reasoning = text.asText(); + int size = messages.size(); + JsonNode last = size > 0 ? messages.get(size - 1) : null; + if (last instanceof ObjectNode && "assistant".equals(last.path("role").asText(""))) { + ((ObjectNode) last).put("reasoning_content", reasoning); + return; + } + ObjectNode assistant = messages.addObject(); + assistant.put("role", "assistant"); + assistant.put("content", ""); + assistant.put("reasoning_content", reasoning); + } + private static String inputContentText(JsonNode content) { if (content.isTextual()) { return content.asText(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 9352df368..71a34f0e7 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -47,6 +47,30 @@ public void requestJoinsTheTextPartsOfOneInputItemWithANewline() throws IOExcept * llama.cpp's own {@code /v1/responses} ({@code to_json_oaicompat_resp}). It was the constant * {@code 0}, so clients showed every response as created on 1 January 1970. */ + @Test + public void reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream() throws IOException { + // A reasoning item carries no role; read as a message it became a user turn holding the model's + // own earlier reasoning. Upstream's convert_responses_to_chatcmpl attaches it as + // reasoning_content to the preceding assistant message, or to a new assistant message. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q\"}," + + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"plan\"}]}," + + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"f\",\"arguments\":\"{}\"}," + + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"more\"}]}," + + "{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"x\"}]}")) + .path("messages"); + for (JsonNode message : messages) { + if ("user".equals(message.path("role").asText())) { + assertThat(message.path("content").asText(), is("q")); + } + } + assertThat(messages.get(1).path("role").asText(), is("assistant")); + assertThat(messages.get(1).path("reasoning_content").asText(), is("plan")); + assertThat(messages.get(2).path("role").asText(), is("assistant")); + assertThat(messages.get(2).path("reasoning_content").asText(), is("more")); + assertThat(messages.size(), is(3)); + } + @Test public void requestMapsTheReasoningEffortLikeUpstream() throws IOException { // Upstream's Responses converter maps reasoning.effort to reasoning_effort, which the chat From 82aa10bc5ce3934d691f73b2105da07652e2121d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:19:44 +0000 Subject: [PATCH 148/589] Record the commit of F85 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5547299b1..7218c07a8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -659,7 +659,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream` (neue Assistenten-Nachricht nach einem Nutzer, Zusammenführung mit einem `function_call`, verschlüsseltes Item übersprungen) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "q" but: was "plan"“ – der Denktext als Benutzernachricht), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (nach Herausziehen zweier doppelter Aufrufe, PRMC). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `7a8b262` ## Offene Verdachte ohne Fund-ID From a2f8de11fdb393e580363276766572e61b9f4e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:27:15 +0000 Subject: [PATCH 149/589] Keep the parts of a Responses tool output array appendInput read a function_call_output's output as text, which is empty for an array of input_text and input_image parts, so the tool result reached the model empty. Turn the parts into text and image_url parts of the tool message, as upstream's convert_responses_to_chatcmpl does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 ++++++++- .../llama/server/ResponsesApiSupport.java | 31 ++++++++++++++++++- .../llama/server/ResponsesApiSupportTest.java | 20 ++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 06b401459..a46015238 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -192,3 +192,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` ↔ upstream `to_json_oaicompat_resp_stream` (`server-task.cpp:591`, `:1198`, b11639; Schnittstelle Denkinhalt im Stream) · `007523f` · 2026-10-18 · Fund F83 — Folgenummern lückenlos, `output_index` in der Reihenfolge des Öffnens; der Denkinhalt fehlte (F83). - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`appendOpenAiMessages`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:395`–`540`, b11639; Schnittstelle Verlauf) · `fc5f49b` · 2026-10-18 · Fund F84 — `text` (mit `\n` verbunden, F60), `image` (base64/url → `image_url`), `tool_use` (`input` → `arguments`), `tool_result` (eigene `role: tool`-Nachricht) wie upstream; `thinking` fehlte (F84). Abweichung ohne Wirkung: Werkzeug-Runde ohne Text trägt `content: null`, upstream `""` (beides vom Parser angenommen). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:40`–`260`, b11639; Schnittstelle Responses-Eingabe-Items) · `a2333b5` · 2026-10-18 · Fund F85 — `message` (String/Teile, Bilder), `function_call`, `function_call_output` (String) wie upstream; `reasoning` wurde zur Benutzernachricht (F85); Array-`output` und das Zusammenführen paralleler Aufrufe als Verdacht. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call_output`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:189`–`220`, b11639) · `210b00c` · 2026-10-18 · Fund F86 — String-`output` wie upstream; Array-`output` kam leer an (F86). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7218c07a8..27b8c372f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -64,6 +64,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F83](#f83) — niedrig — *gefixt* - [F84](#f84) — niedrig — *gefixt* - [F85](#f85) — mittel — *gefixt* +- [F86](#f86) — mittel — *gefixt* ## Funde @@ -661,6 +662,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `7a8b262` + +### F86 – java-llama.cpp: Responses-Werkzeugergebnis als Teil-Array kommt leer beim Modell an +- **Zeit:** 2026-10-18 03:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:203` (`toolOutputParts`, neu; Aufruf in `appendInput`) +- **Schwere:** mittel +- **Beschreibung:** Ein `function_call_output` darf sein `output` als String oder als Array aus `input_text`-/`input_image`-Teilen schicken (z. B. ein Werkzeug, das einen Screenshot liefert). `appendInput` las `output.asText("")`; für ein Array ist das `""`, das Werkzeugergebnis erreichte das Modell also leer und es arbeitete ohne Ergebnis weiter. upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:189`–`220`, b11639) macht aus den Teilen `text`- und `image_url`-Teile der `role: tool`-Nachricht. +- **Fix:** ein Array-`output` wird zu Inhaltsteilen (`input_text` → `text`, `input_image` → `image_url`), andere Teiltypen werden übersprungen (upstream weist sie ab); ein String bleibt wie bisher. +- **Reproduktion:** `ResponsesApiSupportTest#functionCallOutputArrayKeepsItsPartsLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "text" but: was ""“; die erste Fassung des Tests lief über `get(0)` in eine NPE und wurde auf `path(0)` umgestellt, damit der Fehler an der Assertion sichtbar wird), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -675,4 +688,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Responses-Eingabe `function_call_output` mit einem `output`-**Array** (`input_text`/`input_image`-Teile): `appendInput` liest `output.asText("")` und gibt dem Werkzeugergebnis damit leeren Inhalt; upstream (`server-chat.cpp:189`–`220`) übernimmt die Teile als `text`/`image_url`. Außerdem legt Java für jeden `function_call` eine eigene Assistenten-Nachricht an, upstream hängt aufeinanderfolgende Aufrufe an die vorherige (`merge_prev`); parallele Werkzeugaufrufe erscheinen bei uns als mehrere Runden. +- *(niedrig, nur beschrieben, 2026-10-18)* Responses-Eingabe (Array-`output` seit F86 erledigt): Java legt Java für jeden `function_call` eine eigene Assistenten-Nachricht an, upstream hängt aufeinanderfolgende Aufrufe an die vorherige (`merge_prev`); parallele Werkzeugaufrufe erscheinen bei uns als mehrere Runden. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index a2333b5da..210b00c20 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -135,7 +135,12 @@ private static void appendInput(ArrayNode messages, JsonNode input) { ObjectNode toolMessage = messages.addObject(); toolMessage.put("role", "tool"); toolMessage.put("tool_call_id", item.path("call_id").asText("")); - toolMessage.put("content", item.path("output").asText("")); + JsonNode output = item.path("output"); + if (output.isArray()) { + toolMessage.set("content", toolOutputParts(output)); + } else { + toolMessage.put("content", output.asText("")); + } break; case "reasoning": appendReasoningItem(messages, item); @@ -189,6 +194,30 @@ private static void appendInput(ArrayNode messages, JsonNode input) { return hasImage ? parts : null; } + /** + * The content parts of a {@code function_call_output} whose {@code output} is an array: {@code + * input_text} becomes a {@code text} part, {@code input_image} an {@code image_url} part, as in + * upstream's {@code convert_responses_to_chatcmpl}. Reading such an output as text gave the tool + * message an empty content. Other part types are skipped (upstream rejects them). + */ + private static ArrayNode toolOutputParts(JsonNode output) { + ArrayNode parts = OBJECT_MAPPER.createArrayNode(); + for (JsonNode part : output) { + String type = part.path("type").asText(""); + if ("input_text".equals(type)) { + parts.addObject() + .put("type", "text") + .put("text", part.path("text").asText("")); + } else if ("input_image".equals(type) && part.path("image_url").isTextual()) { + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", part.path("image_url").asText()); + } + } + return parts; + } + /** * A {@code reasoning} input item (an earlier answer's reasoning, sent back by the client) becomes * {@code reasoning_content} of the preceding assistant message, or of a new one, as in upstream's diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 71a34f0e7..454dd28bd 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -71,6 +71,26 @@ public void reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream() assertThat(messages.size(), is(3)); } + @Test + public void functionCallOutputArrayKeepsItsPartsLikeUpstream() throws IOException { + // A tool result may be an array of input_text / input_image parts; upstream's + // convert_responses_to_chatcmpl turns them into text / image_url parts of the tool message. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"f\",\"arguments\":\"{}\"}," + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":[" + + "{\"type\":\"input_text\",\"text\":\"sunny\"}," + + "{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,AA==\"}]}]}")) + .path("messages"); + JsonNode tool = messages.get(1); + assertThat(tool.path("role").asText(), is("tool")); + assertThat(tool.path("tool_call_id").asText(), is("c1")); + assertThat(tool.path("content").path(0).path("type").asText(), is("text")); + assertThat(tool.path("content").path(0).path("text").asText(), is("sunny")); + assertThat(tool.path("content").path(1).path("type").asText(), is("image_url")); + assertThat( + tool.path("content").path(1).path("image_url").path("url").asText(), is("data:image/png;base64,AA==")); + } + @Test public void requestMapsTheReasoningEffortLikeUpstream() throws IOException { // Upstream's Responses converter maps reasoning.effort to reasoning_effort, which the chat From 53f273540d8d261ad91e885b43b65595c58cd168 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:27:15 +0000 Subject: [PATCH 150/589] Record the commit of F86 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 27b8c372f..9bf32c4d9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -672,7 +672,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#functionCallOutputArrayKeepsItsPartsLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "text" but: was ""“; die erste Fassung des Tests lief über `get(0)` in eine NPE und wurde auf `path(0)` umgestellt, damit der Fehler an der Assertion sichtbar wird), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `28054ba` ## Offene Verdachte ohne Fund-ID From a4ac92ad8ad47e730ff77fe7a6e75699f936db1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:36:57 +0000 Subject: [PATCH 151/589] Merge consecutive Responses function calls into one assistant turn appendInput opened a new assistant message for every function_call item, so parallel calls became separate turns and their results followed the last one. Upstream's convert_responses_to_chatcmpl appends each call to the preceding assistant message. Do the same; the reasoning test now uses a realistic tool loop, since its first sequence relied on the separate messages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++- .../llama/server/ResponsesApiSupport.java | 24 +++++++-- .../llama/server/ResponsesApiSupportTest.java | 53 ++++++++++++++----- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index a46015238..8061b5acc 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -193,3 +193,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`appendOpenAiMessages`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:395`–`540`, b11639; Schnittstelle Verlauf) · `fc5f49b` · 2026-10-18 · Fund F84 — `text` (mit `\n` verbunden, F60), `image` (base64/url → `image_url`), `tool_use` (`input` → `arguments`), `tool_result` (eigene `role: tool`-Nachricht) wie upstream; `thinking` fehlte (F84). Abweichung ohne Wirkung: Werkzeug-Runde ohne Text trägt `content: null`, upstream `""` (beides vom Parser angenommen). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:40`–`260`, b11639; Schnittstelle Responses-Eingabe-Items) · `a2333b5` · 2026-10-18 · Fund F85 — `message` (String/Teile, Bilder), `function_call`, `function_call_output` (String) wie upstream; `reasoning` wurde zur Benutzernachricht (F85); Array-`output` und das Zusammenführen paralleler Aufrufe als Verdacht. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call_output`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:189`–`220`, b11639) · `210b00c` · 2026-10-18 · Fund F86 — String-`output` wie upstream; Array-`output` kam leer an (F86). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call`) ↔ upstream `convert_responses_to_chatcmpl` (`merge_prev`, `server-chat.cpp:44`, `:177`, b11639) · `32fe049` · 2026-10-18 · Fund F87 — Mit F85–F87 folgen alle Item-Typen der Responses-Eingabe upstreams Regeln; Abweichungen ohne Wirkung: unbekannte Typen und Teile werden übersprungen statt mit 400 abgewiesen. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9bf32c4d9..056f8ef8d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -65,6 +65,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F84](#f84) — niedrig — *gefixt* - [F85](#f85) — mittel — *gefixt* - [F86](#f86) — mittel — *gefixt* +- [F87](#f87) — niedrig — *gefixt* ## Funde @@ -674,6 +675,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `28054ba` + +### F87 – java-llama.cpp: Responses-`function_call`-Items werden einzelne Assistenten-Runden statt einer Runde mit mehreren Aufrufen +- **Zeit:** 2026-10-18 04:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:198` (`assistantToolCalls`, neu; Aufruf in `appendInput`) +- **Schwere:** niedrig +- **Beschreibung:** Die Responses-API gibt parallele Werkzeugaufrufe als aufeinanderfolgende `function_call`-Items zurück, oft nach einem Text des Assistenten. `appendInput` legte für jedes Item eine eigene Assistenten-Nachricht an: aus „Text, Aufruf c1, Aufruf c2, Ergebnis c1, Ergebnis c2“ wurden drei Assistenten-Runden, und die Ergebnisse zu c1 folgten erst nach der Runde von c2. upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:177`–`187`, b11639) hängt jeden Aufruf an die vorherige Assistenten-Nachricht (`merge_prev`); die Vorlage sieht eine Runde mit zwei Aufrufen, wie sie das Modell erzeugt hat. +- **Fix:** ein `function_call` hängt sich an die `tool_calls` der letzten Assistenten-Nachricht (das Array wird bei Bedarf angelegt), sonst entsteht eine neue Assistenten-Nachricht mit `content: null`. Folge wie bei upstream: ein `reasoning`-Item nach einem Aufruf ohne Ergebnis dazwischen überschreibt den Denktext derselben Nachricht (`prev_msg["reasoning_content"] = …`). +- **Reproduktion:** `ResponsesApiSupportTest#consecutiveFunctionCallsJoinThePrecedingAssistantMessageLikeUpstream`; der F85-Test wurde auf eine realistische Werkzeugschleife umgestellt (Denktext, Aufruf, Ergebnis, Denktext, Antwort), weil seine erste Folge die getrennten Nachrichten der alten Variante voraussetzte. +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <4> but: was <6>“, ebenso der umgestellte F85-Test), mit Fix grün (17/17 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -688,4 +701,3 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18)* Responses-Eingabe (Array-`output` seit F86 erledigt): Java legt Java für jeden `function_call` eine eigene Assistenten-Nachricht an, upstream hängt aufeinanderfolgende Aufrufe an die vorherige (`merge_prev`); parallele Werkzeugaufrufe erscheinen bei uns als mehrere Runden. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 210b00c20..32fe04908 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -119,11 +119,7 @@ private static void appendInput(ArrayNode messages, JsonNode input) { String type = item.path("type").asText("message"); switch (type) { case "function_call": - ObjectNode assistant = messages.addObject(); - assistant.put("role", "assistant"); - assistant.putNull("content"); - ArrayNode toolCalls = assistant.putArray("tool_calls"); - ObjectNode toolCall = toolCalls.addObject(); + ObjectNode toolCall = assistantToolCalls(messages).addObject(); toolCall.put( "id", item.path("call_id").asText(item.path("id").asText(""))); toolCall.put("type", "function"); @@ -194,6 +190,24 @@ private static void appendInput(ArrayNode messages, JsonNode input) { return hasImage ? parts : null; } + /** + * The {@code tool_calls} array a {@code function_call} item joins: the preceding assistant message's + * (created if it has none), as upstream's {@code convert_responses_to_chatcmpl} merges parallel calls + * into one assistant turn, else a new assistant message with {@code null} content. + */ + private static ArrayNode assistantToolCalls(ArrayNode messages) { + int size = messages.size(); + JsonNode last = size > 0 ? messages.get(size - 1) : null; + if (last instanceof ObjectNode && "assistant".equals(last.path("role").asText(""))) { + JsonNode existing = last.path("tool_calls"); + return existing instanceof ArrayNode ? (ArrayNode) existing : ((ObjectNode) last).putArray("tool_calls"); + } + ObjectNode assistant = messages.addObject(); + assistant.put("role", "assistant"); + assistant.putNull("content"); + return assistant.putArray("tool_calls"); + } + /** * The content parts of a {@code function_call_output} whose {@code output} is an array: {@code * input_text} becomes a {@code text} part, {@code input_image} an {@code image_url} part, as in diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 454dd28bd..da884ae38 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -51,24 +51,32 @@ public void requestJoinsTheTextPartsOfOneInputItemWithANewline() throws IOExcept public void reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream() throws IOException { // A reasoning item carries no role; read as a message it became a user turn holding the model's // own earlier reasoning. Upstream's convert_responses_to_chatcmpl attaches it as - // reasoning_content to the preceding assistant message, or to a new assistant message. - JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" - + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q\"}," - + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"plan\"}]}," - + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"f\",\"arguments\":\"{}\"}," - + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"more\"}]}," - + "{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"x\"}]}")) + // reasoning_content to the preceding assistant message, or to a new assistant message. The + // sequence is a tool loop as a Responses client sends it back. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest( + read( + "{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q\"}," + + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"plan\"}]}," + + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"f\",\"arguments\":\"{}\"}," + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":\"r\"}," + + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"more\"}]}," + + "{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"x\"}," + + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"42\"}]}]}")) .path("messages"); for (JsonNode message : messages) { if ("user".equals(message.path("role").asText())) { assertThat(message.path("content").asText(), is("q")); } } - assertThat(messages.get(1).path("role").asText(), is("assistant")); - assertThat(messages.get(1).path("reasoning_content").asText(), is("plan")); - assertThat(messages.get(2).path("role").asText(), is("assistant")); - assertThat(messages.get(2).path("reasoning_content").asText(), is("more")); - assertThat(messages.size(), is(3)); + assertThat(messages.size(), is(5)); + assertThat(messages.path(1).path("role").asText(), is("assistant")); + assertThat(messages.path(1).path("reasoning_content").asText(), is("plan")); + assertThat(messages.path(1).path("tool_calls").size(), is(1)); + assertThat(messages.path(2).path("role").asText(), is("tool")); + assertThat(messages.path(3).path("role").asText(), is("assistant")); + assertThat(messages.path(3).path("reasoning_content").asText(), is("more")); + assertThat(messages.path(4).path("content").asText(), is("42")); } @Test @@ -91,6 +99,27 @@ public void functionCallOutputArrayKeepsItsPartsLikeUpstream() throws IOExceptio tool.path("content").path(1).path("image_url").path("url").asText(), is("data:image/png;base64,AA==")); } + @Test + public void consecutiveFunctionCallsJoinThePrecedingAssistantMessageLikeUpstream() throws IOException { + // Parallel tool calls follow the assistant's text as separate items; upstream's + // convert_responses_to_chatcmpl appends each to the preceding assistant message (merge_prev), + // so the template sees one assistant turn with two tool calls. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"weather?\"}," + + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"checking\"}]}," + + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"f\",\"arguments\":\"{}\"}," + + "{\"type\":\"function_call\",\"call_id\":\"c2\",\"name\":\"g\",\"arguments\":\"{}\"}," + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":\"a\"}," + + "{\"type\":\"function_call_output\",\"call_id\":\"c2\",\"output\":\"b\"}]}")) + .path("messages"); + assertThat(messages.size(), is(4)); + assertThat(messages.path(1).path("content").asText(), is("checking")); + assertThat(messages.path(1).path("tool_calls").size(), is(2)); + assertThat(messages.path(1).path("tool_calls").path(1).path("id").asText(), is("c2")); + assertThat(messages.path(2).path("role").asText(), is("tool")); + assertThat(messages.path(3).path("role").asText(), is("tool")); + } + @Test public void requestMapsTheReasoningEffortLikeUpstream() throws IOException { // Upstream's Responses converter maps reasoning.effort to reasoning_effort, which the chat From 04c62f125d8b891bec4cf476da31b6fd300d86de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:36:57 +0000 Subject: [PATCH 152/589] Record the commit of F87 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 056f8ef8d..561446246 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -685,7 +685,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#consecutiveFunctionCallsJoinThePrecedingAssistantMessageLikeUpstream`; der F85-Test wurde auf eine realistische Werkzeugschleife umgestellt (Denktext, Aufruf, Ergebnis, Denktext, Antwort), weil seine erste Folge die getrennten Nachrichten der alten Variante voraussetzte. - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <4> but: was <6>“, ebenso der umgestellte F85-Test), mit Fix grün (17/17 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `77a6903` ## Offene Verdachte ohne Fund-ID From 3ddf132ffea129e39b27a328060b310495cb1c6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:47:28 +0000 Subject: [PATCH 153/589] Keep the images of an Anthropic tool result toolResultText kept only the text blocks of a tool_result, so an image a tool returned never reached the model. Upstream's convert_anthropic_to_oai turns a result with images into text and image_url parts of the tool message. Do the same and keep a text-only result as a string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++ .../llama/server/AnthropicApiSupport.java | 36 ++++++++++++++++++- .../llama/server/AnthropicApiSupportTest.java | 21 +++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 8061b5acc..30bf2664c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -194,3 +194,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:40`–`260`, b11639; Schnittstelle Responses-Eingabe-Items) · `a2333b5` · 2026-10-18 · Fund F85 — `message` (String/Teile, Bilder), `function_call`, `function_call_output` (String) wie upstream; `reasoning` wurde zur Benutzernachricht (F85); Array-`output` und das Zusammenführen paralleler Aufrufe als Verdacht. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call_output`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:189`–`220`, b11639) · `210b00c` · 2026-10-18 · Fund F86 — String-`output` wie upstream; Array-`output` kam leer an (F86). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call`) ↔ upstream `convert_responses_to_chatcmpl` (`merge_prev`, `server-chat.cpp:44`, `:177`, b11639) · `32fe049` · 2026-10-18 · Fund F87 — Mit F85–F87 folgen alle Item-Typen der Responses-Eingabe upstreams Regeln; Abweichungen ohne Wirkung: unbekannte Typen und Teile werden übersprungen statt mit 400 abgewiesen. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`tool_result`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:453`–`512`, b11639; Durchsicht der Typ-Weichen nach F85) · `565f7f8` · 2026-10-18 · Fund F88 — Blocktypen `text`, `thinking`, `image`, `tool_use`, `tool_result` wie upstream (unbekannte überspringen beide); Bilder im `tool_result` fehlten (F88). Kleine Abweichung ohne Wirkung: Textteile eines `tool_result` werden bei uns ohne Trenner verbunden, wie bei upstream (`result_text += text`). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 561446246..6148e65cc 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -66,6 +66,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F85](#f85) — mittel — *gefixt* - [F86](#f86) — mittel — *gefixt* - [F87](#f87) — niedrig — *gefixt* +- [F88](#f88) — mittel — *gefixt* ## Funde @@ -687,6 +688,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `77a6903` + +### F88 – java-llama.cpp: Anthropic-`tool_result` verwirft Bilder +- **Zeit:** 2026-10-18 04:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:257` (`toolResultContent`, neu; Aufruf in `appendOpenAiMessages`) +- **Schwere:** mittel +- **Beschreibung:** Werkzeuge liefern ihr Ergebnis als `tool_result` mit Inhaltsblöcken, darunter Bilder (ein Screenshot, eine Bilddatei, die ein Agent wie Claude Code liest). `toolResultText` übernahm nur die Textblöcke; das Bild erreichte das Modell nie, ein Vision-Modell antwortete, als hätte das Werkzeug keins geliefert. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:453`–`512`, b11639) macht aus einem Ergebnis mit Bildern Inhaltsteile `text`/`image_url` der `role: tool`-Nachricht und lässt nur ein reines Text-Ergebnis als String. Das Anthropic-Gegenstück zu F86, gefunden in der Durchsicht der Typ-Weichen gegen upstreams Liste. +- **Fix:** `toolResultContent` gibt bei einem Bild die Teile in Blockreihenfolge zurück (`text` und `image_url` über das vorhandene `imageUrl`), sonst wie bisher den Text. +- **Reproduktion:** `AnthropicApiSupportTest#toolResultImagesReachTheToolMessageLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "cat.png" but: was ""“ – der Inhalt war ein String), mit Fix grün (18/18 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (nach `new TextNode(...)` statt `textNode(...)`, AI_ANNOTATION_ISSUES_NEEDS_NULLABLE). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -701,3 +714,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. +- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Responses-Inhaltsteile gegen upstreams Liste (`convert_responses_to_chatcmpl`, `server-chat.cpp:71`–`140`): upstream kennt `input_file` (Datei als Teil) und `refusal` (Assistenten-Ablehnung als Text); `ResponsesApiSupport.inputContentText`/`inputContentWithImages` übernehmen nur Teile mit `text` und `input_image`, ein `refusal` mit Feld `refusal` statt `text` ginge so verloren. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index fc5f49b32..565f7f804 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import java.io.IOException; /** @@ -190,7 +191,7 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag ObjectNode toolMessage = out.addObject(); toolMessage.put("role", "tool"); toolMessage.put("tool_call_id", block.path("tool_use_id").asText("")); - toolMessage.put("content", toolResultText(block.path("content"))); + toolMessage.set("content", toolResultContent(block.path("content"))); hadToolResult = true; break; default: @@ -248,6 +249,39 @@ private static void putReasoning(ObjectNode message, CharSequence reasoning) { return null; } + /** + * The content of the tool message for a {@code tool_result}: its text, or, when it carries an image, + * text and {@code image_url} parts in block order, as upstream's {@code convert_anthropic_to_oai} + * does. Flattening to text alone dropped an image a tool returned (a screenshot, an image file). + */ + private static JsonNode toolResultContent(JsonNode content) { + if (content.isArray()) { + ArrayNode parts = OBJECT_MAPPER.createArrayNode(); + boolean hadImage = false; + for (JsonNode block : content) { + String type = block.path("type").asText(""); + if ("text".equals(type)) { + parts.addObject() + .put("type", "text") + .put("text", block.path("text").asText("")); + } else if ("image".equals(type)) { + String url = imageUrl(block.path("source")); + if (url != null) { + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", url); + hadImage = true; + } + } + } + if (hadImage) { + return parts; + } + } + return new TextNode(toolResultText(content)); + } + private static String toolResultText(JsonNode content) { if (content.isTextual()) { return content.asText(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index a2f619302..fcdfc0f2d 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -78,6 +78,27 @@ public void requestKeepsThinkingBlocksOfTheHistoryAsReasoningContentLikeUpstream assertThat(messages.get(2).path("content").asText(), is("")); } + @Test + public void toolResultImagesReachTheToolMessageLikeUpstream() throws IOException { + // A tool that returns an image (a screenshot, an image file read by an agent) sends it as an + // image block inside tool_result; upstream's convert_anthropic_to_oai keeps text and image as + // content parts of the tool message. + JsonNode messages = AnthropicApiSupport.toOpenAiChatRequest( + read( + "{\"messages\":[" + + "{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\",\"input\":{}}]}," + + "{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"cat.png\"}," + + "{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/png\",\"data\":\"AA==\"}}]}]}]}")) + .path("messages"); + JsonNode tool = messages.path(1); + assertThat(tool.path("role").asText(), is("tool")); + assertThat(tool.path("content").path(0).path("text").asText(), is("cat.png")); + assertThat(tool.path("content").path(1).path("type").asText(), is("image_url")); + assertThat( + tool.path("content").path(1).path("image_url").path("url").asText(), is("data:image/png;base64,AA==")); + } + @Test public void requestMapsAnEnabledThinkingBudgetLikeUpstream() throws IOException { // Upstream's convert_anthropic_to_oai turns thinking.budget_tokens into thinking_budget_tokens, From e06d9462056f7223c0a71d88c3c1a2a0f9962ad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:47:28 +0000 Subject: [PATCH 154/589] Record the commit of F88 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6148e65cc..6c0f1354b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -698,7 +698,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#toolResultImagesReachTheToolMessageLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "cat.png" but: was ""“ – der Inhalt war ein String), mit Fix grün (18/18 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (nach `new TextNode(...)` statt `textNode(...)`, AI_ANNOTATION_ISSUES_NEEDS_NULLABLE). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ba93540` ## Offene Verdachte ohne Fund-ID From 576a886abd581f6c66adfaec42bf452b223c0783 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:58:13 +0000 Subject: [PATCH 155/589] Reject a Responses input_file part like upstream The Responses translation kept text and image parts and dropped an input_file, so the model answered as if the attached file had never been sent. Upstream answers it with 400. Reject it with upstream's message, and answer a request the translation rejects with 400 instead of leaving the handler without a response. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 ++++++++++++++- .../llama/server/OpenAiCompatServer.java | 9 ++++++++- .../llama/server/ResponsesApiSupport.java | 17 +++++++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 14 ++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 30bf2664c..4f0610594 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -195,3 +195,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call_output`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:189`–`220`, b11639) · `210b00c` · 2026-10-18 · Fund F86 — String-`output` wie upstream; Array-`output` kam leer an (F86). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call`) ↔ upstream `convert_responses_to_chatcmpl` (`merge_prev`, `server-chat.cpp:44`, `:177`, b11639) · `32fe049` · 2026-10-18 · Fund F87 — Mit F85–F87 folgen alle Item-Typen der Responses-Eingabe upstreams Regeln; Abweichungen ohne Wirkung: unbekannte Typen und Teile werden übersprungen statt mit 400 abgewiesen. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`tool_result`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:453`–`512`, b11639; Durchsicht der Typ-Weichen nach F85) · `565f7f8` · 2026-10-18 · Fund F88 — Blocktypen `text`, `thinking`, `image`, `tool_use`, `tool_result` wie upstream (unbekannte überspringen beide); Bilder im `tool_result` fehlten (F88). Kleine Abweichung ohne Wirkung: Textteile eines `tool_result` werden bei uns ohne Trenner verbunden, wie bei upstream (`result_text += text`). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Inhaltsteile) + `OpenAiCompatServer.handleResponses` ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:71`–`146`, b11639) · `1fd936a`/`bc376ce` · 2026-10-18 · Fund F89 — `input_text`/`input_image`/`output_text` wie upstream; `input_file` wurde still verworfen (F89); `refusal` als Verdacht (upstream scheitert dort selbst). Die übrigen Übersetzer-Routen (Anthropic, Ollama) rufen den Übersetzer ebenfalls vor der Fehlerbehandlung, werfen dort aber nichts. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6c0f1354b..f447aeae2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -67,6 +67,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F86](#f86) — mittel — *gefixt* - [F87](#f87) — niedrig — *gefixt* - [F88](#f88) — mittel — *gefixt* +- [F89](#f89) — niedrig — *gefixt* ## Funde @@ -700,6 +701,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ba93540` + +### F89 – java-llama.cpp: Responses-`input_file` wird still verworfen statt abgewiesen +- **Zeit:** 2026-10-18 04:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:240` (`rejectInputFiles`, neu), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:703` (`handleResponses`) +- **Schwere:** niedrig +- **Beschreibung:** Hängt ein Responses-Client eine Datei als Inhaltsteil `input_file` an, übernahmen `inputContentText`/`inputContentWithImages` nur Text- und Bildteile; die Datei fiel weg und das Modell antwortete, als wäre sie nie geschickt worden (eine „Zusammenfassung“ ohne Dokument). upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:92`, b11639) antwortet darauf mit 400 „'input_file' is not supported by llamacpp at this moment“. Beim Fix zeigte sich zudem: `handleResponses` rief den Übersetzer außerhalb der Fehlerbehandlung auf, eine dort geworfene `IllegalArgumentException` hätte den Handler ohne Antwort verlassen. +- **Fix:** `rejectInputFiles` wirft bei einem `input_file`-Teil eine `IllegalArgumentException` mit upstreams Meldung (plus Datei-Kennung, SpotBugs WEM); `handleResponses` meldet eine Ablehnung des Übersetzers als 400. +- **Reproduktion:** `OpenAiCompatServerHttpTest#responsesWithAnInputFileIsRejectedLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <400> but: was <200>“), mit Fix grün (46/46 der Klasse, skipped 0); Gegenprobe per `git stash` beider Dateien rot, nach `pop` grün. `ResponsesApiSupportTest` 17/17, `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -714,4 +727,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. -- *(niedrig, nur beschrieben, 2026-10-18, nächstes Ziel)* Responses-Inhaltsteile gegen upstreams Liste (`convert_responses_to_chatcmpl`, `server-chat.cpp:71`–`140`): upstream kennt `input_file` (Datei als Teil) und `refusal` (Assistenten-Ablehnung als Text); `ResponsesApiSupport.inputContentText`/`inputContentWithImages` übernehmen nur Teile mit `text` und `input_image`, ein `refusal` mit Feld `refusal` statt `text` ginge so verloren. +- *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 09c96406c..bc376ce8e 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -698,7 +698,14 @@ private void handleResponses(HttpExchange exchange) throws IOException { if (request == null) { return; } - JsonNode openAiRequest = ResponsesApiSupport.toOpenAiChatRequest(request); + final JsonNode openAiRequest; + try { + openAiRequest = ResponsesApiSupport.toOpenAiChatRequest(request); + } catch (IllegalArgumentException e) { + // A request the translation rejects (e.g. an input_file part), answered like upstream. + sendError(exchange, HTTP_BAD_REQUEST, ERROR_TYPE_REQUEST, message(e)); + return; + } String model = request.path("model").asText(config.getModelId()); String responseId = "resp_" + Long.toHexString(System.nanoTime()); if (ResponsesApiSupport.isStreaming(request)) { diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 32fe04908..1fd936aef 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -143,6 +143,7 @@ private static void appendInput(ArrayNode messages, JsonNode input) { break; case "message": default: + rejectInputFiles(item.path("content")); ObjectNode message = messages.addObject(); message.put("role", item.path("role").asText("user")); JsonNode imageContent = inputContentWithImages(item.path("content")); @@ -232,6 +233,22 @@ private static ArrayNode toolOutputParts(JsonNode output) { return parts; } + /** + * Rejects an {@code input_file} content part the way upstream's {@code convert_responses_to_chatcmpl} + * does (400, "not supported"); dropping it let the model answer as if the file had not been sent. + */ + private static void rejectInputFiles(JsonNode content) { + if (!content.isArray()) { + return; + } + for (JsonNode part : content) { + if ("input_file".equals(part.path("type").asText(""))) { + throw new IllegalArgumentException("'input_file' is not supported by llamacpp at this moment (file " + + part.path("file_id").asText(part.path("filename").asText("")) + ")"); + } + } + } + /** * A {@code reasoning} input item (an earlier answer's reasoning, sent back by the client) becomes * {@code reasoning_content} of the preceding assistant message, or of a new one, as in upstream's diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 958483304..ad06a1697 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -271,6 +271,20 @@ public void responsesNonStreamingReturnsResponseObject() throws IOException { } } + @Test + public void responsesWithAnInputFileIsRejectedLikeUpstream() throws IOException { + // Upstream answers an input_file part with 400 ("not supported"); dropping it silently let the + // model answer as if the attached file had never been sent. + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { + String body = "{\"model\":\"test-model\",\"input\":[{\"type\":\"message\",\"role\":\"user\"," + + "\"content\":[{\"type\":\"input_text\",\"text\":\"summarize\"}," + + "{\"type\":\"input_file\",\"file_id\":\"file-1\"}]}]}"; + Response response = post(server.getPort(), "/v1/responses", body, ""); + assertThat(response.code, is(400)); + assertThat(response.body, containsString("input_file")); + } + } + @Test public void responsesStreamingEmitsEventSequence() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { From a110a57d4842ba575c912ae2d8520e2e6d203227 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:58:13 +0000 Subject: [PATCH 156/589] Record the commit of F89 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f447aeae2..2254e362a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -711,7 +711,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#responsesWithAnInputFileIsRejectedLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <400> but: was <200>“), mit Fix grün (46/46 der Klasse, skipped 0); Gegenprobe per `git stash` beider Dateien rot, nach `pop` grün. `ResponsesApiSupportTest` 17/17, `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `3280821` ## Offene Verdachte ohne Fund-ID From 70779b3d64c56004efeeb4f862e56f9a8f027e8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:04:21 +0000 Subject: [PATCH 157/589] Normalize Claude Code's billing stamp so the prefix cache hits Claude Code starts its system prompt with an x-anthropic-billing-header line whose cch stamp changes on every request, so every prompt differed at its start and the KV prefix cache never hit. Upstream's normalize_anthropic_billing_header overwrites the stamp; do the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++++++++ .../llama/server/AnthropicApiSupport.java | 32 +++++++++++++++++-- .../llama/server/AnthropicApiSupportTest.java | 26 +++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 4f0610594..9638b7d83 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -196,3 +196,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call`) ↔ upstream `convert_responses_to_chatcmpl` (`merge_prev`, `server-chat.cpp:44`, `:177`, b11639) · `32fe049` · 2026-10-18 · Fund F87 — Mit F85–F87 folgen alle Item-Typen der Responses-Eingabe upstreams Regeln; Abweichungen ohne Wirkung: unbekannte Typen und Teile werden übersprungen statt mit 400 abgewiesen. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`tool_result`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:453`–`512`, b11639; Durchsicht der Typ-Weichen nach F85) · `565f7f8` · 2026-10-18 · Fund F88 — Blocktypen `text`, `thinking`, `image`, `tool_use`, `tool_result` wie upstream (unbekannte überspringen beide); Bilder im `tool_result` fehlten (F88). Kleine Abweichung ohne Wirkung: Textteile eines `tool_result` werden bei uns ohne Trenner verbunden, wie bei upstream (`result_text += text`). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Inhaltsteile) + `OpenAiCompatServer.handleResponses` ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:71`–`146`, b11639) · `1fd936a`/`bc376ce` · 2026-10-18 · Fund F89 — `input_text`/`input_image`/`output_text` wie upstream; `input_file` wurde still verworfen (F89); `refusal` als Verdacht (upstream scheitert dort selbst). Die übrigen Übersetzer-Routen (Anthropic, Ollama) rufen den Übersetzer ebenfalls vor der Fehlerbehandlung, werfen dort aber nichts. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`systemText`) ↔ upstream `server_chat_convert_anthropic_to_oai` (System-Prompt, `server-chat.cpp:310`–`368`, b11639) · `9b64484` · 2026-10-18 · Fund F90 — String und Text-Blöcke ohne Trenner verbunden wie upstream; die Normalisierung des Billing-Stempels fehlte (F90). Upstream verlangt `messages` (sonst 400), wir liefern ohne `messages` eine leere Liste – ohne Wirkung, der Chat-Mapper weist leere `messages` ab. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2254e362a..75805777e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -68,6 +68,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F87](#f87) — niedrig — *gefixt* - [F88](#f88) — mittel — *gefixt* - [F89](#f89) — niedrig — *gefixt* +- [F90](#f90) — mittel — *gefixt* ## Funde @@ -713,6 +714,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3280821` + +### F90 – java-llama.cpp: Claude Codes wechselnder `cch`-Stempel im System-Prompt verhindert jeden Prefix-Cache-Treffer +- **Zeit:** 2026-10-18 04:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:144` (`normalizeBillingHeader`, neu; Aufruf in `systemText`) +- **Schwere:** mittel (Leistung) +- **Beschreibung:** Claude Code beginnt seinen System-Prompt mit `x-anthropic-billing-header: cc_version=…; cc_entrypoint=cli; cch=a5145;…`, und der `cch`-Wert ändert sich bei jeder Anfrage. Der Prompt unterscheidet sich damit schon in den ersten Token, und der KV-Präfix-Cache (`cache_prompt`, vom Mapper auf `true` gesetzt) trifft nie: jede Runde einer Agenten-Sitzung rechnete den ganzen, viele Tausend Token langen System-Prompt samt Verlauf neu. upstreams `server_chat_convert_anthropic_to_oai` ruft dafür `normalize_anthropic_billing_header` (`server-chat.cpp:326`, llama.cpp #21793, b11639) und überschreibt die fünf Stempelzeichen mit `fffff`. +- **Fix:** `normalizeBillingHeader` macht dasselbe wie upstream (nur bei diesem Präfix, nur wenn nach den fünf Zeichen ein `;` folgt), angewendet auf den String- und jeden Text-Block des `system`-Felds. +- **Reproduktion:** `AnthropicApiSupportTest#billingHeaderStampIsNormalizedSoThePrefixCacheHitsLikeUpstream` (Block- und String-Form, zwei verschiedene Stempel ergeben denselben Text) +- **Rot-Grün-Nachweis:** ohne Fix rot (Stempel `a5145` unverändert), mit Fix grün (19/19 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 46/46, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **CI-Bedarf:** dass der Cache danach tatsächlich trifft (`cached_tokens` > 0 in der zweiten Runde), zeigt nur ein modellgestützter Lauf. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 565f7f804..9b644845e 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -32,6 +32,12 @@ final class AnthropicApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** First line of Claude Code's system prompt, whose {@code cch} stamp changes per request. */ + private static final String BILLING_HEADER_PREFIX = "x-anthropic-billing-header:"; + + /** Length of the {@code cch} stamp upstream overwrites. */ + private static final int BILLING_STAMP_LENGTH = 5; + /** Reasoning budget of an enabled {@code thinking} block without {@code budget_tokens} (upstream's default). */ private static final int DEFAULT_THINKING_BUDGET_TOKENS = 10_000; @@ -115,13 +121,13 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { private static String systemText(JsonNode system) { if (system.isTextual()) { - return system.asText(); + return normalizeBillingHeader(system.asText()); } if (system.isArray()) { StringBuilder sb = new StringBuilder(); for (JsonNode block : system) { if (block.path("text").isTextual()) { - sb.append(block.path("text").asText()); + sb.append(normalizeBillingHeader(block.path("text").asText())); } } return sb.toString(); @@ -129,6 +135,28 @@ private static String systemText(JsonNode system) { return ""; } + /** + * Overwrites the {@code cch} stamp of Claude Code's {@code x-anthropic-billing-header} system line, + * as upstream's {@code normalize_anthropic_billing_header} (llama.cpp #21793) does. The stamp changes + * on every request, so leaving it in place made every prompt differ at its start and the KV prefix + * cache never hit: each turn re-processed the whole system prompt. Any other text is returned as is. + */ + static String normalizeBillingHeader(String systemText) { + if (!systemText.startsWith(BILLING_HEADER_PREFIX)) { + return systemText; + } + int stamp = systemText.indexOf("cch=", BILLING_HEADER_PREFIX.length()); + if (stamp < 0) { + return systemText; + } + int start = stamp + "cch=".length(); + int end = start + BILLING_STAMP_LENGTH; + if (end >= systemText.length() || systemText.charAt(end) != ';') { + return systemText; + } + return systemText.substring(0, start) + "fffff" + systemText.substring(end); + } + private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessage) { String role = anthropicMessage.path("role").asText("user"); JsonNode content = anthropicMessage.path("content"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index fcdfc0f2d..95e459e57 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -99,6 +99,32 @@ public void toolResultImagesReachTheToolMessageLikeUpstream() throws IOException tool.path("content").path(1).path("image_url").path("url").asText(), is("data:image/png;base64,AA==")); } + @Test + public void billingHeaderStampIsNormalizedSoThePrefixCacheHitsLikeUpstream() throws IOException { + // Claude Code starts its system prompt with a billing header whose cch stamp changes on every + // request; upstream (normalize_anthropic_billing_header, llama.cpp #21793) overwrites the stamp + // so the prompt prefix, and with it the KV cache, stays identical across requests. + String first = AnthropicApiSupport.toOpenAiChatRequest(read("{\"system\":[{\"type\":\"text\",\"text\":" + + "\"x-anthropic-billing-header: cc_version=2.1.101.e51; cc_entrypoint=cli; cch=a5145;You are Claude Code.\"}]," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")) + .path("messages") + .path(0) + .path("content") + .asText(); + String second = AnthropicApiSupport.toOpenAiChatRequest(read("{\"system\":" + + "\"x-anthropic-billing-header: cc_version=2.1.101.e51; cc_entrypoint=cli; cch=0b9c2;You are Claude Code.\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")) + .path("messages") + .path(0) + .path("content") + .asText(); + assertThat( + first, + is( + "x-anthropic-billing-header: cc_version=2.1.101.e51; cc_entrypoint=cli; cch=fffff;You are Claude Code.")); + assertThat(second, is(first)); + } + @Test public void requestMapsAnEnabledThinkingBudgetLikeUpstream() throws IOException { // Upstream's convert_anthropic_to_oai turns thinking.budget_tokens into thinking_budget_tokens, From 4452849700b4434ddaadc04312c83c83b7c47f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:04:21 +0000 Subject: [PATCH 158/589] Record the commit of F90 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 75805777e..c0572cad5 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -725,7 +725,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot (Stempel `a5145` unverändert), mit Fix grün (19/19 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 46/46, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **CI-Bedarf:** dass der Cache danach tatsächlich trifft (`cached_tokens` > 0 in der zweiten Runde), zeigt nur ein modellgestützter Lauf. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `7d08a0c` ## Offene Verdachte ohne Fund-ID From 3eb6d6c5a73352cdcd448e93b4b189227ef1cf67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:06:32 +0000 Subject: [PATCH 159/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 9638b7d83..bc7f25935 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -197,3 +197,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`tool_result`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:453`–`512`, b11639; Durchsicht der Typ-Weichen nach F85) · `565f7f8` · 2026-10-18 · Fund F88 — Blocktypen `text`, `thinking`, `image`, `tool_use`, `tool_result` wie upstream (unbekannte überspringen beide); Bilder im `tool_result` fehlten (F88). Kleine Abweichung ohne Wirkung: Textteile eines `tool_result` werden bei uns ohne Trenner verbunden, wie bei upstream (`result_text += text`). - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Inhaltsteile) + `OpenAiCompatServer.handleResponses` ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:71`–`146`, b11639) · `1fd936a`/`bc376ce` · 2026-10-18 · Fund F89 — `input_text`/`input_image`/`output_text` wie upstream; `input_file` wurde still verworfen (F89); `refusal` als Verdacht (upstream scheitert dort selbst). Die übrigen Übersetzer-Routen (Anthropic, Ollama) rufen den Übersetzer ebenfalls vor der Fehlerbehandlung, werfen dort aber nichts. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`systemText`) ↔ upstream `server_chat_convert_anthropic_to_oai` (System-Prompt, `server-chat.cpp:310`–`368`, b11639) · `9b64484` · 2026-10-18 · Fund F90 — String und Text-Blöcke ohne Trenner verbunden wie upstream; die Normalisierung des Billing-Stempels fehlte (F90). Upstream verlangt `messages` (sonst 400), wir liefern ohne `messages` eine leere Liste – ohne Wirkung, der Chat-Mapper weist leere `messages` ab. +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (ganzer Umsetzer) ↔ upstream `server_chat_convert_anthropic_to_oai` (`server-chat.cpp:520`–`620`, b11639; zweite Lesung von oben bis unten nach F90) · `9b64484` · 2026-10-18 · kein Fund — Werkzeuge (`name`, `description`, `input_schema` → `parameters`), `tool_choice` (`auto`; `any`/`tool` → `required`; `none` ohne Abbildung wie upstream), `stop_sequences`, `thinking` (F76), `max_tokens` (Vorgabe 4096 fehlt, Verdacht) stimmen. **Bewusst anders und richtig:** upstream hängt die `role: tool`-Nachrichten einer Benutzernachricht **hinter** deren Text, Java davor, also direkt hinter die `tool_calls` des Assistenten; Claude Code schickt `tool_result` + ``-Text in einer Nachricht, und der OpenAI-Vertrag verlangt die Werkzeugergebnisse unmittelbar nach dem Aufruf. Java nicht ändern. From 8b0872bcb57bebe965fd46e9e7da71a136abba5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:13:18 +0000 Subject: [PATCH 160/589] Merge a Responses assistant message into the preceding assistant turn A Responses client sends each earlier turn back as a reasoning item and an assistant message item; the message opened a second assistant message, so the template saw every turn split in two. Upstream's convert_responses_to_chatcmpl merges it into the preceding assistant message. Do the same: empty content is replaced, texts are joined with a newline, parts are concatenated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++ .../llama/server/ResponsesApiSupport.java | 51 +++++++++++++++++-- .../llama/server/ResponsesApiSupportTest.java | 22 +++++++- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index bc7f25935..68cc22126 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -198,3 +198,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Inhaltsteile) + `OpenAiCompatServer.handleResponses` ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:71`–`146`, b11639) · `1fd936a`/`bc376ce` · 2026-10-18 · Fund F89 — `input_text`/`input_image`/`output_text` wie upstream; `input_file` wurde still verworfen (F89); `refusal` als Verdacht (upstream scheitert dort selbst). Die übrigen Übersetzer-Routen (Anthropic, Ollama) rufen den Übersetzer ebenfalls vor der Fehlerbehandlung, werfen dort aber nichts. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`systemText`) ↔ upstream `server_chat_convert_anthropic_to_oai` (System-Prompt, `server-chat.cpp:310`–`368`, b11639) · `9b64484` · 2026-10-18 · Fund F90 — String und Text-Blöcke ohne Trenner verbunden wie upstream; die Normalisierung des Billing-Stempels fehlte (F90). Upstream verlangt `messages` (sonst 400), wir liefern ohne `messages` eine leere Liste – ohne Wirkung, der Chat-Mapper weist leere `messages` ab. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (ganzer Umsetzer) ↔ upstream `server_chat_convert_anthropic_to_oai` (`server-chat.cpp:520`–`620`, b11639; zweite Lesung von oben bis unten nach F90) · `9b64484` · 2026-10-18 · kein Fund — Werkzeuge (`name`, `description`, `input_schema` → `parameters`), `tool_choice` (`auto`; `any`/`tool` → `required`; `none` ohne Abbildung wie upstream), `stop_sequences`, `thinking` (F76), `max_tokens` (Vorgabe 4096 fehlt, Verdacht) stimmen. **Bewusst anders und richtig:** upstream hängt die `role: tool`-Nachrichten einer Benutzernachricht **hinter** deren Text, Java davor, also direkt hinter die `tool_calls` des Assistenten; Claude Code schickt `tool_result` + ``-Text in einer Nachricht, und der OpenAI-Vertrag verlangt die Werkzeugergebnisse unmittelbar nach dem Aufruf. Java nicht ändern. +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (ganzer Umsetzer) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:1`–`310`, b11639; Lesung von oben bis unten) · `945d6fd` · 2026-10-18 · Fund F91 — `instructions` → System-Nachricht, `input` als String → Benutzernachricht, Item-Typen und Inhaltsteile wie in F85–F89; `merge_prev` fehlte noch für Assistenten-Nachrichten (F91). `max_output_tokens` → `max_tokens`, `reasoning.effort` (F75), Werkzeuge ohne `type: function` übersprungen (upstream ebenso). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c0572cad5..062a566ee 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -69,6 +69,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F88](#f88) — mittel — *gefixt* - [F89](#f89) — niedrig — *gefixt* - [F90](#f90) — mittel — *gefixt* +- [F91](#f91) — niedrig — *gefixt* ## Funde @@ -727,6 +728,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `7d08a0c` + +### F91 – java-llama.cpp: Responses-Verlauf spaltet jede Assistenten-Runde in Denk- und Antwortnachricht +- **Zeit:** 2026-10-18 04:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:257` (`joinContent`, `lastAssistant`, neu; Aufruf im `message`-Zweig von `appendInput`) +- **Schwere:** niedrig +- **Beschreibung:** Ein Responses-Client gibt jede frühere Runde als `reasoning`-Item und danach als `message`-Item mit `role: assistant` zurück. Seit F85 wird das reasoning-Item zu einer Assistenten-Nachricht mit `reasoning_content`; das folgende `message`-Item legte dann eine **zweite** Assistenten-Nachricht mit der Antwort an. Die Vorlage sah jede Runde als zwei aufeinanderfolgende Assistenten-Runden, eine ohne Antwort und eine ohne Denkinhalt. upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:150`, b11639) führt ein Assistenten-`message`-Item mit der vorherigen Assistenten-Nachricht zusammen (`merge_prev`), wie bei Werkzeugaufrufen (F87). Gefunden beim Lesen des upstream-Umsetzers von oben bis unten (Regel aus F90). +- **Fix:** Folgt ein Assistenten-`message`-Item auf eine Assistenten-Nachricht, wird sein Inhalt angehängt: ein leerer oder `null`-Inhalt ersetzt, zwei Texte mit Zeilenumbruch verbunden (wie `concat_content_parts`), sonst Teil-Arrays zusammengefügt. +- **Reproduktion:** `ResponsesApiSupportTest#anAssistantMessageJoinsThePrecedingAssistantTurnLikeUpstream`; der F85-Test erwartet seine abschließende Antwort jetzt in der Nachricht mit dem zweiten Denktext. +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <3> but: was <4>“, ebenso der angepasste F85-Test), mit Fix grün (18/18 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 46/46, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 1fd936aef..945d6fd51 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import java.io.IOException; import org.jspecify.annotations.Nullable; @@ -144,13 +145,17 @@ private static void appendInput(ArrayNode messages, JsonNode input) { case "message": default: rejectInputFiles(item.path("content")); - ObjectNode message = messages.addObject(); - message.put("role", item.path("role").asText("user")); + String role = item.path("role").asText("user"); JsonNode imageContent = inputContentWithImages(item.path("content")); - if (imageContent != null) { - message.set("content", imageContent); + JsonNode content = + imageContent != null ? imageContent : new TextNode(inputContentText(item.path("content"))); + ObjectNode previous = "assistant".equals(role) ? lastAssistant(messages) : null; + if (previous != null) { + previous.set("content", joinContent(previous.path("content"), content)); } else { - message.put("content", inputContentText(item.path("content"))); + ObjectNode message = messages.addObject(); + message.put("role", role); + message.set("content", content); } break; } @@ -233,6 +238,42 @@ private static ArrayNode toolOutputParts(JsonNode output) { return parts; } + /** The last message if it is an assistant message, else {@code null} (upstream's {@code merge_prev}). */ + private static @Nullable ObjectNode lastAssistant(ArrayNode messages) { + int size = messages.size(); + JsonNode last = size > 0 ? messages.get(size - 1) : null; + return last instanceof ObjectNode + && "assistant".equals(last.path("role").asText("")) + ? (ObjectNode) last + : null; + } + + /** + * The content of an assistant message after an assistant message item joined it, as upstream's + * {@code convert_responses_to_chatcmpl} appends the item's content to the preceding assistant + * message: an empty or {@code null} content is replaced, two texts are joined with a newline (as + * {@code concat_content_parts} joins text parts), otherwise the parts are concatenated. + */ + private static JsonNode joinContent(JsonNode previous, JsonNode added) { + if (previous.isNull() + || previous.isMissingNode() + || (previous.isTextual() && previous.asText().isEmpty())) { + return added; + } + if (previous.isTextual() && added.isTextual()) { + return added.asText().isEmpty() ? previous : new TextNode(previous.asText() + "\n" + added.asText()); + } + ArrayNode parts = OBJECT_MAPPER.createArrayNode(); + for (JsonNode node : new JsonNode[] {previous, added}) { + if (node.isArray()) { + parts.addAll((ArrayNode) node); + } else if (node.isTextual() && !node.asText().isEmpty()) { + parts.addObject().put("type", "text").put("text", node.asText()); + } + } + return parts; + } + /** * Rejects an {@code input_file} content part the way upstream's {@code convert_responses_to_chatcmpl} * does (400, "not supported"); dropping it let the model answer as if the file had not been sent. diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index da884ae38..8f5dcc1a5 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -69,14 +69,14 @@ public void reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream() assertThat(message.path("content").asText(), is("q")); } } - assertThat(messages.size(), is(5)); + assertThat(messages.size(), is(4)); assertThat(messages.path(1).path("role").asText(), is("assistant")); assertThat(messages.path(1).path("reasoning_content").asText(), is("plan")); assertThat(messages.path(1).path("tool_calls").size(), is(1)); assertThat(messages.path(2).path("role").asText(), is("tool")); assertThat(messages.path(3).path("role").asText(), is("assistant")); assertThat(messages.path(3).path("reasoning_content").asText(), is("more")); - assertThat(messages.path(4).path("content").asText(), is("42")); + assertThat(messages.path(3).path("content").asText(), is("42")); } @Test @@ -120,6 +120,24 @@ public void consecutiveFunctionCallsJoinThePrecedingAssistantMessageLikeUpstream assertThat(messages.path(3).path("role").asText(), is("tool")); } + @Test + public void anAssistantMessageJoinsThePrecedingAssistantTurnLikeUpstream() throws IOException { + // Each earlier turn comes back as a reasoning item followed by the assistant's message item; + // upstream's convert_responses_to_chatcmpl merges the message into the preceding assistant + // message (merge_prev), so the template sees one turn with reasoning and answer. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q\"}," + + "{\"type\":\"reasoning\",\"summary\":[],\"content\":[{\"type\":\"reasoning_text\",\"text\":\"plan\"}]}," + + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"42\"}]}," + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"and?\"}]}")) + .path("messages"); + assertThat(messages.size(), is(3)); + assertThat(messages.path(1).path("role").asText(), is("assistant")); + assertThat(messages.path(1).path("reasoning_content").asText(), is("plan")); + assertThat(messages.path(1).path("content").asText(), is("42")); + assertThat(messages.path(2).path("content").asText(), is("and?")); + } + @Test public void requestMapsTheReasoningEffortLikeUpstream() throws IOException { // Upstream's Responses converter maps reasoning.effort to reasoning_effort, which the chat From 5356fbd7441de40c2d7783762625d123e52eac65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:13:18 +0000 Subject: [PATCH 161/589] Record the commit of F91 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 062a566ee..6bd8a95b0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -738,7 +738,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#anAssistantMessageJoinsThePrecedingAssistantTurnLikeUpstream`; der F85-Test erwartet seine abschließende Antwort jetzt in der Nachricht mit dem zweiten Denktext. - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <3> but: was <4>“, ebenso der angepasste F85-Test), mit Fix grün (18/18 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 46/46, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `04d4cdb` ## Offene Verdachte ohne Fund-ID From 6da86f8c37a46bf4e2dcf46710ca238057bd2583 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:15:41 +0000 Subject: [PATCH 162/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 68cc22126..e74ad0c81 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -199,3 +199,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`systemText`) ↔ upstream `server_chat_convert_anthropic_to_oai` (System-Prompt, `server-chat.cpp:310`–`368`, b11639) · `9b64484` · 2026-10-18 · Fund F90 — String und Text-Blöcke ohne Trenner verbunden wie upstream; die Normalisierung des Billing-Stempels fehlte (F90). Upstream verlangt `messages` (sonst 400), wir liefern ohne `messages` eine leere Liste – ohne Wirkung, der Chat-Mapper weist leere `messages` ab. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (ganzer Umsetzer) ↔ upstream `server_chat_convert_anthropic_to_oai` (`server-chat.cpp:520`–`620`, b11639; zweite Lesung von oben bis unten nach F90) · `9b64484` · 2026-10-18 · kein Fund — Werkzeuge (`name`, `description`, `input_schema` → `parameters`), `tool_choice` (`auto`; `any`/`tool` → `required`; `none` ohne Abbildung wie upstream), `stop_sequences`, `thinking` (F76), `max_tokens` (Vorgabe 4096 fehlt, Verdacht) stimmen. **Bewusst anders und richtig:** upstream hängt die `role: tool`-Nachrichten einer Benutzernachricht **hinter** deren Text, Java davor, also direkt hinter die `tool_calls` des Assistenten; Claude Code schickt `tool_result` + ``-Text in einer Nachricht, und der OpenAI-Vertrag verlangt die Werkzeugergebnisse unmittelbar nach dem Aufruf. Java nicht ändern. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (ganzer Umsetzer) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:1`–`310`, b11639; Lesung von oben bis unten) · `945d6fd` · 2026-10-18 · Fund F91 — `instructions` → System-Nachricht, `input` als String → Benutzernachricht, Item-Typen und Inhaltsteile wie in F85–F89; `merge_prev` fehlte noch für Assistenten-Nachrichten (F91). `max_output_tokens` → `max_tokens`, `reasoning.effort` (F75), Werkzeuge ohne `type: function` übersprungen (upstream ebenso). +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`versionJson`, `tagsJson`, `showJson`, `toOllamaChatResponse`, `toOllamaDoneLine`; Schnittstelle Ollama-Discovery ↔ Clients) · `ed9aff1` · 2026-10-18 · kein Fund — `/api/show` nennt `general.architecture` und den passenden Schlüssel `.context_length`, den Ollama-Clients (Copilot) für das Kontextfenster lesen; `capabilities` mit `tools`/`insert`/`vision`. Fähigkeit `thinking` wird nicht beworben; Clients, die `think` nur bei dieser Fähigkeit schicken, nutzen F78 daher nicht – kein Fehlverhalten. `done_reason` und fehlende Zähler im Stream-Ende sind bereits offene Verdachte. Dazu die offenen BAF-Verdachte durchgesehen: alle Entscheidung des Nutzers, außerhalb des Ziels oder mit OpenCL-/CI-Bedarf. From 948c6aa743f50c9be8b427df1a29854f725ded91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:18:05 +0000 Subject: [PATCH 163/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index e74ad0c81..1006b0ca4 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -200,3 +200,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (ganzer Umsetzer) ↔ upstream `server_chat_convert_anthropic_to_oai` (`server-chat.cpp:520`–`620`, b11639; zweite Lesung von oben bis unten nach F90) · `9b64484` · 2026-10-18 · kein Fund — Werkzeuge (`name`, `description`, `input_schema` → `parameters`), `tool_choice` (`auto`; `any`/`tool` → `required`; `none` ohne Abbildung wie upstream), `stop_sequences`, `thinking` (F76), `max_tokens` (Vorgabe 4096 fehlt, Verdacht) stimmen. **Bewusst anders und richtig:** upstream hängt die `role: tool`-Nachrichten einer Benutzernachricht **hinter** deren Text, Java davor, also direkt hinter die `tool_calls` des Assistenten; Claude Code schickt `tool_result` + ``-Text in einer Nachricht, und der OpenAI-Vertrag verlangt die Werkzeugergebnisse unmittelbar nach dem Aufruf. Java nicht ändern. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (ganzer Umsetzer) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:1`–`310`, b11639; Lesung von oben bis unten) · `945d6fd` · 2026-10-18 · Fund F91 — `instructions` → System-Nachricht, `input` als String → Benutzernachricht, Item-Typen und Inhaltsteile wie in F85–F89; `merge_prev` fehlte noch für Assistenten-Nachrichten (F91). `max_output_tokens` → `max_tokens`, `reasoning.effort` (F75), Werkzeuge ohne `type: function` übersprungen (upstream ebenso). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`versionJson`, `tagsJson`, `showJson`, `toOllamaChatResponse`, `toOllamaDoneLine`; Schnittstelle Ollama-Discovery ↔ Clients) · `ed9aff1` · 2026-10-18 · kein Fund — `/api/show` nennt `general.architecture` und den passenden Schlüssel `.context_length`, den Ollama-Clients (Copilot) für das Kontextfenster lesen; `capabilities` mit `tools`/`insert`/`vision`. Fähigkeit `thinking` wird nicht beworben; Clients, die `think` nur bei dieser Fähigkeit schicken, nutzen F78 daher nicht – kein Fehlverhalten. `done_reason` und fehlende Zähler im Stream-Ende sind bereits offene Verdachte. Dazu die offenen BAF-Verdachte durchgesehen: alle Entscheidung des Nutzers, außerhalb des Ziels oder mit OpenCL-/CI-Bedarf. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` ↔ upstream `oaicompat_chat_params_parse` (`server-common.cpp:1270`–`1470`, b11639; Abweisungen und Sonderfelder) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `tool_choice` als Objekt fällt bei upstream ebenso auf `auto` zurück (`json_value(…, std::string)`); `stop` als String wird zur Liste wie bei uns; die Abweisungen (Grammatik + Werkzeuge, `response_format`-Typ, `messages` fehlt/leer, `logprobs` + Werkzeuge im Stream) treffen bei uns über die native Seite ein. `n` fehlt (Verdacht „Feldverlust“ ergänzt). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6bd8a95b0..f87e6e373 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -753,5 +753,5 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. -- *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. +- *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. From d741b6abb9f82d0b6f3049914c122e5a54e480db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:20:48 +0000 Subject: [PATCH 164/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 1006b0ca4..081e71451 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -201,3 +201,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (ganzer Umsetzer) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:1`–`310`, b11639; Lesung von oben bis unten) · `945d6fd` · 2026-10-18 · Fund F91 — `instructions` → System-Nachricht, `input` als String → Benutzernachricht, Item-Typen und Inhaltsteile wie in F85–F89; `merge_prev` fehlte noch für Assistenten-Nachrichten (F91). `max_output_tokens` → `max_tokens`, `reasoning.effort` (F75), Werkzeuge ohne `type: function` übersprungen (upstream ebenso). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`versionJson`, `tagsJson`, `showJson`, `toOllamaChatResponse`, `toOllamaDoneLine`; Schnittstelle Ollama-Discovery ↔ Clients) · `ed9aff1` · 2026-10-18 · kein Fund — `/api/show` nennt `general.architecture` und den passenden Schlüssel `.context_length`, den Ollama-Clients (Copilot) für das Kontextfenster lesen; `capabilities` mit `tools`/`insert`/`vision`. Fähigkeit `thinking` wird nicht beworben; Clients, die `think` nur bei dieser Fähigkeit schicken, nutzen F78 daher nicht – kein Fehlverhalten. `done_reason` und fehlende Zähler im Stream-Ende sind bereits offene Verdachte. Dazu die offenen BAF-Verdachte durchgesehen: alle Entscheidung des Nutzers, außerhalb des Ziels oder mit OpenCL-/CI-Bedarf. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` ↔ upstream `oaicompat_chat_params_parse` (`server-common.cpp:1270`–`1470`, b11639; Abweisungen und Sonderfelder) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `tool_choice` als Objekt fällt bei upstream ebenso auf `auto` zurück (`json_value(…, std::string)`); `stop` als String wird zur Liste wie bei uns; die Abweisungen (Grammatik + Werkzeuge, `response_format`-Typ, `messages` fehlt/leer, `logprobs` + Werkzeuge im Stream) treffen bei uns über die native Seite ein. `n` fehlt (Verdacht „Feldverlust“ ergänzt). +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`applySampling`, `applyResponseFormat`, `toToolChoiceString`, `toFinishReason`) ↔ `ChatRequestParameters` von langchain4j (Schnittstelle Standardparameter, Musterdurchsicht nach F77) · `550a6c2` · 2026-10-18 · kein Fund — alle Standardparameter (`temperature`, `topP`, `topK`, `maxOutputTokens` → `n_predict`, Frequenz-/Präsenzstrafe, `stopSequences`), `toolChoice` (`AUTO`/`REQUIRED`/`NONE`), JSON-`responseFormat` mit und ohne Schema; blockierend und gestreamt über dieselbe Abbildung (`toJllamaRequest`). `finishReason` deckt `stop`/`length`/`tool_calls`/`content_filter` ab, Unbekanntes → `OTHER`. From 7a64dd94d4faee72b4f293dbf3ad956dda01e93c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:25:55 +0000 Subject: [PATCH 165/589] Carry the reasoning into the blocking LangChain4j response JllamaChatModel built its AiMessage from text and tool calls only, so AiMessage.thinking() was always null while the streaming adapter sets it. Read reasoning_content from the native answer in both branches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../llama/langchain4j/LangChain4jMapping.java | 18 +++++++++++++++++- .../langchain4j/LangChain4jMappingTest.java | 15 +++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 081e71451..57d31c334 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -202,3 +202,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`versionJson`, `tagsJson`, `showJson`, `toOllamaChatResponse`, `toOllamaDoneLine`; Schnittstelle Ollama-Discovery ↔ Clients) · `ed9aff1` · 2026-10-18 · kein Fund — `/api/show` nennt `general.architecture` und den passenden Schlüssel `.context_length`, den Ollama-Clients (Copilot) für das Kontextfenster lesen; `capabilities` mit `tools`/`insert`/`vision`. Fähigkeit `thinking` wird nicht beworben; Clients, die `think` nur bei dieser Fähigkeit schicken, nutzen F78 daher nicht – kein Fehlverhalten. `done_reason` und fehlende Zähler im Stream-Ende sind bereits offene Verdachte. Dazu die offenen BAF-Verdachte durchgesehen: alle Entscheidung des Nutzers, außerhalb des Ziels oder mit OpenCL-/CI-Bedarf. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` ↔ upstream `oaicompat_chat_params_parse` (`server-common.cpp:1270`–`1470`, b11639; Abweisungen und Sonderfelder) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `tool_choice` als Objekt fällt bei upstream ebenso auf `auto` zurück (`json_value(…, std::string)`); `stop` als String wird zur Liste wie bei uns; die Abweisungen (Grammatik + Werkzeuge, `response_format`-Typ, `messages` fehlt/leer, `logprobs` + Werkzeuge im Stream) treffen bei uns über die native Seite ein. `n` fehlt (Verdacht „Feldverlust“ ergänzt). - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`applySampling`, `applyResponseFormat`, `toToolChoiceString`, `toFinishReason`) ↔ `ChatRequestParameters` von langchain4j (Schnittstelle Standardparameter, Musterdurchsicht nach F77) · `550a6c2` · 2026-10-18 · kein Fund — alle Standardparameter (`temperature`, `topP`, `topK`, `maxOutputTokens` → `n_predict`, Frequenz-/Präsenzstrafe, `stopSequences`), `toolChoice` (`AUTO`/`REQUIRED`/`NONE`), JSON-`responseFormat` mit und ohne Schema; blockierend und gestreamt über dieselbe Abbildung (`toJllamaRequest`). `finishReason` deckt `stop`/`length`/`tool_calls`/`content_filter` ab, Unbekanntes → `OTHER`. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `195bf32` · 2026-10-18 · Fund F92 — blockierender Pfad ohne `thinking()`, jetzt aus `reasoning_content` gesetzt diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f87e6e373..a18f12451 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -70,6 +70,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F89](#f89) — niedrig — *gefixt* - [F90](#f90) — mittel — *gefixt* - [F91](#f91) — niedrig — *gefixt* +- [F92](#f92) — niedrig — *gefixt* ## Funde @@ -740,6 +741,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `04d4cdb` +### F92 – java-llama.cpp: blockierende LangChain4j-Antwort verliert den Denkinhalt +- **Zeit:** 2026-10-18 05:10 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:206` (`toAiMessage`) +- **Schwere:** niedrig +- **Beschreibung:** `JllamaChatModel` (blockierend) baute die `AiMessage` nur aus Text und Werkzeugaufrufen; der `reasoning_content` der nativen Antwort ging verloren, `AiMessage.thinking()` war immer `null`. Der Streaming-Pfad (`StreamingChunkAssembler`) setzt ihn, und die Modul-README sagt, Reasoning erscheine als `AiMessage.thinking()`. Ein Wechsel von `JllamaStreamingChatModel` zu `JllamaChatModel` ließ den Denkinhalt also stillschweigend verschwinden. +- **Fix:** `toAiMessage` liest den Denkinhalt mit `ChatResponseParser.extractChoiceReasoningContent` aus dem rohen JSON und setzt ihn in beiden Zweigen (mit und ohne Werkzeugaufrufe), wenn er nicht leer ist. +- **Reproduktion:** `LangChain4jMappingTest#blockingResponseCarriesTheThinkingLikeTheStreamedOne` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7"“), mit Fix grün (21/21 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün; alle Modultests grün (Integrationstests ohne Modell übersprungen). Das Modul deklariert weder Spotless noch SpotBugs (weder eigene noch Eltern-POM), diese Gates entfallen hier. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java index 550a6c2c3..195bf3247 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java @@ -56,6 +56,10 @@ final class LangChain4jMapping { /** OAI {@code response_format} payload selecting plain JSON-object mode (no schema). */ static final String JSON_OBJECT_RESPONSE_FORMAT = "{\"type\":\"json_object\"}"; + /** Reads the reasoning of a blocking answer from its raw JSON. */ + private static final net.ladenthin.llama.json.ChatResponseParser CHAT_RESPONSE_PARSER = + new net.ladenthin.llama.json.ChatResponseParser(); + private LangChain4jMapping() {} /** @@ -202,8 +206,17 @@ static String toToolChoiceString(ToolChoice choice) { private static AiMessage toAiMessage(net.ladenthin.llama.value.ChatResponse response) { net.ladenthin.llama.value.ChatMessage first = response.getFirstMessage().orElse(null); + // The reasoning is not part of the typed message; read it from the native answer, as the + // streamed path does from the reasoning_content deltas. + String thinking = CHAT_RESPONSE_PARSER.extractChoiceReasoningContent(response.getRawJson()); if (first == null || first.getToolCalls().isEmpty()) { - return AiMessage.from(response.getFirstContent()); + if (thinking.isEmpty()) { + return AiMessage.from(response.getFirstContent()); + } + return AiMessage.builder() + .text(response.getFirstContent()) + .thinking(thinking) + .build(); } List requests = new ArrayList<>(first.getToolCalls().size()); for (ToolCall call : first.getToolCalls()) { @@ -218,6 +231,9 @@ private static AiMessage toAiMessage(net.ladenthin.llama.value.ChatResponse resp if (text != null && !text.isEmpty()) { builder.text(text); } + if (!thinking.isEmpty()) { + builder.thinking(thinking); + } return builder.build(); } diff --git a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java index 320dd3275..9a1df7d96 100644 --- a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java +++ b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java @@ -263,6 +263,21 @@ void mapsAssistantToolCallTurnIntoHistory() { assertThat(result.getToolCallId().orElse(""), is("call_7")); } + @Test + void blockingResponseCarriesTheThinkingLikeTheStreamedOne() { + // The streamed final response sets AiMessage.thinking() from reasoning_content; the blocking + // one must carry the same reasoning, which the native answer holds in the raw JSON. + net.ladenthin.llama.value.ChatResponse response = new net.ladenthin.llama.json.ChatResponseParser() + .parseResponse("{\"id\":\"id-1\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\"," + + "\"message\":{\"role\":\"assistant\",\"content\":\"42\",\"reasoning_content\":\"6 times 7\"}}]}"); + + dev.langchain4j.model.chat.response.ChatResponse mapped = + LangChain4jMapping.toLangChainResponse(response); + + assertThat(mapped.aiMessage().text(), is("42")); + assertThat(mapped.aiMessage().thinking(), is("6 times 7")); + } + @Test void toolCallResponseBecomesAiMessageWithToolExecutionRequests() { ChatMessage assistant = ChatMessage.assistantToolCalls( From 503321c68fa74b4dd7a7cd500a7f96156fbad051 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:25:55 +0000 Subject: [PATCH 166/589] Record the commit of F92 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a18f12451..3a0aa182f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -750,7 +750,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LangChain4jMappingTest#blockingResponseCarriesTheThinkingLikeTheStreamedOne` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7"“), mit Fix grün (21/21 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün; alle Modultests grün (Integrationstests ohne Modell übersprungen). Das Modul deklariert weder Spotless noch SpotBugs (weder eigene noch Eltern-POM), diese Gates entfallen hier. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `6caa722` ## Offene Verdachte ohne Fund-ID From 6c2adf88e4ead2f810787b98f074834fe903688d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:30:05 +0000 Subject: [PATCH 167/589] Ask the LangChain4j stream for its usage chunk Upstream sends the trailing usage chunk only for stream_options.include_usage, which toStreamingParameters never set, so the streamed ChatResponse never had token usage while the blocking one did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../llama/langchain4j/LangChain4jMapping.java | 10 ++++++++-- .../llama/langchain4j/LangChain4jMappingTest.java | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 57d31c334..09466b159 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -203,3 +203,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` ↔ upstream `oaicompat_chat_params_parse` (`server-common.cpp:1270`–`1470`, b11639; Abweisungen und Sonderfelder) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `tool_choice` als Objekt fällt bei upstream ebenso auf `auto` zurück (`json_value(…, std::string)`); `stop` als String wird zur Liste wie bei uns; die Abweisungen (Grammatik + Werkzeuge, `response_format`-Typ, `messages` fehlt/leer, `logprobs` + Werkzeuge im Stream) treffen bei uns über die native Seite ein. `n` fehlt (Verdacht „Feldverlust“ ergänzt). - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`applySampling`, `applyResponseFormat`, `toToolChoiceString`, `toFinishReason`) ↔ `ChatRequestParameters` von langchain4j (Schnittstelle Standardparameter, Musterdurchsicht nach F77) · `550a6c2` · 2026-10-18 · kein Fund — alle Standardparameter (`temperature`, `topP`, `topK`, `maxOutputTokens` → `n_predict`, Frequenz-/Präsenzstrafe, `stopSequences`), `toolChoice` (`AUTO`/`REQUIRED`/`NONE`), JSON-`responseFormat` mit und ohne Schema; blockierend und gestreamt über dieselbe Abbildung (`toJllamaRequest`). `finishReason` deckt `stop`/`length`/`tool_calls`/`content_filter` ab, Unbekanntes → `OTHER`. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `195bf32` · 2026-10-18 · Fund F92 — blockierender Pfad ohne `thinking()`, jetzt aus `reasoning_content` gesetzt +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `832ca3e` · 2026-10-18 · Fund F93 — gestreamte Parameter ohne `include_usage`, Token-Nutzung fehlte im Stream diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3a0aa182f..d48194af9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -71,6 +71,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F90](#f90) — mittel — *gefixt* - [F91](#f91) — niedrig — *gefixt* - [F92](#f92) — niedrig — *gefixt* +- [F93](#f93) — niedrig — *gefixt* ## Funde @@ -752,6 +753,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6caa722` +### F93 – java-llama.cpp: gestreamte LangChain4j-Antwort hat nie eine Token-Nutzung +- **Zeit:** 2026-10-18 04:45 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:105` (`toStreamingParameters`) +- **Schwere:** niedrig +- **Beschreibung:** `StreamingChunkAssembler` übernimmt die Token-Nutzung aus dem abschließenden `usage`-Chunk. upstream (`server_task_result_cmpl_final::to_json_oaicompat_chat_stream`, `server-task.cpp:494`, b11639) sendet diesen Chunk aber nur bei `stream_options.include_usage`, und `toStreamingParameters` setzte das nie. Die gestreamte `ChatResponse` von `JllamaStreamingChatModel` hatte daher immer `tokenUsage() == null`, die blockierende dagegen die echte Zählung. Die Modul-README verspricht beides („both the blocking and the streamed final response carry the model's real finish reason and token usage“). Gefunden über den Vergleich blockierender und gestreamter Pfad (Erkenntnis aus F92). +- **Fix:** `toStreamingParameters` setzt `stream_options` `{"include_usage":true}` vor dem Customizer (der es weiterhin überschreiben kann). `ChatStreamChunkParser` reicht den Usage-Chunk des Endschritts bereits einzeln weiter, der Assembler liest ihn. +- **Reproduktion:** `LangChain4jMappingTest#streamingParametersRequestTheUsageChunk` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1 Fehlschlag), nach `pop` grün; alle Modultests grün (`StreamingChunkAssemblerTest` 7/7, `JsonSchemaElementSerializerTest` 8/8, Integrationstests ohne Modell übersprungen). Spotless/SpotBugs sind im Modul nicht deklariert (siehe F92). +- **CI-Bedarf:** Der Integrationsjob `test-java-llama-langchain4j-integration` läuft mit Modell; ein Ende-zu-Ende-Nachweis der Zählung im Stream wäre dort möglich, ist aber nicht Voraussetzung. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java index 195bf3247..832ca3e22 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java @@ -56,6 +56,9 @@ final class LangChain4jMapping { /** OAI {@code response_format} payload selecting plain JSON-object mode (no schema). */ static final String JSON_OBJECT_RESPONSE_FORMAT = "{\"type\":\"json_object\"}"; + /** OAI {@code stream_options} payload asking the native stream for its trailing usage chunk. */ + static final String INCLUDE_USAGE_STREAM_OPTIONS = "{\"include_usage\":true}"; + /** Reads the reasoning of a blocking answer from its raw JSON. */ private static final net.ladenthin.llama.json.ChatResponseParser CHAT_RESPONSE_PARSER = new net.ladenthin.llama.json.ChatResponseParser(); @@ -96,8 +99,11 @@ static net.ladenthin.llama.parameters.ChatRequest toJllamaRequest(ChatRequest re */ static InferenceParameters toStreamingParameters(ChatRequest request) { net.ladenthin.llama.parameters.ChatRequest jllama = toJllamaRequest(request); - InferenceParameters params = - InferenceParameters.empty().withMessagesJson(jllama.buildMessagesJson()); + // The native stream carries a usage chunk only when asked for; without it the streamed + // final response would lack the token usage the blocking one has. + InferenceParameters params = InferenceParameters.empty() + .withMessagesJson(jllama.buildMessagesJson()) + .withStreamOptions(INCLUDE_USAGE_STREAM_OPTIONS); java.util.Optional toolsJson = jllama.buildToolsJson(); if (toolsJson.isPresent()) { params = params.withToolsJson(toolsJson.get()); diff --git a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java index 9a1df7d96..b9784c831 100644 --- a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java +++ b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java @@ -82,6 +82,20 @@ void flattensMultiTextUserMessageToText() { assertThat(mapped.hasParts(), is(false)); } + @Test + void streamingParametersRequestTheUsageChunk() throws Exception { + ChatRequest request = + ChatRequest.builder().messages(UserMessage.from("hi")).build(); + + String json = LangChain4jMapping.toStreamingParameters(request).toJson(); + + // Upstream emits the trailing usage chunk only for stream_options.include_usage; without it + // the streamed response never carries token usage, unlike the blocking one. + com.fasterxml.jackson.databind.JsonNode node = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + assertThat(node.path("stream_options").path("include_usage").asBoolean(false), is(true)); + } + @Test void appliesSamplingParametersToInferenceJson() { ChatRequest request = From f6de3d4424eb404e58732f99f8946aeaf6235924 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:30:05 +0000 Subject: [PATCH 168/589] Record the commit of F93 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d48194af9..6531316e5 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -763,7 +763,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1 Fehlschlag), nach `pop` grün; alle Modultests grün (`StreamingChunkAssemblerTest` 7/7, `JsonSchemaElementSerializerTest` 8/8, Integrationstests ohne Modell übersprungen). Spotless/SpotBugs sind im Modul nicht deklariert (siehe F92). - **CI-Bedarf:** Der Integrationsjob `test-java-llama-langchain4j-integration` läuft mit Modell; ein Ende-zu-Ende-Nachweis der Zählung im Stream wäre dort möglich, ist aber nicht Voraussetzung. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `cde7159` ## Offene Verdachte ohne Fund-ID From 980c48db81b5e0acd596d8cf12ebfe77a92337f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:33:40 +0000 Subject: [PATCH 169/589] Join the text parts of a LangChain4j user message with a newline Multiple TextContent parts were concatenated without a separator, so the words at each seam merged. Join them with a newline as upstream's concat_content_parts does, like the Anthropic and Responses translations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../llama/langchain4j/LangChain4jMapping.java | 8 +++++--- .../langchain4j/LangChain4jMappingTest.java | 16 +++++++++++++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 09466b159..3dfeb9f50 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -204,3 +204,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`applySampling`, `applyResponseFormat`, `toToolChoiceString`, `toFinishReason`) ↔ `ChatRequestParameters` von langchain4j (Schnittstelle Standardparameter, Musterdurchsicht nach F77) · `550a6c2` · 2026-10-18 · kein Fund — alle Standardparameter (`temperature`, `topP`, `topK`, `maxOutputTokens` → `n_predict`, Frequenz-/Präsenzstrafe, `stopSequences`), `toolChoice` (`AUTO`/`REQUIRED`/`NONE`), JSON-`responseFormat` mit und ohne Schema; blockierend und gestreamt über dieselbe Abbildung (`toJllamaRequest`). `finishReason` deckt `stop`/`length`/`tool_calls`/`content_filter` ab, Unbekanntes → `OTHER`. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `195bf32` · 2026-10-18 · Fund F92 — blockierender Pfad ohne `thinking()`, jetzt aus `reasoning_content` gesetzt - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `832ca3e` · 2026-10-18 · Fund F93 — gestreamte Parameter ohne `include_usage`, Token-Nutzung fehlte im Stream +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `e054789` · 2026-10-18 · Fund F94 — Textteile ohne Trenner verbunden; Assistent mit `thinking()` im Verlauf wird ohne Denkinhalt gesendet (wie LangChain4js OpenAI-Adapter ohne `sendThinking`, kein Fund) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6531316e5..92c9c01a3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -72,6 +72,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F91](#f91) — niedrig — *gefixt* - [F92](#f92) — niedrig — *gefixt* - [F93](#f93) — niedrig — *gefixt* +- [F94](#f94) — niedrig — *gefixt* ## Funde @@ -765,6 +766,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `cde7159` +### F94 – java-llama.cpp: Textteile einer LangChain4j-Nutzernachricht werden ohne Trenner zusammengeklebt +- **Zeit:** 2026-10-18 04:55 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:287` (`toJllamaUserMessage`) +- **Schwere:** niedrig +- **Beschreibung:** Musterdurchsicht nach F60/F61 im LangChain4j-Adapter. Eine `UserMessage` mit mehreren `TextContent`-Teilen und ohne Medien wurde zu einem String verbunden, aber ohne Trenner: `first` + `second` → `firstsecond`. LangChain4j-Werkzeuge (RAG-Kontext plus Frage, Vorlagen) teilen Nutzerzüge regelmäßig so auf. upstream verbindet Textteile für Vorlagen ohne typisierten Inhalt mit `\n` (`concat_content_parts`, `common/chat.cpp`, b11639); der Adapter reicht keine Parts weiter, sondern muss selbst einen Trenner wählen. Der bestehende Test `flattensMultiTextUserMessageToText` schrieb das Verhalten nur mit einem Leerzeichen am Ende des ersten Teils fest, das den Fehler verdeckte. +- **Fix:** Verbinden mit `StringJoiner("\n")`, wie bei F60 (Anthropic) und F61 (Responses). Der festschreibende Test erwartet jetzt `"Hello \nworld"`. +- **Reproduktion:** `LangChain4jMappingTest#joinsTextPartsWithANewlineLikeUpstream`, dazu `#flattensMultiTextUserMessageToText` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, ebenso der angepasste Test), mit Fix grün (23/23 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün; alle Modultests grün (Integrationstests ohne Modell übersprungen). Spotless/SpotBugs sind im Modul nicht deklariert (siehe F92). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java index 832ca3e22..e05478933 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java @@ -31,6 +31,7 @@ import java.util.Base64; import java.util.List; import java.util.Locale; +import java.util.StringJoiner; import net.ladenthin.llama.json.RerankResponseParser; import net.ladenthin.llama.parameters.InferenceParameters; import net.ladenthin.llama.value.ContentPart; @@ -281,10 +282,11 @@ private static net.ladenthin.llama.value.ChatMessage toJllamaUserMessage(UserMes return new net.ladenthin.llama.value.ChatMessage("user", message.singleText()); } if (!hasNonTextContent(message)) { - // Multiple text parts, no media: keep the flat-string form (no array-content needed). - StringBuilder text = new StringBuilder(); + // Multiple text parts, no media: keep the flat-string form (no array-content needed), + // joined with a newline as upstream's concat_content_parts does. + StringJoiner text = new StringJoiner("\n"); for (Content content : message.contents()) { - text.append(((TextContent) content).text()); + text.add(((TextContent) content).text()); } return new net.ladenthin.llama.value.ChatMessage("user", text.toString()); } diff --git a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java index b9784c831..24e2e3d72 100644 --- a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java +++ b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/LangChain4jMappingTest.java @@ -77,11 +77,25 @@ void flattensMultiTextUserMessageToText() { ChatMessage mapped = LangChain4jMapping.toJllamaRequest(request).getMessages().get(0); assertThat(mapped.getRole(), is("user")); - assertThat(mapped.getContent(), is("Hello world")); + assertThat(mapped.getContent(), is("Hello \nworld")); // Text-only content needs no multimodal array form. assertThat(mapped.hasParts(), is(false)); } + @Test + void joinsTextPartsWithANewlineLikeUpstream() { + ChatRequest request = + ChatRequest.builder() + .messages(UserMessage.from(TextContent.from("first"), TextContent.from("second"))) + .build(); + + ChatMessage mapped = LangChain4jMapping.toJllamaRequest(request).getMessages().get(0); + + // Upstream's concat_content_parts joins text parts with a newline; without a separator the + // words at the seam merge ("firstsecond"). + assertThat(mapped.getContent(), is("first\nsecond")); + } + @Test void streamingParametersRequestTheUsageChunk() throws Exception { ChatRequest request = From 567a99b811f3311afe5ae16ef595525ce360a952 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:33:40 +0000 Subject: [PATCH 170/589] Record the commit of F94 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 92c9c01a3..ab5403098 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -775,7 +775,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LangChain4jMappingTest#joinsTextPartsWithANewlineLikeUpstream`, dazu `#flattensMultiTextUserMessageToText` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, ebenso der angepasste Test), mit Fix grün (23/23 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün; alle Modultests grün (Integrationstests ohne Modell übersprungen). Spotless/SpotBugs sind im Modul nicht deklariert (siehe F92). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ee99d85` ## Offene Verdachte ohne Fund-ID From 4ef7c49b5eee9c1b6329988f063786676617c78e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:36:11 +0000 Subject: [PATCH 171/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3dfeb9f50..b9334c773 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -205,3 +205,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `195bf32` · 2026-10-18 · Fund F92 — blockierender Pfad ohne `thinking()`, jetzt aus `reasoning_content` gesetzt - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `832ca3e` · 2026-10-18 · Fund F93 — gestreamte Parameter ohne `include_usage`, Token-Nutzung fehlte im Stream - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `e054789` · 2026-10-18 · Fund F94 — Textteile ohne Trenner verbunden; Assistent mit `thinking()` im Verlauf wird ohne Denkinhalt gesendet (wie LangChain4js OpenAI-Adapter ohne `sendThinking`, kein Fund) +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` ↔ `LlamaModel.handleRerank` · `37473c2` · 2026-10-18 · kein Fund — Schnittstelle (2.3): `rerank_results_to_json` liefert je Ergebnis `index` und lehnt fehlende/ungültige ab, die Rückfall-Zuordnung nach Arrayposition greift also nie; Scores werden nach `index` einsortiert, doppelte Dokumente (anders als in `LlamaModel.rerank(String, String...)`, das nach Text in eine Map schreibt) gehen hier nicht verloren. `JllamaEmbeddingModel` (`9a4b965`): ein `embed` je Segment, Reihenfolge erhalten. +- Musterdurchsicht `awaitTermination`/`shutdownNow` nach F66 (BAF `KeyProducerJavaSocket.interrupt`, `Finder.shutdownAndAwaitTermination`; java-llama.cpp `OpenAiCompatServer.close`) · – · 2026-10-18 · kein Fund — Socket-Producer schließt die Verbindungen nach `shutdownNow`, das blockierte `read` endet dadurch; `Finder` reicht nach Ablauf der Wartezeit `interrupt()` an alle Producer weiter, bevor der Consumer gestoppt wird (der Consumer-Fall ist F66); `OpenAiCompatServer.close` gibt das native Modell nicht frei, laufende Streams enden über den Schreibfehler des geschlossenen Sockets und den Abbruch in `streamChatCompletion`. From 114953339ccd0f2898bf14e51ce89630f37221db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:51:58 +0000 Subject: [PATCH 172/589] Map llama.cpp's sampling fields in the OpenAI request mapper The non-streamed /v1/completions forwards its body to upstream's schema, the streamed one and both chat paths build InferenceParameters from a fixed field list that dropped min_p, repeat_penalty, grammar and the other llama.cpp extensions, so the same request sampled differently with stream set. Map every such field that has a setter, read n_predict before its max_tokens aliases, and apply json_schema only without a grammar, as upstream does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 ++- .../llama/server/OpenAiRequestMapper.java | 88 ++++++++++++++++++- .../llama/server/OpenAiRequestMapperTest.java | 49 +++++++++++ 4 files changed, 147 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index b9334c773..f7aab4736 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -207,3 +207,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `e054789` · 2026-10-18 · Fund F94 — Textteile ohne Trenner verbunden; Assistent mit `thinking()` im Verlauf wird ohne Denkinhalt gesendet (wie LangChain4js OpenAI-Adapter ohne `sendThinking`, kein Fund) - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` ↔ `LlamaModel.handleRerank` · `37473c2` · 2026-10-18 · kein Fund — Schnittstelle (2.3): `rerank_results_to_json` liefert je Ergebnis `index` und lehnt fehlende/ungültige ab, die Rückfall-Zuordnung nach Arrayposition greift also nie; Scores werden nach `index` einsortiert, doppelte Dokumente (anders als in `LlamaModel.rerank(String, String...)`, das nach Text in eine Map schreibt) gehen hier nicht verloren. `JllamaEmbeddingModel` (`9a4b965`): ein `embed` je Segment, Reihenfolge erhalten. - Musterdurchsicht `awaitTermination`/`shutdownNow` nach F66 (BAF `KeyProducerJavaSocket.interrupt`, `Finder.shutdownAndAwaitTermination`; java-llama.cpp `OpenAiCompatServer.close`) · – · 2026-10-18 · kein Fund — Socket-Producer schließt die Verbindungen nach `shutdownNow`, das blockierte `read` endet dadurch; `Finder` reicht nach Ablauf der Wartezeit `interrupt()` an alle Producer weiter, bevor der Consumer gestoppt wird (der Consumer-Fall ist F66); `OpenAiCompatServer.close` gibt das native Modell nicht frei, laufende Streams enden über den Schreibfehler des geschlossenen Sockets und den Abbruch in `streamChatCompletion`. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `f10f4c6` · 2026-10-18 · Fund F95 — llama.cpp-Sampling-Felder (`min_p`, `repeat_penalty`, `grammar`, `n_predict` …) verworfen; Feldmenge gegen `server-schema.cpp` gehalten diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ab5403098..0b7dbf52f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -73,6 +73,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F92](#f92) — niedrig — *gefixt* - [F93](#f93) — niedrig — *gefixt* - [F94](#f94) — niedrig — *gefixt* +- [F95](#f95) — mittel — *gefixt* ## Funde @@ -777,6 +778,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ee99d85` +### F95 – java-llama.cpp: gestreamtes `/v1/completions` und der Chat-Pfad verwerfen llama.cpps Sampling-Felder +- **Zeit:** 2026-10-18 05:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:200` (`applyLlamaCppSampling`, neu; `readMaxTokens`) +- **Schwere:** mittel +- **Beschreibung:** Aus dem offenen Verdacht vom 2026-10-18 (gestreamtes `/v1/completions`). Das nicht gestreamte `/v1/completions` reicht den Körper an upstreams Schema durch (`server-schema.cpp`, b11639), das gestreamte baut `InferenceParameters` im `OpenAiRequestMapper` aus einer festen Feldliste. Alles außerhalb dieser Liste ging verloren: `min_p`, `typical_p`, `repeat_penalty`, `repeat_last_n`, `top_n_sigma`, `xtc_*`, `dynatemp_*`, `min_keep`, `n_keep`, `ignore_eos`, `grammar`/`json_schema`, und `n_predict` (upstreams Name, `max_tokens`/`max_completion_tokens` sind seine Aliase und verlieren gegen ihn). Dieselbe Anfrage erzeugte gestreamt also andere Ausgaben, eine Grammatik wirkte nur ohne `stream`. Derselbe Mapper bedient beide Chat-Pfade, die Felder fehlten dort ebenso. Alle Felder haben bereits einen Setter; anders als im Verdacht befürchtet, braucht dieser Teil keinen API-Umbau. +- **Fix:** `applyLlamaCppSampling` (aus `applyCommonFields`) übernimmt die Felder mit den vorhandenen Settern; `json_schema` nur ohne `grammar`, wie upstream. `readMaxTokens` liest zuerst `n_predict`. Weiter offen (Verdacht bleibt): `logit_bias`, `n_probs`/`logprobs` (im gestreamten Completion-Chunk ohnehin nicht ausgegeben) und `n`. +- **Reproduktion:** `OpenAiRequestMapperTest#llamaCppSamplingExtensionsReachTheStreamedCompletion`, `#nPredictWinsOverMaxTokensLikeUpstream`, `#jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven` +- **Rot-Grün-Nachweis:** ohne Fix rot (3 Fehlschläge: „Expected: is a numeric value within <1.0E-4> of <0.05> but: <0.0>“, „Expected: is <7> but: was <50>“, „Expected: is "object" but: was ""“), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 19/19, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit allen Feldern in einer Methode meldete `CE_CLASS_ENVY`, eine Map-Tabelle `DMC_DUBIOUS_MAP_COLLECTION`; jetzt je Feld ein Aufruf über `withFloat`/`withInt`). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -790,5 +802,5 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. -- *(mittel, nur beschrieben, 2026-10-18)* Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. +- *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index d89f722d2..f10f4c646 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -11,6 +11,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; import net.ladenthin.llama.parameters.InferenceParameters; /** @@ -21,7 +22,9 @@ * verbatim as raw JSON so the full OpenAI shape (assistant {@code tool_calls}, * {@code role:"tool"} results with {@code tool_call_id}, and vision {@code image_url} content parts) * round-trips untouched into the native chat-template parser. Sampling fields are translated to the - * matching {@code InferenceParameters.with*} setter, and so are the reasoning controls upstream's chat + * matching {@code InferenceParameters.with*} setter, as are the llama.cpp sampling extensions upstream's + * request schema reads ({@code min_p}, {@code repeat_penalty}, {@code grammar}, ...) and the reasoning + * controls upstream's chat * parser reads ({@code chat_template_kwargs}, {@code reasoning_effort}, the reasoning budget); unknown * fields are ignored. * @@ -165,6 +168,8 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo params = params.withStopStrings(stops); } + params = applyLlamaCppSampling(params, request); + // Forward stream_options verbatim (e.g. {"include_usage":true}) so the native server emits the // trailing usage chunk the OpenAI streaming protocol — and the Copilot custom endpoint — expect. JsonNode streamOptions = request.path("stream_options"); @@ -183,13 +188,88 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo } /** - * Read the output-token cap, preferring the newer {@code max_completion_tokens} over the legacy - * {@code max_tokens}. + * Apply the llama.cpp sampling extensions upstream's request schema ({@code server-schema.cpp}, + * b11639) reads next to the OpenAI fields. The non-streamed {@code /v1/completions} forwards the body + * to that schema, so without them the streamed form of the same request would sample differently. + * {@code json_schema} applies only without a {@code grammar}, as upstream decides. + * + * @param params the parameters to extend + * @param request the parsed request object + * @return a new instance with the recognised extensions applied + */ + private InferenceParameters applyLlamaCppSampling(InferenceParameters params, JsonNode request) { + params = withFloat(params, request, "min_p", InferenceParameters::withMinP); + params = withFloat(params, request, "typical_p", InferenceParameters::withTypicalP); + params = withFloat(params, request, "repeat_penalty", InferenceParameters::withRepeatPenalty); + params = withFloat(params, request, "top_n_sigma", InferenceParameters::withTopNSigma); + params = withFloat(params, request, "xtc_probability", InferenceParameters::withXtcProbability); + params = withFloat(params, request, "xtc_threshold", InferenceParameters::withXtcThreshold); + params = withFloat(params, request, "dynatemp_range", InferenceParameters::withDynamicTemperatureRange); + params = withFloat(params, request, "dynatemp_exponent", InferenceParameters::withDynamicTemperatureExponent); + params = withInt(params, request, "repeat_last_n", InferenceParameters::withRepeatLastN); + params = withInt(params, request, "min_keep", InferenceParameters::withMinKeep); + params = withInt(params, request, "n_keep", InferenceParameters::withNKeep); + JsonNode ignoreEos = request.path("ignore_eos"); + if (ignoreEos.isBoolean()) { + params = params.withIgnoreEos(ignoreEos.asBoolean()); + } + JsonNode grammar = request.path("grammar"); + if (grammar.isTextual()) { + return params.withGrammar(grammar.asText()); + } + JsonNode jsonSchema = request.path("json_schema"); + return jsonSchema.isObject() ? params.withJsonSchema(jsonSchema.toString()) : params; + } + + /** + * Apply {@code setter} with the request's number {@code field}, if it is one. + * + * @param params the parameters to extend + * @param request the parsed request object + * @param field the request field + * @param setter the matching setter + * @return the extended parameters, or {@code params} when the field is absent or not a number + */ + private static InferenceParameters withFloat( + InferenceParameters params, + JsonNode request, + String field, + BiFunction setter) { + JsonNode value = request.path(field); + return value.isNumber() ? setter.apply(params, (float) value.asDouble()) : params; + } + + /** + * Apply {@code setter} with the request's integer {@code field}, if it is one. + * + * @param params the parameters to extend + * @param request the parsed request object + * @param field the request field + * @param setter the matching setter + * @return the extended parameters, or {@code params} when the field is absent or not an integer + */ + private static InferenceParameters withInt( + InferenceParameters params, + JsonNode request, + String field, + BiFunction setter) { + JsonNode value = request.path(field); + return value.isIntegralNumber() ? setter.apply(params, value.asInt()) : params; + } + + /** + * Read the output-token cap: upstream's {@code n_predict}, else the newer {@code + * max_completion_tokens}, else the legacy {@code max_tokens} (upstream reads the latter two as + * aliases of the first). * * @param request the parsed OpenAI request object - * @return the requested cap, or {@code -1} when neither field is a number + * @return the requested cap, or {@code -1} when none of the fields is a number */ private int readMaxTokens(JsonNode request) { + JsonNode nPredict = request.path("n_predict"); + if (nPredict.isNumber()) { + return nPredict.asInt(); + } JsonNode maxCompletion = request.path("max_completion_tokens"); if (maxCompletion.isNumber()) { return maxCompletion.asInt(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index f5be4957a..ae55b67c8 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -245,6 +245,55 @@ public void cacheAndSlotExtensionsForwarded() throws IOException { assertThat(out.path("id_slot").asInt(), is(2)); } + @Test + public void llamaCppSamplingExtensionsReachTheStreamedCompletion() throws IOException { + // The non-streamed /v1/completions forwards the body to upstream's schema, which reads these + // fields; the streamed path maps the body here and must not answer the same request with + // other sampling. + JsonNode out = MAPPER.readTree(mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\"," + + "\"min_p\":0.05,\"typical_p\":0.9,\"repeat_penalty\":1.1,\"repeat_last_n\":32," + + "\"top_n_sigma\":1.5,\"xtc_probability\":0.3,\"xtc_threshold\":0.2," + + "\"dynatemp_range\":0.4,\"dynatemp_exponent\":1.2,\"min_keep\":2,\"n_keep\":8," + + "\"ignore_eos\":true,\"grammar\":\"root ::= \\\"a\\\"\"}")) + .toJson()); + assertThat(out.path("min_p").asDouble(), is(closeTo(0.05, 1e-4))); + assertThat(out.path("typical_p").asDouble(), is(closeTo(0.9, 1e-4))); + assertThat(out.path("repeat_penalty").asDouble(), is(closeTo(1.1, 1e-4))); + assertThat(out.path("repeat_last_n").asInt(), is(32)); + assertThat(out.path("top_n_sigma").asDouble(), is(closeTo(1.5, 1e-4))); + assertThat(out.path("xtc_probability").asDouble(), is(closeTo(0.3, 1e-4))); + assertThat(out.path("xtc_threshold").asDouble(), is(closeTo(0.2, 1e-4))); + assertThat(out.path("dynatemp_range").asDouble(), is(closeTo(0.4, 1e-4))); + assertThat(out.path("dynatemp_exponent").asDouble(), is(closeTo(1.2, 1e-4))); + assertThat(out.path("min_keep").asInt(), is(2)); + assertThat(out.path("n_keep").asInt(), is(8)); + assertThat(out.path("ignore_eos").asBoolean(false), is(true)); + assertThat(out.path("grammar").asText(), is("root ::= \"a\"")); + } + + @Test + public void nPredictWinsOverMaxTokensLikeUpstream() throws IOException { + // upstream reads n_predict with max_completion_tokens and max_tokens as aliases, n_predict first. + JsonNode out = MAPPER.readTree( + mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"max_tokens\":50,\"n_predict\":7}")) + .toJson()); + assertThat(out.path("n_predict").asInt(), is(7)); + } + + @Test + public void jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven() throws IOException { + JsonNode schema = MAPPER.readTree(mapper.toCompletionParameters( + MAPPER.readTree("{\"prompt\":\"p\",\"json_schema\":{\"type\":\"object\"}}")) + .toJson()); + assertThat(schema.path("json_schema").path("type").asText(), is("object")); + // upstream applies json_schema only without a grammar. + JsonNode both = MAPPER.readTree(mapper.toCompletionParameters(MAPPER.readTree( + "{\"prompt\":\"p\",\"grammar\":\"root ::= \\\"a\\\"\",\"json_schema\":{\"type\":\"object\"}}")) + .toJson()); + assertThat(both.has("json_schema"), is(false)); + assertThat(both.path("grammar").asText(), is("root ::= \"a\"")); + } + @Test public void unknownFieldsIgnored() throws IOException { JsonNode out = mapAndSerialize( From b2de22f976e4a2fa82dc77dba3474a1f0371683b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:51:58 +0000 Subject: [PATCH 173/589] Record the commit of F95 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0b7dbf52f..3bba984d8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -787,7 +787,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#llamaCppSamplingExtensionsReachTheStreamedCompletion`, `#nPredictWinsOverMaxTokensLikeUpstream`, `#jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven` - **Rot-Grün-Nachweis:** ohne Fix rot (3 Fehlschläge: „Expected: is a numeric value within <1.0E-4> of <0.05> but: <0.0>“, „Expected: is <7> but: was <50>“, „Expected: is "object" but: was ""“), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 19/19, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit allen Feldern in einer Methode meldete `CE_CLASS_ENVY`, eine Map-Tabelle `DMC_DUBIOUS_MAP_COLLECTION`; jetzt je Feld ein Aufruf über `withFloat`/`withInt`). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `8ce9ffc` ## Offene Verdachte ohne Fund-ID From 4b20c57e05fc79f21c8310c41ce0b2398f100b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:59:02 +0000 Subject: [PATCH 174/589] Cap Anthropic requests without max_tokens and pass chat_template_kwargs As upstream's convert_anthropic_to_oai, a /v1/messages request without max_tokens is capped at 4096 instead of generating until the context is full, and chat_template_kwargs reach the chat mapper that reads them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 +++++++++++++- .../llama/server/AnthropicApiSupport.java | 16 +++++++++++++++- .../llama/server/AnthropicApiSupportTest.java | 12 ++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f7aab4736..532be48dc 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -208,3 +208,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` ↔ `LlamaModel.handleRerank` · `37473c2` · 2026-10-18 · kein Fund — Schnittstelle (2.3): `rerank_results_to_json` liefert je Ergebnis `index` und lehnt fehlende/ungültige ab, die Rückfall-Zuordnung nach Arrayposition greift also nie; Scores werden nach `index` einsortiert, doppelte Dokumente (anders als in `LlamaModel.rerank(String, String...)`, das nach Text in eine Map schreibt) gehen hier nicht verloren. `JllamaEmbeddingModel` (`9a4b965`): ein `embed` je Segment, Reihenfolge erhalten. - Musterdurchsicht `awaitTermination`/`shutdownNow` nach F66 (BAF `KeyProducerJavaSocket.interrupt`, `Finder.shutdownAndAwaitTermination`; java-llama.cpp `OpenAiCompatServer.close`) · – · 2026-10-18 · kein Fund — Socket-Producer schließt die Verbindungen nach `shutdownNow`, das blockierte `read` endet dadurch; `Finder` reicht nach Ablauf der Wartezeit `interrupt()` an alle Producer weiter, bevor der Consumer gestoppt wird (der Consumer-Fall ist F66); `OpenAiCompatServer.close` gibt das native Modell nicht frei, laufende Streams enden über den Schreibfehler des geschlossenen Sockets und den Abbruch in `streamChatCompletion`. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `f10f4c6` · 2026-10-18 · Fund F95 — llama.cpp-Sampling-Felder (`min_p`, `repeat_penalty`, `grammar`, `n_predict` …) verworfen; Feldmenge gegen `server-schema.cpp` gehalten +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `7376c4a` · 2026-10-18 · Fund F96 — `max_tokens`-Vorgabe 4096 und `chat_template_kwargs` wie upstreams `convert_anthropic_to_oai` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3bba984d8..5388f8418 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -74,6 +74,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F93](#f93) — niedrig — *gefixt* - [F94](#f94) — niedrig — *gefixt* - [F95](#f95) — mittel — *gefixt* +- [F96](#f96) — niedrig — *gefixt* ## Funde @@ -789,6 +790,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `8ce9ffc` +### F96 – java-llama.cpp: Anthropic-Anfrage ohne `max_tokens` läuft unbegrenzt, `chat_template_kwargs` geht verloren +- **Zeit:** 2026-10-18 05:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:111` (`toOpenAiChatRequest`) +- **Schwere:** niedrig +- **Beschreibung:** Aus dem offenen Verdacht „Anthropic-Rest nach F76“. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:585`, b11639) setzt `max_tokens` 4096, wenn die Anfrage keines trägt, und reicht `chat_template_kwargs` durch. `/v1/messages` übernahm `max_tokens` nur, wenn vorhanden, eine Anfrage ohne generierte also, bis der Kontext voll war; und `chat_template_kwargs` (etwa Qwen3s `enable_thinking`) wurde verworfen, obwohl der nachgeschaltete `OpenAiRequestMapper` es seit F77 liest. +- **Fix:** Ohne Zahl in `max_tokens` wird `DEFAULT_MAX_TOKENS` (4096) gesetzt; ein Objekt in `chat_template_kwargs` wird kopiert. +- **Reproduktion:** `AnthropicApiSupportTest#requestDefaultsMaxTokensAndPassesTemplateKwargsLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <4096> but: was <0>“; die zweite Assertion zu `chat_template_kwargs` ist dahinter, ohne Fix fehlt das Feld ganz), mit Fix grün (20/20 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 25/25, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -799,7 +811,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. -- *(niedrig, nur beschrieben, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. +- *(erledigt durch F96, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 9b644845e..7376c4aa3 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -38,6 +38,9 @@ final class AnthropicApiSupport { /** Length of the {@code cch} stamp upstream overwrites. */ private static final int BILLING_STAMP_LENGTH = 5; + /** Output cap of a request without {@code max_tokens} (upstream's default). */ + private static final int DEFAULT_MAX_TOKENS = 4096; + /** Reasoning budget of an enabled {@code thinking} block without {@code budget_tokens} (upstream's default). */ private static final int DEFAULT_THINKING_BUDGET_TOKENS = 10_000; @@ -103,13 +106,24 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { } } - copyNumber(request, "max_tokens", openAi, "max_tokens"); + // As upstream's convert_anthropic_to_oai: the protocol requires max_tokens, a request without + // it is capped at upstream's default instead of generating until the context is full. + if (request.path("max_tokens").isNumber()) { + copyNumber(request, "max_tokens", openAi, "max_tokens"); + } else { + openAi.put("max_tokens", DEFAULT_MAX_TOKENS); + } copyNumber(request, "temperature", openAi, "temperature"); copyNumber(request, "top_p", openAi, "top_p"); copyNumber(request, "top_k", openAi, "top_k"); if (request.path("stop_sequences").isArray()) { openAi.set("stop", request.path("stop_sequences").deepCopy()); } + // Passed through like upstream; the chat mapper hands it to the template. + JsonNode templateKwargs = request.path("chat_template_kwargs"); + if (templateKwargs.isObject()) { + openAi.set("chat_template_kwargs", templateKwargs.deepCopy()); + } // As upstream's convert_anthropic_to_oai: an enabled thinking block becomes the reasoning budget // the chat parser hands to the sampler, with upstream's default when budget_tokens is absent. JsonNode thinking = request.path("thinking"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 95e459e57..6713b8416 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -150,6 +150,18 @@ public void requestJoinsTheTextBlocksOfOneMessageWithANewline() throws IOExcepti assertThat(openAi.path("messages").path(0).path("content").asText(), is("first\nsecond")); } + @Test + public void requestDefaultsMaxTokensAndPassesTemplateKwargsLikeUpstream() throws IOException { + // upstream's convert_anthropic_to_oai caps a request without max_tokens at 4096 instead of + // generating until the context is full, and passes chat_template_kwargs through (e.g. Qwen3's + // enable_thinking), which the shared chat mapper reads. + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[{\"role\":\"user\"," + + "\"content\":\"hi\"}],\"chat_template_kwargs\":{\"enable_thinking\":false}}")); + assertThat(openAi.path("max_tokens").asInt(), is(4096)); + assertThat(openAi.path("chat_template_kwargs").path("enable_thinking").isBoolean(), is(true)); + assertThat(openAi.path("chat_template_kwargs").path("enable_thinking").asBoolean(true), is(false)); + } + @Test public void isStreamingDefaultsFalse() throws IOException { assertThat(AnthropicApiSupport.isStreaming(read("{}")), is(false)); From 1483d10e858c901eda6ae978b7a80a119db3e31a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 04:59:02 +0000 Subject: [PATCH 175/589] Record the commit of F96 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5388f8418..d8ad92c1d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -799,7 +799,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#requestDefaultsMaxTokensAndPassesTemplateKwargsLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <4096> but: was <0>“; die zweite Assertion zu `chat_template_kwargs` ist dahinter, ohne Fix fehlt das Feld ganz), mit Fix grün (20/20 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 25/25, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `d29e084` ## Offene Verdachte ohne Fund-ID From fb4dd64f8bc37552d6dfe8fc3f0717c84b3bea45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:06:01 +0000 Subject: [PATCH 176/589] Carry the thinking through Ollama's /api/chat Ollama returns a model's thinking as message.thinking, blocking and in every streamed line, and clients send it back in the history. The translation dropped reasoning_content on the way out, skipped thinking-only stream chunks and left the history's thinking where llama.cpp does not read it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 +++++++++- .../llama/server/OllamaApiSupport.java | 26 +++++++++++++++---- .../llama/server/OllamaApiSupportTest.java | 24 +++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 532be48dc..ef678f2f5 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -209,3 +209,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - Musterdurchsicht `awaitTermination`/`shutdownNow` nach F66 (BAF `KeyProducerJavaSocket.interrupt`, `Finder.shutdownAndAwaitTermination`; java-llama.cpp `OpenAiCompatServer.close`) · – · 2026-10-18 · kein Fund — Socket-Producer schließt die Verbindungen nach `shutdownNow`, das blockierte `read` endet dadurch; `Finder` reicht nach Ablauf der Wartezeit `interrupt()` an alle Producer weiter, bevor der Consumer gestoppt wird (der Consumer-Fall ist F66); `OpenAiCompatServer.close` gibt das native Modell nicht frei, laufende Streams enden über den Schreibfehler des geschlossenen Sockets und den Abbruch in `streamChatCompletion`. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `f10f4c6` · 2026-10-18 · Fund F95 — llama.cpp-Sampling-Felder (`min_p`, `repeat_penalty`, `grammar`, `n_predict` …) verworfen; Feldmenge gegen `server-schema.cpp` gehalten - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `7376c4a` · 2026-10-18 · Fund F96 — `max_tokens`-Vorgabe 4096 und `chat_template_kwargs` wie upstreams `convert_anthropic_to_oai` +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `6f86c26` · 2026-10-18 · Fund F97 — `message.thinking` ⇄ `reasoning_content` in Antwort, Stream und Verlauf diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d8ad92c1d..3a6a864c3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -75,6 +75,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F94](#f94) — niedrig — *gefixt* - [F95](#f95) — mittel — *gefixt* - [F96](#f96) — niedrig — *gefixt* +- [F97](#f97) — niedrig — *gefixt* ## Funde @@ -801,6 +802,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d29e084` +### F97 – java-llama.cpp: Ollama-`/api/chat` verliert den Denkinhalt in beide Richtungen +- **Zeit:** 2026-10-18 06:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:267` (`toOllamaChatResponse`, `toOllamaContentLine`, `toOpenAiMessage`) +- **Schwere:** niedrig +- **Beschreibung:** Aus dem offenen Verdacht „Ollama-Antworten tragen den Denkinhalt nicht“. Ollama legt den Denkinhalt eines Modells in `message.thinking` ab, blockierend und je Stream-Zeile, und Clients senden ihn im Verlauf zurück. `OllamaApiSupport` übernahm aus OpenAIs Antwort nur `content` und `tool_calls`; im Stream lieferte `toOllamaContentLine` für einen Chunk ohne Text `null`, Denk-Deltas fielen also ganz weg. Seit F78 kann ein Client mit `think` das Denken steuern, sah es aber nie. Im Verlauf blieb `thinking` als unbekannter Schlüssel stehen, den llama.cpps Parser ignoriert (`common/chat.cpp:422` liest `reasoning_content`), wie bei F84 für Anthropic. +- **Fix:** Blockierend und im Stream wird `reasoning_content` zu `message.thinking` (eine Denk-Zeile trägt `content` leer); im Verlauf wird `thinking` zu `reasoning_content`. +- **Reproduktion:** `OllamaApiSupportTest#chatCarriesTheThinkingInBothDirectionsLikeOllama` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7" but: was ""“, erste von drei Richtungen; die Stream- und die Verlaufs-Assertion folgen dahinter), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 20/20, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -813,6 +825,6 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(erledigt durch F96, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. -- *(niedrig, nur beschrieben, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. +- *(erledigt durch F97, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index ed9aff1f1..6f86c2622 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -192,6 +192,12 @@ private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { } message.set("content", parts); } + // Ollama returns a model's thinking as message.thinking and clients send it back; llama.cpp's + // chat parser reads it as reasoning_content. + JsonNode thinking = message.remove("thinking"); + if (thinking != null && thinking.isTextual() && !thinking.asText().isEmpty()) { + message.put("reasoning_content", thinking.asText()); + } JsonNode toolCalls = message.path("tool_calls"); if (toolCalls.isArray()) { for (JsonNode toolCall : toolCalls) { @@ -258,6 +264,10 @@ static String toOllamaChatResponse(String openAiCompletionJson, String model) { JsonNode choice = completion.path("choices").path(0); JsonNode openAiMessage = choice.path("message"); message.put("content", openAiMessage.path("content").asText("")); + String reasoning = openAiMessage.path("reasoning_content").asText(""); + if (!reasoning.isEmpty()) { + message.put("thinking", reasoning); + } ArrayNode ollamaToolCalls = toOllamaToolCalls(openAiMessage.path("tool_calls")); if (ollamaToolCalls.size() > 0) { message.set("tool_calls", ollamaToolCalls); @@ -280,8 +290,9 @@ static String toOllamaChatResponse(String openAiCompletionJson, String model) { } /** - * Translate one streamed OpenAI chunk into an Ollama NDJSON content line, or return {@code null} when - * the chunk carries no assistant text to emit (role-only, finish-only or usage-only chunks). + * Translate one streamed OpenAI chunk into an Ollama NDJSON content line (reasoning deltas as + * {@code message.thinking}, as Ollama streams them), or return {@code null} when the chunk carries + * neither assistant text nor thinking (role-only, finish-only or usage-only chunks). * * @param openAiChunkJson one OpenAI {@code chat.completion.chunk} * @param model the model id to echo @@ -290,8 +301,10 @@ static String toOllamaChatResponse(String openAiCompletionJson, String model) { static @Nullable String toOllamaContentLine(String openAiChunkJson, String model) { try { JsonNode chunk = OBJECT_MAPPER.readTree(openAiChunkJson); - JsonNode content = chunk.path("choices").path(0).path("delta").path("content"); - if (!content.isTextual() || content.asText().isEmpty()) { + JsonNode delta = chunk.path("choices").path(0).path("delta"); + String content = delta.path("content").asText(""); + String thinking = delta.path("reasoning_content").asText(""); + if (content.isEmpty() && thinking.isEmpty()) { return null; } ObjectNode root = OBJECT_MAPPER.createObjectNode(); @@ -299,7 +312,10 @@ static String toOllamaChatResponse(String openAiCompletionJson, String model) { root.put("created_at", nowIso()); ObjectNode message = root.putObject("message"); message.put("role", "assistant"); - message.put("content", content.asText()); + message.put("content", content); + if (!thinking.isEmpty()) { + message.put("thinking", thinking); + } root.put("done", false); return root.toString() + "\n"; } catch (IOException e) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index c0f91ca30..0411e4ff5 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -176,6 +176,30 @@ public void toOpenAiChatRequestForwardsStopFromOptions() throws IOException { assertThat(openAi.path("stop").get(1).asText(), is("END")); } + @Test + public void chatCarriesTheThinkingInBothDirectionsLikeOllama() throws IOException { + // Ollama puts a model's thinking into message.thinking, in the blocking answer and in each + // streamed line; clients send it back in the history, where llama.cpp reads reasoning_content. + JsonNode blocking = read(OllamaApiSupport.toOllamaChatResponse( + "{\"choices\":[{\"message\":{" + + "\"role\":\"assistant\",\"content\":\"42\",\"reasoning_content\":\"6 times 7\"}," + + "\"finish_reason\":\"stop\"}]}", + "m")); + assertThat(blocking.path("message").path("thinking").asText(), is("6 times 7")); + assertThat(blocking.path("message").path("content").asText(), is("42")); + + String line = OllamaApiSupport.toOllamaContentLine( + "{\"choices\":[{\"delta\":{\"reasoning_content\":\"6 ti\"}}]}", "m"); + assertThat( + line == null ? "" : read(line).path("message").path("thinking").asText(), is("6 ti")); + + JsonNode history = OllamaApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"messages\":[" + + "{\"role\":\"user\",\"content\":\"q\"}," + + "{\"role\":\"assistant\",\"content\":\"42\",\"thinking\":\"6 times 7\"}]}")); + assertThat(history.path("messages").path(1).path("reasoning_content").asText(), is("6 times 7")); + assertThat(history.path("messages").path(1).has("thinking"), is(false)); + } + @Test public void toOllamaChatResponseExtractsContentAndCountsAndDone() throws IOException { String openAi = "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"hello\"}," From 3df2c32d4d9d324bc8d9c7abb6d90fc096a4e978 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:06:01 +0000 Subject: [PATCH 177/589] Record the commit of F97 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3a6a864c3..76c2afe81 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -811,7 +811,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#chatCarriesTheThinkingInBothDirectionsLikeOllama` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7" but: was ""“, erste von drei Richtungen; die Stream- und die Verlaufs-Assertion folgen dahinter), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 20/20, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `9eab6e0` ## Offene Verdachte ohne Fund-ID From e72c48e0159c8c9a45a0be11cc7b47ba2ef0a020 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:08:49 +0000 Subject: [PATCH 178/589] Split the F97 test into one test per direction Each of the three directions now shows its own red against the file before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 4 ++-- .../llama/server/OllamaApiSupportTest.java | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 76c2afe81..494a0df00 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -808,8 +808,8 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** niedrig - **Beschreibung:** Aus dem offenen Verdacht „Ollama-Antworten tragen den Denkinhalt nicht“. Ollama legt den Denkinhalt eines Modells in `message.thinking` ab, blockierend und je Stream-Zeile, und Clients senden ihn im Verlauf zurück. `OllamaApiSupport` übernahm aus OpenAIs Antwort nur `content` und `tool_calls`; im Stream lieferte `toOllamaContentLine` für einen Chunk ohne Text `null`, Denk-Deltas fielen also ganz weg. Seit F78 kann ein Client mit `think` das Denken steuern, sah es aber nie. Im Verlauf blieb `thinking` als unbekannter Schlüssel stehen, den llama.cpps Parser ignoriert (`common/chat.cpp:422` liest `reasoning_content`), wie bei F84 für Anthropic. - **Fix:** Blockierend und im Stream wird `reasoning_content` zu `message.thinking` (eine Denk-Zeile trägt `content` leer); im Verlauf wird `thinking` zu `reasoning_content`. -- **Reproduktion:** `OllamaApiSupportTest#chatCarriesTheThinkingInBothDirectionsLikeOllama` -- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7" but: was ""“, erste von drei Richtungen; die Stream- und die Verlaufs-Assertion folgen dahinter), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 20/20, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Reproduktion:** `OllamaApiSupportTest#blockingChatAnswerCarriesTheThinkingLikeOllama`, `#streamedThinkingDeltaBecomesAThinkingLineLikeOllama`, `#historyThinkingBecomesReasoningContent` (zunächst ein Sammeltest, nach der Regel „ein Test je Richtung“ aufgeteilt) +- **Rot-Grün-Nachweis:** gegen die Produktionsdatei vor `9eab6e0` rot, jede Richtung für sich (3 Fehlschläge: „Expected: is "6 times 7" but: was ""“ blockierend und im Verlauf, „Expected: is "6 ti" but: was ""“ im Stream), mit Fix grün (27/27 der Klasse, skipped 0). `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 20/20, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `9eab6e0` diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 0411e4ff5..a29a36d39 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -177,9 +177,8 @@ public void toOpenAiChatRequestForwardsStopFromOptions() throws IOException { } @Test - public void chatCarriesTheThinkingInBothDirectionsLikeOllama() throws IOException { - // Ollama puts a model's thinking into message.thinking, in the blocking answer and in each - // streamed line; clients send it back in the history, where llama.cpp reads reasoning_content. + public void blockingChatAnswerCarriesTheThinkingLikeOllama() throws IOException { + // Ollama puts a model's thinking into message.thinking. JsonNode blocking = read(OllamaApiSupport.toOllamaChatResponse( "{\"choices\":[{\"message\":{" + "\"role\":\"assistant\",\"content\":\"42\",\"reasoning_content\":\"6 times 7\"}," @@ -187,12 +186,20 @@ public void chatCarriesTheThinkingInBothDirectionsLikeOllama() throws IOExceptio "m")); assertThat(blocking.path("message").path("thinking").asText(), is("6 times 7")); assertThat(blocking.path("message").path("content").asText(), is("42")); + } + @Test + public void streamedThinkingDeltaBecomesAThinkingLineLikeOllama() throws IOException { + // A chunk carrying only reasoning is a line of its own in Ollama's stream, not dropped. String line = OllamaApiSupport.toOllamaContentLine( "{\"choices\":[{\"delta\":{\"reasoning_content\":\"6 ti\"}}]}", "m"); assertThat( line == null ? "" : read(line).path("message").path("thinking").asText(), is("6 ti")); + } + @Test + public void historyThinkingBecomesReasoningContent() throws IOException { + // Clients send the thinking back in the history; llama.cpp's chat parser reads reasoning_content. JsonNode history = OllamaApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"messages\":[" + "{\"role\":\"user\",\"content\":\"q\"}," + "{\"role\":\"assistant\",\"content\":\"42\",\"thinking\":\"6 times 7\"}]}")); From c2cf72f8fa5e1c68e024d90e846e6714344f1e6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:12:19 +0000 Subject: [PATCH 179/589] Split the F96 test into one test per part Both the max_tokens default and the chat_template_kwargs passthrough now show their own red against the file before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 4 ++-- .../llama/server/AnthropicApiSupportTest.java | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 494a0df00..35614e46f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -797,8 +797,8 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** niedrig - **Beschreibung:** Aus dem offenen Verdacht „Anthropic-Rest nach F76“. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:585`, b11639) setzt `max_tokens` 4096, wenn die Anfrage keines trägt, und reicht `chat_template_kwargs` durch. `/v1/messages` übernahm `max_tokens` nur, wenn vorhanden, eine Anfrage ohne generierte also, bis der Kontext voll war; und `chat_template_kwargs` (etwa Qwen3s `enable_thinking`) wurde verworfen, obwohl der nachgeschaltete `OpenAiRequestMapper` es seit F77 liest. - **Fix:** Ohne Zahl in `max_tokens` wird `DEFAULT_MAX_TOKENS` (4096) gesetzt; ein Objekt in `chat_template_kwargs` wird kopiert. -- **Reproduktion:** `AnthropicApiSupportTest#requestDefaultsMaxTokensAndPassesTemplateKwargsLikeUpstream` -- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <4096> but: was <0>“; die zweite Assertion zu `chat_template_kwargs` ist dahinter, ohne Fix fehlt das Feld ganz), mit Fix grün (20/20 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 25/25, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Reproduktion:** `AnthropicApiSupportTest#requestWithoutMaxTokensIsCappedLikeUpstream`, `#requestPassesChatTemplateKwargsLikeUpstream` (zunächst ein Sammeltest, nach der Regel „ein Test je Richtung“ aufgeteilt) +- **Rot-Grün-Nachweis:** gegen die Produktionsdatei vor `d29e084` rot, jeder Teil für sich (2 Fehlschläge: „Expected: is <4096> but: was <0>“, „Expected: is but: was “ für `chat_template_kwargs`), mit Fix grün (21/21 der Klasse, skipped 0). `OpenAiCompatServerHttpTest` 46/46, `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 25/25, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `d29e084` diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 6713b8416..b1644afcb 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -151,13 +151,20 @@ public void requestJoinsTheTextBlocksOfOneMessageWithANewline() throws IOExcepti } @Test - public void requestDefaultsMaxTokensAndPassesTemplateKwargsLikeUpstream() throws IOException { + public void requestWithoutMaxTokensIsCappedLikeUpstream() throws IOException { // upstream's convert_anthropic_to_oai caps a request without max_tokens at 4096 instead of - // generating until the context is full, and passes chat_template_kwargs through (e.g. Qwen3's - // enable_thinking), which the shared chat mapper reads. - JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[{\"role\":\"user\"," - + "\"content\":\"hi\"}],\"chat_template_kwargs\":{\"enable_thinking\":false}}")); + // generating until the context is full. + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")); assertThat(openAi.path("max_tokens").asInt(), is(4096)); + } + + @Test + public void requestPassesChatTemplateKwargsLikeUpstream() throws IOException { + // upstream passes chat_template_kwargs through (e.g. Qwen3's enable_thinking); the shared chat + // mapper reads them. + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest(read("{\"max_tokens\":64,\"messages\":" + + "[{\"role\":\"user\",\"content\":\"hi\"}],\"chat_template_kwargs\":{\"enable_thinking\":false}}")); assertThat(openAi.path("chat_template_kwargs").path("enable_thinking").isBoolean(), is(true)); assertThat(openAi.path("chat_template_kwargs").path("enable_thinking").asBoolean(true), is(false)); } From 95af953d0c0b1da27476972a4c320400ef5c9384 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:18:59 +0000 Subject: [PATCH 180/589] Report the token counts on Ollama's streamed done line The blocking /api/chat reports prompt_eval_count and eval_count, the streamed one neither asked for the native usage chunk nor carried the counts. Request the chunk as the Anthropic and Responses routes do and put its counts on the done line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ BUGHUNT-FOUND.md | 14 +++++++- .../llama/server/OllamaApiSupport.java | 33 +++++++++++++++++++ .../llama/server/OpenAiCompatServer.java | 12 +++++-- .../server/OpenAiCompatServerHttpTest.java | 15 +++++++++ 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ef678f2f5..3d03414de 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -210,3 +210,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `f10f4c6` · 2026-10-18 · Fund F95 — llama.cpp-Sampling-Felder (`min_p`, `repeat_penalty`, `grammar`, `n_predict` …) verworfen; Feldmenge gegen `server-schema.cpp` gehalten - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `7376c4a` · 2026-10-18 · Fund F96 — `max_tokens`-Vorgabe 4096 und `chat_template_kwargs` wie upstreams `convert_anthropic_to_oai` - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `6f86c26` · 2026-10-18 · Fund F97 — `message.thinking` ⇄ `reasoning_content` in Antwort, Stream und Verlauf +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `77913cb` · 2026-10-18 · Fund F98 — `streamOllamaChat` ohne Usage-Chunk +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `7e6c0b0` · 2026-10-18 · Fund F98, F97 — Tokenzahlen in der Abschlusszeile, `usageOf` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 35614e46f..a4d6840aa 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -76,6 +76,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F95](#f95) — mittel — *gefixt* - [F96](#f96) — niedrig — *gefixt* - [F97](#f97) — niedrig — *gefixt* +- [F98](#f98) — niedrig — *gefixt* ## Funde @@ -813,10 +814,21 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `9eab6e0` +### F98 – java-llama.cpp: gestreamtes Ollama-`/api/chat` meldet keine Tokenzahlen +- **Zeit:** 2026-10-18 06:15 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:545` (`streamOllamaChat`), `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaDoneLine`, `usageOf`) +- **Schwere:** niedrig +- **Beschreibung:** Aus dem Nachtrag zum offenen Verdacht „`done_reason` uneinheitlich“. Das blockierende `/api/chat` meldet `prompt_eval_count`/`eval_count`, die Abschlusszeile des Streams nicht. `streamOllamaChat` forderte den Usage-Chunk auch nicht an (anders als die Anthropic- und Responses-Routen mit `withUsageChunk`), upstream sendet ihn nur bei `stream_options.include_usage`. Clients, die Token/s aus `eval_count` der Abschlusszeile berechnen (Ollamas eigene CLI mit `--verbose`, Open WebUI), sahen nichts. Gefunden über den Vergleich blockierender und gestreamter Pfad (Erkenntnis aus F92). +- **Fix:** `streamOllamaChat` fordert den Usage-Chunk an (`withUsageChunk`), merkt sich ihn über `OllamaApiSupport.usageOf` und gibt ihn an `toOllamaDoneLine(model, accumulator, usage)` weiter, das die Zählung wie die blockierende Antwort einträgt. `done_reason` bleibt unverändert (Teil des Verdachts, Entscheidung des Nutzers). +- **Reproduktion:** `OpenAiCompatServerHttpTest#ollamaChatStreamingDoneLineCarriesTheTokenCountsLikeTheBlockingAnswer` (das `FakeBackend` sendet den Usage-Chunk wie upstream nur bei `include_usage`) +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <12> but: was <-1>“), mit Fix grün (47/47 der Klasse, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1), nach `pop` grün. `OllamaApiSupportTest` 27/27, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `AnthropicStreamTranslatorTest` 5/5, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. -- *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18: Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. +- *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 6f86c2622..7e6c0b0fa 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -332,6 +332,19 @@ static String toOllamaChatResponse(String openAiCompletionJson, String model) { * @return the final Ollama NDJSON line (with trailing newline) */ static String toOllamaDoneLine(String model, ToolCallDeltaAccumulator accumulator) { + return toOllamaDoneLine(model, accumulator, null); + } + + /** + * Build the terminating Ollama NDJSON line ({@code "done":true}) with the token counts of the + * stream's usage chunk, as the blocking answer reports them. + * + * @param model the model id to echo + * @param accumulator the tool-call accumulator fed with the stream's chunks + * @param usage the OpenAI {@code usage} object of the stream's trailing chunk, or {@code null} + * @return the final Ollama NDJSON line (with trailing newline) + */ + static String toOllamaDoneLine(String model, ToolCallDeltaAccumulator accumulator, @Nullable JsonNode usage) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("model", model); root.put("created_at", nowIso()); @@ -344,11 +357,31 @@ static String toOllamaDoneLine(String model, ToolCallDeltaAccumulator accumulato message.set("tool_calls", ollamaToolCalls); } } + if (usage != null && usage.isObject()) { + root.put("prompt_eval_count", usage.path("prompt_tokens").asLong(0)); + root.put("eval_count", usage.path("completion_tokens").asLong(0)); + } root.put("done", true); root.put("done_reason", "stop"); return root.toString() + "\n"; } + /** + * Read the {@code usage} object of a streamed OpenAI chunk (the trailing chunk the stream emits for + * {@code stream_options.include_usage}). + * + * @param openAiChunkJson one OpenAI {@code chat.completion.chunk} + * @return the usage object, or {@code null} when the chunk carries none or does not parse + */ + static @Nullable JsonNode usageOf(String openAiChunkJson) { + try { + JsonNode usage = OBJECT_MAPPER.readTree(openAiChunkJson).path("usage"); + return usage.isObject() ? usage : null; + } catch (IOException e) { + return null; + } + } + /** Convert OpenAI tool calls (arguments = JSON string) to Ollama tool calls (arguments = object). */ private static ArrayNode toOllamaToolCalls(JsonNode openAiToolCalls) { ArrayNode out = OBJECT_MAPPER.createArrayNode(); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index bc376ce8e..77913cbd8 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -27,6 +27,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import net.ladenthin.llama.LlamaModel; import net.ladenthin.llama.exception.InvalidRequestException; import org.jspecify.annotations.Nullable; @@ -536,16 +537,23 @@ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, Str exchange.getResponseHeaders().set("Cache-Control", "no-cache"); exchange.sendResponseHeaders(HTTP_OK, 0); final ToolCallDeltaAccumulator accumulator = new ToolCallDeltaAccumulator(); + // The done line reports the token counts, as the blocking answer does; they arrive in the + // stream's usage chunk. + final AtomicReference usage = new AtomicReference<>(); try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { try { - backend.stream(openAiRequest, chunkJson -> { + backend.stream(withUsageChunk(openAiRequest), chunkJson -> { accumulator.accept(chunkJson); + JsonNode chunkUsage = OllamaApiSupport.usageOf(chunkJson); + if (chunkUsage != null) { + usage.set(chunkUsage); + } String line = OllamaApiSupport.toOllamaContentLine(chunkJson, model); if (line != null) { out.writeStrict(line); } }); - out.writeStrict(OllamaApiSupport.toOllamaDoneLine(model, accumulator)); + out.writeStrict(OllamaApiSupport.toOllamaDoneLine(model, accumulator, usage.get())); } catch (IllegalArgumentException | InvalidRequestException e) { out.writeQuietly(ollamaError(message(e)) + "\n"); } catch (IOException e) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index ad06a1697..79782cc11 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -231,6 +231,21 @@ public void ollamaChatStreamingReturnsNdjsonEndingWithDone() throws IOException } } + @Test + public void ollamaChatStreamingDoneLineCarriesTheTokenCountsLikeTheBlockingAnswer() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { + // The blocking /api/chat reports prompt_eval_count/eval_count; clients compute tokens/s from + // the done line of the stream, which needs the native stream's usage chunk. + String body = "{\"model\":\"test-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + Response response = post(server.getPort(), "/api/chat", body, ""); + String[] lines = response.body.trim().split("\n"); + JsonNode done = new com.fasterxml.jackson.databind.ObjectMapper().readTree(lines[lines.length - 1]); + assertThat(done.path("done").asBoolean(false), is(true)); + assertThat(done.path("prompt_eval_count").asInt(-1), is(12)); + assertThat(done.path("eval_count").asInt(-1), is(3)); + } + } + @Test public void anthropicMessagesNonStreamingReturnsMessage() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { From 5147709749242e54e30c337e2680f8de710fe1db Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:18:59 +0000 Subject: [PATCH 181/589] Record the commit of F98 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a4d6840aa..f811075be 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -823,7 +823,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#ollamaChatStreamingDoneLineCarriesTheTokenCountsLikeTheBlockingAnswer` (das `FakeBackend` sendet den Usage-Chunk wie upstream nur bei `include_usage`) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <12> but: was <-1>“), mit Fix grün (47/47 der Klasse, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1), nach `pop` grün. `OllamaApiSupportTest` 27/27, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `AnthropicStreamTranslatorTest` 5/5, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `0cd9f8a` ## Offene Verdachte ohne Fund-ID From 1939435e359f42f53bd61dc0ca16896b545108df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:26:30 +0000 Subject: [PATCH 182/589] Leave the empty message item out of a blocking Responses answer The blocking /v1/responses always added a message item, an empty one beside the function calls of a tool-only turn. Upstream's to_json_oaicompat_resp and the streamed form add it only for text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++ .../llama/server/ResponsesApiSupport.java | 23 +++++++++++-------- .../llama/server/ResponsesApiSupportTest.java | 18 +++++++++++++-- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3d03414de..cd7b21d8c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -212,3 +212,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `6f86c26` · 2026-10-18 · Fund F97 — `message.thinking` ⇄ `reasoning_content` in Antwort, Stream und Verlauf - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `77913cb` · 2026-10-18 · Fund F98 — `streamOllamaChat` ohne Usage-Chunk - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `7e6c0b0` · 2026-10-18 · Fund F98, F97 — Tokenzahlen in der Abschlusszeile, `usageOf` +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `2358a6a` · 2026-10-18 · Fund F99 — blockierende `output`-Liste gegen upstreams `to_json_oaicompat_resp` und den eigenen Stream gehalten (Reasoning-, Message-, `function_call`-Items, Usage gleich) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f811075be..71f05343b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -77,6 +77,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F96](#f96) — niedrig — *gefixt* - [F97](#f97) — niedrig — *gefixt* - [F98](#f98) — niedrig — *gefixt* +- [F99](#f99) — niedrig — *gefixt* ## Funde @@ -825,6 +826,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0cd9f8a` +### F99 – java-llama.cpp: blockierende Responses-Antwort trägt ein leeres `message`-Item neben Werkzeugaufrufen +- **Zeit:** 2026-10-18 06:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:378` (`toResponsesResponse`) +- **Schwere:** niedrig +- **Beschreibung:** Vergleich blockierender und gestreamter Pfad (Erkenntnis aus F92). `toResponsesResponse` legte immer ein `message`-Item an, auch mit leerem Text; bei einer Runde, die nur Werkzeuge aufruft, stand so ein leeres `output_text` vor den `function_call`-Items. upstreams `to_json_oaicompat_resp` (`server-task.cpp`, b11639) legt das Item nur bei nicht leerem Text an, und unser eigener Stream (`ResponsesStreamTranslator`) öffnet es erst mit dem ersten Text-Delta. Dieselbe Anfrage ergab also blockierend eine andere `output`-Liste als gestreamt; ein Client, der das leere Item zurückschickt, erzeugt im nächsten Zug eine leere Assistenten-Nachricht im Verlauf. Der Test `responseEmitsFunctionCallItemsForToolCalls` schrieb das leere Item fest („output[0] is the (empty) message“). +- **Fix:** Das `message`-Item wird nur bei nicht leerem Text angelegt; der festschreibende Test erwartet den `function_call` jetzt an Stelle 0. +- **Reproduktion:** `ResponsesApiSupportTest#toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream`, dazu `#responseEmitsFunctionCallItemsForToolCalls` +- **Rot-Grün-Nachweis:** ohne Fix rot (2 Fehlschläge: „Expected: is <1> but: was <2>“, „Expected: is "function_call" but: was "message"“), mit Fix grün (19/19 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 27/27, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 945d6fd51..2358a6a44 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -375,17 +375,20 @@ static String toResponsesResponse(String openAiCompletionJson, String model, Str reasoningItem.put("encrypted_content", ""); reasoningItem.put("status", "completed"); } + // As upstream (and the streamed form): a message item only when there is text. String text = message.path("content").asText(""); - ObjectNode messageItem = output.addObject(); - messageItem.put("type", "message"); - messageItem.put("id", "msg_" + responseId); - messageItem.put("status", "completed"); - messageItem.put("role", "assistant"); - ArrayNode content = messageItem.putArray("content"); - ObjectNode textPart = content.addObject(); - textPart.put("type", "output_text"); - textPart.put("text", text); - textPart.putArray("annotations"); + if (!text.isEmpty()) { + ObjectNode messageItem = output.addObject(); + messageItem.put("type", "message"); + messageItem.put("id", "msg_" + responseId); + messageItem.put("status", "completed"); + messageItem.put("role", "assistant"); + ArrayNode content = messageItem.putArray("content"); + ObjectNode textPart = content.addObject(); + textPart.put("type", "output_text"); + textPart.put("text", text); + textPart.putArray("annotations"); + } JsonNode toolCalls = message.path("tool_calls"); if (toolCalls.isArray()) { for (JsonNode toolCall : toolCalls) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 8f5dcc1a5..b20ec5274 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -164,6 +164,20 @@ public void responseCarriesTheReasoningAsAReasoningItemFirstLikeUpstream() throw assertThat(output.get(1).path("type").asText(), is("message")); } + @Test + public void toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream() throws IOException { + // upstream's to_json_oaicompat_resp adds a message item only when there is text, and the + // streamed form (ResponsesStreamTranslator) opens one only on the first text delta; an empty + // message beside the function calls made the blocking answer differ from both. + String openAi = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"\"," + + "\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"f\"," + + "\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}"; + JsonNode output = read(ResponsesApiSupport.toResponsesResponse(openAi, "m", "resp_1")) + .path("output"); + assertThat(output.size(), is(1)); + assertThat(output.get(0).path("type").asText(), is("function_call")); + } + @Test public void responseCarriesTheCurrentUnixTimeAsCreatedAt() throws IOException { long before = System.currentTimeMillis() / 1000L; @@ -288,8 +302,8 @@ public void responseEmitsFunctionCallItemsForToolCalls() throws IOException { + "\"tool_calls\":[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"f\"," + "\"arguments\":\"{\\\"a\\\":1}\"}}]},\"finish_reason\":\"tool_calls\"}]}"; JsonNode out = read(ResponsesApiSupport.toResponsesResponse(openAi, "m", "resp_1")); - // output[0] is the (empty) message, output[1] is the function_call item. - JsonNode functionCall = out.path("output").get(1); + // The function_call item is the only output item: upstream adds a message item only for text. + JsonNode functionCall = out.path("output").get(0); assertThat(functionCall.path("type").asText(), is("function_call")); assertThat(functionCall.path("call_id").asText(), is("c1")); assertThat(functionCall.path("name").asText(), is("f")); From 6489d067f072462bc0ca9b28df43c32bb1174f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:26:30 +0000 Subject: [PATCH 183/589] Record the commit of F99 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 71f05343b..fa6c0e87a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -835,7 +835,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream`, dazu `#responseEmitsFunctionCallItemsForToolCalls` - **Rot-Grün-Nachweis:** ohne Fix rot (2 Fehlschläge: „Expected: is <1> but: was <2>“, „Expected: is "function_call" but: was "message"“), mit Fix grün (19/19 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 27/27, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `9d0a021` ## Offene Verdachte ohne Fund-ID From f7e1e2013a012d15562dc45d29fc5729c1cc8f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 05:38:07 +0000 Subject: [PATCH 184/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index fa6c0e87a..ec43e6a57 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -852,3 +852,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(erledigt durch F97, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. +- *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). From 57cbdc0dbf7395728243e84bed2b643abe484928 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:02:36 +0000 Subject: [PATCH 185/589] Treat id_slot -1 as upstream's any-slot default The request mapper passed every integer id_slot to withSlotId, which rejects negatives, so a chat request carrying llama.cpp's own default -1 was answered 400, while the verbatim non-streamed /v1/completions accepted it. Leave a negative id_slot unset. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/OpenAiRequestMapper.java | 3 ++- .../llama/server/OpenAiRequestMapperTest.java | 10 ++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index cd7b21d8c..1b911c974 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -213,3 +213,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `77913cb` · 2026-10-18 · Fund F98 — `streamOllamaChat` ohne Usage-Chunk - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `7e6c0b0` · 2026-10-18 · Fund F98, F97 — Tokenzahlen in der Abschlusszeile, `usageOf` - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `2358a6a` · 2026-10-18 · Fund F99 — blockierende `output`-Liste gegen upstreams `to_json_oaicompat_resp` und den eigenen Stream gehalten (Reasoning-, Message-, `function_call`-Items, Usage gleich) +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `69becc1` · 2026-10-18 · Fund F102 — Setter-Prüfungen gegen upstreams Grenzen gehalten: `id_slot` -1 ist upstreams Standard; `n_cache_reuse` (0..), `repeat_last_n`/`dry_penalty_last_n` (0..) decken sich mit dem Schema diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ec43e6a57..b40baea50 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -78,6 +78,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F97](#f97) — niedrig — *gefixt* - [F98](#f98) — niedrig — *gefixt* - [F99](#f99) — niedrig — *gefixt* +- [F102](#f102) — niedrig — *gefixt* ## Funde @@ -837,6 +838,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `9d0a021` +### F102 – java-llama.cpp: Chat-Anfrage mit `id_slot: -1` wird mit 400 abgewiesen +- **Zeit:** 2026-10-18 07:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:133` (`applyCommonFields`) +- **Schwere:** niedrig +- **Beschreibung:** upstream liest `id_slot` mit dem Standardwert `-1` („beliebiger Slot“, `server-context.cpp:4845`, b11639). Der Mapper reichte jeden ganzzahligen Wert an `InferenceParameters.withSlotId` weiter, das negative Werte mit `IllegalArgumentException` ablehnt; der Server macht daraus 400. Ein Client, der den llama.cpp-Standard ausdrücklich mitschickt, scheiterte so auf beiden Chat-Pfaden und beim gestreamten `/v1/completions`, während das nicht gestreamte `/v1/completions` (wörtlich an upstream durchgereicht) dieselbe Anfrage annahm. +- **Fix:** Ein negativer `id_slot` wird nicht gesetzt, das entspricht upstreams Standard. `withSlotId` bleibt streng (ein Java-Aufrufer pinnt bewusst einen Slot). +- **Reproduktion:** `OpenAiRequestMapperTest#slotMinusOneMeansAnySlotLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception thrown: java.lang.IllegalArgumentException: slotId must be non-negative but was -1“, als Assertion über `assertDoesNotThrow`), mit Fix grün (26/26 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index f10f4c646..69becc131 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -128,8 +128,9 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo if (cacheReuse.isIntegralNumber()) { params = params.withCacheReuse(cacheReuse.asInt()); } + // upstream's default -1 means "any slot"; leaving the field unset is the same request. JsonNode slotId = request.path("id_slot"); - if (slotId.isIntegralNumber()) { + if (slotId.isIntegralNumber() && slotId.asInt() >= 0) { params = params.withSlotId(slotId.asInt()); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index ae55b67c8..843907bb6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -7,6 +7,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.closeTo; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; @@ -245,6 +246,15 @@ public void cacheAndSlotExtensionsForwarded() throws IOException { assertThat(out.path("id_slot").asInt(), is(2)); } + @Test + public void slotMinusOneMeansAnySlotLikeUpstream() throws IOException { + // upstream reads id_slot with -1 as its default ("any slot"); a client echoing that default must + // not get a 400 here, which the non-streamed /v1/completions (forwarded verbatim) never gives. + JsonNode request = MAPPER.readTree("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"id_slot\":-1}"); + InferenceParameters params = assertDoesNotThrow(() -> mapper.toInferenceParameters(request)); + assertThat(MAPPER.readTree(params.toJson()).path("id_slot").asInt(-1), is(-1)); + } + @Test public void llamaCppSamplingExtensionsReachTheStreamedCompletion() throws IOException { // The non-streamed /v1/completions forwards the body to upstream's schema, which reads these From 0397cc1d5619a2b9125e24c99f877027567035c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:02:36 +0000 Subject: [PATCH 186/589] Record the commit of F102 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b40baea50..c3c1c2f1a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -847,7 +847,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#slotMinusOneMeansAnySlotLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception thrown: java.lang.IllegalArgumentException: slotId must be non-negative but was -1“, als Assertion über `assertDoesNotThrow`), mit Fix grün (26/26 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ded2762` ## Offene Verdachte ohne Fund-ID From d7f91db30f3ed717fe8df60c2e5b554d7007a276 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:09:08 +0000 Subject: [PATCH 187/589] Keep a zero token cap in the OpenAI request mapper Upstream reads n_predict/max_tokens 0 as evaluate the prompt into the cache and generate nothing; the mapper dropped every cap below 1, so the mapped routes generated without a cap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/OpenAiRequestMapper.java | 3 ++- .../llama/server/OpenAiRequestMapperTest.java | 10 ++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 1b911c974..d92349332 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -214,3 +214,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `7e6c0b0` · 2026-10-18 · Fund F98, F97 — Tokenzahlen in der Abschlusszeile, `usageOf` - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `2358a6a` · 2026-10-18 · Fund F99 — blockierende `output`-Liste gegen upstreams `to_json_oaicompat_resp` und den eigenen Stream gehalten (Reasoning-, Message-, `function_call`-Items, Usage gleich) - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `69becc1` · 2026-10-18 · Fund F102 — Setter-Prüfungen gegen upstreams Grenzen gehalten: `id_slot` -1 ist upstreams Standard; `n_cache_reuse` (0..), `repeat_last_n`/`dry_penalty_last_n` (0..) decken sich mit dem Schema +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `530fb40` · 2026-10-18 · Fund F103 — Obergrenze 0 übernommen; `seed` über `asInt` (uint32 upstream) nur für Werte über 2^31 abweichend, nicht weiter verfolgt; `top_k`/`temperature`/`top_p` ohne Java-seitige Prüfung, upstreams Grenzen greifen nativ diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c3c1c2f1a..a1494d225 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -79,6 +79,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F98](#f98) — niedrig — *gefixt* - [F99](#f99) — niedrig — *gefixt* - [F102](#f102) — niedrig — *gefixt* +- [F103](#f103) — niedrig — *gefixt* ## Funde @@ -849,6 +850,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ded2762` +### F103 – java-llama.cpp: `max_tokens: 0` generiert unbegrenzt statt nur den Cache zu füllen +- **Zeit:** 2026-10-18 07:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:164` (`applyCommonFields`) +- **Schwere:** niedrig +- **Beschreibung:** Musterdurchsicht nach F102 (Werte, die der Mapper anders behandelt als upstream). upstreams Schema (`server-schema.cpp`, `n_predict` mit den Aliasen `max_completion_tokens`/`max_tokens`, Grenzen -1..INT32_MAX, b11639): „When 0, no tokens will be generated but the prompt is evaluated into the cache“. Der Mapper übernahm die Obergrenze nur bei `> 0`; ein Client, der mit `0` den Cache vorwärmt, bekam auf den gemappten Wegen (beide Chat-Pfade, gestreamtes `/v1/completions`, und damit Anthropic/Responses/Ollama) eine Generierung ohne Obergrenze bis zum Kontextende, das wörtlich durchgereichte `/v1/completions` dagegen keine. +- **Fix:** Die Obergrenze wird bei `>= 0` gesetzt; `-1` bzw. kein Feld lässt upstreams Standard (unbegrenzt). +- **Reproduktion:** `OpenAiRequestMapperTest#zeroTokenCapOnlyWarmsTheCacheLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <0> but: was <-1>“), mit Fix grün (27/27 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 69becc131..530fb40b1 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -159,8 +159,9 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo params = params.withFrequencyPenalty((float) frequencyPenalty.asDouble()); } + // 0 is a valid cap upstream (evaluate the prompt into the cache, generate nothing); -1 is its default. int maxTokens = readMaxTokens(request); - if (maxTokens > 0) { + if (maxTokens >= 0) { params = params.withNPredict(maxTokens); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 843907bb6..20d01c93f 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -255,6 +255,16 @@ public void slotMinusOneMeansAnySlotLikeUpstream() throws IOException { assertThat(MAPPER.readTree(params.toJson()).path("id_slot").asInt(-1), is(-1)); } + @Test + public void zeroTokenCapOnlyWarmsTheCacheLikeUpstream() throws IOException { + // upstream: "When 0, no tokens will be generated but the prompt is evaluated into the cache". Dropping + // a 0 made the mapped routes generate without a cap, unlike the verbatim /v1/completions. + JsonNode out = + MAPPER.readTree(mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"max_tokens\":0}")) + .toJson()); + assertThat(out.path("n_predict").asInt(-1), is(0)); + } + @Test public void llamaCppSamplingExtensionsReachTheStreamedCompletion() throws IOException { // The non-streamed /v1/completions forwards the body to upstream's schema, which reads these From 0436984f295bc84a39cb4e3a2de320ae234d76dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:09:08 +0000 Subject: [PATCH 188/589] Record the commit of F103 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a1494d225..cb6aa56d2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -859,7 +859,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#zeroTokenCapOnlyWarmsTheCacheLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <0> but: was <-1>“), mit Fix grün (27/27 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `bcbafbc` ## Offene Verdachte ohne Fund-ID From ce983cfefe93f5462a47c2abfd69908dd461df56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:16:23 +0000 Subject: [PATCH 189/589] Copy Ollama's llama.cpp-named sampling options on every route Ollama keeps repeat_penalty, repeat_last_n, min_p, typical_p, the penalties and num_keep under options with llama.cpp's names; the three Ollama routes copied only five of them and dropped the rest. Copy them on all three, leave repeat_last_n -1 to the server default and turn num_predict -2 into upstream's -1, which upstream's limit requires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 +++++ .../llama/server/OllamaApiSupport.java | 40 ++++++++++++-- .../llama/server/OllamaApiSupportTest.java | 52 +++++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index d92349332..2a6a422d2 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -215,3 +215,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `2358a6a` · 2026-10-18 · Fund F99 — blockierende `output`-Liste gegen upstreams `to_json_oaicompat_resp` und den eigenen Stream gehalten (Reasoning-, Message-, `function_call`-Items, Usage gleich) - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `69becc1` · 2026-10-18 · Fund F102 — Setter-Prüfungen gegen upstreams Grenzen gehalten: `id_slot` -1 ist upstreams Standard; `n_cache_reuse` (0..), `repeat_last_n`/`dry_penalty_last_n` (0..) decken sich mit dem Schema - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `530fb40` · 2026-10-18 · Fund F103 — Obergrenze 0 übernommen; `seed` über `asInt` (uint32 upstream) nur für Werte über 2^31 abweichend, nicht weiter verfolgt; `top_k`/`temperature`/`top_p` ohne Java-seitige Prüfung, upstreams Grenzen greifen nativ +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a944f9a` · 2026-10-18 · Fund F104 — Optionsmenge der drei Ollama-Routen gegen llama.cpps Namen und Sonderwerte gehalten diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cb6aa56d2..be09812eb 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -80,6 +80,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F99](#f99) — niedrig — *gefixt* - [F102](#f102) — niedrig — *gefixt* - [F103](#f103) — niedrig — *gefixt* +- [F104](#f104) — niedrig — *gefixt* ## Funde @@ -861,6 +862,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `bcbafbc` +### F104 – java-llama.cpp: Ollama-Routen verwerfen `repeat_penalty`, `min_p` und weitere Optionen; `num_predict: -2` wird abgewiesen +- **Zeit:** 2026-10-18 07:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:246` (`copyLlamaCppOptions`, `copyNumPredict`, neu; Aufrufe in `toOpenAiChatRequest`, `toOpenAiCompletionRequest`, `toInfillRequest`) +- **Schwere:** niedrig +- **Beschreibung:** Musterdurchsicht nach F102/F103 (Sonderwerte) und F74 (Optionsmengen der Geschwister). Ollama legt die Sampling-Optionen unter `options` mit llama.cpps Namen ab (`repeat_penalty`, `repeat_last_n`, `min_p`, `typical_p`, `presence_penalty`, `frequency_penalty`, `num_keep`), Clients wie Open WebUI setzen sie. Alle drei Ollama-Routen übernahmen nur `temperature`, `top_p`, `top_k`, `seed`, `num_predict` und `stop`; der Rest ging still verloren, obwohl jede Route ihn an upstreams Schema weitergeben kann (`/api/chat` seit F95 über den Mapper, `/api/generate` und FIM wörtlich). Dazu ein Sonderwert: Ollamas `num_predict: -2` („Kontext füllen“) ging unverändert als `max_tokens`/`n_predict` -2 weiter, upstreams harte Grenze beginnt bei -1 – `/api/generate` und FIM antworteten mit einem Fehler. +- **Fix:** Ein gemeinsamer Helfer kopiert die llama.cpp-benannten Optionen auf allen drei Routen (`num_keep` → `n_keep`); `repeat_last_n: -1` (Ollama: Kontextgröße, upstream: ungültig) bleibt beim Server-Standard; `num_predict` unter -1 wird -1. +- **Reproduktion:** `OllamaApiSupportTest#chatCopiesTheLlamaCppSamplingOptions`, `#generateCopiesTheLlamaCppSamplingOptions`, `#infillCopiesTheLlamaCppSamplingOptions`, `#numPredictMinusTwoFillsTheContextAsUpstreamsMinusOne`; `#repeatLastNMinusOneIsLeftToTheServerDefault` schützt die Ausnahme (ohne Fix grün, weil gar nichts kopiert wurde) +- **Rot-Grün-Nachweis:** ohne Fix rot (4 Fehlschläge: dreimal „Expected: is <0.05> but: was <0.0>“, einmal „Expected: is <-1> but: was <-2>“), mit Fix grün (32/32 der Klasse, skipped 0); Gegenprobe per `git stash` rot (4), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 7e6c0b0fa..a944f9ab7 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -153,7 +153,8 @@ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { copyNumber(options, "top_p", openAi, "top_p"); copyNumber(options, "top_k", openAi, "top_k"); copyNumber(options, "seed", openAi, "seed"); - copyNumber(options, "num_predict", openAi, "max_tokens"); + copyNumPredict(options, openAi, "max_tokens"); + copyLlamaCppOptions(options, openAi); if (options.path("stop").isArray()) { openAi.set("stop", options.path("stop").deepCopy()); } @@ -237,6 +238,37 @@ private static String toImageDataUrl(String image) { return "data:" + mimeType + ";base64," + image; } + /** + * Copy the sampling options Ollama names as llama.cpp does, which every route hands on to llama.cpp's + * request schema (the chat route through the shared mapper). Ollama's {@code repeat_last_n} of + * {@code -1} (the context size) is left to the server default: upstream accepts {@code 0} and up only. + */ + private static void copyLlamaCppOptions(JsonNode options, ObjectNode to) { + copyNumber(options, "min_p", to, "min_p"); + copyNumber(options, "typical_p", to, "typical_p"); + copyNumber(options, "repeat_penalty", to, "repeat_penalty"); + copyNumber(options, "presence_penalty", to, "presence_penalty"); + copyNumber(options, "frequency_penalty", to, "frequency_penalty"); + copyNumber(options, "num_keep", to, "n_keep"); + JsonNode repeatLastN = options.path("repeat_last_n"); + if (repeatLastN.isIntegralNumber() && repeatLastN.asInt() >= 0) { + to.set("repeat_last_n", repeatLastN); + } + } + + /** + * Copy Ollama's {@code num_predict}; its {@code -2} ("fill the context") becomes upstream's {@code -1}, + * whose hard limit starts there. + */ + private static void copyNumPredict(JsonNode options, ObjectNode to, String toKey) { + JsonNode numPredict = options.path("num_predict"); + if (numPredict.isIntegralNumber() && numPredict.asInt() < -1) { + to.put(toKey, -1); + } else { + copyNumber(options, "num_predict", to, toKey); + } + } + private static void copyNumber(JsonNode from, String fromKey, ObjectNode to, String toKey) { JsonNode value = from.path(fromKey); if (value.isNumber()) { @@ -444,7 +476,8 @@ static ObjectNode toOpenAiCompletionRequest(JsonNode request) { copyNumber(options, "top_p", openAi, "top_p"); copyNumber(options, "top_k", openAi, "top_k"); copyNumber(options, "seed", openAi, "seed"); - copyNumber(options, "num_predict", openAi, "max_tokens"); + copyNumPredict(options, openAi, "max_tokens"); + copyLlamaCppOptions(options, openAi); if (options.path("stop").isArray()) { openAi.set("stop", options.path("stop").deepCopy()); } @@ -467,7 +500,8 @@ static ObjectNode toInfillRequest(JsonNode request) { copyNumber(options, "top_p", infill, "top_p"); copyNumber(options, "top_k", infill, "top_k"); copyNumber(options, "seed", infill, "seed"); - copyNumber(options, "num_predict", infill, "n_predict"); + copyNumPredict(options, infill, "n_predict"); + copyLlamaCppOptions(options, infill); if (options.path("stop").isArray()) { infill.set("stop", options.path("stop").deepCopy()); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index a29a36d39..a97414f4d 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -176,6 +176,58 @@ public void toOpenAiChatRequestForwardsStopFromOptions() throws IOException { assertThat(openAi.path("stop").get(1).asText(), is("END")); } + private static void assertSamplingOptionsCopied(JsonNode out) { + assertThat(out.path("min_p").asDouble(), is(0.05)); + assertThat(out.path("typical_p").asDouble(), is(0.9)); + assertThat(out.path("repeat_penalty").asDouble(), is(1.1)); + assertThat(out.path("repeat_last_n").asInt(), is(32)); + assertThat(out.path("presence_penalty").asDouble(), is(0.2)); + assertThat(out.path("frequency_penalty").asDouble(), is(0.3)); + assertThat(out.path("n_keep").asInt(), is(8)); + } + + @Test + public void chatCopiesTheLlamaCppSamplingOptions() throws IOException { + // Ollama's options keep llama.cpp's names; Open WebUI and others set repeat_penalty, min_p, ... + assertSamplingOptionsCopied( + OllamaApiSupport.toOpenAiChatRequest( + read( + "{\"model\":\"m\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"q\"}],\"options\":{\"min_p\":0.05,\"typical_p\":0.9,\"repeat_penalty\":1.1,\"repeat_last_n\":32,\"presence_penalty\":0.2,\"frequency_penalty\":0.3,\"num_keep\":8}}"))); + } + + @Test + public void generateCopiesTheLlamaCppSamplingOptions() throws IOException { + assertSamplingOptionsCopied( + OllamaApiSupport.toOpenAiCompletionRequest( + read( + "{\"model\":\"m\",\"prompt\":\"p\",\"options\":{\"min_p\":0.05,\"typical_p\":0.9,\"repeat_penalty\":1.1,\"repeat_last_n\":32,\"presence_penalty\":0.2,\"frequency_penalty\":0.3,\"num_keep\":8}}"))); + } + + @Test + public void infillCopiesTheLlamaCppSamplingOptions() throws IOException { + assertSamplingOptionsCopied( + OllamaApiSupport.toInfillRequest( + read( + "{\"model\":\"m\",\"prompt\":\"p\",\"suffix\":\"s\",\"options\":{\"min_p\":0.05,\"typical_p\":0.9,\"repeat_penalty\":1.1,\"repeat_last_n\":32,\"presence_penalty\":0.2,\"frequency_penalty\":0.3,\"num_keep\":8}}"))); + } + + @Test + public void numPredictMinusTwoFillsTheContextAsUpstreamsMinusOne() throws IOException { + // Ollama's -2 means "fill the context"; upstream's n_predict hard limit starts at -1 and rejects it. + JsonNode out = OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"model\":\"m\",\"prompt\":\"p\",\"options\":{\"num_predict\":-2}}")); + assertThat(out.path("max_tokens").asInt(), is(-1)); + } + + @Test + public void repeatLastNMinusOneIsLeftToTheServerDefault() throws IOException { + // Ollama's -1 means "the context size"; upstream's repeat_last_n accepts 0.. only. + JsonNode out = OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"model\":\"m\",\"prompt\":\"p\",\"options\":{\"repeat_last_n\":-1}}")); + assertThat(out.has("repeat_last_n"), is(false)); + } + @Test public void blockingChatAnswerCarriesTheThinkingLikeOllama() throws IOException { // Ollama puts a model's thinking into message.thinking. From 745d83dae936a20f2184eee5256d2cd8b316c5bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:16:23 +0000 Subject: [PATCH 190/589] Record the commit of F104 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index be09812eb..4a4a19571 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -871,7 +871,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#chatCopiesTheLlamaCppSamplingOptions`, `#generateCopiesTheLlamaCppSamplingOptions`, `#infillCopiesTheLlamaCppSamplingOptions`, `#numPredictMinusTwoFillsTheContextAsUpstreamsMinusOne`; `#repeatLastNMinusOneIsLeftToTheServerDefault` schützt die Ausnahme (ohne Fix grün, weil gar nichts kopiert wurde) - **Rot-Grün-Nachweis:** ohne Fix rot (4 Fehlschläge: dreimal „Expected: is <0.05> but: was <0.0>“, einmal „Expected: is <-1> but: was <-2>“), mit Fix grün (32/32 der Klasse, skipped 0); Gegenprobe per `git stash` rot (4), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `0eba89d` ## Offene Verdachte ohne Fund-ID From f9e680a0f50642e18a41ad56632ba54303f9eb1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:23:14 +0000 Subject: [PATCH 191/589] Reject previous_response_id on /v1/responses like upstream There is no response store, so a turn that refers to an earlier one by id was answered without its history. Upstream's converter rejects the field; reject it too and say to send the earlier turns in input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../ladenthin/llama/server/ResponsesApiSupport.java | 8 ++++++++ .../llama/server/ResponsesApiSupportTest.java | 13 +++++++++++++ 4 files changed, 35 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2a6a422d2..f8b7c38e8 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -216,3 +216,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `69becc1` · 2026-10-18 · Fund F102 — Setter-Prüfungen gegen upstreams Grenzen gehalten: `id_slot` -1 ist upstreams Standard; `n_cache_reuse` (0..), `repeat_last_n`/`dry_penalty_last_n` (0..) decken sich mit dem Schema - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `530fb40` · 2026-10-18 · Fund F103 — Obergrenze 0 übernommen; `seed` über `asInt` (uint32 upstream) nur für Werte über 2^31 abweichend, nicht weiter verfolgt; `top_k`/`temperature`/`top_p` ohne Java-seitige Prüfung, upstreams Grenzen greifen nativ - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a944f9a` · 2026-10-18 · Fund F104 — Optionsmenge der drei Ollama-Routen gegen llama.cpps Namen und Sonderwerte gehalten +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `886b3cb` · 2026-10-18 · Fund F105 — `previous_response_id` abgewiesen; Feldmenge gegen upstreams Kopie des ganzen Körpers gehalten (Verdacht notiert) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 4a4a19571..30a10520f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -81,6 +81,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F102](#f102) — niedrig — *gefixt* - [F103](#f103) — niedrig — *gefixt* - [F104](#f104) — niedrig — *gefixt* +- [F105](#f105) — mittel — *gefixt* ## Funde @@ -873,6 +874,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0eba89d` +### F105 – java-llama.cpp: Responses-Anfrage mit `previous_response_id` wird ohne Verlauf beantwortet +- **Zeit:** 2026-10-18 08:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:56` (`toOpenAiChatRequest`) +- **Schwere:** mittel +- **Beschreibung:** Gefunden beim Lesen von upstreams `server_chat_convert_responses_to_chatcmpl` (`server-chat.cpp`, b11639) von oben (Regel aus F90): Gleich nach der `input`-Prüfung lehnt upstream `previous_response_id` mit „llama.cpp does not support 'previous_response_id'.“ ab. `/v1/responses` hier ignorierte das Feld. Es gibt keinen Antwortspeicher, also beantwortete der Server einen Zug, der nur über die ID an den vorigen anknüpft (OpenAIs Standardweg mit `store`), ohne dessen Verlauf – still eine Antwort auf eine andere Frage. +- **Fix:** Ein nicht leeres `previous_response_id` wirft eine `IllegalArgumentException` (seit F89 eine 400) mit upstreams Wortlaut, dem Wert und dem Hinweis, die früheren Züge in `input` zu senden. +- **Reproduktion:** `ResponsesApiSupportTest#previousResponseIdIsRejectedLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (20/20 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -889,3 +901,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). +- *(niedrig, nur beschrieben, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 2358a6a44..886b3cb1f 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -48,8 +48,16 @@ static boolean isStreaming(JsonNode request) { * * @param request the parsed Responses request * @return an OpenAI {@code /v1/chat/completions} request object + * @throws IllegalArgumentException if the request continues an earlier response by {@code previous_response_id} */ static ObjectNode toOpenAiChatRequest(JsonNode request) { + // As upstream's convert_responses_to_chatcmpl: there is no response store, so a turn that refers + // to an earlier one by id would be answered without its history. + String previousResponseId = request.path("previous_response_id").asText(""); + if (!previousResponseId.isEmpty()) { + throw new IllegalArgumentException("llama.cpp does not support 'previous_response_id' (got " + + previousResponseId + "); send the earlier turns in 'input' instead"); + } ObjectNode openAi = OBJECT_MAPPER.createObjectNode(); if (request.path("model").isTextual()) { openAi.put("model", request.path("model").asText()); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index b20ec5274..13d82f893 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -6,9 +6,11 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.both; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -178,6 +180,17 @@ public void toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream() assertThat(output.get(0).path("type").asText(), is("function_call")); } + @Test + public void previousResponseIdIsRejectedLikeUpstream() throws IOException { + // There is no response store: answering a turn that continues by previous_response_id without + // its history would silently answer something else. upstream rejects it with a 400. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest( + read("{\"model\":\"m\",\"input\":\"and then?\",\"previous_response_id\":\"resp_1\"}"))); + assertThat(e.getMessage(), containsString("previous_response_id")); + } + @Test public void responseCarriesTheCurrentUnixTimeAsCreatedAt() throws IOException { long before = System.currentTimeMillis() / 1000L; From ffd62a269caf2447b5c2b0b2d6e20543676594a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:23:14 +0000 Subject: [PATCH 192/589] Record the commit of F105 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 30a10520f..a8c9ba102 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -883,7 +883,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#previousResponseIdIsRejectedLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (20/20 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `b9bfb45` ## Offene Verdachte ohne Fund-ID From b37064355ae29d154c987d493352f4e4141a7c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:29:56 +0000 Subject: [PATCH 193/589] Pass the chat fields of a Responses request through like upstream Upstream's convert_responses_to_chatcmpl copies the whole body into the chat request; the translation kept four fields and dropped top_k, seed, stop, the penalties, chat_template_kwargs, response_format and llama.cpp's extensions, all of which the chat mapper reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 14 +++++- .../llama/server/ResponsesApiSupport.java | 44 +++++++++++++++++++ .../llama/server/ResponsesApiSupportTest.java | 44 +++++++++++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f8b7c38e8..ff28ac485 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -217,3 +217,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `530fb40` · 2026-10-18 · Fund F103 — Obergrenze 0 übernommen; `seed` über `asInt` (uint32 upstream) nur für Werte über 2^31 abweichend, nicht weiter verfolgt; `top_k`/`temperature`/`top_p` ohne Java-seitige Prüfung, upstreams Grenzen greifen nativ - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a944f9a` · 2026-10-18 · Fund F104 — Optionsmenge der drei Ollama-Routen gegen llama.cpps Namen und Sonderwerte gehalten - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `886b3cb` · 2026-10-18 · Fund F105 — `previous_response_id` abgewiesen; Feldmenge gegen upstreams Kopie des ganzen Körpers gehalten (Verdacht notiert) +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1724b42` · 2026-10-18 · Fund F106 — Felder, die der Chat-Mapper liest, werden durchgereicht diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a8c9ba102..9d002c122 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -82,6 +82,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F103](#f103) — niedrig — *gefixt* - [F104](#f104) — niedrig — *gefixt* - [F105](#f105) — mittel — *gefixt* +- [F106](#f106) — niedrig — *gefixt* ## Funde @@ -885,6 +886,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b9bfb45` +### F106 – java-llama.cpp: Responses-Anfrage verliert `top_k`, `seed`, `stop` und llama.cpps Erweiterungen +- **Zeit:** 2026-10-18 08:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:146` (`toOpenAiChatRequest`, `CHAT_PASSTHROUGH_FIELDS` neu) +- **Schwere:** niedrig +- **Beschreibung:** Aus dem offenen Verdacht der vorigen Runde. upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp`, b11639) beginnt mit `chatcmpl_body = response_body` und kopiert damit jedes Feld in die Chat-Anfrage, die der Chat-Parser liest. `ResponsesApiSupport` übernahm nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; `top_k`, `seed`, `stop`, die Strafen, `chat_template_kwargs`, `response_format`, die Cache-Felder und llama.cpps Sampling-Erweiterungen gingen still verloren, obwohl der nachgeschaltete Mapper sie seit F95 liest (Muster F74/F104). +- **Fix:** Eine Liste der Felder, die der Chat-Mapper liest, wird unverändert übernommen; `input`, `tools` und `tool_choice` bleiben übersetzt statt kopiert. +- **Reproduktion:** `ResponsesApiSupportTest#requestFieldsTheChatMapperReadsPassThroughLikeUpstream` (parametrisiert, ein Fall je Feld, 27 Fälle) +- **Rot-Grün-Nachweis:** ohne Fix rot, jeder Fall für sich (27 Fehlschläge, z. B. „Expected: is <0.05> but: was <>“), mit Fix grün (47/47 der Klasse, skipped 0); Gegenprobe per `git stash` rot (27), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -901,4 +913,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). -- *(niedrig, nur beschrieben, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. +- *(erledigt durch F106, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 886b3cb1f..1724b4252 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -10,6 +10,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.jspecify.annotations.Nullable; /** @@ -31,6 +34,41 @@ final class ResponsesApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * Request fields handed on to the chat request unchanged. Upstream's {@code + * convert_responses_to_chatcmpl} copies the whole body; these are the ones the shared chat mapper + * reads (OpenAI sampling and llama.cpp's extensions). The structural fields ({@code input}, {@code + * tools}, {@code tool_choice}) are translated above, not copied. + */ + private static final List CHAT_PASSTHROUGH_FIELDS = Collections.unmodifiableList(Arrays.asList( + "top_k", + "seed", + "presence_penalty", + "frequency_penalty", + "stop", + "chat_template_kwargs", + "response_format", + "cache_prompt", + "n_cache_reuse", + "id_slot", + "min_p", + "typical_p", + "repeat_penalty", + "repeat_last_n", + "top_n_sigma", + "xtc_probability", + "xtc_threshold", + "dynatemp_range", + "dynatemp_exponent", + "min_keep", + "n_keep", + "ignore_eos", + "grammar", + "json_schema", + "n_predict", + "reasoning_budget_tokens", + "thinking_budget_tokens")); + private ResponsesApiSupport() {} /** @@ -105,6 +143,12 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { copyNumber(request, "temperature", openAi, "temperature"); copyNumber(request, "top_p", openAi, "top_p"); copyNumber(request, "max_output_tokens", openAi, "max_tokens"); + for (String field : CHAT_PASSTHROUGH_FIELDS) { + JsonNode value = request.path(field); + if (!value.isMissingNode() && !value.isNull()) { + openAi.set(field, value.deepCopy()); + } + } // Upstream's converter maps reasoning.effort to the chat field reasoning_effort ("none" turns // the model's thinking off); the other reasoning settings have no chat equivalent there either. if (request.path("reasoning").path("effort").isTextual()) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 13d82f893..4c9814e5b 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -16,6 +16,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; /** * Unit tests for {@link ResponsesApiSupport}: the OpenAI Responses ↔ OpenAI chat request/response @@ -180,6 +182,48 @@ public void toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream() assertThat(output.get(0).path("type").asText(), is("function_call")); } + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "top_k|40", + "seed|7", + "presence_penalty|0.2", + "frequency_penalty|0.3", + "stop|[\"END\"]", + "chat_template_kwargs|{\"enable_thinking\":false}", + "response_format|{\"type\":\"json_object\"}", + "cache_prompt|false", + "n_cache_reuse|64", + "id_slot|1", + "min_p|0.05", + "typical_p|0.9", + "repeat_penalty|1.1", + "repeat_last_n|32", + "top_n_sigma|1.5", + "xtc_probability|0.3", + "xtc_threshold|0.2", + "dynatemp_range|0.4", + "dynatemp_exponent|1.2", + "min_keep|2", + "n_keep|8", + "ignore_eos|true", + "grammar|\"root ::= x\"", + "json_schema|{\"type\":\"object\"}", + "n_predict|16", + "reasoning_budget_tokens|100", + "thinking_budget_tokens|100" + }) + public void requestFieldsTheChatMapperReadsPassThroughLikeUpstream(String field, String json) throws IOException { + // upstream's convert_responses_to_chatcmpl copies the whole body into the chat request; the + // shared chat mapper reads these fields, so dropping them here changed the sampling silently. + JsonNode value = read(json); + com.fasterxml.jackson.databind.node.ObjectNode request = + (com.fasterxml.jackson.databind.node.ObjectNode) read("{\"model\":\"m\",\"input\":\"hi\"}"); + request.set(field, value); + assertThat(ResponsesApiSupport.toOpenAiChatRequest(request).path(field), is(value)); + } + @Test public void previousResponseIdIsRejectedLikeUpstream() throws IOException { // There is no response store: answering a turn that continues by previous_response_id without From b0a104b9149d667c1dff3e426326d078c5a7e1af Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:29:57 +0000 Subject: [PATCH 194/589] Record the commit of F106 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9d002c122..f10a34421 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -895,7 +895,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#requestFieldsTheChatMapperReadsPassThroughLikeUpstream` (parametrisiert, ein Fall je Feld, 27 Fälle) - **Rot-Grün-Nachweis:** ohne Fix rot, jeder Fall für sich (27 Fehlschläge, z. B. „Expected: is <0.05> but: was <>“), mit Fix grün (47/47 der Klasse, skipped 0); Gegenprobe per `git stash` rot (27), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `87635b2` ## Offene Verdachte ohne Fund-ID From d58662f7d94e1a304dd5ddfc35c1996246276225 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:36:46 +0000 Subject: [PATCH 195/589] Map Anthropic's tool_choice none instead of running as auto tool_choice {"type":"none"} forbids tool use, but the translation knew only auto, any and tool, so the request ran as auto and the model could call the tools it was given. Map it to OpenAI's tool_choice none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/AnthropicApiSupport.java | 4 ++++ .../llama/server/AnthropicApiSupportTest.java | 11 +++++++++++ 4 files changed, 28 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ff28ac485..ceeb64f2e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -218,3 +218,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a944f9a` · 2026-10-18 · Fund F104 — Optionsmenge der drei Ollama-Routen gegen llama.cpps Namen und Sonderwerte gehalten - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `886b3cb` · 2026-10-18 · Fund F105 — `previous_response_id` abgewiesen; Feldmenge gegen upstreams Kopie des ganzen Körpers gehalten (Verdacht notiert) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1724b42` · 2026-10-18 · Fund F106 — Felder, die der Chat-Mapper liest, werden durchgereicht +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `6ae6dd6` · 2026-10-18 · Fund F107 — Anthropic-Feldmenge und `tool_choice` gegen upstreams `convert_anthropic_to_oai` gehalten (Feldmenge gleich seit F96) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f10a34421..a26773aec 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -83,6 +83,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F104](#f104) — niedrig — *gefixt* - [F105](#f105) — mittel — *gefixt* - [F106](#f106) — niedrig — *gefixt* +- [F107](#f107) — niedrig — *gefixt* ## Funde @@ -897,6 +898,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `87635b2` +### F107 – java-llama.cpp: Anthropic-`tool_choice` `none` läuft als `auto` +- **Zeit:** 2026-10-18 08:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:348` (`toOpenAiToolChoice`) +- **Schwere:** niedrig +- **Beschreibung:** Anthropics `tool_choice` `{"type":"none"}` verbietet Werkzeugaufrufe (Clients setzen es, um eine Runde ohne Werkzeug zu erzwingen, etwa für eine Zusammenfassung, während die Werkzeugliste für den Prompt-Cache gleich bleibt). `toOpenAiToolChoice` kannte nur `auto`, `any` und `tool`; `none` ergab kein `tool_choice`, die Anfrage lief als `auto`, und das Modell durfte die mitgeschickten Werkzeuge aufrufen. upstreams `convert_anthropic_to_oai` (b11639) hat dieselbe Lücke; der Chat-Mapper und llama.cpps Chat-Parser verstehen OpenAIs `tool_choice` `none` aber, die Übersetzung kostet eine Zeile. Hier also bewusst über upstream hinaus. +- **Fix:** `none` wird zu `tool_choice` `none`. +- **Reproduktion:** `AnthropicApiSupportTest#toolChoiceNoneForbidsToolCalls` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `ResponsesApiSupportTest` 47/47, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 7376c4aa3..6ae6dd66d 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -345,6 +345,10 @@ private static String toolResultText(JsonNode content) { if ("auto".equals(type)) { return "auto"; } + if ("none".equals(type)) { + // Forbids tool use; upstream's converter leaves it out and the request runs as "auto". + return "none"; + } if ("any".equals(type) || "tool".equals(type)) { // OpenAI's textual tool_choice cannot name a specific function; "required" is the closest. return "required"; diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index b1644afcb..eaf4743f6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -239,6 +239,17 @@ public void requestConcatenatesSystemBlocksAndMapsStopSequences() throws IOExcep assertThat(openAi.path("stop").get(0).asText(), is("X")); } + @Test + public void toolChoiceNoneForbidsToolCalls() throws IOException { + // Anthropic's {"type":"none"} forbids tool use; left out, the request ran as "auto" and the model + // could still call the tools it was given. OpenAI's equivalent is tool_choice "none". + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest(read("{\"max_tokens\":16," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]," + + "\"tools\":[{\"name\":\"f\",\"input_schema\":{\"type\":\"object\"}}]," + + "\"tool_choice\":{\"type\":\"none\"}}")); + assertThat(openAi.path("tool_choice").asText(), is("none")); + } + @Test public void toolChoiceAnyMapsToRequired() throws IOException { JsonNode openAi = From 29685d14f068a7b45a1ddf833ace8e629394537a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:36:46 +0000 Subject: [PATCH 196/589] Record the commit of F107 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a26773aec..f15bb066d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -907,7 +907,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#toolChoiceNoneForbidsToolCalls` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `ResponsesApiSupportTest` 47/47, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `b85eed8` ## Offene Verdachte ohne Fund-ID From 8e9116804db0517c5001a16c9cabff3203c35e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:46:41 +0000 Subject: [PATCH 197/589] Translate Responses text.format into chat's response_format The Responses API carries structured output in text.format; /v1/responses did not translate it, so a schema was ignored and the model answered free text. Map json_schema (name, description, schema, strict under json_schema) and json_object to response_format. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 +++++++ .../llama/server/ResponsesApiSupport.java | 33 +++++++++++++++++++ .../llama/server/ResponsesApiSupportTest.java | 29 ++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ceeb64f2e..4914fc60e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -219,3 +219,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `886b3cb` · 2026-10-18 · Fund F105 — `previous_response_id` abgewiesen; Feldmenge gegen upstreams Kopie des ganzen Körpers gehalten (Verdacht notiert) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1724b42` · 2026-10-18 · Fund F106 — Felder, die der Chat-Mapper liest, werden durchgereicht - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `6ae6dd6` · 2026-10-18 · Fund F107 — Anthropic-Feldmenge und `tool_choice` gegen upstreams `convert_anthropic_to_oai` gehalten (Feldmenge gleich seit F96) +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `e44a70b` · 2026-10-18 · Fund F108 — `text.format` → `response_format` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f15bb066d..fa66acf41 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -84,6 +84,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F105](#f105) — mittel — *gefixt* - [F106](#f106) — niedrig — *gefixt* - [F107](#f107) — niedrig — *gefixt* +- [F108](#f108) — niedrig — *gefixt* ## Funde @@ -909,6 +910,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b85eed8` +### F108 – java-llama.cpp: Responses-`text.format` (strukturierte Ausgabe) wird ignoriert +- **Zeit:** 2026-10-18 08:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:177` (`appendResponseFormat`, neu; Aufruf in `toOpenAiChatRequest`) +- **Schwere:** niedrig +- **Beschreibung:** Die Responses-API trägt strukturierte Ausgabe in `text.format` (`{"type":"json_schema","name","schema","strict"}` bzw. `{"type":"json_object"}`), die Chat-API in `response_format` mit dem Schema unter `json_schema`. `/v1/responses` übersetzte das nicht; ein Client, der ein Schema verlangte, bekam freien Text ohne Grammatik. upstreams `convert_responses_to_chatcmpl` (b11639) kopiert `text` unverändert in die Chat-Anfrage, wo der Chat-Parser es nicht liest – dieselbe Lücke. Der Chat-Mapper reicht `response_format` an die native Seite weiter, die Übersetzung reicht also. +- **Fix:** `json_schema` wird zu `response_format` mit `name`, `description`, `schema` und `strict` unter `json_schema`; `json_object` wird übernommen; `text` braucht nichts. Ein vom Client zusätzlich gesetztes `response_format` (seit F106 durchgereicht) hat Vorrang. +- **Reproduktion:** `ResponsesApiSupportTest#textFormatJsonSchemaBecomesTheChatResponseFormat`, `#textFormatJsonObjectBecomesTheChatResponseFormat`; `#textFormatTextLeavesTheResponseFormatOut` schützt den `text`-Fall (ohne Fix grün) +- **Rot-Grün-Nachweis:** ohne Fix rot (2 Fehlschläge: „Expected: is "json_schema" but: was ""“, „Expected: is "json_object" but: was ""“), mit Fix grün (50/50 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit einem Array-Literal meldete `SACM_STATIC_ARRAY_CREATED_IN_METHOD`, jetzt eine Konstante). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 1724b4252..e44a70bf7 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -34,6 +34,10 @@ final class ResponsesApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** The keys of a Responses {@code json_schema} format that chat nests under {@code json_schema}. */ + private static final List JSON_SCHEMA_FORMAT_FIELDS = + Collections.unmodifiableList(Arrays.asList("name", "description", "schema", "strict")); + /** * Request fields handed on to the chat request unchanged. Upstream's {@code * convert_responses_to_chatcmpl} copies the whole body; these are the ones the shared chat mapper @@ -149,6 +153,9 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { openAi.set(field, value.deepCopy()); } } + if (!openAi.has("response_format")) { + appendResponseFormat(openAi, request.path("text").path("format")); + } // Upstream's converter maps reasoning.effort to the chat field reasoning_effort ("none" turns // the model's thinking off); the other reasoning settings have no chat equivalent there either. if (request.path("reasoning").path("effort").isTextual()) { @@ -158,6 +165,32 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { return openAi; } + /** + * Translate Responses' structured-output setting {@code text.format} into chat's {@code + * response_format}: {@code json_schema} keeps its name, schema, strict flag and description, now + * nested under {@code json_schema}; {@code json_object} carries over; {@code text} needs nothing. + * Upstream's converter copies {@code text} unchanged, where the chat parser does not read it. + * + * @param openAi the chat request being built + * @param format the request's {@code text.format} + */ + private static void appendResponseFormat(ObjectNode openAi, JsonNode format) { + String type = format.path("type").asText(""); + if ("json_object".equals(type)) { + openAi.putObject("response_format").put("type", "json_object"); + } else if ("json_schema".equals(type)) { + ObjectNode responseFormat = openAi.putObject("response_format"); + responseFormat.put("type", "json_schema"); + ObjectNode jsonSchema = responseFormat.putObject("json_schema"); + for (String key : JSON_SCHEMA_FORMAT_FIELDS) { + JsonNode value = format.path(key); + if (!value.isMissingNode() && !value.isNull()) { + jsonSchema.set(key, value.deepCopy()); + } + } + } + } + private static void appendInput(ArrayNode messages, JsonNode input) { if (input.isTextual()) { ObjectNode message = messages.addObject(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 4c9814e5b..51e884775 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -224,6 +224,35 @@ public void requestFieldsTheChatMapperReadsPassThroughLikeUpstream(String field, assertThat(ResponsesApiSupport.toOpenAiChatRequest(request).path(field), is(value)); } + @Test + public void textFormatJsonSchemaBecomesTheChatResponseFormat() throws IOException { + // Responses carries structured output in text.format, chat in response_format with the schema + // nested under json_schema; without the translation the schema was ignored. + JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"input\":\"hi\"," + + "\"text\":{\"format\":{\"type\":\"json_schema\",\"name\":\"answer\",\"strict\":true," + + "\"schema\":{\"type\":\"object\"}}}}")); + JsonNode responseFormat = openAi.path("response_format"); + assertThat(responseFormat.path("type").asText(), is("json_schema")); + assertThat(responseFormat.path("json_schema").path("name").asText(), is("answer")); + assertThat( + responseFormat.path("json_schema").path("schema").path("type").asText(), is("object")); + assertThat(responseFormat.path("json_schema").path("strict").asBoolean(false), is(true)); + } + + @Test + public void textFormatJsonObjectBecomesTheChatResponseFormat() throws IOException { + JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest( + read("{\"model\":\"m\",\"input\":\"hi\",\"text\":{\"format\":{\"type\":\"json_object\"}}}")); + assertThat(openAi.path("response_format").path("type").asText(), is("json_object")); + } + + @Test + public void textFormatTextLeavesTheResponseFormatOut() throws IOException { + JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest( + read("{\"model\":\"m\",\"input\":\"hi\",\"text\":{\"format\":{\"type\":\"text\"}}}")); + assertThat(openAi.has("response_format"), is(false)); + } + @Test public void previousResponseIdIsRejectedLikeUpstream() throws IOException { // There is no response store: answering a turn that continues by previous_response_id without From 3e71d95c475e9b7555c977509d6c54c40112959e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:46:41 +0000 Subject: [PATCH 198/589] Record the commit of F108 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index fa66acf41..ff58e0628 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -919,7 +919,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#textFormatJsonSchemaBecomesTheChatResponseFormat`, `#textFormatJsonObjectBecomesTheChatResponseFormat`; `#textFormatTextLeavesTheResponseFormatOut` schützt den `text`-Fall (ohne Fix grün) - **Rot-Grün-Nachweis:** ohne Fix rot (2 Fehlschläge: „Expected: is "json_schema" but: was ""“, „Expected: is "json_object" but: was ""“), mit Fix grün (50/50 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit einem Array-Literal meldete `SACM_STATIC_ARRAY_CREATED_IN_METHOD`, jetzt eine Konstante). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `941357f` ## Offene Verdachte ohne Fund-ID From c5823a5ed250c65072f6f998d47be7e5568fec2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:52:56 +0000 Subject: [PATCH 199/589] Require a tool call when tool_choice names a function OpenAI chat and the Responses API name a tool with an object tool_choice; only the string form was mapped, so the request ran as auto and the model could answer without calling the tool. llama.cpp reads tool_choice only as a string; map the object to required, the closest value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/OpenAiRequestMapper.java | 4 ++++ .../ladenthin/llama/server/ResponsesApiSupport.java | 8 ++++++-- .../llama/server/OpenAiRequestMapperTest.java | 11 +++++++++++ .../llama/server/ResponsesApiSupportTest.java | 9 +++++++++ 6 files changed, 44 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 4914fc60e..abd70131f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -220,3 +220,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1724b42` · 2026-10-18 · Fund F106 — Felder, die der Chat-Mapper liest, werden durchgereicht - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `6ae6dd6` · 2026-10-18 · Fund F107 — Anthropic-Feldmenge und `tool_choice` gegen upstreams `convert_anthropic_to_oai` gehalten (Feldmenge gleich seit F96) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `e44a70b` · 2026-10-18 · Fund F108 — `text.format` → `response_format` +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `bfa8160` · 2026-10-18 · Fund F109, F103, F102 — benanntes Werkzeug in `tool_choice` → `required` +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `12c6846` · 2026-10-18 · Fund F109, F108, F106 — benanntes Werkzeug in `tool_choice` → `required` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ff58e0628..ca2282ae4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -85,6 +85,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F106](#f106) — niedrig — *gefixt* - [F107](#f107) — niedrig — *gefixt* - [F108](#f108) — niedrig — *gefixt* +- [F109](#f109) — niedrig — *gefixt* ## Funde @@ -921,6 +922,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `941357f` +### F109 – java-llama.cpp: `tool_choice`, das eine Funktion benennt, läuft als `auto` +- **Zeit:** 2026-10-18 09:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:74`, `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest`, Werkzeugblock) +- **Schwere:** niedrig +- **Beschreibung:** Musterdurchsicht nach F107. OpenAI-Chat benennt ein Werkzeug mit `tool_choice` `{"type":"function","function":{"name":"f"}}`, die Responses-API mit `{"type":"function","name":"f"}`. Mapper und Responses-Übersetzung nahmen nur die Textform; ein Objekt ging verloren, die Anfrage lief als `auto`, das Modell durfte antworten, ohne das verlangte Werkzeug aufzurufen. upstream liest `tool_choice` nur als Zeichenkette (`json_value(body, "tool_choice", "auto")`, `server-common.cpp:1282`, b11639; `common_chat_tool_choice_parse_oaicompat` kennt `auto`/`none`/`required`) und fällt bei einem Objekt ebenso auf `auto` zurück. Die Anthropic-Übersetzung bildet ihr `{"type":"tool"}` schon auf `required` ab. +- **Fix:** Ein Objekt mit `type` `function` wird auf beiden Wegen zu `required`, dem nächsten Wert, den llama.cpp kennt (mit einem einzigen Werkzeug genau das verlangte). +- **Reproduktion:** `OpenAiRequestMapperTest#toolChoiceNamingAFunctionRequiresAToolCall`, `ResponsesApiSupportTest#toolChoiceNamingAFunctionRequiresAToolCall` +- **Rot-Grün-Nachweis:** ohne Fix rot, je Weg für sich (2 Fehlschläge, je „Expected: is "required" but: was ""“), mit Fix grün (28/28 und 51/51, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1 + 1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesStreamTranslatorTest` 5/5, `OllamaApiSupportTest` 32/32, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 530fb40b1..bfa81607d 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -71,6 +71,10 @@ InferenceParameters toInferenceParameters(JsonNode request) { JsonNode toolChoice = request.path("tool_choice"); if (toolChoice.isTextual()) { params = params.withToolChoice(toolChoice.asText()); + } else if ("function".equals(toolChoice.path("type").asText(""))) { + // A named function: llama.cpp's chat parser reads tool_choice only as a string and would + // fall back to "auto"; "required" is the closest it allows. + params = params.withToolChoice("required"); } JsonNode parallelToolCalls = request.path("parallel_tool_calls"); if (parallelToolCalls.isBoolean()) { diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index e44a70bf7..12c684668 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -134,8 +134,12 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { // The Responses API uses the same tool_choice + parallel_tool_calls fields as chat; forward // them so the shared chat core honors them. The mapper consumes the string form of // tool_choice ("auto"/"none"/"required"), which is what we forward here. - if (request.path("tool_choice").isTextual()) { - openAi.put("tool_choice", request.path("tool_choice").asText()); + JsonNode toolChoice = request.path("tool_choice"); + if (toolChoice.isTextual()) { + openAi.put("tool_choice", toolChoice.asText()); + } else if ("function".equals(toolChoice.path("type").asText(""))) { + // {"type":"function","name":...}: the chat side knows only strings, "required" is closest. + openAi.put("tool_choice", "required"); } if (request.path("parallel_tool_calls").isBoolean()) { openAi.put( diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 20d01c93f..90547a11f 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -193,6 +193,17 @@ public void toolsEnableChatTemplateAndForwardChoice() throws IOException { assertThat(out.has("use_jinja"), is(false)); } + @Test + public void toolChoiceNamingAFunctionRequiresAToolCall() throws IOException { + // {"type":"function","function":{"name":...}} asks for that tool; llama.cpp's chat parser reads + // tool_choice only as a string and fell back to "auto". "required" is the closest it allows, as + // for Anthropic's {"type":"tool"}. + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + + "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]," + + "\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"f\"}}}"); + assertThat(out.path("tool_choice").asText(), is("required")); + } + @Test public void parallelToolCallsForwarded() throws IOException { JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 51e884775..60b08ded2 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -253,6 +253,15 @@ public void textFormatTextLeavesTheResponseFormatOut() throws IOException { assertThat(openAi.has("response_format"), is(false)); } + @Test + public void toolChoiceNamingAFunctionRequiresAToolCall() throws IOException { + // Responses names a tool as {"type":"function","name":...}; the chat side only knows strings. + JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"input\":\"hi\"," + + "\"tools\":[{\"type\":\"function\",\"name\":\"f\",\"parameters\":{\"type\":\"object\"}}]," + + "\"tool_choice\":{\"type\":\"function\",\"name\":\"f\"}}")); + assertThat(openAi.path("tool_choice").asText(), is("required")); + } + @Test public void previousResponseIdIsRejectedLikeUpstream() throws IOException { // There is no response store: answering a turn that continues by previous_response_id without From 4665c55635d26f6282ab47e09e6451b126c32886 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:52:56 +0000 Subject: [PATCH 200/589] Record the commit of F109 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ca2282ae4..6fa080184 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -931,7 +931,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#toolChoiceNamingAFunctionRequiresAToolCall`, `ResponsesApiSupportTest#toolChoiceNamingAFunctionRequiresAToolCall` - **Rot-Grün-Nachweis:** ohne Fix rot, je Weg für sich (2 Fehlschläge, je „Expected: is "required" but: was ""“), mit Fix grün (28/28 und 51/51, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1 + 1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesStreamTranslatorTest` 5/5, `OllamaApiSupportTest` 32/32, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `b7151fc` ## Offene Verdachte ohne Fund-ID From e41032afff24a662911e5aa87782243e8e8915b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 06:58:36 +0000 Subject: [PATCH 201/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index abd70131f..0509828b2 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -222,3 +222,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `e44a70b` · 2026-10-18 · Fund F108 — `text.format` → `response_format` - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `bfa8160` · 2026-10-18 · Fund F109, F103, F102 — benanntes Werkzeug in `tool_choice` → `required` - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `12c6846` · 2026-10-18 · Fund F109, F108, F106 — benanntes Werkzeug in `tool_choice` → `required` +- Nachprüfung der Kommentare neben den Fixes dieser Sitzung (Erkenntnis aus F110): streambuffer `StreamBuffer.java` (`trim`-Erwähnungen außer F110 zutreffend), java-llama.cpp `OpenAiRequestMapper`/`ResponsesApiSupport` (zwei veraltete Kommentare zu `tool_choice`, als Nachtrag zu F109 notiert) · – · 2026-10-18 · kein neuer Fund diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6fa080184..f42299fc5 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -930,6 +930,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Ein Objekt mit `type` `function` wird auf beiden Wegen zu `required`, dem nächsten Wert, den llama.cpp kennt (mit einem einzigen Werkzeug genau das verlangte). - **Reproduktion:** `OpenAiRequestMapperTest#toolChoiceNamingAFunctionRequiresAToolCall`, `ResponsesApiSupportTest#toolChoiceNamingAFunctionRequiresAToolCall` - **Rot-Grün-Nachweis:** ohne Fix rot, je Weg für sich (2 Fehlschläge, je „Expected: is "required" but: was ""“), mit Fix grün (28/28 und 51/51, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1 + 1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesStreamTranslatorTest` 5/5, `OllamaApiSupportTest` 32/32, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Nachtrag (2026-10-18, nur beschrieben):** Zwei Kommentare neben dem Fix sind nicht nachgezogen: die Klassen-Javadoc von `OpenAiRequestMapper` nennt `tool_choice` unter den Feldern, die „verbatim as raw JSON“ weitergehen (galt schon vorher nur für die Textform), und der Kommentar über dem Werkzeugblock in `ResponsesApiSupport.toOpenAiChatRequest` beschreibt nur die Textform. Reine Doku-Abweichung, nach der Nutzerregel ohne Test nicht nachträglich geändert; Vorschlag: `tool_choice` aus der Verbatim-Aufzählung nehmen und „ein benanntes Werkzeug wird zu `required`“ ergänzen. - **Status:** gefixt - **Commit:** java-llama.cpp `b7151fc` From 310cde5572988ad6736241b7e5152c6eaf13b4f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:00:26 +0000 Subject: [PATCH 202/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 0509828b2..2607c29be 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -223,3 +223,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `bfa8160` · 2026-10-18 · Fund F109, F103, F102 — benanntes Werkzeug in `tool_choice` → `required` - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `12c6846` · 2026-10-18 · Fund F109, F108, F106 — benanntes Werkzeug in `tool_choice` → `required` - Nachprüfung der Kommentare neben den Fixes dieser Sitzung (Erkenntnis aus F110): streambuffer `StreamBuffer.java` (`trim`-Erwähnungen außer F110 zutreffend), java-llama.cpp `OpenAiRequestMapper`/`ResponsesApiSupport` (zwei veraltete Kommentare zu `tool_choice`, als Nachtrag zu F109 notiert) · – · 2026-10-18 · kein neuer Fund +- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `value/StopReason.java` ↔ upstream `stop_type_to_str` (Schnittstelle Stopptypen, b11639) · `707296e` · 2026-10-18 · kein Fund — upstream kennt `eos`/`word`/`limit` (dazu `none` für Zwischenschritte), `StopReason` bildet alle vier ab; der gestreamte Completion-Weg macht `limit` zu `length`, sonst `stop`, wie upstreams `finish_reason`. From b84732ad5a434ead10d235ee7e1208c906316b0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:02:42 +0000 Subject: [PATCH 203/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2607c29be..af1e9859f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -224,3 +224,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `12c6846` · 2026-10-18 · Fund F109, F108, F106 — benanntes Werkzeug in `tool_choice` → `required` - Nachprüfung der Kommentare neben den Fixes dieser Sitzung (Erkenntnis aus F110): streambuffer `StreamBuffer.java` (`trim`-Erwähnungen außer F110 zutreffend), java-llama.cpp `OpenAiRequestMapper`/`ResponsesApiSupport` (zwei veraltete Kommentare zu `tool_choice`, als Nachtrag zu F109 notiert) · – · 2026-10-18 · kein neuer Fund - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `value/StopReason.java` ↔ upstream `stop_type_to_str` (Schnittstelle Stopptypen, b11639) · `707296e` · 2026-10-18 · kein Fund — upstream kennt `eos`/`word`/`limit` (dazu `none` für Zwischenschritte), `StopReason` bildet alle vier ab; der gestreamte Completion-Weg macht `limit` zu `length`, sonst `stop`, wie upstreams `finish_reason`. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Authentifizierung) · `77913cb` · 2026-10-18 · kein Fund — `x-api-key` (Anthropic-Clients) und `Authorization: Bearer` (Schema ohne Groß-/Kleinschreibung) werden mit `MessageDigest.isEqual` verglichen; die CORS-Vorabfrage erlaubt beide Kopfzeilen. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` (Typ-Weiche gegen langchain4j-core 1.22.0) · `6399310` · 2026-10-18 · kein Fund — alle elf Elementtypen haben einen Zweig, ein unbekannter wirft (kein stilles Verwerfen); `$defs`/`#/$defs/` wie langchain4js interne Konvention, `required`/`additionalProperties` nur wenn gesetzt. From 6ff8eea577e4af462551866d24944b0b18870be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:12:09 +0000 Subject: [PATCH 204/589] Accept text-embeddings-inference rerank requests like upstream Upstream's /rerank also takes Hugging Face TEI requests, which name the documents texts and get a bare [{index, score}] array back, with text on return_text. The server registered /rerank but required documents and answered TEI clients 400. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 + BUGHUNT-FOUND.md | 12 ++++ .../llama/server/LlamaModelBackend.java | 3 +- .../llama/server/OaiRerankSupport.java | 62 ++++++++++++++++++- .../llama/server/OaiRerankSupportTest.java | 31 ++++++++++ 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index af1e9859f..225856e19 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -226,3 +226,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `value/StopReason.java` ↔ upstream `stop_type_to_str` (Schnittstelle Stopptypen, b11639) · `707296e` · 2026-10-18 · kein Fund — upstream kennt `eos`/`word`/`limit` (dazu `none` für Zwischenschritte), `StopReason` bildet alle vier ab; der gestreamte Completion-Weg macht `limit` zu `length`, sonst `stop`, wie upstreams `finish_reason`. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Authentifizierung) · `77913cb` · 2026-10-18 · kein Fund — `x-api-key` (Anthropic-Clients) und `Authorization: Bearer` (Schema ohne Groß-/Kleinschreibung) werden mit `MessageDigest.isEqual` verglichen; die CORS-Vorabfrage erlaubt beide Kopfzeilen. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` (Typ-Weiche gegen langchain4j-core 1.22.0) · `6399310` · 2026-10-18 · kein Fund — alle elf Elementtypen haben einen Zweig, ein unbekannter wirft (kein stilles Verwerfen); `$defs`/`#/$defs/` wie langchain4js interne Konvention, `required`/`additionalProperties` nur wenn gesetzt. +- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream +- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Fund F111 — `rerank` über `OaiRerankSupport.toResponse` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f42299fc5..b7a7e0303 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -86,6 +86,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F107](#f107) — niedrig — *gefixt* - [F108](#f108) — niedrig — *gefixt* - [F109](#f109) — niedrig — *gefixt* +- [F111](#f111) — niedrig — *gefixt* ## Funde @@ -934,6 +935,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b7151fc` +### F111 – java-llama.cpp: Rerank im TEI-Format (`texts`) wird mit 400 abgewiesen +- **Zeit:** 2026-10-18 09:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java:113` (`readDocuments`, `toResponse`/`toTeiResponse` neu), `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` (`rerank`) +- **Schwere:** niedrig +- **Beschreibung:** upstreams `/rerank` (`server-context.cpp:5705`, b11639) unterstützt neben dem Jina/Cohere-Format auch Hugging Faces text-embeddings-inference: eine Anfrage mit `texts` statt `documents` wird angenommen und als bloßes Array `[{index, score}]` beantwortet, bestes zuerst, mit `text` bei `return_text` (`format_response_rerank`, `server-common.cpp:1535`). Der Server hier registriert `/rerank` ebenfalls, verlangte aber `documents` und antwortete TEI-Clients (etwa LangChains `HuggingFaceTEI`-Reranker oder Werkzeuge, die TEI als Reranker ansprechen) mit 400. +- **Fix:** `readDocuments` liest `texts`, wenn `documents` fehlt; `toResponse` wählt das Antwortformat nach der Anfrage, `toTeiResponse` liefert das TEI-Array (Text aus dem `document` der nativen Antwort). Die Naht `toResponse` wurde zuerst verhaltensneutral eingezogen (delegiert an `toOaiResponse`), das Rot ist gegen diesen Stand gezeigt. +- **Reproduktion:** `OaiRerankSupportTest#readDocumentsAcceptsTheTeiTexts`, `#teiRequestGetsTheTeiArrayWithScoreLikeUpstream`, `#teiReturnTextAddsTheDocumentText` +- **Rot-Grün-Nachweis:** gegen den Stand mit der neutralen Naht rot, jeder Teil für sich (3 Fehlschläge: „Unexpected exception thrown: … 'documents' must be a non-empty array“, „Expected: is but: was “, „Expected: is "b" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0). Die `git stash`-Gegenprobe ging hier nicht (ohne Naht kompiliert der Test nicht; der danach gelesene Bericht war der alte) und wurde durch den Lauf gegen den Naht-Stand ersetzt. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java index 3d47cacd5..1e8895872 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java @@ -137,9 +137,8 @@ public String infill(JsonNode request) { public String rerank(JsonNode request) { final String query = OaiRerankSupport.readQuery(request); final String[] documents = OaiRerankSupport.readDocuments(request); - final int topN = OaiRerankSupport.readTopN(request); final String requestModel = request.path("model").asText(""); final String nativeJson = model.handleRerank(query, documents); - return OaiRerankSupport.toOaiResponse(nativeJson, requestModel, topN); + return OaiRerankSupport.toResponse(request, nativeJson, requestModel); } } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java index b578b9852..b3c1c3130 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java @@ -53,9 +53,10 @@ static String readQuery(JsonNode request) { * @throws IllegalArgumentException if {@code documents} is absent, empty, or holds an unsupported entry */ static String[] readDocuments(JsonNode request) { - JsonNode documents = request.path("documents"); + // Hugging Face text-embeddings-inference names them "texts"; upstream reads it when "documents" is absent. + JsonNode documents = request.has("documents") ? request.path("documents") : request.path("texts"); if (!documents.isArray() || documents.size() == 0) { - throw new IllegalArgumentException("'documents' must be a non-empty array"); + throw new IllegalArgumentException("'documents' (or TEI's 'texts') must be a non-empty array"); } List out = new ArrayList<>(documents.size()); for (JsonNode document : documents) { @@ -81,6 +82,63 @@ static int readTopN(JsonNode request) { return topN.isInt() ? topN.asInt() : -1; } + /** + * Shape the native rerank array as the response the request's format expects. + * + * @param request the parsed rerank request + * @param nativeArrayJson the native {@code [{document,index,score}]} array as JSON + * @param model the model id to echo (omitted when empty) + * @return the rerank response JSON + */ + static String toResponse(JsonNode request, String nativeArrayJson, String model) { + if (request.has("texts")) { + return toTeiResponse( + nativeArrayJson, + readTopN(request), + request.path("return_text").asBoolean(false)); + } + return toOaiResponse(nativeArrayJson, model, readTopN(request)); + } + + /** + * Reshape the native rerank array into the text-embeddings-inference response, as upstream does for a + * request with {@code texts}: a bare array of {@code {index, score}}, best first, with the document as + * {@code text} when {@code return_text} is set. + * + * @param nativeArrayJson the native {@code [{document,index,score}]} array as JSON + * @param topN keep only the top-N highest-scoring entries, or {@code <= 0} to keep all + * @param returnText whether to include each document's text + * @return the TEI rerank response JSON + */ + static String toTeiResponse(String nativeArrayJson, int topN, boolean returnText) { + final List entries = new ArrayList<>(); + try { + JsonNode arr = OBJECT_MAPPER.readTree(nativeArrayJson); + if (arr.isArray()) { + for (JsonNode entry : arr) { + ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("index", entry.path("index").asInt()); + result.put("score", entry.path("score").asDouble()); + if (returnText) { + result.put("text", entry.path("document").asText("")); + } + entries.add(result); + } + } + } catch (IOException e) { + // The native call already succeeded; an unexpected body just yields an empty array. + entries.clear(); + } + entries.sort((a, b) -> + Double.compare(b.path("score").asDouble(), a.path("score").asDouble())); + final ArrayNode results = OBJECT_MAPPER.createArrayNode(); + final int limit = topN > 0 ? Math.min(topN, entries.size()) : entries.size(); + for (int i = 0; i < limit; i++) { + results.add(entries.get(i)); + } + return results.toString(); + } + /** * Reshape the native rerank array into the OpenAI-style rerank response. * diff --git a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java index 3bad7134b..7674ce7a3 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; @@ -98,6 +99,36 @@ public void toOaiResponseAppliesTopN() throws IOException { assertThat(out.has("model"), is(false)); } + @Test + public void readDocumentsAcceptsTheTeiTexts() throws IOException { + // Hugging Face text-embeddings-inference sends "texts"; upstream reads it when "documents" is absent. + JsonNode request = read("{\"query\":\"q\",\"texts\":[\"a\",\"b\"]}"); + String[] documents = assertDoesNotThrow(() -> OaiRerankSupport.readDocuments(request)); + assertThat(documents.length, is(2)); + assertThat(documents[1], is("b")); + } + + @Test + public void teiRequestGetsTheTeiArrayWithScoreLikeUpstream() throws IOException { + // upstream answers a request with "texts" as a bare array of {index, score}, best first. + JsonNode out = read(OaiRerankSupport.toResponse( + read("{\"query\":\"q\",\"texts\":[\"a\",\"b\"]}"), + "[{\"document\":\"a\",\"index\":0,\"score\":0.1},{\"document\":\"b\",\"index\":1,\"score\":0.9}]", + "m")); + assertThat(out.isArray(), is(true)); + assertThat(out.path(0).path("index").asInt(), is(1)); + assertThat(out.path(0).path("score").asDouble(), is(0.9)); + } + + @Test + public void teiReturnTextAddsTheDocumentText() throws IOException { + JsonNode out = read(OaiRerankSupport.toResponse( + read("{\"query\":\"q\",\"texts\":[\"a\",\"b\"],\"return_text\":true}"), + "[{\"document\":\"a\",\"index\":0,\"score\":0.1},{\"document\":\"b\",\"index\":1,\"score\":0.9}]", + "m")); + assertThat(out.path(0).path("text").asText(), is("b")); + } + @Test public void toOaiResponseOnMalformedNativeBodyYieldsEmptyResults() throws IOException { JsonNode out = read(OaiRerankSupport.toOaiResponse("not json", "m", -1)); From 03b4fbb03b6fce6c09f81f869c151448a0dd4950 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:12:09 +0000 Subject: [PATCH 205/589] Record the commit of F111 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b7a7e0303..a02ffa38a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -944,7 +944,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OaiRerankSupportTest#readDocumentsAcceptsTheTeiTexts`, `#teiRequestGetsTheTeiArrayWithScoreLikeUpstream`, `#teiReturnTextAddsTheDocumentText` - **Rot-Grün-Nachweis:** gegen den Stand mit der neutralen Naht rot, jeder Teil für sich (3 Fehlschläge: „Unexpected exception thrown: … 'documents' must be a non-empty array“, „Expected: is but: was “, „Expected: is "b" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0). Die `git stash`-Gegenprobe ging hier nicht (ohne Naht kompiliert der Test nicht; der danach gelesene Bericht war der alte) und wurde durch den Lauf gegen den Naht-Stand ersetzt. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `2831610` ## Offene Verdachte ohne Fund-ID From 909edc51e1cabd35f9ca81f5c0585958b59c2470 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:20:23 +0000 Subject: [PATCH 206/589] Match the server's routes exactly instead of by prefix com.sun.net.httpserver picks the context with the longest matching prefix, and no handler checked the path, so every route also answered the paths below it: POST /v1/messages/count_tokens, where Claude Code counts tokens, ran a whole generation through the messages handler. Let each handler answer only its own path (and one trailing slash), and register upstream's /v1/reranking, which only the prefix had reached. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 ++++++++++++ .../llama/server/OpenAiCompatServer.java | 21 ++++++++++++++++++- .../server/OpenAiCompatServerHttpTest.java | 19 +++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 225856e19..baa865fc4 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -228,3 +228,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` (Typ-Weiche gegen langchain4j-core 1.22.0) · `6399310` · 2026-10-18 · kein Fund — alle elf Elementtypen haben einen Zweig, ein unbekannter wirft (kein stilles Verwerfen); `$defs`/`#/$defs/` wie langchain4js interne Konvention, `required`/`additionalProperties` nur wenn gesetzt. - `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Fund F111 — `rerank` über `OaiRerankSupport.toResponse` +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `e38eb7e` · 2026-10-18 · Fund F112 — Routenliste gegen upstreams `server.cpp` gehalten; Kontexte jetzt exakt diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a02ffa38a..5d2f0883a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -87,6 +87,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F108](#f108) — niedrig — *gefixt* - [F109](#f109) — niedrig — *gefixt* - [F111](#f111) — niedrig — *gefixt* +- [F112](#f112) — mittel — *gefixt* ## Funde @@ -946,6 +947,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `2831610` +### F112 – java-llama.cpp: Unterpfade einer Route laufen durch deren Handler (`/v1/messages/count_tokens` erzeugt eine ganze Antwort) +- **Zeit:** 2026-10-18 09:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:271` (`register`, `exactPath` neu) +- **Schwere:** mittel +- **Beschreibung:** Gefunden beim Abgleich mit upstreams Routenliste (`tools/server/server.cpp`, b11639). `com.sun.net.httpserver` ordnet eine Anfrage dem Kontext mit dem **längsten Präfix** zu, nicht dem exakten Pfad, und kein Handler prüfte den Pfad nach. Damit beantwortete jede Route auch alle Pfade, die mit ihr beginnen: `POST /v1/messages/count_tokens` (Claude Code zählt dort Token vor dem Senden) lief als vollständige Nachricht – eine Generierung bis zu 4096 Token (F96) statt eines Fehlers; ebenso upstreams `/v1/chat/completions/input_tokens` und `/v1/responses/input_tokens`, und `/v1/modelsXYZ` lieferte die Modellliste. upstream ordnet exakt zu. +- **Fix:** `register` umhüllt jeden Handler außer dem Sammel-Kontext `/` mit `exactPath`: nur der Pfad selbst (und mit einem abschließenden Schrägstrich) erreicht den Handler, jeder andere bekommt 404. `/v1/reranking`, das nur über das Präfix von `/v1/rerank` erreichbar war, ist jetzt eigens registriert wie bei upstream. +- **Reproduktion:** `OpenAiCompatServerHttpTest#aSubPathOfARouteIsNotHandledByTheRoute`; `#upstreamsRerankingAliasStillAnswers` schützt den Alias und den abschließenden Schrägstrich (ohne Fix grün) +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <404> but: was <200>“), mit Fix grün (49/49 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1, Bericht vor dem Lauf gelöscht), nach `pop` grün. Alle 25 Testklassen des Pakets `server` grün, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. Der Agent (`llama-atmosphere-agent`) ruft keine Unterpfade auf. +- **Offen (nicht geändert):** upstream bietet `/v1/messages/count_tokens` und die `input_tokens`-Routen tatsächlich an; sie hier nachzubauen bräuchte eine Tokenzählung über das Backend (`OpenAiBackend` hat keine) – ein API-Zusatz. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 77913cbd8..e38eb7ec6 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -209,6 +209,7 @@ public OpenAiCompatServer(OpenAiBackend backend, OpenAiServerConfig config) thro register(PATH_RERANK, this::handleRerank); register("/rerank", this::handleRerank); register("/reranking", this::handleRerank); + register("/v1/reranking", this::handleRerank); register(PATH_INFILL, this::handleInfill); register("/v1/infill", this::handleInfill); register(PATH_MESSAGES, this::handleAnthropicMessages); @@ -257,7 +258,25 @@ public void close() { * cross-cutting CORS/preflight wiring applies uniformly to every route (including the catch-all). */ private void register(String path, HttpHandler handler) { - http.createContext(path, handler).getFilters().add(corsFilter); + http.createContext(path, "/".equals(path) ? handler : exactPath(path, handler)) + .getFilters() + .add(corsFilter); + } + + /** + * Let {@code handler} answer only {@code path} itself (and {@code path} with one trailing slash). The + * JDK server matches a context by prefix, so without this {@code /v1/messages/count_tokens} ran a whole + * generation through the {@code /v1/messages} handler; upstream routes match exactly. + */ + private HttpHandler exactPath(String path, HttpHandler handler) { + return exchange -> { + String requested = exchange.getRequestURI().getPath(); + if (path.equals(requested) || (path + "/").equals(requested)) { + handler.handle(exchange); + } else { + handleNotFound(exchange); + } + }; } /** diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 79782cc11..4edffde48 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -246,6 +246,25 @@ public void ollamaChatStreamingDoneLineCarriesTheTokenCountsLikeTheBlockingAnswe } } + @Test + public void aSubPathOfARouteIsNotHandledByTheRoute() throws IOException { + // The JDK server matches contexts by prefix, so /v1/messages/count_tokens (Claude Code counts + // tokens there) ran a whole generation through the /v1/messages handler instead of a 404. + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { + String body = "{\"model\":\"m\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + assertThat(post(server.getPort(), "/v1/messages/count_tokens", body, "").code, is(404)); + } + } + + @Test + public void upstreamsRerankingAliasStillAnswers() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { + String body = "{\"query\":\"q\",\"documents\":[\"a\"]}"; + assertThat(post(server.getPort(), "/v1/reranking", body, "").code, is(200)); + assertThat(post(server.getPort(), "/v1/chat/completions/", CHAT_BODY, "").code, is(200)); + } + } + @Test public void anthropicMessagesNonStreamingReturnsMessage() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { From ddae47d2f534602e06185c374cbe07df97e42fae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:20:23 +0000 Subject: [PATCH 207/589] Record the commit of F112 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5d2f0883a..2c58410db 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -957,7 +957,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <404> but: was <200>“), mit Fix grün (49/49 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1, Bericht vor dem Lauf gelöscht), nach `pop` grün. Alle 25 Testklassen des Pakets `server` grün, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. Der Agent (`llama-atmosphere-agent`) ruft keine Unterpfade auf. - **Offen (nicht geändert):** upstream bietet `/v1/messages/count_tokens` und die `input_tokens`-Routen tatsächlich an; sie hier nachzubauen bräuchte eine Tokenzählung über das Backend (`OpenAiBackend` hat keine) – ein API-Zusatz. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `609b53e` ## Offene Verdachte ohne Fund-ID From b313ad2449a1263d7baa214f2935d8b7a232309f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:27:06 +0000 Subject: [PATCH 208/589] Add the required created timestamp to the model list The OpenAI model object requires id, object, created and owned_by; /v1/models left created out, which strictly typed clients reject. Upstream sets the current Unix time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/OpenAiSseFormatter.java | 2 ++ .../llama/server/OpenAiSseFormatterTest.java | 13 +++++++++++++ 4 files changed, 28 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index baa865fc4..6cb2f92d8 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -229,3 +229,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Fund F111 — `rerank` über `OaiRerankSupport.toResponse` - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `e38eb7e` · 2026-10-18 · Fund F112 — Routenliste gegen upstreams `server.cpp` gehalten; Kontexte jetzt exakt +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `e5eb40e` · 2026-10-18 · Fund F113 — Modellobjekt gegen OpenAI-Pflichtfelder und upstreams `get_res_model_info` gehalten diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2c58410db..094317587 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -88,6 +88,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F109](#f109) — niedrig — *gefixt* - [F111](#f111) — niedrig — *gefixt* - [F112](#f112) — mittel — *gefixt* +- [F113](#f113) — niedrig — *gefixt* ## Funde @@ -959,6 +960,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `609b53e` +### F113 – java-llama.cpp: `/v1/models` lässt das Pflichtfeld `created` weg +- **Zeit:** 2026-10-18 10:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java:142` (`modelsJson`) +- **Schwere:** niedrig +- **Beschreibung:** Das OpenAI-Modellobjekt hat vier Pflichtfelder (`id`, `object`, `created`, `owned_by`); `modelsJson` setzte `created` nicht. Streng typisierte Clients lesen die Liste dann nicht ein (etwa Rusts `async-openai`, dessen `Model` `created: u32` verlangt, oder Clients, die ihre Modelle mit dem OpenAI-Schema validieren). upstreams `get_res_model_info` (`server-context.cpp:5078`, b11639) setzt `created` auf die aktuelle Unix-Zeit. +- **Fix:** `created` wird mit der aktuellen Unix-Zeit in Sekunden gesetzt, wie upstream. +- **Reproduktion:** `OpenAiSseFormatterTest#modelsJsonCarriesTheRequiredCreatedTimestampLikeUpstream` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ für `isIntegralNumber`), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 49/49, `OaiRerankSupportTest` 13/13, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java index 1a04e66a4..e5eb40ec8 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java @@ -138,6 +138,8 @@ static String modelsJson(String modelId, @Nullable String ftype) { ObjectNode model = OBJECT_MAPPER.createObjectNode(); model.put("id", modelId); model.put("object", "model"); + // Required by the OpenAI model object; upstream's get_res_model_info sends the current time. + model.put("created", System.currentTimeMillis() / 1000L); model.put("owned_by", "llama.cpp"); if (ftype != null && !ftype.isEmpty()) { model.put("ftype", ftype); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java index 14d049fc1..3af5b3875 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java @@ -107,6 +107,19 @@ public void modelsJsonAdvertisesTheConfiguredModel() throws IOException { assertThat(root.path("data").get(0).has("ftype"), is(false)); } + @Test + public void modelsJsonCarriesTheRequiredCreatedTimestampLikeUpstream() throws IOException { + // The OpenAI model object requires an integer "created"; strictly typed clients fail to read a + // list without it. upstream's get_res_model_info sends the current Unix time. + long before = System.currentTimeMillis() / 1000L; + JsonNode created = MAPPER.readTree(OpenAiSseFormatter.modelsJson("m")) + .path("data") + .path(0) + .path("created"); + assertThat(created.isIntegralNumber(), is(true)); + assertThat(created.asLong() >= before, is(true)); + } + @Test public void modelsJsonIncludesFtypeWhenKnownAndOmitsWhenBlank() throws IOException { JsonNode withFtype = MAPPER.readTree(OpenAiSseFormatter.modelsJson("gemma-local", "Q4_K - Medium")); From e287cea829fe03db4b4197a56756e39c9d024df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:27:06 +0000 Subject: [PATCH 209/589] Record the commit of F113 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 094317587..011e23305 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -969,7 +969,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiSseFormatterTest#modelsJsonCarriesTheRequiredCreatedTimestampLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ für `isIntegralNumber`), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 49/49, `OaiRerankSupportTest` 13/13, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `9c165a8` ## Offene Verdachte ohne Fund-ID From 03ad149789f188939ef662c63a957f713748dc39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:37:42 +0000 Subject: [PATCH 210/589] Advertise an Ollama version Copilot's Ollama provider accepts VS Code Copilot's Ollama provider checks /api/version against its minimum 0.6.4 before any model operation and gives up below it; the server advertised 0.1.0. Advertise 0.9.0, the first Ollama with think, the newest request feature the routes translate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../ladenthin/llama/server/OllamaApiSupport.java | 8 ++++++-- .../llama/server/OllamaApiSupportTest.java | 14 ++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 6cb2f92d8..297cf4aea 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -230,3 +230,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Fund F111 — `rerank` über `OaiRerankSupport.toResponse` - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `e38eb7e` · 2026-10-18 · Fund F112 — Routenliste gegen upstreams `server.cpp` gehalten; Kontexte jetzt exakt - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `e5eb40e` · 2026-10-18 · Fund F113 — Modellobjekt gegen OpenAI-Pflichtfelder und upstreams `get_res_model_info` gehalten +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `906cf46` · 2026-10-18 · Fund F114 — `/api/version` gegen Copilots Mindestversion gehalten; `/api/tags`- und `/api/show`-Felder wie bisher diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 011e23305..21e4ce3e3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -89,6 +89,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F111](#f111) — niedrig — *gefixt* - [F112](#f112) — mittel — *gefixt* - [F113](#f113) — niedrig — *gefixt* +- [F114](#f114) — mittel — *gefixt* ## Funde @@ -971,6 +972,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `9c165a8` +### F114 – java-llama.cpp: `/api/version` meldet 0.1.0, VS Code Copilots Ollama-Anbieter lehnt den Server ab +- **Zeit:** 2026-10-18 10:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:36` (`OLLAMA_VERSION`) +- **Schwere:** mittel +- **Beschreibung:** Die Ollama-Routen sind laut CLAUDE.md und Klassen-Javadoc für Copilots Ollama-Anbieter gedacht (`/api/show` meldet dafür Fähigkeiten und Kontextlänge). Dieser Anbieter (`microsoft/vscode-copilot-chat`, `src/extension/byok/vscode-node/ollamaProvider.ts`) prüft vor jeder Modelloperation `GET /api/version` gegen `MINIMUM_OLLAMA_VERSION = '0.6.4'` (Teile numerisch verglichen, nur `0.0.0` gilt als Entwicklerversion) und bricht sonst mit „Ollama server version … is not supported“ ab. Der Server meldete `0.1.0` – der ganze Copilot-Weg scheiterte an dieser Prüfung. Die Javadoc der Konstante behauptete, Clients prüften nur, dass eine Version kommt. +- **Fix:** `OLLAMA_VERSION` ist `0.9.0`, die erste Ollama-Version mit `think` (die neueste Anfragefunktion, die die Routen seit F78/F97 übersetzen); die Javadoc nennt jetzt den Grund. +- **Reproduktion:** `OllamaApiSupportTest#versionPassesCopilotsMinimumOllamaVersion` (Copilots Vergleich nachgebildet) +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “), mit Fix grün (33/33 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 49/49, `OpenAiSseFormatterTest` 15/15, `OaiRerankSupportTest` 13/13, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index a944f9ab7..906cf4699 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -28,8 +28,12 @@ final class OllamaApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - /** Advertised Ollama API version (clients only check that {@code /api/version} responds with one). */ - static final String OLLAMA_VERSION = "0.1.0"; + /** + * Advertised Ollama API version. Clients gate on it: VS Code Copilot's Ollama provider refuses a server + * below 0.6.4. 0.9.0 is the first Ollama with {@code think}, the newest request feature these routes + * translate. + */ + static final String OLLAMA_VERSION = "0.9.0"; private OllamaApiSupport() {} diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index a97414f4d..fd8570bf0 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -228,6 +228,20 @@ public void repeatLastNMinusOneIsLeftToTheServerDefault() throws IOException { assertThat(out.has("repeat_last_n"), is(false)); } + @Test + public void versionPassesCopilotsMinimumOllamaVersion() throws IOException { + // VS Code Copilot's Ollama provider (ollamaProvider.ts, MINIMUM_OLLAMA_VERSION) refuses a server + // below 0.6.4, comparing the dot-separated parts numerically; "0.1.0" failed it before any request. + String[] version = + read(OllamaApiSupport.versionJson()).path("version").asText().split("\\."); + int[] minimum = {0, 6, 4}; + int cmp = 0; + for (int i = 0; i < minimum.length && cmp == 0; i++) { + cmp = Integer.compare(i < version.length ? Integer.parseInt(version[i]) : 0, minimum[i]); + } + assertThat(cmp >= 0, is(true)); + } + @Test public void blockingChatAnswerCarriesTheThinkingLikeOllama() throws IOException { // Ollama puts a model's thinking into message.thinking. From 79db18d748d518fd4cd16f6c1571f7263e3843d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:37:42 +0000 Subject: [PATCH 211/589] Record the commit of F114 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 21e4ce3e3..3d095dd44 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -981,7 +981,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#versionPassesCopilotsMinimumOllamaVersion` (Copilots Vergleich nachgebildet) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “), mit Fix grün (33/33 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 49/49, `OpenAiSseFormatterTest` 15/15, `OaiRerankSupportTest` 13/13, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `fbffef0` ## Offene Verdachte ohne Fund-ID From 6684744ac763d5825268b36b999ff50ecc3bdfce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:40:41 +0000 Subject: [PATCH 212/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 297cf4aea..eb5b61731 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -231,3 +231,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `e38eb7e` · 2026-10-18 · Fund F112 — Routenliste gegen upstreams `server.cpp` gehalten; Kontexte jetzt exakt - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `e5eb40e` · 2026-10-18 · Fund F113 — Modellobjekt gegen OpenAI-Pflichtfelder und upstreams `get_res_model_info` gehalten - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `906cf46` · 2026-10-18 · Fund F114 — `/api/version` gegen Copilots Mindestversion gehalten; `/api/tags`- und `/api/show`-Felder wie bisher +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`tagsJson`, `showJson`) + `OpenAiCompatServer.handleOllamaShow` ↔ VS Code Copilots `ollamaProvider.ts` (Schnittstelle Ollama-Anbieter) · `906cf46` · 2026-10-18 · kein Fund nach F114 — Copilot liest `models[].model` aus `/api/tags`, schickt `{"model": id}` an `/api/show` (der Server bedient sein eines Modell, Körper egal), nimmt `capabilities` (`tools`, `vision`) und `model_info[".context_length"]` (wir: `llama` + `llama.context_length`), fällt bei fehlendem `general.basename` auf die Modell-ID zurück und chattet dann über `/v1/chat/completions`. From 3e2f3b5e593afc3aa76f0d900125c58d51aaba22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:47:04 +0000 Subject: [PATCH 213/589] Report the context length in Ollama's show parameters Continue's Ollama provider reads a model's context length only from the parameters text of /api/show (num_ctx lines, Ollama's Modelfile form); the server sent an empty string, so Continue sized its prompts by its own default instead of the server's window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../llama/server/OllamaApiSupport.java | 3 ++- .../llama/server/OllamaApiSupportTest.java | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index eb5b61731..0f072159d 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -232,3 +232,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `e5eb40e` · 2026-10-18 · Fund F113 — Modellobjekt gegen OpenAI-Pflichtfelder und upstreams `get_res_model_info` gehalten - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `906cf46` · 2026-10-18 · Fund F114 — `/api/version` gegen Copilots Mindestversion gehalten; `/api/tags`- und `/api/show`-Felder wie bisher - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`tagsJson`, `showJson`) + `OpenAiCompatServer.handleOllamaShow` ↔ VS Code Copilots `ollamaProvider.ts` (Schnittstelle Ollama-Anbieter) · `906cf46` · 2026-10-18 · kein Fund nach F114 — Copilot liest `models[].model` aus `/api/tags`, schickt `{"model": id}` an `/api/show` (der Server bedient sein eines Modell, Körper egal), nimmt `capabilities` (`tools`, `vision`) und `model_info[".context_length"]` (wir: `llama` + `llama.context_length`), fällt bei fehlendem `general.basename` auf die Modell-ID zurück und chattet dann über `/v1/chat/completions`. +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a058a51` · 2026-10-18 · Fund F115 — `/api/show` gegen Continues `Ollama.ts` gehalten (`parameters`/`num_ctx`; FIM über `template` als Verdacht notiert) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3d095dd44..310383500 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -90,6 +90,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F112](#f112) — mittel — *gefixt* - [F113](#f113) — niedrig — *gefixt* - [F114](#f114) — mittel — *gefixt* +- [F115](#f115) — niedrig — *gefixt* ## Funde @@ -983,6 +984,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `fbffef0` +### F115 – java-llama.cpp: `/api/show` verschweigt Continue die Kontextlänge +- **Zeit:** 2026-10-18 10:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:89` (`showJson`) +- **Schwere:** niedrig +- **Beschreibung:** Fortsetzung von F114 (Quelltext der genannten Clients lesen). Continues Ollama-Anbieter (`continuedev/continue`, `core/llm/llms/Ollama.ts`) liest die Kontextlänge eines Modells ausschließlich aus dem Text `parameters` von `/api/show` (Zeilen ` `, Ollamas Modelfile-Form, `num_ctx`), nicht aus `model_info`. `showJson` lieferte `parameters: ""`; Continue rechnete daher mit seiner eigenen Vorgabe statt mit dem Fenster des Servers – bei einem kleineren `-c` schickt es zu lange Prompts, bei einem größeren nutzt es das Fenster nicht. +- **Fix:** `parameters` trägt `num_ctx ` (dieselbe Zahl wie `model_info["llama.context_length"]`). +- **Reproduktion:** `OllamaApiSupportTest#showParametersCarryTheContextLengthWhereContinueReadsIt` (mit Continues regulärem Ausdruck) +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "4096" but: was null“), mit Fix grün (34/34 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. Breite Liste wie F114 grün, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -1000,3 +1012,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(erledigt durch F106, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. +- *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 906cf4699..a058a51b6 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -85,7 +85,8 @@ static String showJson(String modelId, int contextLength, boolean vision) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("license", ""); root.put("modelfile", ""); - root.put("parameters", ""); + // Ollama's " " lines; Continue's Ollama provider reads the context length only from here. + root.put("parameters", "num_ctx " + contextLength); root.put("template", ""); ObjectNode details = root.putObject("details"); details.put("family", "llama"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index fd8570bf0..d9c866dbc 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -228,6 +228,24 @@ public void repeatLastNMinusOneIsLeftToTheServerDefault() throws IOException { assertThat(out.has("repeat_last_n"), is(false)); } + @Test + public void showParametersCarryTheContextLengthWhereContinueReadsIt() throws IOException { + // Continue's Ollama provider (core/llm/llms/Ollama.ts) reads the context length only from the + // "parameters" text, one " " line each, matched by ^(\S+)\s+((?:".*")|\S+)$. + String parameters = read(OllamaApiSupport.showJson("m", 4096, false)) + .path("parameters") + .asText(); + java.util.regex.Pattern line = java.util.regex.Pattern.compile("^(\\S+)\\s+((?:\".*\")|\\S+)$"); + String numCtx = null; + for (String l : parameters.split("\n")) { + java.util.regex.Matcher m = line.matcher(l); + if (m.matches() && "num_ctx".equals(m.group(1))) { + numCtx = m.group(2); + } + } + assertThat(numCtx, is("4096")); + } + @Test public void versionPassesCopilotsMinimumOllamaVersion() throws IOException { // VS Code Copilot's Ollama provider (ollamaProvider.ts, MINIMUM_OLLAMA_VERSION) refuses a server From cdc9d397cfafccac4d7e9a24a1f4a07e85a01caf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:47:04 +0000 Subject: [PATCH 214/589] Record the commit of F115 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 310383500..bf3674ece 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -993,7 +993,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#showParametersCarryTheContextLengthWhereContinueReadsIt` (mit Continues regulärem Ausdruck) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "4096" but: was null“), mit Fix grün (34/34 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. Breite Liste wie F114 grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `4d45be1` ## Offene Verdachte ohne Fund-ID From e4f54a6a705884816f3b87df95c03cc244c1bd47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:54:03 +0000 Subject: [PATCH 215/589] Keep the tool's name on Ollama tool results Ollama clients send a tool result as role tool with tool_name and no tool_call_id; llama.cpp's chat parser reads the name from name, so the template could not tell which tool had answered. Map tool_name to name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++++++++ .../net/ladenthin/llama/server/OllamaApiSupport.java | 5 +++++ .../ladenthin/llama/server/OllamaApiSupportTest.java | 12 ++++++++++++ 4 files changed, 30 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 0f072159d..03304880f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -233,3 +233,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `906cf46` · 2026-10-18 · Fund F114 — `/api/version` gegen Copilots Mindestversion gehalten; `/api/tags`- und `/api/show`-Felder wie bisher - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`tagsJson`, `showJson`) + `OpenAiCompatServer.handleOllamaShow` ↔ VS Code Copilots `ollamaProvider.ts` (Schnittstelle Ollama-Anbieter) · `906cf46` · 2026-10-18 · kein Fund nach F114 — Copilot liest `models[].model` aus `/api/tags`, schickt `{"model": id}` an `/api/show` (der Server bedient sein eines Modell, Körper egal), nimmt `capabilities` (`tools`, `vision`) und `model_info[".context_length"]` (wir: `llama` + `llama.context_length`), fällt bei fehlendem `general.basename` auf die Modell-ID zurück und chattet dann über `/v1/chat/completions`. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a058a51` · 2026-10-18 · Fund F115 — `/api/show` gegen Continues `Ollama.ts` gehalten (`parameters`/`num_ctx`; FIM über `template` als Verdacht notiert) +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `73f11e1` · 2026-10-18 · Fund F116 — Chat-Antwort und Verlauf gegen Continues `Ollama.ts` gehalten (`thinking`, `tool_calls` mit Objekt-Argumenten, `tool_name`) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index bf3674ece..ddd5e667c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -91,6 +91,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F113](#f113) — niedrig — *gefixt* - [F114](#f114) — mittel — *gefixt* - [F115](#f115) — niedrig — *gefixt* +- [F116](#f116) — niedrig — *gefixt* ## Funde @@ -995,6 +996,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4d45be1` +### F116 – java-llama.cpp: Ollama-Werkzeugergebnisse verlieren den Werkzeugnamen +- **Zeit:** 2026-10-18 10:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:208` (`toOpenAiMessage`) +- **Schwere:** niedrig +- **Beschreibung:** Ollama-Clients (etwa Continues Ollama-Anbieter) schicken Werkzeugergebnisse als `{"role":"tool","content":…,"tool_name":…}`, ohne `tool_call_id`. `toOpenAiMessage` kopierte die Nachricht unverändert; llama.cpps Chat-Parser liest den Namen eines Werkzeugergebnisses aber aus `name` (`common/chat.cpp:426`, b11639) und kennt `tool_name` nicht. Ohne Kennung und ohne Namen konnte die Vorlage nicht sagen, welches Werkzeug geantwortet hat – bei mehreren Aufrufen in einer Runde ordnet das Modell die Ergebnisse nur noch nach der Reihenfolge zu. +- **Fix:** `tool_name` wird zu `name` (ein vorhandenes `name` hat Vorrang). +- **Reproduktion:** `OllamaApiSupportTest#toolResultNameBecomesTheNameLlamaCppReads` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "get_weather" but: was ""“), mit Fix grün (35/35 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. Breite Liste wie F114 grün, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index a058a51b6..73f11e11e 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -204,6 +204,11 @@ private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { if (thinking != null && thinking.isTextual() && !thinking.asText().isEmpty()) { message.put("reasoning_content", thinking.asText()); } + // Ollama names the tool of a result "tool_name"; llama.cpp's chat parser reads "name". + JsonNode toolName = message.remove("tool_name"); + if (toolName != null && toolName.isTextual() && !message.has("name")) { + message.put("name", toolName.asText()); + } JsonNode toolCalls = message.path("tool_calls"); if (toolCalls.isArray()) { for (JsonNode toolCall : toolCalls) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index d9c866dbc..4caa7fa09 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -281,6 +281,18 @@ public void streamedThinkingDeltaBecomesAThinkingLineLikeOllama() throws IOExcep line == null ? "" : read(line).path("message").path("thinking").asText(), is("6 ti")); } + @Test + public void toolResultNameBecomesTheNameLlamaCppReads() throws IOException { + // Ollama names the tool of a result "tool_name"; llama.cpp's chat parser reads "name" (chat.cpp:426), + // which templates use to say which tool answered. + JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"messages\":[" + + "{\"role\":\"user\",\"content\":\"q\"}," + + "{\"role\":\"tool\",\"content\":\"21 C\",\"tool_name\":\"get_weather\"}]}")); + JsonNode toolMessage = openAi.path("messages").path(1); + assertThat(toolMessage.path("name").asText(), is("get_weather")); + assertThat(toolMessage.has("tool_name"), is(false)); + } + @Test public void historyThinkingBecomesReasoningContent() throws IOException { // Clients send the thinking back in the history; llama.cpp's chat parser reads reasoning_content. From 9a4d2962a4d5d7b4fba6cc631eaff11235c9a7f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:54:03 +0000 Subject: [PATCH 216/589] Record the commit of F116 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ddd5e667c..61720a554 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1005,7 +1005,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toolResultNameBecomesTheNameLlamaCppReads` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "get_weather" but: was ""“), mit Fix grün (35/35 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. Breite Liste wie F114 grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `a5e86bf` ## Offene Verdachte ohne Fund-ID From a6e8da40845e419630002700d6beb49c6f00c8ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 07:56:33 +0000 Subject: [PATCH 217/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 03304880f..20c71b2ac 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -234,3 +234,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`tagsJson`, `showJson`) + `OpenAiCompatServer.handleOllamaShow` ↔ VS Code Copilots `ollamaProvider.ts` (Schnittstelle Ollama-Anbieter) · `906cf46` · 2026-10-18 · kein Fund nach F114 — Copilot liest `models[].model` aus `/api/tags`, schickt `{"model": id}` an `/api/show` (der Server bedient sein eines Modell, Körper egal), nimmt `capabilities` (`tools`, `vision`) und `model_info[".context_length"]` (wir: `llama` + `llama.context_length`), fällt bei fehlendem `general.basename` auf die Modell-ID zurück und chattet dann über `/v1/chat/completions`. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a058a51` · 2026-10-18 · Fund F115 — `/api/show` gegen Continues `Ollama.ts` gehalten (`parameters`/`num_ctx`; FIM über `template` als Verdacht notiert) - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `73f11e1` · 2026-10-18 · Fund F116 — Chat-Antwort und Verlauf gegen Continues `Ollama.ts` gehalten (`thinking`, `tool_calls` mit Objekt-Argumenten, `tool_name`) +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Ereignisfolge für Claude Code) · `bd6ac4e` · 2026-10-18 · kein Fund — `message_start`, dann Blöcke nacheinander mit steigendem Index (Denkblock mit `signature_delta` vor dem Schließen, Text, danach je Werkzeug `tool_use` + `input_json_delta` + Stopp), `message_delta` mit Usage und `stop_reason`, `message_stop`. Ein Denk-Delta nach begonnenem Text würde einen zweiten Block neben dem offenen Text öffnen; llama.cpps Diffs lassen `reasoning_content` aber vor `content` wachsen, praktisch unerreichbar. From bd3db6ad427d49dcfbd4d0e4b0439264296688b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:05:53 +0000 Subject: [PATCH 218/589] Report a failed Responses stream as response.failed A streamed /v1/responses that failed ended with an `error` event whose data carried no `type`, and never with `response.failed`. Responses clients dispatch on the data's `type` and read the failure from `response.failed` (Codex: parse_failed_response reads response.error.message), so they reported "stream closed before response.completed" instead of the actual message. The translator now emits `response.failed` (status failed, error code and message) ahead of the existing error event, which stays for clients that read a top-level `error` object. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 + BUGHUNT-FOUND.md | 12 ++++++ .../llama/server/OpenAiCompatServer.java | 6 ++- .../server/ResponsesStreamTranslator.java | 19 +++++++++ .../server/OpenAiCompatServerHttpTest.java | 41 +++++++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 20c71b2ac..7c841586f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -235,3 +235,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a058a51` · 2026-10-18 · Fund F115 — `/api/show` gegen Continues `Ollama.ts` gehalten (`parameters`/`num_ctx`; FIM über `template` als Verdacht notiert) - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `73f11e1` · 2026-10-18 · Fund F116 — Chat-Antwort und Verlauf gegen Continues `Ollama.ts` gehalten (`thinking`, `tool_calls` mit Objekt-Argumenten, `tool_name`) - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Ereignisfolge für Claude Code) · `bd6ac4e` · 2026-10-18 · kein Fund — `message_start`, dann Blöcke nacheinander mit steigendem Index (Denkblock mit `signature_delta` vor dem Schließen, Text, danach je Werkzeug `tool_use` + `input_json_delta` + Stopp), `message_delta` mit Usage und `stop_reason`, `message_stop`. Ein Denk-Delta nach begonnenem Text würde einen zweiten Block neben dem offenen Text öffnen; llama.cpps Diffs lassen `reasoning_content` aber vor `content` wachsen, praktisch unerreichbar. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`streamResponses`) · `65df73d` · 2026-10-18 · Fund F117 — Fehlerzweige des Responses-Streams gegen Codex (`responses.rs`, `responses_error.rs`) gehalten; jetzt `response.failed` vor dem `error`-Ereignis +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `6d97f3a` · 2026-10-18 · Fund F117 — Ereignisfolge gegen Codex gehalten: `type` + `sequence_number` in den Daten, `response.completed` mit `id` und Usage (`input_tokens`/`output_tokens`/`total_tokens`), `output_item.done`-Items parsebar; neu `failed()` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 61720a554..d8a0d2f18 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -92,6 +92,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F114](#f114) — mittel — *gefixt* - [F115](#f115) — niedrig — *gefixt* - [F116](#f116) — niedrig — *gefixt* +- [F117](#f117) — mittel — *gefixt* ## Funde @@ -1007,6 +1008,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `a5e86bf` +### F117 – java-llama.cpp: Gestreamte Responses-Fehler erreichen Responses-Clients nicht +- **Zeit:** 2026-10-18 11:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:782` (`streamResponses`), `ResponsesStreamTranslator.java` +- **Schwere:** mittel +- **Beschreibung:** Scheiterte ein gestreamtes `/v1/responses` (abgelehnte Anfrage, Fehler bei der Inferenz), schrieb der Server nach `response.created` nur `event: error` mit `{"error":{…}}` – ohne `type` im Datenobjekt und ohne `response.failed`. Responses-Clients verteilen nach dem `type` der Daten: Codex (`codex-api/src/sse/responses.rs`, `process_responses_event`) verwirft ein Ereignis ohne `type` beim Parsen und liest die Fehlermeldung nur aus `response.failed` (`responses_error.rs`, `parse_failed_response` → `response.error.message`/`code`); Ergebnis war „stream closed before response.completed“ statt der eigentlichen Meldung, etwa des Kontextüberlaufs. +- **Fix:** `ResponsesStreamTranslator.failed(message, code)` erzeugt ein `response.failed` mit der Antwort (`status: failed`, `error.code`/`error.message`) und fortlaufender `sequence_number`; `streamResponses` schreibt es in beiden Fehlerzweigen vor das bisherige `error`-Ereignis, das für Clients bleibt, die ein `error`-Objekt auf oberster Ebene auswerten (OpenAIs Python-SDK) und upstreams Form entspricht. +- **Reproduktion:** `OpenAiCompatServerHttpTest#rejectedStreamedResponseEndsWithAResponseFailedEvent`, `#failedStreamedResponseEndsWithAResponseFailedEvent` +- **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: a string containing "event: response.failed" but: was "event: response.created …"“), mit Fix grün (51/51 der Klasse, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (2), nach `pop` grün. Breite Liste (Server-Tests inkl. `ResponsesStreamTranslatorTest`, `LlamaArchitectureTest`) grün, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index e38eb7ec6..65df73d38 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -780,13 +780,15 @@ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, Stri }); out.writeStrict(translator.end()); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly("event: error\ndata: " + out.writeQuietly(translator.failed(message(e), ERROR_TYPE_REQUEST) + + "event: error\ndata: " + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null) + "\n\n"); } catch (IOException e) { LOG.debug("responses client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("responses streaming failed", e); - out.writeQuietly("event: error\ndata: " + out.writeQuietly(translator.failed(message(e), ERROR_TYPE_SERVER) + + "event: error\ndata: " + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null) + "\n\n"); } finally { if (heartbeat != null) { diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java index 007523f1a..6d97f3a5b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java @@ -186,6 +186,25 @@ String end() { return out.toString(); } + /** + * The terminal {@code response.failed} event for a stream that ended in an error. A Responses client + * dispatches on the event {@code type} and reads the failure from {@code response.error}; without + * this event it sees a stream that ends before {@code response.completed} and loses the message. + * + * @param message the error message + * @param code the error code + * @return the framed SSE event + */ + String failed(String message, String code) { + ObjectNode data = ResponsesApiSupport.dataObject(); + ObjectNode response = ResponsesApiSupport.newResponseShell(model, responseId, "failed"); + ObjectNode error = response.putObject("error"); + error.put("code", code); + error.put("message", message); + data.set("response", response); + return ResponsesApiSupport.sseEvent("response.failed", sequence++, data); + } + private void captureUsage(JsonNode usage) { if (!usage.isObject()) { return; diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 4edffde48..fa2c66459 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -463,6 +463,37 @@ public void nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent() th } } + // A Responses client dispatches on the data's "type" and reads a stream failure from a + // response.failed event (Codex: process_responses_event -> parse_failed_response, which reads + // response.error.message); without one it reports only "stream closed before response.completed". + + @Test + public void rejectedStreamedResponseEndsWithAResponseFailedEvent() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new RejectingBackend(), config()).start()) { + String body = "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}"; + String events = post(server.getPort(), "/v1/responses", body, "").body; + assertThat(events, containsString("event: response.failed")); + String failed = events.substring(events.indexOf("event: response.failed")); + assertThat(failed, containsString("\"type\":\"response.failed\"")); + assertThat(failed, containsString("\"status\":\"failed\"")); + assertThat(failed, containsString("\"code\":\"invalid_request_error\"")); + assertThat(failed, containsString(RejectingBackend.REASON_FRAGMENT)); + } + } + + @Test + public void failedStreamedResponseEndsWithAResponseFailedEvent() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new StreamFailingBackend(), config()).start()) { + String body = "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}"; + String events = post(server.getPort(), "/v1/responses", body, "").body; + assertThat(events, containsString("event: response.failed")); + String failed = events.substring(events.indexOf("event: response.failed")); + assertThat(failed, containsString("\"status\":\"failed\"")); + assertThat(failed, containsString("\"code\":\"server_error\"")); + assertThat(failed, containsString(StreamFailingBackend.REASON)); + } + } + @Test public void nativeRejectionOnTheOllamaAndAnthropicSurfacesReturns400() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new RejectingBackend(), config()).start()) { @@ -739,6 +770,16 @@ public String complete(JsonNode request) { } } + /** Backend whose streamed generation fails like an inference failure: a plain {@link LlamaException}. */ + static final class StreamFailingBackend extends FakeBackend { + static final String REASON = "decoding failed"; + + @Override + public void stream(JsonNode request, ChunkSink sink) { + throw new LlamaException(REASON); + } + } + /** Backend that stalls before emitting, so the server's heartbeat fires during the gap. */ static final class SlowFakeBackend implements OpenAiBackend { @Override From 7dcf8fd88e10d1551972b41e62592526ac5b1f4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:05:53 +0000 Subject: [PATCH 219/589] Record the commit of F117 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d8a0d2f18..c9ef8d1ea 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1017,7 +1017,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#rejectedStreamedResponseEndsWithAResponseFailedEvent`, `#failedStreamedResponseEndsWithAResponseFailedEvent` - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: a string containing "event: response.failed" but: was "event: response.created …"“), mit Fix grün (51/51 der Klasse, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (2), nach `pop` grün. Breite Liste (Server-Tests inkl. `ResponsesStreamTranslatorTest`, `LlamaArchitectureTest`) grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `011f5c3` ## Offene Verdachte ohne Fund-ID From ae72c299480cc840aa1514e4b93301ce69dd4771 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:13:23 +0000 Subject: [PATCH 220/589] Report Anthropic server failures as api_error Every Anthropic error body said invalid_request_error, including a 500 and the error event of a stream that failed during inference. Anthropic reserves that type for the client's fault and uses api_error for an unexpected server failure; in a stream, where the status is already 200, the type is the only thing that tells the two apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 12 ++++++ .../llama/server/OpenAiCompatServer.java | 19 ++++++--- .../server/OpenAiCompatServerHttpTest.java | 39 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7c841586f..9b7af5cd9 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -237,3 +237,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Ereignisfolge für Claude Code) · `bd6ac4e` · 2026-10-18 · kein Fund — `message_start`, dann Blöcke nacheinander mit steigendem Index (Denkblock mit `signature_delta` vor dem Schließen, Text, danach je Werkzeug `tool_use` + `input_json_delta` + Stopp), `message_delta` mit Usage und `stop_reason`, `message_stop`. Ein Denk-Delta nach begonnenem Text würde einen zweiten Block neben dem offenen Text öffnen; llama.cpps Diffs lassen `reasoning_content` aber vor `content` wachsen, praktisch unerreichbar. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`streamResponses`) · `65df73d` · 2026-10-18 · Fund F117 — Fehlerzweige des Responses-Streams gegen Codex (`responses.rs`, `responses_error.rs`) gehalten; jetzt `response.failed` vor dem `error`-Ereignis - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `6d97f3a` · 2026-10-18 · Fund F117 — Ereignisfolge gegen Codex gehalten: `type` + `sequence_number` in den Daten, `response.completed` mit `id` und Usage (`input_tokens`/`output_tokens`/`total_tokens`), `output_item.done`-Items parsebar; neu `failed()` +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Anthropic-Fehlerpfade) · `a6ed187` · 2026-10-18 · Fund F118 — Fehlerkörper aller vier Oberflächen verglichen: Ollama `{"error":…}` wie Ollama selbst, Responses seit F117 mit `response.failed`, Anthropic jetzt `api_error` für 500 diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c9ef8d1ea..b262655d1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -93,6 +93,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F115](#f115) — niedrig — *gefixt* - [F116](#f116) — niedrig — *gefixt* - [F117](#f117) — mittel — *gefixt* +- [F118](#f118) — niedrig — *gefixt* ## Funde @@ -1019,6 +1020,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `011f5c3` +### F118 – java-llama.cpp: Anthropic-Serverfehler heißen `invalid_request_error` +- **Zeit:** 2026-10-18 11:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:718` (`anthropicError`) +- **Schwere:** niedrig +- **Beschreibung:** `anthropicError` setzte den Fehlertyp immer auf `invalid_request_error`, auch für eine Antwort mit HTTP 500 und für das `error`-Ereignis eines gestreamten `/v1/messages`, das an einem Inferenzfehler scheiterte. Anthropics Fehlertypen trennen die Schuld: `invalid_request_error` ist der Fehler des Clients (400), `api_error` ein unerwarteter Serverfehler (500). Ein Client (Claude Code), der nach dem Typ entscheidet, ob ein Wiederholen lohnt, hielt einen Serverfehler für eine fehlerhafte eigene Anfrage – im Stream kommt der Status 200 ohnehin vor dem Fehler, dort trägt nur der Typ die Unterscheidung. +- **Fix:** `anthropicError(message, type)`; die Ablehnungszweige (`IllegalArgumentException`/`InvalidRequestException`) behalten `invalid_request_error`, die Fehlerzweige melden `api_error`. +- **Reproduktion:** `OpenAiCompatServerHttpTest#aFailedAnthropicMessageIsAnApiError`, `#aFailedStreamedAnthropicMessageIsAnApiError` (dazu als Schutz `#aRejectedStreamedAnthropicMessageStaysAnInvalidRequestError`) +- **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: a string containing "\"error\":{\"type\":\"api_error\"" but: was "{"type":"error","error":{"type":"invalid_request_error","message":"decoding failed"}}"“), mit Fix grün (54/54 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 65df73d38..a6ed18795 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -151,6 +151,11 @@ public final class OpenAiCompatServer implements AutoCloseable { private static final String ERROR_TYPE_REQUEST = "invalid_request_error"; private static final String ERROR_TYPE_SERVER = "server_error"; + /** Anthropic's error type for a request that is the client's fault (HTTP 400). */ + private static final String ANTHROPIC_ERROR_REQUEST = "invalid_request_error"; + /** Anthropic's error type for an unexpected server-side failure (HTTP 500). */ + private static final String ANTHROPIC_ERROR_SERVER = "api_error"; + private static final String HEALTH_BODY = "{\"status\":\"ok\"}"; private final OpenAiServerConfig config; @@ -647,11 +652,11 @@ private void handleAnthropicMessages(HttpExchange exchange) throws IOException { try { body = backend.complete(openAiRequest); } catch (IllegalArgumentException | InvalidRequestException e) { - sendJson(exchange, HTTP_BAD_REQUEST, anthropicError(message(e))); + sendJson(exchange, HTTP_BAD_REQUEST, anthropicError(message(e), ANTHROPIC_ERROR_REQUEST)); return; } catch (IOException | RuntimeException e) { LOG.warn("anthropic messages failed", e); - sendJson(exchange, HTTP_SERVER_ERROR, anthropicError(message(e))); + sendJson(exchange, HTTP_SERVER_ERROR, anthropicError(message(e), ANTHROPIC_ERROR_SERVER)); return; } sendJson(exchange, HTTP_OK, AnthropicApiSupport.toAnthropicResponse(body, model)); @@ -685,12 +690,14 @@ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, Stri }); out.writeStrict(translator.end()); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly(AnthropicApiSupport.sseEvent("error", anthropicError(message(e)))); + out.writeQuietly( + AnthropicApiSupport.sseEvent("error", anthropicError(message(e), ANTHROPIC_ERROR_REQUEST))); } catch (IOException e) { LOG.debug("anthropic client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("anthropic streaming failed", e); - out.writeQuietly(AnthropicApiSupport.sseEvent("error", anthropicError(message(e)))); + out.writeQuietly( + AnthropicApiSupport.sseEvent("error", anthropicError(message(e), ANTHROPIC_ERROR_SERVER))); } finally { if (heartbeat != null) { heartbeat.cancel(false); @@ -708,11 +715,11 @@ private static JsonNode withUsageChunk(JsonNode request) { return copy; } - private static String anthropicError(String message) { + private static String anthropicError(String message, String type) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("type", "error"); ObjectNode error = root.putObject("error"); - error.put("type", "invalid_request_error"); + error.put("type", type); error.put("message", message); return root.toString(); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index fa2c66459..c115875f9 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -515,6 +515,45 @@ public void aPlainLlamaExceptionStaysAServerError() throws IOException { } } + // Anthropic's error types: invalid_request_error is the client's fault (400), api_error an + // unexpected server error (500). A 500 that calls itself invalid_request_error contradicts its status. + + @Test + public void aFailedAnthropicMessageIsAnApiError() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new FailingBackend(), config()).start()) { + String body = "{\"model\":\"test-model\",\"max_tokens\":8," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + Response response = post(server.getPort(), "/v1/messages", body, ""); + assertThat(response.code, is(500)); + assertThat(response.body, containsString("\"error\":{\"type\":\"api_error\"")); + } + } + + @Test + public void aFailedStreamedAnthropicMessageIsAnApiError() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new StreamFailingBackend(), config()).start()) { + String body = "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + String events = post(server.getPort(), "/v1/messages", body, "").body; + assertThat(events, containsString("event: error")); + assertThat( + events.substring(events.indexOf("event: error")), + containsString("\"error\":{\"type\":\"api_error\"")); + } + } + + @Test + public void aRejectedStreamedAnthropicMessageStaysAnInvalidRequestError() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new RejectingBackend(), config()).start()) { + String body = "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + String events = post(server.getPort(), "/v1/messages", body, "").body; + assertThat( + events.substring(events.indexOf("event: error")), + containsString("\"error\":{\"type\":\"invalid_request_error\"")); + } + } + @Test public void getOnChatCompletionsReturns405() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { From 7c67e590a496d9eeabf1ce57e0ab837ae58669ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:13:23 +0000 Subject: [PATCH 221/589] Record the commit of F118 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b262655d1..466d6ae53 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1029,7 +1029,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#aFailedAnthropicMessageIsAnApiError`, `#aFailedStreamedAnthropicMessageIsAnApiError` (dazu als Schutz `#aRejectedStreamedAnthropicMessageStaysAnInvalidRequestError`) - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: a string containing "\"error\":{\"type\":\"api_error\"" but: was "{"type":"error","error":{"type":"invalid_request_error","message":"decoding failed"}}"“), mit Fix grün (54/54 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ccba21a` ## Offene Verdachte ohne Fund-ID From 8309d75f49c781a3e8aa0eb1956bbe9046c38389 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:13:56 +0000 Subject: [PATCH 222/589] Update the bug-hunt ledger Add the missing anchors the overview links point at. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 466d6ae53..f8bdeb1d1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -358,6 +358,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ed6236d` + ### F56 – java-llama.cpp: nicht darstellbarer Offline-Modellpfad ergibt `InvalidPathException` statt `ModelUnavailableException` - **Zeit:** 2026-10-17 22:10 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java:62` (`check`) @@ -764,6 +765,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `04d4cdb` + ### F92 – java-llama.cpp: blockierende LangChain4j-Antwort verliert den Denkinhalt - **Zeit:** 2026-10-18 05:10 UTC - **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:206` (`toAiMessage`) @@ -775,6 +777,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6caa722` + ### F93 – java-llama.cpp: gestreamte LangChain4j-Antwort hat nie eine Token-Nutzung - **Zeit:** 2026-10-18 04:45 UTC - **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:105` (`toStreamingParameters`) @@ -787,6 +790,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `cde7159` + ### F94 – java-llama.cpp: Textteile einer LangChain4j-Nutzernachricht werden ohne Trenner zusammengeklebt - **Zeit:** 2026-10-18 04:55 UTC - **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java:287` (`toJllamaUserMessage`) @@ -798,6 +802,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ee99d85` + ### F95 – java-llama.cpp: gestreamtes `/v1/completions` und der Chat-Pfad verwerfen llama.cpps Sampling-Felder - **Zeit:** 2026-10-18 05:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:200` (`applyLlamaCppSampling`, neu; `readMaxTokens`) @@ -809,6 +814,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `8ce9ffc` + ### F96 – java-llama.cpp: Anthropic-Anfrage ohne `max_tokens` läuft unbegrenzt, `chat_template_kwargs` geht verloren - **Zeit:** 2026-10-18 05:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:111` (`toOpenAiChatRequest`) @@ -820,6 +826,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d29e084` + ### F97 – java-llama.cpp: Ollama-`/api/chat` verliert den Denkinhalt in beide Richtungen - **Zeit:** 2026-10-18 06:00 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:267` (`toOllamaChatResponse`, `toOllamaContentLine`, `toOpenAiMessage`) @@ -831,6 +838,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `9eab6e0` + ### F98 – java-llama.cpp: gestreamtes Ollama-`/api/chat` meldet keine Tokenzahlen - **Zeit:** 2026-10-18 06:15 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:545` (`streamOllamaChat`), `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaDoneLine`, `usageOf`) @@ -842,6 +850,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0cd9f8a` + ### F99 – java-llama.cpp: blockierende Responses-Antwort trägt ein leeres `message`-Item neben Werkzeugaufrufen - **Zeit:** 2026-10-18 06:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:378` (`toResponsesResponse`) @@ -853,6 +862,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `9d0a021` + ### F102 – java-llama.cpp: Chat-Anfrage mit `id_slot: -1` wird mit 400 abgewiesen - **Zeit:** 2026-10-18 07:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:133` (`applyCommonFields`) @@ -864,6 +874,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ded2762` + ### F103 – java-llama.cpp: `max_tokens: 0` generiert unbegrenzt statt nur den Cache zu füllen - **Zeit:** 2026-10-18 07:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:164` (`applyCommonFields`) @@ -875,6 +886,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `bcbafbc` + ### F104 – java-llama.cpp: Ollama-Routen verwerfen `repeat_penalty`, `min_p` und weitere Optionen; `num_predict: -2` wird abgewiesen - **Zeit:** 2026-10-18 07:50 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:246` (`copyLlamaCppOptions`, `copyNumPredict`, neu; Aufrufe in `toOpenAiChatRequest`, `toOpenAiCompletionRequest`, `toInfillRequest`) @@ -886,6 +898,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0eba89d` + ### F105 – java-llama.cpp: Responses-Anfrage mit `previous_response_id` wird ohne Verlauf beantwortet - **Zeit:** 2026-10-18 08:05 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:56` (`toOpenAiChatRequest`) @@ -897,6 +910,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b9bfb45` + ### F106 – java-llama.cpp: Responses-Anfrage verliert `top_k`, `seed`, `stop` und llama.cpps Erweiterungen - **Zeit:** 2026-10-18 08:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:146` (`toOpenAiChatRequest`, `CHAT_PASSTHROUGH_FIELDS` neu) @@ -908,6 +922,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `87635b2` + ### F107 – java-llama.cpp: Anthropic-`tool_choice` `none` läuft als `auto` - **Zeit:** 2026-10-18 08:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java:348` (`toOpenAiToolChoice`) @@ -919,6 +934,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b85eed8` + ### F108 – java-llama.cpp: Responses-`text.format` (strukturierte Ausgabe) wird ignoriert - **Zeit:** 2026-10-18 08:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java:177` (`appendResponseFormat`, neu; Aufruf in `toOpenAiChatRequest`) @@ -930,6 +946,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `941357f` + ### F109 – java-llama.cpp: `tool_choice`, das eine Funktion benennt, läuft als `auto` - **Zeit:** 2026-10-18 09:00 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:74`, `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest`, Werkzeugblock) @@ -942,6 +959,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b7151fc` + ### F111 – java-llama.cpp: Rerank im TEI-Format (`texts`) wird mit 400 abgewiesen - **Zeit:** 2026-10-18 09:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java:113` (`readDocuments`, `toResponse`/`toTeiResponse` neu), `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` (`rerank`) @@ -953,6 +971,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `2831610` + ### F112 – java-llama.cpp: Unterpfade einer Route laufen durch deren Handler (`/v1/messages/count_tokens` erzeugt eine ganze Antwort) - **Zeit:** 2026-10-18 09:50 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:271` (`register`, `exactPath` neu) @@ -965,6 +984,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `609b53e` + ### F113 – java-llama.cpp: `/v1/models` lässt das Pflichtfeld `created` weg - **Zeit:** 2026-10-18 10:05 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java:142` (`modelsJson`) @@ -976,6 +996,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `9c165a8` + ### F114 – java-llama.cpp: `/api/version` meldet 0.1.0, VS Code Copilots Ollama-Anbieter lehnt den Server ab - **Zeit:** 2026-10-18 10:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:36` (`OLLAMA_VERSION`) @@ -987,6 +1008,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `fbffef0` + ### F115 – java-llama.cpp: `/api/show` verschweigt Continue die Kontextlänge - **Zeit:** 2026-10-18 10:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:89` (`showJson`) @@ -998,6 +1020,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4d45be1` + ### F116 – java-llama.cpp: Ollama-Werkzeugergebnisse verlieren den Werkzeugnamen - **Zeit:** 2026-10-18 10:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:208` (`toOpenAiMessage`) @@ -1009,6 +1032,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `a5e86bf` + ### F117 – java-llama.cpp: Gestreamte Responses-Fehler erreichen Responses-Clients nicht - **Zeit:** 2026-10-18 11:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:782` (`streamResponses`), `ResponsesStreamTranslator.java` @@ -1020,6 +1044,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `011f5c3` + ### F118 – java-llama.cpp: Anthropic-Serverfehler heißen `invalid_request_error` - **Zeit:** 2026-10-18 11:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java:718` (`anthropicError`) From 852e37698ed28a5154518acb7184183a39ae51cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:17:12 +0000 Subject: [PATCH 223/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 9b7af5cd9..3e02ca59e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -238,3 +238,6 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`streamResponses`) · `65df73d` · 2026-10-18 · Fund F117 — Fehlerzweige des Responses-Streams gegen Codex (`responses.rs`, `responses_error.rs`) gehalten; jetzt `response.failed` vor dem `error`-Ereignis - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `6d97f3a` · 2026-10-18 · Fund F117 — Ereignisfolge gegen Codex gehalten: `type` + `sequence_number` in den Daten, `response.completed` mit `id` und Usage (`input_tokens`/`output_tokens`/`total_tokens`), `output_item.done`-Items parsebar; neu `failed()` - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Anthropic-Fehlerpfade) · `a6ed187` · 2026-10-18 · Fund F118 — Fehlerkörper aller vier Oberflächen verglichen: Ollama `{"error":…}` wie Ollama selbst, Responses seit F117 mit `response.failed`, Anthropic jetzt `api_error` für 500 +- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`streamChatCompletion` ↔ `jllama_task_release`/`stream_next`) · `96eafab` · 2026-10-18 · kein Fund — Abbruch durch werfenden Sink oder nativen Fehler: `stream_next` gibt den Reader schon beim Fehler frei, `erase_reader` ist für eine unbekannte Id ein No-op, das `cancelCompletion` im `finally` verdeckt die ursprüngliche Ausnahme also nie +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream` ↔ Heartbeat) · `a6ed187` · 2026-10-18 · kein Fund — Ereignisse und `: ping` unter einem Lock je Ereignis geschrieben, ein Heartbeat nach `close()` wird verschluckt; NDJSON-Routen (Ollama) haben bewusst keinen SSE-Heartbeat +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — Fehler beim Erzeugen und aus Handler-Rückrufen landen in `onError`, `onCompleteResponse` nur nach sauberem Ende From 6a0e6f87d3056a529ac850e2a92b460fa51e1973 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:32:53 +0000 Subject: [PATCH 224/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3e02ca59e..dba14cf9f 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -241,3 +241,6 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`streamChatCompletion` ↔ `jllama_task_release`/`stream_next`) · `96eafab` · 2026-10-18 · kein Fund — Abbruch durch werfenden Sink oder nativen Fehler: `stream_next` gibt den Reader schon beim Fehler frei, `erase_reader` ist für eine unbekannte Id ein No-op, das `cancelCompletion` im `finally` verdeckt die ursprüngliche Ausnahme also nie - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream` ↔ Heartbeat) · `a6ed187` · 2026-10-18 · kein Fund — Ereignisse und `: ping` unter einem Lock je Ereignis geschrieben, ein Heartbeat nach `close()` wird verschluckt; NDJSON-Routen (Ollama) haben bewusst keinen SSE-Heartbeat - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — Fehler beim Erzeugen und aus Handler-Rückrufen landen in `onError`, `onCompleteResponse` nur nach sauberem Ende +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` (↔ `LlamaModel.complete(params, token)`) · `0ff46cf` · 2026-10-18 · kein Fund — Abbruch der Koroutine setzt das Token, `coroutineScope` wartet auf das Kind, `complete` gibt die Aufgabe an der nächsten Token-Grenze frei; vorab abgebrochenes Token gibt sofort frei; Javadoc „chat ohne Token-Überladung“ stimmt +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-18 · kein Fund — `use` schließt die Quelle bei Ende, Fehler und Abbruch (`take(n)`); Blockieren des Sammel-Dispatchers ist dokumentiert +- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`complete(params, token)` ↔ `stream_next`) · `96eafab` · 2026-10-18 · kein Fund — bei nativem Fehler gibt `stream_next` den Reader selbst frei, ein fehlendes `cancelCompletion` im Fehlerpfad leckt also nichts From 2690d4e9211e1abbd71ce42896e7468868655ac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:36:11 +0000 Subject: [PATCH 225/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ BUGHUNT-FOUND.md | 1 + 2 files changed, 3 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index dba14cf9f..9033c88d4 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -244,3 +244,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` (↔ `LlamaModel.complete(params, token)`) · `0ff46cf` · 2026-10-18 · kein Fund — Abbruch der Koroutine setzt das Token, `coroutineScope` wartet auf das Kind, `complete` gibt die Aufgabe an der nächsten Token-Grenze frei; vorab abgebrochenes Token gibt sofort frei; Javadoc „chat ohne Token-Überladung“ stimmt - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-18 · kein Fund — `use` schließt die Quelle bei Ende, Fehler und Abbruch (`take(n)`); Blockieren des Sammel-Dispatchers ist dokumentiert - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`complete(params, token)` ↔ `stream_next`) · `96eafab` · 2026-10-18 · kein Fund — bei nativem Fehler gibt `stream_next` den Reader selbst frei, ein fehlendes `cancelCompletion` im Fehlerpfad leckt also nichts +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters` ↔ upstream `oaicompat_completion_params_parse`) · `bfa8160` · 2026-10-18 · Verdacht notiert — Ablehnungen (`echo`, `best_of`, `suffix`) wie upstream; `applyCommonFields` für Chat und Completion gemeinsam; nur ein String-`prompt` erlaubt +- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` (`streamCompletions` ↔ upstream `to_json_oaicompat`) · `1e88958` · 2026-10-18 · Verdacht notiert — letzter Chunk ohne `usage`, sonst wie upstream (`finish_reason` `length`/`stop`, `logprobs: null`); Sink-Fehler bricht ab und schließt die Aufgabe über `LlamaIterable.close()` diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f8bdeb1d1..79727f6f6 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1074,3 +1074,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(erledigt durch F106, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. +- *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). From 2f7695aef11df431319c9258c72d4ce85a9eb44d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:38:59 +0000 Subject: [PATCH 226/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 9033c88d4..96be5fc18 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -246,3 +246,6 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`complete(params, token)` ↔ `stream_next`) · `96eafab` · 2026-10-18 · kein Fund — bei nativem Fehler gibt `stream_next` den Reader selbst frei, ein fehlendes `cancelCompletion` im Fehlerpfad leckt also nichts - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters` ↔ upstream `oaicompat_completion_params_parse`) · `bfa8160` · 2026-10-18 · Verdacht notiert — Ablehnungen (`echo`, `best_of`, `suffix`) wie upstream; `applyCommonFields` für Chat und Completion gemeinsam; nur ein String-`prompt` erlaubt - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` (`streamCompletions` ↔ upstream `to_json_oaicompat`) · `1e88958` · 2026-10-18 · Verdacht notiert — letzter Chunk ohne `usage`, sonst wie upstream (`finish_reason` `length`/`stop`, `logprobs: null`); Sink-Fehler bricht ab und schließt die Aufgabe über `LlamaIterable.close()` +- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (↔ upstream `post_router_models_load`/`_unload`/`get_router_models`, `server-models.cpp` b11639) · `5396c0a` · 2026-10-18 · kein Fund — Körper `{"model":…}` wie upstream; `load` setzt `LOADING` synchron vor der Antwort, ein früherer Fehlschlag (`UNLOADED` + `exit_code`) wird beim Warten also nicht fälschlich als Fehlschlag gelesen; `SLEEPING` gilt wie upstreams `is_ready` als geladen; Alias-Verdacht steht schon +- `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-18 · kein Fund — `status.value`/`failed`/`exit_code`, `architecture.*_modalities`, `context_length` wie upstream emittiert +- `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-18 · kein Fund — Statusnamen decken `server_model_status_to_string` vollständig ab, `unknown` → `UNKNOWN` From a95da6dbc19be1f7c18329332d9ea2eeb6e37f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:45:49 +0000 Subject: [PATCH 227/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 96be5fc18..0ef7d5ff0 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -40,7 +40,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `c1840dc` · 2026-10-17 · Fund F37 — F37 parseArgumentsToObject: nur Objekt/Array durchreichen, sonst {} - `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-17 · kein Fund — gelesen, keine Notiz - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) -- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. +- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-18 · Verdacht notiert — öffentliche Methoden der Reihe nach gelesen (die Zeile vom 2026-10-17 trug versehentlich die Notiz von `EmbeddingResponseParser`): Batch-Methoden laufen auf dem gemeinsamen `ForkJoinPool` und übergeben nicht „sofort“, `rerank(query, docs)` fasst gleiche Dokumente zusammen; `complete`/`completeWithStats`/`completeAsync`/`chat*`/`rerank(boolean, …)` sonst ohne Befund - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild - `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` - `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 79727f6f6..50014cf6a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1075,3 +1075,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(erledigt durch F106, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). +- *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `LlamaModel.java:194` (`completeBatch`, ebenso `completeBatchWithStats`, `chatBatch`): Die Javadoc sagt „Each request is sent immediately; the native scheduler dispatches tasks across whatever slot count `setParallel` was configured with“. Tatsächlich startet jede Anfrage als `CompletableFuture.supplyAsync` auf dem gemeinsamen `ForkJoinPool`, dessen Parallelität `Runtime.availableProcessors() - 1` ist, und der blockierende native Aufruf läuft ohne `ManagedBlocker`. Auf einem Rechner mit 4 Kernen und `setParallel(8)` laufen so höchstens 3 Anfragen gleichzeitig, die übrigen warten in der Pool-Warteschlange, bevor sie überhaupt übergeben werden; bei 2 Kernen (Parallelität 1) nimmt `CompletableFuture` statt des Pools einen Thread je Aufgabe, dort stimmt die Aussage. Ein Fix ohne Pool: alle Aufgaben auf dem Aufrufer-Thread mit `requestCompletion` übergeben, dann der Reihe nach `receiveCompletionJson`, bei einem Übergabefehler die bereits übergebenen mit `cancelCompletion` freigeben. Beweisen ließe sich das nur mit nativer Bibliothek und Modell (Zahl gleichzeitig belegter Slots über `getMetricsTyped`), daher kein Commit. Nebenbei: `rerank(String, String...)` legt die Ergebnisse in eine `HashMap` nach Dokumenttext, zwei gleiche Dokumente fallen dort zu einem Eintrag zusammen (die Überladung mit `reRank` liefert eine Liste und ist davon nicht betroffen). From 7f396a6ac3baba827aca8b9daeb9f81b7f1310c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:18:44 +0000 Subject: [PATCH 228/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 0ef7d5ff0..2b00ca5f5 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -249,3 +249,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (↔ upstream `post_router_models_load`/`_unload`/`get_router_models`, `server-models.cpp` b11639) · `5396c0a` · 2026-10-18 · kein Fund — Körper `{"model":…}` wie upstream; `load` setzt `LOADING` synchron vor der Antwort, ein früherer Fehlschlag (`UNLOADED` + `exit_code`) wird beim Warten also nicht fälschlich als Fehlschlag gelesen; `SLEEPING` gilt wie upstreams `is_ready` als geladen; Alias-Verdacht steht schon - `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-18 · kein Fund — `status.value`/`failed`/`exit_code`, `architecture.*_modalities`, `context_length` wie upstream emittiert - `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-18 · kein Fund — Statusnamen decken `server_model_status_to_string` vollständig ab, `unknown` → `UNKNOWN` +- `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` (Probe mit 21 Schreibweisen) · `a6b12c5` · 2026-10-18 · kein Fund — fehlender/leerer/0/65536/negativer/`+80`-Port, leerer Host, IPv6 mit und ohne Klammern und `host:80:90` werden mit klarer Meldung abgelehnt; `080` wird 80 (wie upstreams `std::stoi`), Leerraum um das Ganze getrimmt, nicht zwischen Doppelpunkt und Port; `parseList` überspringt leere Einträge und lehnt eine leere Liste ab. `GgufInspector` erneut gegen feindliche Längen gehalten: Obergrenzen für Schlüsselzahl, Stringlänge und Arraylänge, Strings werden nur aus vorhandenen Bytes gelesen (F72 deckt die Verschachtelung) From 578614ca5ab9c58f6cf415d76ee4348e91af1be7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:39:01 +0000 Subject: [PATCH 229/589] Map chat logprobs to n_probs A chat request with "logprobs": true got no token probabilities: the mapper read neither logprobs nor top_logprobs. Upstream's chat parser turns them into n_probs (top_logprobs, 20 when absent), after which the native results carry the probabilities. The chat mapper now does the same; the legacy completions "logprobs" is an integer and left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 15 ++++++++++++- .../llama/server/OpenAiRequestMapper.java | 9 ++++++++ .../llama/server/OpenAiRequestMapperTest.java | 22 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2b00ca5f5..2b51060a3 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -250,3 +250,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-18 · kein Fund — `status.value`/`failed`/`exit_code`, `architecture.*_modalities`, `context_length` wie upstream emittiert - `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-18 · kein Fund — Statusnamen decken `server_model_status_to_string` vollständig ab, `unknown` → `UNKNOWN` - `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` (Probe mit 21 Schreibweisen) · `a6b12c5` · 2026-10-18 · kein Fund — fehlender/leerer/0/65536/negativer/`+80`-Port, leerer Host, IPv6 mit und ohne Klammern und `host:80:90` werden mit klarer Meldung abgelehnt; `080` wird 80 (wie upstreams `std::stoi`), Leerraum um das Ganze getrimmt, nicht zwischen Doppelpunkt und Port; `parseList` überspringt leere Einträge und lehnt eine leere Liste ab. `GgufInspector` erneut gegen feindliche Längen gehalten: Obergrenzen für Schlüsselzahl, Stringlänge und Arraylänge, Strings werden nur aus vorhandenen Bytes gelesen (F72 deckt die Verschachtelung) +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Probe mit 14 Feldvarianten) · `20548dd` · 2026-10-18 · Fund F123 — `stop` als String und Array, `max_tokens` als Ganz- und Gleitzahl (als String verworfen), `max_completion_tokens` vorrangig, `seed`/`top_k` als Gleitzahl abgeschnitten, Strafen übernommen; `logprobs` fehlte (F123); `n` und `user` bleiben unbeachtet (`n` ist offener F95-Rest) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 50014cf6a..08ebde823 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -94,6 +94,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F116](#f116) — niedrig — *gefixt* - [F117](#f117) — mittel — *gefixt* - [F118](#f118) — niedrig — *gefixt* +- [F123](#f123) — niedrig — *gefixt* ## Funde @@ -1056,6 +1057,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ccba21a` + +### F123 – java-llama.cpp: `logprobs`/`top_logprobs` einer Chat-Anfrage gehen verloren +- **Zeit:** 2026-10-18 14:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:72` (`toInferenceParameters`) +- **Schwere:** niedrig +- **Beschreibung:** Eine OpenAI-Chat-Anfrage mit `"logprobs":true` (und optional `"top_logprobs":N`) bekam keine Token-Wahrscheinlichkeiten: Der Mapper übernahm keins der beiden Felder, `InferenceParameters` trug also kein `n_probs`. Upstream bildet sie in `oaicompat_chat_params_parse` (`server-common.cpp:1456`, b11639) auf `n_probs = top_logprobs` ab, Standard 20, und die nativen Chat-Ergebnisse tragen die Wahrscheinlichkeiten dann von selbst (blockierend und im OAI-Chunk-Pfad). Teil des offenen F95-Verdachts („`n_probs`/`logprobs`“), der als Blocker eine API-Erweiterung nannte – `withNProbs` gab es aber schon (Erkenntnis F95: Verdacht in Teile zerlegen). Gefunden per Probe-Klasse über den Mapper. +- **Fix:** Bei `logprobs: true` setzt der Chat-Mapper `withNProbs(top_logprobs)`, ohne `top_logprobs` 20 wie upstream. Nur im Chat-Pfad: Das `logprobs` des alten `/v1/completions` ist eine Zahl mit anderer Bedeutung. Nicht übernommen: upstreams zwei Ablehnungen (`top_logprobs` ohne `logprobs`; `logprobs` mit Werkzeugen im Stream). +- **Reproduktion:** `OpenAiRequestMapperTest#logprobsWithTopLogprobsBecomesNProbs`, `#logprobsWithoutTopLogprobsDefaultsToTwentyLikeUpstream` (dazu als Schutz `#logprobsFalseLeavesNProbsUnset`) +- **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: is <3> but: was <-1>“ bzw. „Expected: is <20> but: was <-1>“), mit Fix grün (31/31 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. Der erste Grün-Lauf scheiterte am Compiler (`NotJavadoc`: Konstante zwischen fremde Javadoc und Deklaration eingefügt, wie bei F108) und meldete trotzdem den alten roten Bericht – das Prüfskript löscht den Bericht jetzt auch vor dem Grün-Lauf. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -1069,7 +1082,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(erledigt durch F96, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(erledigt durch F97, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. -- *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n`. Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. +- *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(erledigt durch F106, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index bfa81607d..20548dd10 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -35,6 +35,9 @@ */ final class OpenAiRequestMapper { + /** Upstream's {@code n_probs} for a chat request with {@code logprobs} but no {@code top_logprobs}. */ + private static final int DEFAULT_TOP_LOGPROBS = 20; + /** Completion parameters OpenAI defines that upstream rejects ({@code oaicompat_completion_params_parse}). */ private static final List UNSUPPORTED_COMPLETION_PARAMS = Collections.unmodifiableList(Arrays.asList("best_of", "suffix")); @@ -64,6 +67,12 @@ InferenceParameters toInferenceParameters(JsonNode request) { params = applyCommonFields(params, request); params = applyReasoningControls(params, request); + // Chat logprobs, as upstream's oaicompat_chat_params_parse maps them (the legacy completions + // "logprobs" is an integer and means something else, so this stays on the chat path). + if (request.path("logprobs").asBoolean(false)) { + params = params.withNProbs(request.path("top_logprobs").asInt(DEFAULT_TOP_LOGPROBS)); + } + // Tools are chat-only. JsonNode tools = request.path("tools"); if (tools.isArray() && tools.size() > 0) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 90547a11f..d534a71f8 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -172,6 +172,28 @@ public void samplingFieldsMapped() throws IOException { assertThat(out.path("n_predict").asInt(), is(128)); } + // Upstream's oaicompat_chat_params_parse (server-common.cpp, b11639) turns logprobs=true into + // n_probs = top_logprobs, 20 when top_logprobs is absent; the native layer then reports them. + + @Test + public void logprobsWithTopLogprobsBecomesNProbs() throws IOException { + JsonNode out = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + "\"logprobs\":true,\"top_logprobs\":3}"); + assertThat(out.path("n_probs").asInt(-1), is(3)); + } + + @Test + public void logprobsWithoutTopLogprobsDefaultsToTwentyLikeUpstream() throws IOException { + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":true}"); + assertThat(out.path("n_probs").asInt(-1), is(20)); + } + + @Test + public void logprobsFalseLeavesNProbsUnset() throws IOException { + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":false}"); + assertThat(out.has("n_probs"), is(false)); + } + @Test public void maxCompletionTokensPreferredOverMaxTokens() throws IOException { JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," From b76dd8736e002f76be7338111dc1b12e6b3eb03c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:39:01 +0000 Subject: [PATCH 230/589] Record the commit of F123 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 08ebde823..c3d8b409c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1067,7 +1067,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#logprobsWithTopLogprobsBecomesNProbs`, `#logprobsWithoutTopLogprobsDefaultsToTwentyLikeUpstream` (dazu als Schutz `#logprobsFalseLeavesNProbsUnset`) - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: is <3> but: was <-1>“ bzw. „Expected: is <20> but: was <-1>“), mit Fix grün (31/31 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. Der erste Grün-Lauf scheiterte am Compiler (`NotJavadoc`: Konstante zwischen fremde Javadoc und Deklaration eingefügt, wie bei F108) und meldete trotzdem den alten roten Bericht – das Prüfskript löscht den Bericht jetzt auch vor dem Grün-Lauf. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `3e86360` ## Offene Verdachte ohne Fund-ID From fb64bc6c8acd1b85c2615a27550f90c5a313f383 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:41:52 +0000 Subject: [PATCH 231/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2b51060a3..6690e1544 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -251,3 +251,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-18 · kein Fund — Statusnamen decken `server_model_status_to_string` vollständig ab, `unknown` → `UNKNOWN` - `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` (Probe mit 21 Schreibweisen) · `a6b12c5` · 2026-10-18 · kein Fund — fehlender/leerer/0/65536/negativer/`+80`-Port, leerer Host, IPv6 mit und ohne Klammern und `host:80:90` werden mit klarer Meldung abgelehnt; `080` wird 80 (wie upstreams `std::stoi`), Leerraum um das Ganze getrimmt, nicht zwischen Doppelpunkt und Port; `parseList` überspringt leere Einträge und lehnt eine leere Liste ab. `GgufInspector` erneut gegen feindliche Längen gehalten: Obergrenzen für Schlüsselzahl, Stringlänge und Arraylänge, Strings werden nur aus vorhandenen Bytes gelesen (F72 deckt die Verschachtelung) - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Probe mit 14 Feldvarianten) · `20548dd` · 2026-10-18 · Fund F123 — `stop` als String und Array, `max_tokens` als Ganz- und Gleitzahl (als String verworfen), `max_completion_tokens` vorrangig, `seed`/`top_k` als Gleitzahl abgeschnitten, Strafen übernommen; `logprobs` fehlte (F123); `n` und `user` bleiben unbeachtet (`n` ist offener F95-Rest) +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 7 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — `format: "json"` → `json_object`, `format` als Schema → `json_schema`, `options.stop`/`seed`/`top_k` übernommen; `think: true` bewusst ohne Wirkung (Denken ist Standard), `false` und Stufen schon behandelt; `keep_alive` und `num_ctx` gelten pro Server, nicht pro Anfrage; `options.stop` als String ist in Ollamas API kein gültiger Wert +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — `stop_sequences`, `top_k`, `temperature: 0`, `thinking.budget_tokens` (→ `reasoning_budget_tokens`) und `max_tokens` (→ `n_predict`) kommen in den `InferenceParameters` an; `metadata` ohne Gegenstück, wie upstream From 4b6f612b1d7b4e6348470495940adcde158a9ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:43:53 +0000 Subject: [PATCH 232/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 6690e1544..e1f303aa8 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -253,3 +253,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Probe mit 14 Feldvarianten) · `20548dd` · 2026-10-18 · Fund F123 — `stop` als String und Array, `max_tokens` als Ganz- und Gleitzahl (als String verworfen), `max_completion_tokens` vorrangig, `seed`/`top_k` als Gleitzahl abgeschnitten, Strafen übernommen; `logprobs` fehlte (F123); `n` und `user` bleiben unbeachtet (`n` ist offener F95-Rest) - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 7 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — `format: "json"` → `json_object`, `format` als Schema → `json_schema`, `options.stop`/`seed`/`top_k` übernommen; `think: true` bewusst ohne Wirkung (Denken ist Standard), `false` und Stufen schon behandelt; `keep_alive` und `num_ctx` gelten pro Server, nicht pro Anfrage; `options.stop` als String ist in Ollamas API kein gültiger Wert - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — `stop_sequences`, `top_k`, `temperature: 0`, `thinking.budget_tokens` (→ `reasoning_budget_tokens`) und `max_tokens` (→ `n_predict`) kommen in den `InferenceParameters` an; `metadata` ohne Gegenstück, wie upstream +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 10 Varianten) · `12c6846` · 2026-10-18 · kein Fund — `instructions` → System-Nachricht, `max_output_tokens` → `n_predict`, Sampling, `reasoning.effort` → `chat_template_kwargs`, flache Funktionswerkzeuge → `function`-Form mit `description`, benanntes `tool_choice` → `required` (F109), `text.format` → `response_format` (F108), `input_text`/`input_image` → `text`/`image_url`; Nicht-Funktions-Werkzeuge werden wie upstream übersprungen; `strict` fällt weg, upstream setzt es, liest es in `common/chat.cpp` aber nirgends From b0b1469762c41050276e77215a1dbda80f48fdd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:45:38 +0000 Subject: [PATCH 233/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index e1f303aa8..80368db99 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -254,3 +254,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 7 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — `format: "json"` → `json_object`, `format` als Schema → `json_schema`, `options.stop`/`seed`/`top_k` übernommen; `think: true` bewusst ohne Wirkung (Denken ist Standard), `false` und Stufen schon behandelt; `keep_alive` und `num_ctx` gelten pro Server, nicht pro Anfrage; `options.stop` als String ist in Ollamas API kein gültiger Wert - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — `stop_sequences`, `top_k`, `temperature: 0`, `thinking.budget_tokens` (→ `reasoning_budget_tokens`) und `max_tokens` (→ `n_predict`) kommen in den `InferenceParameters` an; `metadata` ohne Gegenstück, wie upstream - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 10 Varianten) · `12c6846` · 2026-10-18 · kein Fund — `instructions` → System-Nachricht, `max_output_tokens` → `n_predict`, Sampling, `reasoning.effort` → `chat_template_kwargs`, flache Funktionswerkzeuge → `function`-Form mit `description`, benanntes `tool_choice` → `required` (F109), `text.format` → `response_format` (F108), `input_text`/`input_image` → `text`/`image_url`; Nicht-Funktions-Werkzeuge werden wie upstream übersprungen; `strict` fällt weg, upstream setzt es, liest es in `common/chat.cpp` aber nirgends +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Rückrichtung: OpenAI-Antwort → Responses/Anthropic/Ollama, Probe) · `12c6846`/`6ae6dd6`/`73f11e1` · 2026-10-18 · kein Fund (Anmerkung) — `finish_reason: length` wird Anthropic `max_tokens` und Ollama `done_reason: length`; unlesbare Werkzeug-Argumente werden Anthropic/Ollama zu `{}`, Responses reicht den Text wie geliefert durch (`arguments` ist dort ein String); `content: null` ergibt leeren Text. Responses meldet bei `length` `status: completed` statt OpenAIs `incomplete` + `incomplete_details.reason: max_output_tokens` – genau wie upstream (`server-task.cpp`, jeder Responses-Status ist `completed`), daher kein Fund From 338f5a2980df1354f3985a0d6ee1aec160415010 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:50:49 +0000 Subject: [PATCH 234/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 80368db99..3b3a4f390 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -255,3 +255,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — `stop_sequences`, `top_k`, `temperature: 0`, `thinking.budget_tokens` (→ `reasoning_budget_tokens`) und `max_tokens` (→ `n_predict`) kommen in den `InferenceParameters` an; `metadata` ohne Gegenstück, wie upstream - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 10 Varianten) · `12c6846` · 2026-10-18 · kein Fund — `instructions` → System-Nachricht, `max_output_tokens` → `n_predict`, Sampling, `reasoning.effort` → `chat_template_kwargs`, flache Funktionswerkzeuge → `function`-Form mit `description`, benanntes `tool_choice` → `required` (F109), `text.format` → `response_format` (F108), `input_text`/`input_image` → `text`/`image_url`; Nicht-Funktions-Werkzeuge werden wie upstream übersprungen; `strict` fällt weg, upstream setzt es, liest es in `common/chat.cpp` aber nirgends - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Rückrichtung: OpenAI-Antwort → Responses/Anthropic/Ollama, Probe) · `12c6846`/`6ae6dd6`/`73f11e1` · 2026-10-18 · kein Fund (Anmerkung) — `finish_reason: length` wird Anthropic `max_tokens` und Ollama `done_reason: length`; unlesbare Werkzeug-Argumente werden Anthropic/Ollama zu `{}`, Responses reicht den Text wie geliefert durch (`arguments` ist dort ein String); `content: null` ergibt leeren Text. Responses meldet bei `length` `status: completed` statt OpenAIs `incomplete` + `incomplete_details.reason: max_output_tokens` – genau wie upstream (`server-task.cpp`, jeder Responses-Status ist `completed`), daher kein Fund +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Zugangsprüfung je Route) · `a6ed187` · 2026-10-18 · kein Fund — Schlüssel in konstanter Zeit verglichen (`MessageDigest.isEqual`, `Bearer` und `x-api-key`); `/v1/models`, `/metrics`, `/slots` und alle POST-Routen über `authorized`; `/health`, `/props` und die Ollama-Entdeckungsrouten bewusst offen und kommentiert (nur öffentliche Modelldaten); Größenprüfung erst nach der Zugangsprüfung, ein Unbefugter bekommt also kein 413-Orakel +- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-18 · kein Fund — nach `index` geordnet (`TreeMap`), `id`/`name` ersetzt, `arguments` angehängt; passt zu upstreams Diff-Form (Name und Id einmal, danach nur Argumentstücke); ein Chunk ohne `index` zählt als 0 From 8077b3ac973d0da71cb75b551fcb7c3b75c2f531 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:52:42 +0000 Subject: [PATCH 235/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 1 + 2 files changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 3b3a4f390..630c4214a 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -257,3 +257,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Rückrichtung: OpenAI-Antwort → Responses/Anthropic/Ollama, Probe) · `12c6846`/`6ae6dd6`/`73f11e1` · 2026-10-18 · kein Fund (Anmerkung) — `finish_reason: length` wird Anthropic `max_tokens` und Ollama `done_reason: length`; unlesbare Werkzeug-Argumente werden Anthropic/Ollama zu `{}`, Responses reicht den Text wie geliefert durch (`arguments` ist dort ein String); `content: null` ergibt leeren Text. Responses meldet bei `length` `status: completed` statt OpenAIs `incomplete` + `incomplete_details.reason: max_output_tokens` – genau wie upstream (`server-task.cpp`, jeder Responses-Status ist `completed`), daher kein Fund - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Zugangsprüfung je Route) · `a6ed187` · 2026-10-18 · kein Fund — Schlüssel in konstanter Zeit verglichen (`MessageDigest.isEqual`, `Bearer` und `x-api-key`); `/v1/models`, `/metrics`, `/slots` und alle POST-Routen über `authorized`; `/health`, `/props` und die Ollama-Entdeckungsrouten bewusst offen und kommentiert (nur öffentliche Modelldaten); Größenprüfung erst nach der Zugangsprüfung, ein Unbefugter bekommt also kein 413-Orakel - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-18 · kein Fund — nach `index` geordnet (`TreeMap`), `id`/`name` ersetzt, `arguments` angehängt; passt zu upstreams Diff-Form (Name und Id einmal, danach nur Argumentstücke); ein Chunk ohne `index` zählt als 0 +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate` ↔ Continues `_getGenerateOptions`, `raw`/`system`) · `73f11e1` · 2026-10-18 · Verdacht notiert — ohne `raw` wendet Ollama die Vorlage an, wir nie; `system` unbeachtet diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c3d8b409c..cb6a62a68 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1089,3 +1089,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `LlamaModel.java:194` (`completeBatch`, ebenso `completeBatchWithStats`, `chatBatch`): Die Javadoc sagt „Each request is sent immediately; the native scheduler dispatches tasks across whatever slot count `setParallel` was configured with“. Tatsächlich startet jede Anfrage als `CompletableFuture.supplyAsync` auf dem gemeinsamen `ForkJoinPool`, dessen Parallelität `Runtime.availableProcessors() - 1` ist, und der blockierende native Aufruf läuft ohne `ManagedBlocker`. Auf einem Rechner mit 4 Kernen und `setParallel(8)` laufen so höchstens 3 Anfragen gleichzeitig, die übrigen warten in der Pool-Warteschlange, bevor sie überhaupt übergeben werden; bei 2 Kernen (Parallelität 1) nimmt `CompletableFuture` statt des Pools einen Thread je Aufgabe, dort stimmt die Aussage. Ein Fix ohne Pool: alle Aufgaben auf dem Aufrufer-Thread mit `requestCompletion` übergeben, dann der Reihe nach `receiveCompletionJson`, bei einem Übergabefehler die bereits übergebenen mit `cancelCompletion` freigeben. Beweisen ließe sich das nur mit nativer Bibliothek und Modell (Zahl gleichzeitig belegter Slots über `getMetricsTyped`), daher kein Commit. Nebenbei: `rerank(String, String...)` legt die Ergebnisse in eine `HashMap` nach Dokumenttext, zwei gleiche Dokumente fallen dort zu einem Eintrag zusammen (die Überladung mit `reRank` liefert eine Liste und ist davon nicht betroffen). +- *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* java-llama.cpp `server/OllamaApiSupport.java:481` (`toOpenAiCompletionRequest`): Ollamas `/api/generate` wendet ohne `"raw": true` die Chat-Vorlage des Modells an (und eine `system`-Angabe); erst `raw: true` schickt den Prompt unverändert. Unsere Route liest weder `raw` noch `system` und macht aus jedem `/api/generate` ohne `suffix` eine rohe Vervollständigung. Continue (`Ollama.ts` `_getGenerateOptions`) setzt `raw: options.raw`, also nur, wenn der Aufrufer es verlangt – für Nicht-Autocomplete-Aufrufe bekommt ein Instruktionsmodell dann einen Text ohne Rollenmarker und antwortet als Fortsetzung statt als Assistent; `system` geht ganz verloren. Ein Fix hieße, nicht-rohe Anfragen über den Chat-Pfad zu schicken (`messages` aus `system` + `prompt`) – eine Verhaltensänderung der Route, die README und Javadoc als „prompt completion / FIM“ beschreiben, daher Entscheidung des Nutzers. From 2395ab0ec5dcc18c0fcbfcbee0c963f18701dd82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:54:24 +0000 Subject: [PATCH 236/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 630c4214a..bc1266b81 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -258,3 +258,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Zugangsprüfung je Route) · `a6ed187` · 2026-10-18 · kein Fund — Schlüssel in konstanter Zeit verglichen (`MessageDigest.isEqual`, `Bearer` und `x-api-key`); `/v1/models`, `/metrics`, `/slots` und alle POST-Routen über `authorized`; `/health`, `/props` und die Ollama-Entdeckungsrouten bewusst offen und kommentiert (nur öffentliche Modelldaten); Größenprüfung erst nach der Zugangsprüfung, ein Unbefugter bekommt also kein 413-Orakel - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-18 · kein Fund — nach `index` geordnet (`TreeMap`), `id`/`name` ersetzt, `arguments` angehängt; passt zu upstreams Diff-Form (Name und Id einmal, danach nur Argumentstücke); ein Chunk ohne `index` zählt als 0 - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate` ↔ Continues `_getGenerateOptions`, `raw`/`system`) · `73f11e1` · 2026-10-18 · Verdacht notiert — ohne `raw` wendet Ollama die Vorlage an, wir nie; `system` unbeachtet +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images` einer Chat-Nachricht, Probe mit 5 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — rohes Base64 wird zu `image_url` mit `data:`-URL, Medientyp an den ersten Bytes erkannt (PNG/JPEG/GIF), leerer Text fällt weg, leere `images` lassen den String-Inhalt unverändert, System-Nachricht unberührt From 03dd98a277ba982085323b9762dbe7cf19815408 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:56:23 +0000 Subject: [PATCH 237/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index bc1266b81..fa68af702 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -259,3 +259,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-18 · kein Fund — nach `index` geordnet (`TreeMap`), `id`/`name` ersetzt, `arguments` angehängt; passt zu upstreams Diff-Form (Name und Id einmal, danach nur Argumentstücke); ein Chunk ohne `index` zählt als 0 - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate` ↔ Continues `_getGenerateOptions`, `raw`/`system`) · `73f11e1` · 2026-10-18 · Verdacht notiert — ohne `raw` wendet Ollama die Vorlage an, wir nie; `system` unbeachtet - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images` einer Chat-Nachricht, Probe mit 5 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — rohes Base64 wird zu `image_url` mit `data:`-URL, Medientyp an den ersten Bytes erkannt (PNG/JPEG/GIF), leerer Text fällt weg, leere `images` lassen den String-Inhalt unverändert, System-Nachricht unberührt +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images`, weitere 4 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — WEBP erkannt, eine schon fertige `data:`-URL bleibt unverändert, unbekannte Bytes fallen wie dokumentiert auf `image/png` zurück; eine HTTP-URL wird als Base64 behandelt, Ollamas API erlaubt in `images` aber nur Base64 From 7170c5140af8cf2bcea27d8a1510723d41bf7d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 09:58:38 +0000 Subject: [PATCH 238/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index fa68af702..9dd552ce0 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -260,3 +260,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate` ↔ Continues `_getGenerateOptions`, `raw`/`system`) · `73f11e1` · 2026-10-18 · Verdacht notiert — ohne `raw` wendet Ollama die Vorlage an, wir nie; `system` unbeachtet - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images` einer Chat-Nachricht, Probe mit 5 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — rohes Base64 wird zu `image_url` mit `data:`-URL, Medientyp an den ersten Bytes erkannt (PNG/JPEG/GIF), leerer Text fällt weg, leere `images` lassen den String-Inhalt unverändert, System-Nachricht unberührt - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images`, weitere 4 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — WEBP erkannt, eine schon fertige `data:`-URL bleibt unverändert, unbekannte Bytes fallen wie dokumentiert auf `image/png` zurück; eine HTTP-URL wird als Base64 behandelt, Ollamas API erlaubt in `images` aber nur Base64 +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Inhaltsblöcke, Probe mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — Bild als Base64 (`media_type` übernommen) und als URL, `tool_result` mit Text- und Bildteil → `role: tool` mit Teilen, `thinking` → `reasoning_content`; System-Blöcke werden ohne Trenner verbunden und `document` (PDF) fällt weg – beides genau wie upstream (`server_chat_convert_anthropic_to_oai`, `server-chat.cpp:348`, b11639: `system_content += system_text`, kein `document`-Zweig) From cb9f1ba425239336ffd26b0bfd2d605de10d743e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:00:24 +0000 Subject: [PATCH 239/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 9dd552ce0..f372ceaa6 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -261,3 +261,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images` einer Chat-Nachricht, Probe mit 5 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — rohes Base64 wird zu `image_url` mit `data:`-URL, Medientyp an den ersten Bytes erkannt (PNG/JPEG/GIF), leerer Text fällt weg, leere `images` lassen den String-Inhalt unverändert, System-Nachricht unberührt - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images`, weitere 4 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — WEBP erkannt, eine schon fertige `data:`-URL bleibt unverändert, unbekannte Bytes fallen wie dokumentiert auf `image/png` zurück; eine HTTP-URL wird als Base64 behandelt, Ollamas API erlaubt in `images` aber nur Base64 - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Inhaltsblöcke, Probe mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — Bild als Base64 (`media_type` übernommen) und als URL, `tool_result` mit Text- und Bildteil → `role: tool` mit Teilen, `thinking` → `reasoning_content`; System-Blöcke werden ohne Trenner verbunden und `document` (PDF) fällt weg – beides genau wie upstream (`server_chat_convert_anthropic_to_oai`, `server-chat.cpp:348`, b11639: `system_content += system_text`, kein `document`-Zweig) +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`input`-Items, Probe mit 4 Folgen) · `12c6846` · 2026-10-18 · kein Fund — aufeinanderfolgende `function_call`-Items werden zu einer Assistentennachricht mit mehreren `tool_calls` zusammengefasst, eine vorangehende `output_text`-Nachricht wird ihr Text; `function_call_output` als String und als Teilliste → `role: tool`; `developer` bleibt erhalten und wird von upstreams `common/chat.cpp:1336` (`map_developer_role_to_system`, außer GPT-OSS) zu `system` From 692351aa96855ad9dce28f5ba9e49dab81d36218 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:02:29 +0000 Subject: [PATCH 240/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f372ceaa6..fa6fbcf7c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -262,3 +262,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images`, weitere 4 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — WEBP erkannt, eine schon fertige `data:`-URL bleibt unverändert, unbekannte Bytes fallen wie dokumentiert auf `image/png` zurück; eine HTTP-URL wird als Base64 behandelt, Ollamas API erlaubt in `images` aber nur Base64 - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Inhaltsblöcke, Probe mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — Bild als Base64 (`media_type` übernommen) und als URL, `tool_result` mit Text- und Bildteil → `role: tool` mit Teilen, `thinking` → `reasoning_content`; System-Blöcke werden ohne Trenner verbunden und `document` (PDF) fällt weg – beides genau wie upstream (`server_chat_convert_anthropic_to_oai`, `server-chat.cpp:348`, b11639: `system_content += system_text`, kein `document`-Zweig) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`input`-Items, Probe mit 4 Folgen) · `12c6846` · 2026-10-18 · kein Fund — aufeinanderfolgende `function_call`-Items werden zu einer Assistentennachricht mit mehreren `tool_calls` zusammengefasst, eine vorangehende `output_text`-Nachricht wird ihr Text; `function_call_output` als String und als Teilliste → `role: tool`; `developer` bleibt erhalten und wird von upstreams `common/chat.cpp:1336` (`map_developer_role_to_system`, außer GPT-OSS) zu `system` +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk) · `bd6ac4e` · 2026-10-18 · kein Fund — Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) From 7e178913c109ba2d3d44542ee89ec4c1a32ffbd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:04:25 +0000 Subject: [PATCH 241/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index fa6fbcf7c..dfe97306e 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -263,3 +263,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Inhaltsblöcke, Probe mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — Bild als Base64 (`media_type` übernommen) und als URL, `tool_result` mit Text- und Bildteil → `role: tool` mit Teilen, `thinking` → `reasoning_content`; System-Blöcke werden ohne Trenner verbunden und `document` (PDF) fällt weg – beides genau wie upstream (`server_chat_convert_anthropic_to_oai`, `server-chat.cpp:348`, b11639: `system_content += system_text`, kein `document`-Zweig) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`input`-Items, Probe mit 4 Folgen) · `12c6846` · 2026-10-18 · kein Fund — aufeinanderfolgende `function_call`-Items werden zu einer Assistentennachricht mit mehreren `tool_calls` zusammengefasst, eine vorangehende `output_text`-Nachricht wird ihr Text; `function_call_output` als String und als Teilliste → `role: tool`; `developer` bleibt erhalten und wird von upstreams `common/chat.cpp:1336` (`map_developer_role_to_system`, außer GPT-OSS) zu `system` - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk) · `bd6ac4e` · 2026-10-18 · kein Fund — Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Probe: Text, Werkzeugaufruf, `tool_calls`, Usage) · `6d97f3a` · 2026-10-18 · kein Fund — Nachrichten-Item wird vor dem Werkzeugaufruf vollständig geschlossen (`output_text.done`, `content_part.done`, `output_item.done`), dann `function_call`-Item mit `call_id`, Name und vollständigen Argumenten (`added` → `function_call_arguments.done` → `done`), dann `response.completed` mit beiden Items; Folge­nummern lückenlos; Codex liest genau `output_item.done`, das kommt für beide Items From 0ccd23f37d735bc6e3b6f2a2cb22f3ee787dd84e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:06:26 +0000 Subject: [PATCH 242/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index dfe97306e..72b08fcc0 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -264,3 +264,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`input`-Items, Probe mit 4 Folgen) · `12c6846` · 2026-10-18 · kein Fund — aufeinanderfolgende `function_call`-Items werden zu einer Assistentennachricht mit mehreren `tool_calls` zusammengefasst, eine vorangehende `output_text`-Nachricht wird ihr Text; `function_call_output` als String und als Teilliste → `role: tool`; `developer` bleibt erhalten und wird von upstreams `common/chat.cpp:1336` (`map_developer_role_to_system`, außer GPT-OSS) zu `system` - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk) · `bd6ac4e` · 2026-10-18 · kein Fund — Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Probe: Text, Werkzeugaufruf, `tool_calls`, Usage) · `6d97f3a` · 2026-10-18 · kein Fund — Nachrichten-Item wird vor dem Werkzeugaufruf vollständig geschlossen (`output_text.done`, `content_part.done`, `output_item.done`), dann `function_call`-Item mit `call_id`, Name und vollständigen Argumenten (`added` → `function_call_arguments.done` → `done`), dann `response.completed` mit beiden Items; Folge­nummern lückenlos; Codex liest genau `output_item.done`, das kommt für beide Items +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe) · `73f11e1` · 2026-10-18 · kein Fund — Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) From c586e1d1d8c8550bbe6fd7e543dac2a6710fc516 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:08:36 +0000 Subject: [PATCH 243/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 72b08fcc0..2c06f290c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -265,3 +265,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk) · `bd6ac4e` · 2026-10-18 · kein Fund — Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Probe: Text, Werkzeugaufruf, `tool_calls`, Usage) · `6d97f3a` · 2026-10-18 · kein Fund — Nachrichten-Item wird vor dem Werkzeugaufruf vollständig geschlossen (`output_text.done`, `content_part.done`, `output_item.done`), dann `function_call`-Item mit `call_id`, Name und vollständigen Argumenten (`added` → `function_call_arguments.done` → `done`), dann `response.completed` mit beiden Items; Folge­nummern lückenlos; Codex liest genau `output_item.done`, das kommt für beide Items - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe) · `73f11e1` · 2026-10-18 · kein Fund — Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toFinishReason`) + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream) · `e054789`/`b224a28` · 2026-10-18 · kein Fund — `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` From d8c92fafcab6261d3f6e98f9f268a8b5d6b5d527 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:10:26 +0000 Subject: [PATCH 244/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 2c06f290c..0043170c1 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -266,3 +266,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Probe: Text, Werkzeugaufruf, `tool_calls`, Usage) · `6d97f3a` · 2026-10-18 · kein Fund — Nachrichten-Item wird vor dem Werkzeugaufruf vollständig geschlossen (`output_text.done`, `content_part.done`, `output_item.done`), dann `function_call`-Item mit `call_id`, Name und vollständigen Argumenten (`added` → `function_call_arguments.done` → `done`), dann `response.completed` mit beiden Items; Folge­nummern lückenlos; Codex liest genau `output_item.done`, das kommt für beide Items - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe) · `73f11e1` · 2026-10-18 · kein Fund — Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toFinishReason`) + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream) · `e054789`/`b224a28` · 2026-10-18 · kein Fund — `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaRequest`, `applySampling`, `applyResponseFormat`, `toToolChoiceString`) · `e054789` · 2026-10-18 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens` (→ `n_predict`)/`frequencyPenalty`/`presencePenalty`/`stopSequences` übernommen, leere Stoppliste ausgelassen; JSON mit Schema → `json_schema`, ohne → `json_object`, Text-Format unverändert; `ToolChoice` `REQUIRED`/`NONE`/`AUTO` als Zeichenkette; blockierender und gestreamter Pfad nutzen dieselbe Abbildung From 7ad52c838447575a0dfd3a6b7a8ca541fbb9bad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:12:33 +0000 Subject: [PATCH 245/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 0043170c1..f0ee59ea8 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -267,3 +267,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe) · `73f11e1` · 2026-10-18 · kein Fund — Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toFinishReason`) + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream) · `e054789`/`b224a28` · 2026-10-18 · kein Fund — `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaRequest`, `applySampling`, `applyResponseFormat`, `toToolChoiceString`) · `e054789` · 2026-10-18 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens` (→ `n_predict`)/`frequencyPenalty`/`presencePenalty`/`stopSequences` übernommen, leere Stoppliste ausgelassen; JSON mit Schema → `json_schema`, ohne → `json_object`, Text-Format unverändert; `ToolChoice` `REQUIRED`/`NONE`/`AUTO` als Zeichenkette; blockierender und gestreamter Pfad nutzen dieselbe Abbildung +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaMessage`, Nachrichtentypen) · `e054789` · 2026-10-18 · kein Fund (Anmerkung) — System, User (ein Text, mehrere Texte mit `\n` seit F94, gemischte Teile als Teilliste), Assistent mit/ohne Werkzeugaufrufen (fehlende Id → `""`, fehlende Argumente → `{}`), Werkzeugergebnis mit `tool_call_id`; der Werkzeugname des Ergebnisses fällt weg (`ChatMessage.toolResult` kennt kein `name`), die Id genügt aber zur Zuordnung – anders als bei F116, wo Ollama keine Id schickt; `AiMessage.thinking()` wird nicht zurückgeschickt, wie langchain4js eigener OpenAI-Adapter es ohne `sendThinking` hält From 3c064887a16f643f30eea00acadeca9c36fb8194 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:14:30 +0000 Subject: [PATCH 246/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index f0ee59ea8..c46fa73b3 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -268,3 +268,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toFinishReason`) + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream) · `e054789`/`b224a28` · 2026-10-18 · kein Fund — `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaRequest`, `applySampling`, `applyResponseFormat`, `toToolChoiceString`) · `e054789` · 2026-10-18 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens` (→ `n_predict`)/`frequencyPenalty`/`presencePenalty`/`stopSequences` übernommen, leere Stoppliste ausgelassen; JSON mit Schema → `json_schema`, ohne → `json_object`, Text-Format unverändert; `ToolChoice` `REQUIRED`/`NONE`/`AUTO` als Zeichenkette; blockierender und gestreamter Pfad nutzen dieselbe Abbildung - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaMessage`, Nachrichtentypen) · `e054789` · 2026-10-18 · kein Fund (Anmerkung) — System, User (ein Text, mehrere Texte mit `\n` seit F94, gemischte Teile als Teilliste), Assistent mit/ohne Werkzeugaufrufen (fehlende Id → `""`, fehlende Argumente → `{}`), Werkzeugergebnis mit `tool_call_id`; der Werkzeugname des Ergebnisses fällt weg (`ChatMessage.toolResult` kennt kein `name`), die Id genügt aber zur Zuordnung – anders als bei F116, wo Ollama keine Id schickt; `AiMessage.thinking()` wird nicht zurückgeschickt, wie langchain4js eigener OpenAI-Adapter es ohne `sendThinking` hält +- Schnittstelle `llama-langchain4j` → `value.ChatMessage` → `ParameterJsonSerializer.buildMessages` (Werkzeugergebnis- und Aufrufkette) · – · 2026-10-18 · kein Fund — `tool_call_id` nur bei vorhandener Id gesetzt, `tool_calls` mit `id`/`type`/`function.name`/`function.arguments`; die Pflichtfeldprüfung gegen upstreams `common_chat_msgs_parse_oaicompat` ist schon dokumentiert (Zeile zu `buildMessages`), nichts Neues From 720e69e2c91ed56d350c4c132ca43109e923b8b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:16:34 +0000 Subject: [PATCH 247/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index c46fa73b3..8a4d6e088 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -269,3 +269,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaRequest`, `applySampling`, `applyResponseFormat`, `toToolChoiceString`) · `e054789` · 2026-10-18 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens` (→ `n_predict`)/`frequencyPenalty`/`presencePenalty`/`stopSequences` übernommen, leere Stoppliste ausgelassen; JSON mit Schema → `json_schema`, ohne → `json_object`, Text-Format unverändert; `ToolChoice` `REQUIRED`/`NONE`/`AUTO` als Zeichenkette; blockierender und gestreamter Pfad nutzen dieselbe Abbildung - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaMessage`, Nachrichtentypen) · `e054789` · 2026-10-18 · kein Fund (Anmerkung) — System, User (ein Text, mehrere Texte mit `\n` seit F94, gemischte Teile als Teilliste), Assistent mit/ohne Werkzeugaufrufen (fehlende Id → `""`, fehlende Argumente → `{}`), Werkzeugergebnis mit `tool_call_id`; der Werkzeugname des Ergebnisses fällt weg (`ChatMessage.toolResult` kennt kein `name`), die Id genügt aber zur Zuordnung – anders als bei F116, wo Ollama keine Id schickt; `AiMessage.thinking()` wird nicht zurückgeschickt, wie langchain4js eigener OpenAI-Adapter es ohne `sendThinking` hält - Schnittstelle `llama-langchain4j` → `value.ChatMessage` → `ParameterJsonSerializer.buildMessages` (Werkzeugergebnis- und Aufrufkette) · – · 2026-10-18 · kein Fund — `tool_call_id` nur bei vorhandener Id gesetzt, `tool_calls` mit `id`/`type`/`function.name`/`function.arguments`; die Pflichtfeldprüfung gegen upstreams `common_chat_msgs_parse_oaicompat` ist schon dokumentiert (Zeile zu `buildMessages`), nichts Neues +- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Probe mit 6 Formen) · `31d8fb7` · 2026-10-18 · kein Fund — `content: null` mit `tool_calls` → leerer Text und ein Aufruf, `finish_reason` übernommen; leere/fehlende `choices`, `{}` und `""` ergeben eine leere Antwort ohne Ausnahme (F37 deckt `""`); fehlende `usage`/`timings` als Nullwerte; eine in ein Array gehüllte Antwort ergibt leer, die native Seite liefert für eine Chat-Antwort aber ein Objekt From d0b8138766176b0f6d848e1f7aa8cbe93fb43d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:18:39 +0000 Subject: [PATCH 248/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 8a4d6e088..71fe7f1a1 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -270,3 +270,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaMessage`, Nachrichtentypen) · `e054789` · 2026-10-18 · kein Fund (Anmerkung) — System, User (ein Text, mehrere Texte mit `\n` seit F94, gemischte Teile als Teilliste), Assistent mit/ohne Werkzeugaufrufen (fehlende Id → `""`, fehlende Argumente → `{}`), Werkzeugergebnis mit `tool_call_id`; der Werkzeugname des Ergebnisses fällt weg (`ChatMessage.toolResult` kennt kein `name`), die Id genügt aber zur Zuordnung – anders als bei F116, wo Ollama keine Id schickt; `AiMessage.thinking()` wird nicht zurückgeschickt, wie langchain4js eigener OpenAI-Adapter es ohne `sendThinking` hält - Schnittstelle `llama-langchain4j` → `value.ChatMessage` → `ParameterJsonSerializer.buildMessages` (Werkzeugergebnis- und Aufrufkette) · – · 2026-10-18 · kein Fund — `tool_call_id` nur bei vorhandener Id gesetzt, `tool_calls` mit `id`/`type`/`function.name`/`function.arguments`; die Pflichtfeldprüfung gegen upstreams `common_chat_msgs_parse_oaicompat` ist schon dokumentiert (Zeile zu `buildMessages`), nichts Neues - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Probe mit 6 Formen) · `31d8fb7` · 2026-10-18 · kein Fund — `content: null` mit `tool_calls` → leerer Text und ein Aufruf, `finish_reason` übernommen; leere/fehlende `choices`, `{}` und `""` ergeben eine leere Antwort ohne Ausnahme (F37 deckt `""`); fehlende `usage`/`timings` als Nullwerte; eine in ein Array gehüllte Antwort ergibt leer, die native Seite liefert für eine Chat-Antwort aber ein Objekt +- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` (Probe mit 7 Formen) · `707296e` · 2026-10-18 · kein Fund — `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) From 70f3f5028c3f70a4bb0f3a1baa9351f51b995c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:20:47 +0000 Subject: [PATCH 249/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 71fe7f1a1..d8efedbb9 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -271,3 +271,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - Schnittstelle `llama-langchain4j` → `value.ChatMessage` → `ParameterJsonSerializer.buildMessages` (Werkzeugergebnis- und Aufrufkette) · – · 2026-10-18 · kein Fund — `tool_call_id` nur bei vorhandener Id gesetzt, `tool_calls` mit `id`/`type`/`function.name`/`function.arguments`; die Pflichtfeldprüfung gegen upstreams `common_chat_msgs_parse_oaicompat` ist schon dokumentiert (Zeile zu `buildMessages`), nichts Neues - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Probe mit 6 Formen) · `31d8fb7` · 2026-10-18 · kein Fund — `content: null` mit `tool_calls` → leerer Text und ein Aufruf, `finish_reason` übernommen; leere/fehlende `choices`, `{}` und `""` ergeben eine leere Antwort ohne Ausnahme (F37 deckt `""`); fehlende `usage`/`timings` als Nullwerte; eine in ein Array gehüllte Antwort ergibt leer, die native Seite liefert für eine Chat-Antwort aber ein Objekt - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` (Probe mit 7 Formen) · `707296e` · 2026-10-18 · kein Fund — `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) +- `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` (Probe mit 9 Formen) · `6f2a458` · 2026-10-18 · kein Fund — liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab From 66417654638dc09a9dc38f4027fe51d243bc4303 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:22:27 +0000 Subject: [PATCH 250/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index d8efedbb9..70ba7fd8b 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -272,3 +272,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Probe mit 6 Formen) · `31d8fb7` · 2026-10-18 · kein Fund — `content: null` mit `tool_calls` → leerer Text und ein Aufruf, `finish_reason` übernommen; leere/fehlende `choices`, `{}` und `""` ergeben eine leere Antwort ohne Ausnahme (F37 deckt `""`); fehlende `usage`/`timings` als Nullwerte; eine in ein Array gehüllte Antwort ergibt leer, die native Seite liefert für eine Chat-Antwort aber ein Objekt - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` (Probe mit 7 Formen) · `707296e` · 2026-10-18 · kein Fund — `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) - `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` (Probe mit 9 Formen) · `6f2a458` · 2026-10-18 · kein Fund — liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab +- `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639) · `6a637ed` · 2026-10-18 · kein Fund — liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab From 72cf2eae5ee04645f28f2ed853e09353e6e89acb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:49:58 +0000 Subject: [PATCH 251/589] Reject an out-of-range port in OpenAiServerCli before the model loads -p 70000 or -p -1 passed parse() and only failed when the server bound its socket, after the model had loaded, as an uncaught IllegalArgumentException. The port is now checked against 0..65535 like any other usage error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 13 +++++++++++ .../llama/server/OpenAiServerCli.java | 20 +++++++++++++++- .../llama/server/OpenAiServerCliTest.java | 23 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 70ba7fd8b..ce43d9fca 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -54,7 +54,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` · `41c9e8b` · 2026-10-17 · kein Fund — java-llama.cpp `llama-ffm/…/FfmLlamaModel`: `handle()` prüft `closed` und ruft danach ohne Sperre nativ auf; ein paralleles `close()` kann den `jllama_context` dazwischen freigeben (`jllama_model_free` wartet nur auf Aufrufe, die die native Benutzerreferenz schon halten) → Use-after-free. Das ist… - `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` · `a6b12c5` · 2026-10-17 · kein Fund — java-llama.cpp `value/RpcEndpoint`: Parser (genau ein ':', Port 1..65535, max. 5 Ziffern vor `parseInt` → kein Überlauf), Host-Zeichensatz und Länge, `parseList` mit leeren Teilen korrekt; equals/hashCode über host+port. - `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` · `e9aa330` · 2026-10-17 · kein Fund — java-llama.cpp `value/ModelMeta`: alle Getter-Schlüssel gegen den Erzeuger in `jllama_engine.cpp` abgeglichen (`vocab_type`, `n_ctx_train`, `n_params`, `size`, `modalities.*`, `input/output_modalities`, `special_tokens.bos/eos/eot`, `metadata`, `chat_template`, …) – identisch; Default `-1` für fe… -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` · `fea260b` · 2026-10-17 · Fund F24 — java-llama.cpp: `OpenAiServerCli` bewirbt bei kleinem `-c` fast kein Eingabebudget (`-c 2048` → 1 Token) +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` · `c9677a3` · 2026-10-18 · Fund F24, F125 — F24 Eingabebudget bei kleinem `-c`; F125 Port-Bereich; Probe der Werte: Ganzzahlen getrimmt, Cache-Typen ohne Groß/Klein, `--chat-template-kwargs` lehnt Nicht-Objekte und leere Eingabe ab, `--rpc` über `RpcEndpoint.parseList`, Hilfe-Erkennung auch als Wert eines Flags (kosmetisch). - `llama/src/main/java/net/ladenthin/llama/args/ModelFlag.java` · `0807fe4` · 2026-10-17 · übersprungen — java-llama.cpp `args/ModelFlag`: reine Wire-Name-Registry, gegen den echten llama.cpp-Parser durch `test_model_flags.cpp` erzwungen – kein eigenständig prüfbarer Code. - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/Jllama.java` · `1c19474` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` · `6399310` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j/…/JsonSchemaElementSerializer`: alle 11 `JsonSchemaElement`-Typen abgedeckt, `$ref` → `#/$defs/` passend zu den unter `$defs` geschriebenen Definitionen, Enum als String mit `enum`-Liste wie bei langchain4j. Randnotiz (niedrig): `JsonRawSchema` wird mit `re… diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cb6a62a68..caa5fada4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -95,6 +95,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F117](#f117) — mittel — *gefixt* - [F118](#f118) — niedrig — *gefixt* - [F123](#f123) — niedrig — *gefixt* +- [F125](#f125) — niedrig — *gefixt* ## Funde @@ -1069,6 +1070,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3e86360` + +### F125 – java-llama.cpp: `OpenAiServerCli` nimmt einen Port außerhalb von 0..65535 an; der Fehler kommt erst nach dem Modell-Laden +- **Zeit:** 2026-10-18 15:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java:72` (`parse`, `--port`), Aufrufer `OpenAiCompatServer.main` +- **Schwere:** niedrig +- **Beschreibung:** `--port`/`-p` lief nur durch `Integer.parseInt`. `-p 70000` oder `-p -1` wurde angenommen; `OpenAiCompatServer.main` lud dann zuerst das Modell (bei großen GGUFs Minuten) und scheiterte erst beim Binden in `new InetSocketAddress` mit „port out of range“ als nicht gefangener `IllegalArgumentException` samt Stacktrace. Die Javadoc von `main` verspricht für Usage-Fehler eine Meldung auf stderr und Rückkehr; ein nicht numerischer Port wird schon so behandelt, ein numerischer außerhalb des TCP-Bereichs nicht. +- **Fix:** `portValue` prüft 0..65535 (0 bleibt erlaubt: freier Port) und wirft den üblichen Usage-Fehler. +- **Reproduktion:** `OpenAiServerCliTest#portOutsideTheTcpRangeThrows` (`-1`, `65536`, `70000`), Grenzen in `#portAtTheEndsOfTheTcpRangeIsAccepted` (0, 1, 65535) +- **Rot-Grün-Nachweis:** ohne Fix rot in allen drei Fällen („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (45/45 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. Breite Liste: `OpenAiCompatServerHttpTest` 54/54, `ServerLauncherTest` 7/7, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index 200384056..c9677a3fa 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -104,7 +104,7 @@ public static Options parse(String... args) { break; case "-p": case "--port": - port = intValue(args, ++i, arg); + port = portValue(args, ++i, arg); break; case "-c": case "--ctx-size": @@ -277,6 +277,24 @@ private static List rpcValue(String value, String flag) { } } + /** + * Reads a TCP port. {@code 0} stays allowed (the system picks a free port, which + * {@code getPort()} of the started server reports); anything outside {@code 0..65535} is a usage + * error here, not a failure of the bind that only follows the model load. + * + * @param args the command line + * @param valueIndex the index of the value + * @param flag the flag the value belongs to, for the message + * @return the port + */ + private static int portValue(String[] args, int valueIndex, String flag) { + final int port = intValue(args, valueIndex, flag); + if (port < 0 || port > 65535) { + throw error(flag + " expects a port in 0..65535, got: " + port); + } + return port; + } + private static int intValue(String[] args, int valueIndex, String flag) { final String raw = nextValue(args, valueIndex, flag); try { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index 6416cb08c..0bf9c715d 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -12,6 +12,8 @@ import net.ladenthin.llama.args.CacheType; import net.ladenthin.llama.value.RpcEndpoint; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Unit tests for {@link OpenAiServerCli}: parsing of long/short/alias flags, defaults, model-id @@ -229,6 +231,27 @@ public void nonIntegerPortThrows() { assertThat(ex.getMessage(), containsString("expects an integer")); } + /** + * A port outside the TCP range is a usage error like a non-numeric one. Accepted here, it only + * fails when the server binds, which happens after the model has loaded, and as a stack trace. + */ + @ParameterizedTest + @ValueSource(strings = {"-1", "65536", "70000"}) + public void portOutsideTheTcpRangeThrows(String port) { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> OpenAiServerCli.parse("-m", "m.gguf", "--port", port)); + assertThat(ex.getMessage(), containsString("--port expects a port in 0..65535")); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 65535}) + public void portAtTheEndsOfTheTcpRangeIsAccepted(int port) { + assertThat( + OpenAiServerCli.parse("-m", "m.gguf", "--port", Integer.toString(port)) + .getPort(), + is(port)); + } + @Test public void helpRequestedDetection() { assertThat(OpenAiServerCli.isHelpRequested("-h"), is(true)); From ac16d8468f23ae42530ca04b550ce959c3fdadb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 10:49:58 +0000 Subject: [PATCH 252/589] Record the commit of F125 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index caa5fada4..feb592730 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1080,7 +1080,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiServerCliTest#portOutsideTheTcpRangeThrows` (`-1`, `65536`, `70000`), Grenzen in `#portAtTheEndsOfTheTcpRangeIsAccepted` (0, 1, 65535) - **Rot-Grün-Nachweis:** ohne Fix rot in allen drei Fällen („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (45/45 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. Breite Liste: `OpenAiCompatServerHttpTest` 54/54, `ServerLauncherTest` 7/7, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ba26cdc` ## Offene Verdachte ohne Fund-ID From f7f3f0f6bf5e7356094f60d837e7b06168c15971 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:13:06 +0000 Subject: [PATCH 253/589] Do not report tool-call deltas langchain4j cannot represent llama.cpp's first delta of a streamed tool call usually carries the id and the name only, and its incremental parser may report a call before its name. langchain4j's PartialToolCall refuses empty arguments and a blank name, so the assembler threw from the stream callback and the whole streamed tool call ended in onError. Such deltas now only feed the assembled call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 10 ++--- BUGHUNT-FOUND.md | 14 +++++++ .../langchain4j/StreamingChunkAssembler.java | 6 +++ .../StreamingChunkAssemblerTest.java | 42 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ce43d9fca..08497845b 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -24,12 +24,12 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` · `35d2521` · 2026-10-17 · Fund F09 — java-llama.cpp: Loader löscht fremde `llama*`/`ggml*`-Einträge im Temp-Verzeichnis, Verzeichnisse rekursiv - `llama/src/main/java/net/ladenthin/llama/value/CompletionResult.java` · `934a56d` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` · `707296e` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` · `b224a28` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` · `dd4d519` · 2026-10-18 · Fund F128 — Werkzeugaufruf-Fragmente gegen `PartialToolCall`-Vorbedingungen (langchain4j 1.22.0, `javap`) und upstreams Diff-Writer gehalten; `readTree("")` liefert `MissingNode`, alle Pfade bleiben leer. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — erneut gründlich: `doChat` reicht jede Exception aus dem Stream an `onError`, `complete()` erst danach; Fehler im Assembler → F128. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` · `62e0ac1` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` · `37473c2` · 2026-10-17 · kein Fund — gelesen, keine Notiz -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java` · `9a4b965` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` · `37473c2` · 2026-10-18 · kein Fund — erneut gründlich: Scores per `index` der nativen Antwort in Eingabereihenfolge (`parseRerankScores`, fehlende Indizes 0.0, Rückfall auf Array-Reihenfolge), doppelte Dokumente bleiben getrennt; eine leere Liste postet keine Aufgabe (`jllama_engine.cpp` `rerank`). +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java` · `9a4b965` · 2026-10-18 · kein Fund — erneut gründlich: ein `embed` je Segment in Eingabereihenfolge; leere Segmente lässt langchain4j nicht zu, `dimension()` per Standard über ein Probe-Embedding. - `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` · `b69653a` · 2026-10-17 · kein Fund — java-llama.cpp `ToolCallingAgent`: Rundenzählung = max. Anzahl Chat-Aufrufe, deckt sich mit Javadoc („loops until … the round cap is reached“); Handler-Exceptions/unbekannte Tools werden als `{"error":…}` zurückgegeben. - `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` · `eda2bb6` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… - `llama/src/main/java/net/ladenthin/llama/value/GgufMetadata.java` · `6261a64` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… @@ -240,7 +240,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Anthropic-Fehlerpfade) · `a6ed187` · 2026-10-18 · Fund F118 — Fehlerkörper aller vier Oberflächen verglichen: Ollama `{"error":…}` wie Ollama selbst, Responses seit F117 mit `response.failed`, Anthropic jetzt `api_error` für 500 - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`streamChatCompletion` ↔ `jllama_task_release`/`stream_next`) · `96eafab` · 2026-10-18 · kein Fund — Abbruch durch werfenden Sink oder nativen Fehler: `stream_next` gibt den Reader schon beim Fehler frei, `erase_reader` ist für eine unbekannte Id ein No-op, das `cancelCompletion` im `finally` verdeckt die ursprüngliche Ausnahme also nie - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream` ↔ Heartbeat) · `a6ed187` · 2026-10-18 · kein Fund — Ereignisse und `: ping` unter einem Lock je Ereignis geschrieben, ein Heartbeat nach `close()` wird verschluckt; NDJSON-Routen (Ollama) haben bewusst keinen SSE-Heartbeat -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — Fehler beim Erzeugen und aus Handler-Rückrufen landen in `onError`, `onCompleteResponse` nur nach sauberem Ende +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — erneut gründlich: `doChat` reicht jede Exception aus dem Stream an `onError`, `complete()` erst danach; Fehler im Assembler → F128. - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` (↔ `LlamaModel.complete(params, token)`) · `0ff46cf` · 2026-10-18 · kein Fund — Abbruch der Koroutine setzt das Token, `coroutineScope` wartet auf das Kind, `complete` gibt die Aufgabe an der nächsten Token-Grenze frei; vorab abgebrochenes Token gibt sofort frei; Javadoc „chat ohne Token-Überladung“ stimmt - `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-18 · kein Fund — `use` schließt die Quelle bei Ende, Fehler und Abbruch (`take(n)`); Blockieren des Sammel-Dispatchers ist dokumentiert - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`complete(params, token)` ↔ `stream_next`) · `96eafab` · 2026-10-18 · kein Fund — bei nativem Fehler gibt `stream_next` den Reader selbst frei, ein fehlendes `cancelCompletion` im Fehlerpfad leckt also nichts diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index feb592730..3848add2c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -96,6 +96,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F118](#f118) — niedrig — *gefixt* - [F123](#f123) — niedrig — *gefixt* - [F125](#f125) — niedrig — *gefixt* +- [F128](#f128) — mittel — *gefixt* ## Funde @@ -1082,6 +1083,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ba26cdc` + +### F128 – java-llama.cpp: Gestreamte Werkzeugaufrufe in `llama-langchain4j` brechen am ersten Fragment ohne Argumente oder ohne Namen ab +- **Zeit:** 2026-10-18 17:00 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java:131` (`acceptToolCallFragment`), Aufrufer `JllamaStreamingChatModel.doChat` +- **Schwere:** mittel +- **Beschreibung:** Jedes `tool_calls`-Fragment wurde als `PartialToolCall` an den Handler gegeben. langchain4j 1.22.0 baut ihn mit `ensureNotBlank(name)` und `ensureNotEmpty(partialArguments)` (mit `javap -c` am Jar nachgesehen). Upstreams `server_chat_msg_diff_to_json_oaicompat` lässt `arguments` weg, solange der Diff keine hat, und `common_chat_msg_diff::compute_diffs` meldet einen neuen Aufruf mit dem bis dahin geparsten Stand – das erste Fragment trägt typischerweise nur `id` und `name`, und laut Kommentar dort darf der Name anfangs leer sein. Dann warf `PartialToolCall.builder().build()` eine `IllegalArgumentException` aus dem Stream-Callback, `doChat` fing sie und rief `onError`: der ganze gestreamte Werkzeugaufruf schlug fehl. Die vorhandenen Tests schickten nur Fragmente, die schon Argumente trugen. +- **Fix:** Ein Fragment mit leeren Argumenten oder solange der Name fehlt fließt weiter in den zusammengesetzten Aufruf, wird aber nicht als `PartialToolCall` gemeldet (wie langchain4js eigene OpenAI-Anbindung, die nur nicht leere Argumentstücke meldet). +- **Reproduktion:** `StreamingChunkAssemblerTest#toolCallDeltaWithoutArguments_doesNotFailTheStream`, `#toolCallDeltaBeforeItsName_doesNotFailTheStream` +- **Rot-Grün-Nachweis:** ohne Fix beide rot („Unexpected exception thrown: java.lang.IllegalArgumentException: partialArguments cannot be null or empty“ bzw. „… name cannot be null or blank“), mit Fix grün (2/2, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Ganzes Modul grün (`StreamingChunkAssemblerTest` 9/9, `LangChain4jMappingTest` 23/23, `JsonSchemaElementSerializerTest` 8/8, `TestModelPathsTest` 8/8; die Integrationstests überspringen sich ohne Modell wie zuvor). Das Modul hat weder Spotless noch SpotBugs. +- **CI-Bedarf:** `JllamaToolCallingIntegrationTest#streamedRequiredToolCallComesBackAsToolExecutionRequest` (Job `test-java-llama-langchain4j-integration`, kein Release-Gate) im nächsten Lauf ansehen: ob er vor dem Fix rot war, hängt davon ab, ob das CI-Modell den Namen allein in einem Fragment sendet. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java index b224a28f2..dd4d519cb 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java @@ -129,6 +129,12 @@ private void acceptToolCallFragment(JsonNode fragment) { String argumentsFragment = function.path("arguments").isTextual() ? function.path("arguments").asText() : ""; tool.arguments.append(argumentsFragment); + // PartialToolCall refuses empty arguments and a blank name, and llama.cpp sends both: the + // first delta of a call usually carries id and name only, and its incremental parser may + // report a call before its name. Such a delta still feeds the assembled call. + if (argumentsFragment.isEmpty() || tool.name.isEmpty()) { + return; + } handler.onPartialToolCall(PartialToolCall.builder() .index(index) .id(tool.id) diff --git a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java index 171393316..c0c006071 100644 --- a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java +++ b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java @@ -9,6 +9,7 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import dev.langchain4j.model.chat.response.ChatResponse; @@ -118,6 +119,47 @@ void assemblesStreamedToolCallAcrossFragments() { assertThat(response.tokenUsage().outputTokenCount(), is(5)); } + /** + * llama.cpp's first delta of a tool call usually carries the id and the name only: its diff + * writer leaves {@code arguments} out while they are still empty. langchain4j's + * {@link PartialToolCall} refuses empty partial arguments, so that delta must not become one. + */ + @Test + void toolCallDeltaWithoutArguments_doesNotFailTheStream() { + RecordingHandler handler = new RecordingHandler(); + StreamingChunkAssembler assembler = new StreamingChunkAssembler(handler); + + assertDoesNotThrow(() -> assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}}]}," + + "\"finish_reason\":null}]}")); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"function\":{\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}"); + ChatResponse response = assembler.complete(); + + assertThat(response.aiMessage().toolExecutionRequests().get(0).name(), is("get_weather")); + assertThat(response.aiMessage().toolExecutionRequests().get(0).arguments(), is("{}")); + } + + /** + * llama.cpp's incremental parser may discover a tool call before its name ({@code compute_diffs} + * allows the name to go from empty to non-empty); {@link PartialToolCall} refuses a blank name. + */ + @Test + void toolCallDeltaBeforeItsName_doesNotFailTheStream() { + RecordingHandler handler = new RecordingHandler(); + StreamingChunkAssembler assembler = new StreamingChunkAssembler(handler); + + assertDoesNotThrow(() -> assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"function\":{\"arguments\":\"{\\\"ci\"}}]},\"finish_reason\":null}]}")); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"ty\\\":1}\"}}]}," + + "\"finish_reason\":\"tool_calls\"}]}"); + ChatResponse response = assembler.complete(); + + assertThat(response.aiMessage().toolExecutionRequests().get(0).name(), is("get_weather")); + assertThat(response.aiMessage().toolExecutionRequests().get(0).arguments(), is("{\"city\":1}")); + } + @Test void assemblesParallelToolCallsByIndex() { RecordingHandler handler = new RecordingHandler(); From 566e0dc68a707cf4d62cb7184d41eb99b0d908a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:13:06 +0000 Subject: [PATCH 254/589] Record the commit of F128 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3848add2c..ee35c00c9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1094,7 +1094,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix beide rot („Unexpected exception thrown: java.lang.IllegalArgumentException: partialArguments cannot be null or empty“ bzw. „… name cannot be null or blank“), mit Fix grün (2/2, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Ganzes Modul grün (`StreamingChunkAssemblerTest` 9/9, `LangChain4jMappingTest` 23/23, `JsonSchemaElementSerializerTest` 8/8, `TestModelPathsTest` 8/8; die Integrationstests überspringen sich ohne Modell wie zuvor). Das Modul hat weder Spotless noch SpotBugs. - **CI-Bedarf:** `JllamaToolCallingIntegrationTest#streamedRequiredToolCallComesBackAsToolExecutionRequest` (Job `test-java-llama-langchain4j-integration`, kein Release-Gate) im nächsten Lauf ansehen: ob er vor dem Fix rot war, hängt davon ab, ob das CI-Modell den Namen allein in einem Fragment sendet. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `4d599fa` ## Offene Verdachte ohne Fund-ID From ebf08038d2ec6d4c6fadc67bc457e726447aa9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:15:54 +0000 Subject: [PATCH 255/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 08497845b..757e21149 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -273,3 +273,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` (Probe mit 7 Formen) · `707296e` · 2026-10-18 · kein Fund — `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) - `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` (Probe mit 9 Formen) · `6f2a458` · 2026-10-18 · kein Fund — liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639) · `6a637ed` · 2026-10-18 · kein Fund — liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/*.java` ↔ langchain4j-core 1.22.0 (Musterdurchsicht nach F128: Vorbedingungen der Fremdklassen, `javap -c`) · `4d599fa` · 2026-10-18 · kein Fund — `AiMessage(String)` `ensureNotNull` (`ChatResponse.getFirstContent()` liefert nie null, `Optional.map` macht aus einem null-Inhalt ""), `AiMessage(List)` `ensureNotEmpty` (nicht benutzt, der Builder prüft nichts), `PartialThinking` `ensureNotEmpty` (vorher auf leer geprüft), `CompleteToolCall`/`PartialToolCall` `ensureNotNegative(index)` (Index aus upstreams `tool_call_index`, nie negativ), `ChatResponse` `ensureNotNull(aiMessage)` (immer gesetzt), `Embedding(float[])` `ensureNotNull` (natives `embed` liefert ein Array), `ToolExecutionRequest`/`TokenUsage` ohne Prüfungen. `llama-kotlin` baut keine Fremdobjekte mit Vorbedingungen. From fc5acd67a9d5df6e78ed39624779eb6100807465 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:17:48 +0000 Subject: [PATCH 256/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 757e21149..27b42b847 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -274,3 +274,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` (Probe mit 9 Formen) · `6f2a458` · 2026-10-18 · kein Fund — liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639) · `6a637ed` · 2026-10-18 · kein Fund — liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/*.java` ↔ langchain4j-core 1.22.0 (Musterdurchsicht nach F128: Vorbedingungen der Fremdklassen, `javap -c`) · `4d599fa` · 2026-10-18 · kein Fund — `AiMessage(String)` `ensureNotNull` (`ChatResponse.getFirstContent()` liefert nie null, `Optional.map` macht aus einem null-Inhalt ""), `AiMessage(List)` `ensureNotEmpty` (nicht benutzt, der Builder prüft nichts), `PartialThinking` `ensureNotEmpty` (vorher auf leer geprüft), `CompleteToolCall`/`PartialToolCall` `ensureNotNegative(index)` (Index aus upstreams `tool_call_index`, nie negativ), `ChatResponse` `ensureNotNull(aiMessage)` (immer gesetzt), `Embedding(float[])` `ensureNotNull` (natives `embed` liefert ein Array), `ToolExecutionRequest`/`TokenUsage` ohne Prüfungen. `llama-kotlin` baut keine Fremdobjekte mit Vorbedingungen. +- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` ↔ `AnthropicStreamTranslator` / `ResponsesStreamTranslator` / `OllamaApiSupport` (Musterdurchsicht nach F128: Fragmente ohne Argumente oder vor dem Namen) · `58a9bab` · 2026-10-18 · kein Fund — der Akkumulator übernimmt `id`/`name` und hängt Argumente an, ohne etwas weiterzumelden; alle drei Übersetzer geben `tool_use`- bzw. `function_call`-Blöcke und Ollama-`tool_calls` erst am Ende aus dem fertigen Stand aus (`asText("")` für einen nie gelieferten Namen), es gibt keinen Teilaufruf-Event mit Vorbedingungen. Die frühere Prüfzeile zu `StreamingChunkAssembler` (17.10., „gleiche Semantik“) hatte die langchain4j-Vorbedingungen nicht betrachtet – siehe F128. From b4630d5c6032cf7b40e21d589e279e0f49f4292c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:13:01 +0000 Subject: [PATCH 257/589] Remove an earlier pooling type when UNSPECIFIED is set setPoolingType(UNSPECIFIED) only skipped setting --pooling, so a type set before stayed and reached the native load, against its Javadoc. It now removes the option, as setRopeScaling does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 4 +++- BUGHUNT-FOUND.md | 13 +++++++++++++ .../ladenthin/llama/parameters/ModelParameters.java | 11 +++++++---- .../llama/parameters/ModelParametersTest.java | 8 ++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 27b42b847..c436662dd 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -38,7 +38,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` · `6f2a458` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. - `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` · `e167fa7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ContentPart`: Validierung ok. Randnotiz: Lombok-`@ToString` gibt die komplette Base64-Nutzlast (`imageUrl`/`audioData`, schnell mehrere MB) aus – teuer, falls ein `ChatMessage`/`ChatRequest` geloggt wird. - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `c1840dc` · 2026-10-17 · Fund F37 — F37 parseArgumentsToObject: nur Objekt/Array durchreichen, sonst {} -- `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-18 · kein Fund — sechs Werte gegen `include/llama.h` (-1..4) und die CLI-Strings von `--pooling`; `UNSPECIFIED` als Wächter ohne CLI-String, beim einzigen Verbraucher `setPoolingType` fand sich F133. - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) - `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-18 · Verdacht notiert — öffentliche Methoden der Reihe nach gelesen (die Zeile vom 2026-10-17 trug versehentlich die Notiz von `EmbeddingResponseParser`): Batch-Methoden laufen auf dem gemeinsamen `ForkJoinPool` und übergeben nicht „sofort“, `rerank(query, docs)` fasst gleiche Dokumente zusammen; `complete`/`completeWithStats`/`completeAsync`/`chat*`/`rerank(boolean, …)` sonst ohne Befund - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild @@ -275,3 +275,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639) · `6a637ed` · 2026-10-18 · kein Fund — liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/*.java` ↔ langchain4j-core 1.22.0 (Musterdurchsicht nach F128: Vorbedingungen der Fremdklassen, `javap -c`) · `4d599fa` · 2026-10-18 · kein Fund — `AiMessage(String)` `ensureNotNull` (`ChatResponse.getFirstContent()` liefert nie null, `Optional.map` macht aus einem null-Inhalt ""), `AiMessage(List)` `ensureNotEmpty` (nicht benutzt, der Builder prüft nichts), `PartialThinking` `ensureNotEmpty` (vorher auf leer geprüft), `CompleteToolCall`/`PartialToolCall` `ensureNotNegative(index)` (Index aus upstreams `tool_call_index`, nie negativ), `ChatResponse` `ensureNotNull(aiMessage)` (immer gesetzt), `Embedding(float[])` `ensureNotNull` (natives `embed` liefert ein Array), `ToolExecutionRequest`/`TokenUsage` ohne Prüfungen. `llama-kotlin` baut keine Fremdobjekte mit Vorbedingungen. - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` ↔ `AnthropicStreamTranslator` / `ResponsesStreamTranslator` / `OllamaApiSupport` (Musterdurchsicht nach F128: Fragmente ohne Argumente oder vor dem Namen) · `58a9bab` · 2026-10-18 · kein Fund — der Akkumulator übernimmt `id`/`name` und hängt Argumente an, ohne etwas weiterzumelden; alle drei Übersetzer geben `tool_use`- bzw. `function_call`-Blöcke und Ollama-`tool_calls` erst am Ende aus dem fertigen Stand aus (`asText("")` für einen nie gelieferten Namen), es gibt keinen Teilaufruf-Event mit Vorbedingungen. Die frühere Prüfzeile zu `StreamingChunkAssembler` (17.10., „gleiche Semantik“) hatte die langchain4j-Vorbedingungen nicht betrachtet – siehe F128. + +- `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ee35c00c9..d888b776d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -97,6 +97,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F123](#f123) — niedrig — *gefixt* - [F125](#f125) — niedrig — *gefixt* - [F128](#f128) — mittel — *gefixt* +- [F133](#f133) — niedrig — *gefixt* ## Funde @@ -1096,6 +1097,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4d599fa` + +### F133 – java-llama.cpp: `setPoolingType(UNSPECIFIED)` lässt ein vorher gesetztes `--pooling` stehen +- **Zeit:** 2026-10-18 20:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java:652` (`setPoolingType`) +- **Schwere:** niedrig +- **Beschreibung:** Die Javadoc sagt, mit `UNSPECIFIED` werde der Parameter nicht weitergegeben und das Modell nutze seine eigene Pooling-Strategie. Der Code überspringt bei `UNSPECIFIED` nur das Setzen; ein vorher gesetzter Typ bleibt in der Map, `setPoolingType(MEAN).setPoolingType(UNSPECIFIED)` lädt das Modell also mit `--pooling mean`. `setRopeScaling(UNSPECIFIED)` entfernt die Option seit F13 – dessen Eintrag nennt `setPoolingType` als Vorbild, das es nicht war. +- **Fix:** `UNSPECIFIED` ruft `removeOption(ModelOption.POOLING)`, wie `setRopeScaling`; Javadoc um den Satz zum Entfernen ergänzt. +- **Reproduktion:** `ModelParametersTest#testSetPoolingTypeUnspecifiedRemovesAnEarlierPoolingType` +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected: not map containing ["--pooling"->ANYTHING] but: was <{--fit=on, --pooling=mean}>“, die übrigen 92 Tests der Klasse grün), mit Fix 93/93 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `LlamaArchitectureTest` 13/13, `WireNameRegistryTest` 8/8, `ModelParametersExtendedTest` 148/148; `spotless:check` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index 90d097c56..a84e7da73 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -644,16 +644,19 @@ public ModelParameters setJsonSchema(String schema) { /** * Set pooling type for embeddings (default: model default if unspecified). * When {@link net.ladenthin.llama.args.PoolingType#UNSPECIFIED} is passed, the parameter is not forwarded - * to the native layer, leaving the model to use its built-in default pooling strategy. + * to the native layer (a pooling type set before is removed), leaving the model to use its built-in default + * pooling strategy. * * @param type the pooling type for embeddings * @return this builder */ public ModelParameters setPoolingType(PoolingType type) { - if (type != PoolingType.UNSPECIFIED) { - // Don't set if unspecified, as it will use the model's default pooling type - putString(ModelOption.POOLING, type.getArgValue()); + if (type == PoolingType.UNSPECIFIED) { + // unspecified has no CLI string: the model's default pooling type applies + removeOption(ModelOption.POOLING); + return this; } + putString(ModelOption.POOLING, type.getArgValue()); return this; } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java index 2b080a6f0..0a8619c37 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java @@ -319,6 +319,14 @@ public void testSetPoolingTypeUnspecifiedLeavesDefaultUntouched() { assertThat(fresh.parameters, not(hasKey(ModelOption.POOLING.getCliOption()))); } + @Test + public void testSetPoolingTypeUnspecifiedRemovesAnEarlierPoolingType() { + // UNSPECIFIED must leave the model's own pooling, also after another type was set before + ModelParameters p = + new ModelParameters().setPoolingType(PoolingType.MEAN).setPoolingType(PoolingType.UNSPECIFIED); + assertThat(p.parameters, not(hasKey(ModelOption.POOLING.getCliOption()))); + } + @Test public void testSetRopeScaling() { ModelParameters p = new ModelParameters().setRopeScaling(RopeScalingType.YARN2); From 71dbddc8cd4a4792d8d5134a44f070f570b75847 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:13:01 +0000 Subject: [PATCH 258/589] Record the commit of F133 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d888b776d..086d2acae 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1107,7 +1107,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ModelParametersTest#testSetPoolingTypeUnspecifiedRemovesAnEarlierPoolingType` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: not map containing ["--pooling"->ANYTHING] but: was <{--fit=on, --pooling=mean}>“, die übrigen 92 Tests der Klasse grün), mit Fix 93/93 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `LlamaArchitectureTest` 13/13, `WireNameRegistryTest` 8/8, `ModelParametersExtendedTest` 148/148; `spotless:check` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `7231e71` ## Offene Verdachte ohne Fund-ID From 5bba2459114a7d01a09e179a7e355f00e5eff924 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:23:16 +0000 Subject: [PATCH 259/589] Clear an earlier value when a nullable request wither gets null withPrompt, withInputPrefix, withInputSuffix, withGrammar and withToolChoice document null as clearing, but withOptionalJson returned the unchanged instance, so a value set before stayed in the request. A null now removes an existing entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 3 +- BUGHUNT-FOUND.md | 13 +++++++ .../llama/parameters/JsonParameters.java | 15 +++++--- .../parameters/InferenceParametersTest.java | 34 +++++++++++++++++++ .../llama/parameters/JsonParametersTest.java | 10 ++++++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index c436662dd..31235e4f5 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -15,7 +15,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche ## Geprüft -- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingField.java` · `8a305f9` · 2026-10-17 · kein Fund — gelesen, keine Notiz +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingField.java` · `8a305f9` · 2026-10-18 · kein Fund — 15 Schlüssel gegen `TrainingParameters.toJson` (jeder genau einmal geschrieben) und gegen `jllama_train::config_keys()` (durch `test_wire_contracts.cpp` gehalten); `validate()` prüft Korpus, Epochen und `valSplit`. - `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-17 · kein Fund — `json/RouterModelsResponseParser`, `value/RouterModel`: Feldnamen gegen die Testfixtures des Routers (b11538) konsistent, Null/fehlende Felder abgefangen. - `llama/src/main/java/net/ladenthin/llama/value/ChatChoice.java` · `ad1c75f` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). - `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` · `31d8fb7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). @@ -277,3 +277,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` ↔ `AnthropicStreamTranslator` / `ResponsesStreamTranslator` / `OllamaApiSupport` (Musterdurchsicht nach F128: Fragmente ohne Argumente oder vor dem Namen) · `58a9bab` · 2026-10-18 · kein Fund — der Akkumulator übernimmt `id`/`name` und hängt Argumente an, ohne etwas weiterzumelden; alle drei Übersetzer geben `tool_use`- bzw. `function_call`-Blöcke und Ollama-`tool_calls` erst am Ende aus dem fertigen Stand aus (`asText("")` für einen nie gelieferten Namen), es gibt keinen Teilaufruf-Event mit Vorbedingungen. Die frühere Prüfzeile zu `StreamingChunkAssembler` (17.10., „gleiche Semantik“) hatte die langchain4j-Vorbedingungen nicht betrachtet – siehe F128. - `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. +- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung) · `0ebc857`/`386d73f` · 2026-10-18 · Fund F134 — die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 086d2acae..96a6cc2fc 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -98,6 +98,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F125](#f125) — niedrig — *gefixt* - [F128](#f128) — mittel — *gefixt* - [F133](#f133) — niedrig — *gefixt* +- [F134](#f134) — mittel — *gefixt* ## Funde @@ -1109,6 +1110,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `7231e71` + +### F134 – java-llama.cpp: fünf `InferenceParameters`-Wither „`null` clears“ behalten den alten Wert +- **Zeit:** 2026-10-18 21:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java:245` (`withOptionalJson`), Aufrufer `InferenceParameters.withPrompt`/`withInputPrefix`/`withInputSuffix`/`withGrammar`/`withToolChoice` +- **Schwere:** mittel +- **Beschreibung:** Die fünf Wither dokumentieren `null` als „clears“ (bei `withPrompt`: „clears any prior prompt“). Alle gehen über `withOptionalJson`, das bei `null` nur `this` zurückgibt. Ein früher gesetzter Wert bleibt also im Request: `withGrammar(g).withGrammar(null)` schickt die Grammatik weiter mit, die Generierung bleibt eingeschränkt, ebenso `tool_choice` oder ein Infill-Präfix. Der vorhandene Test `testToJsonStringNull` setzt `null` nur auf ein frisches Objekt und sieht das nicht. +- **Fix:** `withOptionalJson(field, null)` entfernt einen vorhandenen Eintrag (neue Instanz, Original unverändert) und gibt nur dann `this` zurück, wenn der Eintrag fehlt; Javadoc angepasst. +- **Reproduktion:** `InferenceParametersTest#test{Prompt,InputPrefix,InputSuffix,Grammar,ToolChoice}NullClearsAnEarlierValue`, `JsonParametersTest#withOptionalJson_nullRemovesAnEarlierValue` +- **Rot-Grün-Nachweis:** ohne Fix rot – alle fünf Wither-Tests (z. B. „Expected: not map containing ["grammar"->ANYTHING] but: was <{grammar="x"}>“) und der Basistest („expected: not same but was: “); mit Fix `InferenceParametersTest` 111/111 und `JsonParametersTest` 26/26 (skipped 0), Gegenprobe per `git stash` wieder rot. PIT auf `JsonParameters`: 18/18 getötet. `LlamaArchitectureTest` 13/13, `WireNameRegistryTest` 8/8, `OpenAiRequestMapperTest` 31/31; `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java index 1c4c9f7d7..0ebc8578d 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java @@ -233,18 +233,25 @@ protected final T withScalar(RequestField field, Obje /** * Conditionally store a JSON-encoded string under {@code key}: when {@code text} - * is {@code null} the call is a no-op (returns {@code this}); otherwise the value - * is JSON-encoded and a new instance is returned. + * is {@code null} an earlier value is removed (and {@code this} returned when there is + * none); otherwise the value is JSON-encoded and a new instance is returned. * * @param field the field to set * @param text the optional input; {@code null} means "leave the parameter unset" * @param the concrete subtype of this parameter set - * @return {@code this} if {@code text} is null, otherwise a new instance with the entry set + * @return {@code this} if {@code text} is null and the field is unset, otherwise a new + * instance with the entry set or removed */ @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) protected final T withOptionalJson(RequestField field, @Nullable String text) { if (text == null) { - return (T) this; + // the withers built on this document "null clears": an earlier value must not survive + if (!parameters.containsKey(field.getKey())) { + return (T) this; + } + Map next = new HashMap<>(parameters); + next.remove(field.getKey()); + return withParameters(Collections.unmodifiableMap(next)); } return withPut(field, serializer.toJsonString(text)); } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java index eb3919483..f478b2a71 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java @@ -745,6 +745,40 @@ public void testToJsonStringEscapesCarriageReturn() { assertThat(params.parameters.get("prompt"), is("\"a\\rb\"")); } + @Test + public void testPromptNullClearsAnEarlierValue() { + InferenceParameters params = InferenceParameters.empty().withPrompt("x").withPrompt(null); + assertThat(params.parameters, not(hasKey("prompt"))); + } + + @Test + public void testInputPrefixNullClearsAnEarlierValue() { + InferenceParameters params = + InferenceParameters.empty().withInputPrefix("x").withInputPrefix(null); + assertThat(params.parameters, not(hasKey("input_prefix"))); + } + + @Test + public void testInputSuffixNullClearsAnEarlierValue() { + InferenceParameters params = + InferenceParameters.empty().withInputSuffix("x").withInputSuffix(null); + assertThat(params.parameters, not(hasKey("input_suffix"))); + } + + @Test + public void testGrammarNullClearsAnEarlierValue() { + InferenceParameters params = + InferenceParameters.empty().withGrammar("x").withGrammar(null); + assertThat(params.parameters, not(hasKey("grammar"))); + } + + @Test + public void testToolChoiceNullClearsAnEarlierValue() { + InferenceParameters params = + InferenceParameters.empty().withToolChoice("x").withToolChoice(null); + assertThat(params.parameters, not(hasKey("tool_choice"))); + } + @Test public void testToJsonStringNull() { // toJsonString(null) returns null — only used internally but verify via grammar diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java index daf400a6e..2c0e3d771 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/JsonParametersTest.java @@ -5,6 +5,7 @@ package net.ladenthin.llama.parameters; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -136,6 +137,15 @@ public void withOptionalJson_nullIsNoOpReturnsSameInstance() { assertSame(original, derived, "null input must short-circuit to this"); } + @Test + public void withOptionalJson_nullRemovesAnEarlierValue() { + TestBuilder original = new TestBuilder().withOptionalJsonPublic(RequestField.GRAMMAR, "abc"); + TestBuilder derived = original.withOptionalJsonPublic(RequestField.GRAMMAR, null); + assertNotSame(original, derived); + assertFalse(derived.parameters.containsKey("grammar"), "null must remove the earlier value"); + assertEquals("\"abc\"", original.parameters.get("grammar"), "the original must stay unchanged"); + } + @Test public void withOptionalJson_nonNullEncodesAndAllocates() { TestBuilder original = new TestBuilder(); From b0c091da16ff3710382709e0e585781e86f8cde1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:23:16 +0000 Subject: [PATCH 260/589] Record the commit of F134 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 96a6cc2fc..933d6e99d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1120,7 +1120,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `InferenceParametersTest#test{Prompt,InputPrefix,InputSuffix,Grammar,ToolChoice}NullClearsAnEarlierValue`, `JsonParametersTest#withOptionalJson_nullRemovesAnEarlierValue` - **Rot-Grün-Nachweis:** ohne Fix rot – alle fünf Wither-Tests (z. B. „Expected: not map containing ["grammar"->ANYTHING] but: was <{grammar="x"}>“) und der Basistest („expected: not same but was: “); mit Fix `InferenceParametersTest` 111/111 und `JsonParametersTest` 26/26 (skipped 0), Gegenprobe per `git stash` wieder rot. PIT auf `JsonParameters`: 18/18 getötet. `LlamaArchitectureTest` 13/13, `WireNameRegistryTest` 8/8, `OpenAiRequestMapperTest` 31/31; `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `f35e7f2` ## Offene Verdachte ohne Fund-ID From a6e4f1e0c735d7f259278e80b952328ea2dd0eda Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:36:32 +0000 Subject: [PATCH 261/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 31235e4f5..4448f7faa 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -278,3 +278,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. - `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung) · `0ebc857`/`386d73f` · 2026-10-18 · Fund F134 — die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. +- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (unsicher, 17.) bleibt unerreichbar, da `server_devices()` nie leer liefert. From 7f79298d261fe8527875322bd65f8ebbb8e5b25d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:55:15 +0000 Subject: [PATCH 262/589] Reject a negative fork target slot before touching the model Session.fork checked only that the target differed from its own slot, so a negative id saved the slot to the scratch file and failed natively in restoreSlot with a LlamaException. It is now rejected up front with the constructor's IllegalArgumentException. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../src/main/java/net/ladenthin/llama/Session.java | 7 ++++++- .../test/java/net/ladenthin/llama/SessionTest.java | 8 ++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 4448f7faa..18a0ce823 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -279,3 +279,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. - `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung) · `0ebc857`/`386d73f` · 2026-10-18 · Fund F134 — die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. - `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (unsicher, 17.) bleibt unerreichbar, da `server_devices()` nie leer liefert. +- `llama/src/main/java/net/ladenthin/llama/Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`) · `61d2505` · 2026-10-18 · Fund F137 — negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 933d6e99d..193b16282 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -99,6 +99,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F128](#f128) — mittel — *gefixt* - [F133](#f133) — niedrig — *gefixt* - [F134](#f134) — mittel — *gefixt* +- [F137](#f137) — niedrig — *gefixt* ## Funde @@ -1122,6 +1123,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f35e7f2` + +### F137 – java-llama.cpp: `Session.fork` prüft eine negative Ziel-Slot-ID erst nach Sichern und Wiederherstellen +- **Zeit:** 2026-10-18 22:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/Session.java:250` (`fork`) +- **Schwere:** niedrig +- **Beschreibung:** `fork(newSlotId, filepath)` prüft vor den Modellaufrufen nur `newSlotId == slotId`. Eine negative ID erreicht `model.saveSlot(slotId, filepath)` (schreibt die Datei) und `model.restoreSlot(newSlotId, filepath)`, das im nativen Teil an der ungültigen ID scheitert – der Aufrufer bekommt eine `LlamaException` statt der `IllegalArgumentException`, die der Konstruktor für genau diesen Wert wirft, und eine geschriebene Scratch-Datei. Die Konstruktorprüfung („Validate here, not per request“) kommt erst danach zum Zug. Die Javadoc nannte als `IllegalArgumentException`-Fall nur die gleiche ID. +- **Fix:** `fork` weist eine negative ID vor jedem Modellaufruf mit derselben Meldung wie der Konstruktor ab; Javadoc ergänzt. +- **Reproduktion:** `SessionTest#forkToANegativeSlotIdRejectedBeforeTheModelIsTouched` (mit `null`-Modell: jeder Modellaufruf zeigt sich als `NullPointerException`) +- **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception type thrown, expected: but was: “), mit Fix 3/3 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `SessionStateTest` 13/13, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. Den nativen Pfad (Datei geschrieben, `LlamaException` aus `restoreSlot`) habe ich nicht laufen lassen: keine native Bibliothek und kein Modell in dieser Umgebung. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/Session.java b/llama/src/main/java/net/ladenthin/llama/Session.java index 7979c5c0a..61d2505e5 100644 --- a/llama/src/main/java/net/ladenthin/llama/Session.java +++ b/llama/src/main/java/net/ladenthin/llama/Session.java @@ -244,10 +244,15 @@ public void rewind(SessionCheckpoint checkpoint) { * @param newSlotId the slot the forked session is bound to (must differ from this session's) * @param filepath scratch file used to transfer the slot KV state * @return the forked session, positioned at this session's current state - * @throws IllegalArgumentException if {@code newSlotId} equals this session's slot id + * @throws IllegalArgumentException if {@code newSlotId} is negative or equals this session's slot id * @throws IllegalStateException if a stream is in progress */ public Session fork(int newSlotId, String filepath) { + // Before any model call: the constructor below would reject it too, but only after the slot + // was saved to filepath and restored into the invalid id. + if (newSlotId < 0) { + throw new IllegalArgumentException("slotId must be non-negative, was " + newSlotId); + } if (newSlotId == slotId) { throw new IllegalArgumentException( "fork target slot must differ from the session's own slot " + slotId + ", was " + newSlotId); diff --git a/llama/src/test/java/net/ladenthin/llama/SessionTest.java b/llama/src/test/java/net/ladenthin/llama/SessionTest.java index a8f8281c4..717319705 100644 --- a/llama/src/test/java/net/ladenthin/llama/SessionTest.java +++ b/llama/src/test/java/net/ladenthin/llama/SessionTest.java @@ -28,4 +28,12 @@ public void negativeSlotIdRejectedAtConstruction() { public void negativeSlotIdRejectedOnCustomizerConstructor() { assertThrows(IllegalArgumentException.class, () -> new Session(null, -7, null, p -> p)); } + + @Test + public void forkToANegativeSlotIdRejectedBeforeTheModelIsTouched() { + // A null model: any model call (saving the slot first) would surface as a NullPointerException. + Session session = new Session(null, 0, null); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> session.fork(-1, "fork.bin")); + assertThat(ex.getMessage(), containsString("slotId must be non-negative")); + } } From 98eaec4d2ead322a34650d5e30bf76996da6884a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:55:15 +0000 Subject: [PATCH 263/589] Record the commit of F137 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 193b16282..ce4e80a01 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1133,7 +1133,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `SessionTest#forkToANegativeSlotIdRejectedBeforeTheModelIsTouched` (mit `null`-Modell: jeder Modellaufruf zeigt sich als `NullPointerException`) - **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception type thrown, expected: but was: “), mit Fix 3/3 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `SessionStateTest` 13/13, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. Den nativen Pfad (Datei geschrieben, `LlamaException` aus `restoreSlot`) habe ich nicht laufen lassen: keine native Bibliothek und kein Modell in dieser Umgebung. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `965b540` ## Offene Verdachte ohne Fund-ID From 806571d9bdfbe72d1e0baceeb2ea6cec0deed67f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:03:38 +0000 Subject: [PATCH 264/589] Reject a non-positive SSE heartbeat interval in OpenAiServerConfig The interval is a scheduleAtFixedRate period; a zero (an obvious way to ask for no heartbeat) made every streaming request answer only an invalid_request_error naming IllegalArgumentException. The builder now refuses it with a message naming the setting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../ladenthin/llama/server/OpenAiServerConfig.java | 8 +++++++- .../llama/server/OpenAiServerConfigTest.java | 13 +++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 18a0ce823..7b5933fb6 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -280,3 +280,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung) · `0ebc857`/`386d73f` · 2026-10-18 · Fund F134 — die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. - `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (unsicher, 17.) bleibt unerreichbar, da `server_devices()` nie leer liefert. - `llama/src/main/java/net/ladenthin/llama/Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`) · `61d2505` · 2026-10-18 · Fund F137 — negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`) · `190803b` · 2026-10-18 · Fund F138 — Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ce4e80a01..efe2a0405 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -100,6 +100,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F133](#f133) — niedrig — *gefixt* - [F134](#f134) — mittel — *gefixt* - [F137](#f137) — niedrig — *gefixt* +- [F138](#f138) — niedrig — *gefixt* ## Funde @@ -1135,6 +1136,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `965b540` + +### F138 – java-llama.cpp: `OpenAiServerConfig.heartbeatMillis(0)` lässt jeden Streaming-Request als Client-Fehler scheitern +- **Zeit:** 2026-10-18 22:55 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (`Builder.heartbeatMillis`), Verbraucher `OpenAiCompatServer` (vier `scheduleAtFixedRate(…, getHeartbeatMillis(), getHeartbeatMillis(), …)`) +- **Schwere:** niedrig +- **Beschreibung:** Der Builder nimmt jeden Wert. Das Intervall ist die Periode von `scheduleAtFixedRate`, die bei ≤ 0 `IllegalArgumentException` wirft; der Streaming-Handler fängt genau diese Klasse als Fehler der Anfrage. Mit `heartbeatMillis(0)` – naheliegend als „kein Heartbeat“ – antwortet daher jeder Streaming-Request mit HTTP 200 und nur `data: {"error":{"message":"IllegalArgumentException","type":"invalid_request_error",…}}`, ohne `[DONE]` (gemessen mit dem modellfreien Testserver); nicht gestreamte Anfragen gehen. Der Fehler sieht nach einem Client-Fehler aus und nennt die Einstellung nicht. +- **Fix:** `heartbeatMillis` weist einen nicht positiven Wert beim Konfigurieren mit `IllegalArgumentException` ab, Javadoc ergänzt. +- **Reproduktion:** `OpenAiServerConfigTest#heartbeatIntervalOfZeroIsRejected`; das Laufzeitsymptom zeigte eine nicht übernommene Probe in `OpenAiCompatServerHttpTest` (Streaming-Chat gegen `FakeBackend` mit `heartbeatMillis(0)`). +- **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix 7/7 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `OpenAiCompatServerHttpTest` 54/54, `OpenAiServerCliTest` 45/45, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java index 7f9c37013..190803beb 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java @@ -303,10 +303,16 @@ public Builder maxOutputTokens(int maxOutputTokens) { /** * Sets the Server-Sent-Events heartbeat interval. * - * @param heartbeatMillis the heartbeat interval in milliseconds + * @param heartbeatMillis the heartbeat interval in milliseconds; must be positive * @return this builder + * @throws IllegalArgumentException if {@code heartbeatMillis} is not positive */ public Builder heartbeatMillis(long heartbeatMillis) { + // the interval is a scheduleAtFixedRate period; a non-positive one would make every + // streaming request fail with an IllegalArgumentException reported as the client's error + if (heartbeatMillis <= 0L) { + throw new IllegalArgumentException("heartbeatMillis must be positive, was " + heartbeatMillis); + } this.heartbeatMillis = heartbeatMillis; return this; } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java index f30c8fb61..371444618 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java @@ -8,6 +8,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; @@ -82,4 +83,16 @@ public void tokenBudgetsAndPortAreConfigurable() { assertThat(config.getMaxInputTokens(), is(6144)); assertThat(config.getMaxOutputTokens(), is(2048)); } + + /** + * The interval is a {@code scheduleAtFixedRate} period, which must be positive. Accepted, a zero + * made every streaming request fail with an {@code invalid_request_error} naming + * {@code IllegalArgumentException} and no {@code [DONE]}, which blames the client for a server setting. + */ + @Test + public void heartbeatIntervalOfZeroIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> OpenAiServerConfig.builder().heartbeatMillis(0L)); + } } From 99b345350c2f2e6ddb297c8dd7292852caea9d2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:03:38 +0000 Subject: [PATCH 265/589] Record the commit of F138 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index efe2a0405..f76b24906 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1146,7 +1146,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiServerConfigTest#heartbeatIntervalOfZeroIsRejected`; das Laufzeitsymptom zeigte eine nicht übernommene Probe in `OpenAiCompatServerHttpTest` (Streaming-Chat gegen `FakeBackend` mit `heartbeatMillis(0)`). - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix 7/7 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `OpenAiCompatServerHttpTest` 54/54, `OpenAiServerCliTest` 45/45, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `6a13092` ## Offene Verdachte ohne Fund-ID From 8df0113b0a0307995e765c0bfe364bd12164d4bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:31:49 +0000 Subject: [PATCH 266/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7b5933fb6..52e12db10 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -281,3 +281,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (unsicher, 17.) bleibt unerreichbar, da `server_devices()` nie leer liefert. - `llama/src/main/java/net/ladenthin/llama/Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`) · `61d2505` · 2026-10-18 · Fund F137 — negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`) · `190803b` · 2026-10-18 · Fund F138 — Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. +- `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (zweite Durchsicht nach F30: `start`, `close`, `Options.parse`, `awaitListening`/`stopAndJoin`; Musterdurchsicht nach F140 – wirft ein früher Schritt nach dem Belegen von `ACTIVE`?) · `0585de5` · 2026-10-18 · kein Fund — `ACTIVE` wird bei jedem gescheiterten Start im `finally` freigegeben, auch wenn die Geräteauflösung wirft; der Server-Thread ist Daemon, ein gescheiterter Start hält die JVM also nicht. Randfall notiert: endet der Server-Thread in `close()` nicht binnen `STOP_TIMEOUT_MILLIS` (30 s), gibt `close()` `ACTIVE` trotzdem frei, und ein neuer Start träfe auf ggmls noch belegten globalen Serverzustand – nicht reproduzierbar ohne native Bibliothek. From 14a5866aedbee6c8d7a2bc74110aed0dc174f0b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:33:42 +0000 Subject: [PATCH 267/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 52e12db10..fd183a1bf 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -282,3 +282,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`) · `61d2505` · 2026-10-18 · Fund F137 — negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`) · `190803b` · 2026-10-18 · Fund F138 — Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (zweite Durchsicht nach F30: `start`, `close`, `Options.parse`, `awaitListening`/`stopAndJoin`; Musterdurchsicht nach F140 – wirft ein früher Schritt nach dem Belegen von `ACTIVE`?) · `0585de5` · 2026-10-18 · kein Fund — `ACTIVE` wird bei jedem gescheiterten Start im `finally` freigegeben, auch wenn die Geräteauflösung wirft; der Server-Thread ist Daemon, ein gescheiterter Start hält die JVM also nicht. Randfall notiert: endet der Server-Thread in `close()` nicht binnen `STOP_TIMEOUT_MILLIS` (30 s), gibt `close()` `ACTIVE` trotzdem frei, und ein neuer Start träfe auf ggmls noch belegten globalen Serverzustand – nicht reproduzierbar ohne native Bibliothek. +- `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` (zweite Durchsicht: `getArchName`/`resolveArmArchType`/`translateOSNameToFolderName` gegen die Verzeichnisse aus `natives.csv`) · `138b590` · 2026-10-18 · kein Fund — `aarch64` steht nicht in `archMapping`, fällt aber unverändert durch `translateArchNameToFolderName` (Linux, Windows, Mac und Android aarch64 → `aarch64`), `amd64`/`x86_64` → `x86_64`, `s390x` gemappt; Android vor Musl vor Linux geprüft. Nicht verfolgt: eine JVM, die `os.arch`=`arm64` meldet, landete auf macOS über `resolveArmArchType` bei `arm` (OpenJDK-Builds melden `aarch64`); `isMusl` sucht "musl" in jedem gemappten Pfad, ein Verzeichnisname mit "musl" auf glibc würde falsch erkannt. From 708dd75d0a282e501c838b95ee0f2298a3b8be61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:35:44 +0000 Subject: [PATCH 268/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index fd183a1bf..dbd63d123 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -283,3 +283,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`) · `190803b` · 2026-10-18 · Fund F138 — Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (zweite Durchsicht nach F30: `start`, `close`, `Options.parse`, `awaitListening`/`stopAndJoin`; Musterdurchsicht nach F140 – wirft ein früher Schritt nach dem Belegen von `ACTIVE`?) · `0585de5` · 2026-10-18 · kein Fund — `ACTIVE` wird bei jedem gescheiterten Start im `finally` freigegeben, auch wenn die Geräteauflösung wirft; der Server-Thread ist Daemon, ein gescheiterter Start hält die JVM also nicht. Randfall notiert: endet der Server-Thread in `close()` nicht binnen `STOP_TIMEOUT_MILLIS` (30 s), gibt `close()` `ACTIVE` trotzdem frei, und ein neuer Start träfe auf ggmls noch belegten globalen Serverzustand – nicht reproduzierbar ohne native Bibliothek. - `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` (zweite Durchsicht: `getArchName`/`resolveArmArchType`/`translateOSNameToFolderName` gegen die Verzeichnisse aus `natives.csv`) · `138b590` · 2026-10-18 · kein Fund — `aarch64` steht nicht in `archMapping`, fällt aber unverändert durch `translateArchNameToFolderName` (Linux, Windows, Mac und Android aarch64 → `aarch64`), `amd64`/`x86_64` → `x86_64`, `s390x` gemappt; Android vor Musl vor Linux geprüft. Nicht verfolgt: eine JVM, die `os.arch`=`arm64` meldet, landete auf macOS über `resolveArmArchType` bei `arm` (OpenJDK-Builds melden `aarch64`); `isMusl` sucht "musl" in jedem gemappten Pfad, ein Verzeichnisname mit "musl" auf glibc würde falsch erkannt. +- `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` (zweite Durchsicht) · `cf96a79` · 2026-10-18 · kein Fund — `removePendingUserTurn` entfernt nur eine offene Nutzerzeile am Ende, `messagesWithPendingUserTurn` kopiert, `snapshot` lässt eine leere Systemnachricht weg, `turnsSnapshot`/`resetTurns` arbeiten auf Kopien (Pair ist unveränderlich). +- `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` ↔ `server_metrics_to_json` (`json_helpers.hpp`) · `2c10c02` · 2026-10-18 · kein Fund — alle 22 gelesenen Schlüssel werden auf der C++-Seite geschrieben (Abgleich per grep), Zeiten in Millisekunden (`to_ms` aus Mikrosekunden), Raten und `getDraftAcceptanceRate` gegen Division durch 0 geschützt, Argumentreihenfolge des `Timings`-Konstruktors stimmt. From 8f423d37385e1ca72336e14a390178c78fb6d548 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:45:02 +0000 Subject: [PATCH 269/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index dbd63d123..7d92d3838 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -285,3 +285,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` (zweite Durchsicht: `getArchName`/`resolveArmArchType`/`translateOSNameToFolderName` gegen die Verzeichnisse aus `natives.csv`) · `138b590` · 2026-10-18 · kein Fund — `aarch64` steht nicht in `archMapping`, fällt aber unverändert durch `translateArchNameToFolderName` (Linux, Windows, Mac und Android aarch64 → `aarch64`), `amd64`/`x86_64` → `x86_64`, `s390x` gemappt; Android vor Musl vor Linux geprüft. Nicht verfolgt: eine JVM, die `os.arch`=`arm64` meldet, landete auf macOS über `resolveArmArchType` bei `arm` (OpenJDK-Builds melden `aarch64`); `isMusl` sucht "musl" in jedem gemappten Pfad, ein Verzeichnisname mit "musl" auf glibc würde falsch erkannt. - `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` (zweite Durchsicht) · `cf96a79` · 2026-10-18 · kein Fund — `removePendingUserTurn` entfernt nur eine offene Nutzerzeile am Ende, `messagesWithPendingUserTurn` kopiert, `snapshot` lässt eine leere Systemnachricht weg, `turnsSnapshot`/`resetTurns` arbeiten auf Kopien (Pair ist unveränderlich). - `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` ↔ `server_metrics_to_json` (`json_helpers.hpp`) · `2c10c02` · 2026-10-18 · kein Fund — alle 22 gelesenen Schlüssel werden auf der C++-Seite geschrieben (Abgleich per grep), Zeiten in Millisekunden (`to_ms` aus Mikrosekunden), Raten und `getDraftAcceptanceRate` gegen Division durch 0 geschützt, Argumentreihenfolge des `Timings`-Konstruktors stimmt. +- `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` ↔ `jllama_engine.cpp` (Modell-Meta-JSON) · `e9aa330` · 2026-10-18 · kein Fund — alle 21 gelesenen Schlüssel/Pfade werden nativ geschrieben (`modalities.{vision,audio,video}`, `special_tokens.{bos,eos,eot}`, `metadata`, Ein-/Ausgabemodalitäten), Vorgaben für fehlende Felder passen zum Vocab-only-Zweig (der nur `vocab_type`, `n_vocab`, `special_tokens` liefert). `vocab_type` wird nativ als int gecastet (common_json-Falle, CLAUDE.md). Außerhalb des Bereichs bemerkt (C++, nicht geändert): Metadaten-Schlüssel landen in einem festen 256-Byte-Puffer, längere würden abgeschnitten. From c25f59746cb3b2f17c3a24b849201bac6f30b154 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:47:42 +0000 Subject: [PATCH 270/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 7d92d3838..313aadd64 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -286,3 +286,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` (zweite Durchsicht) · `cf96a79` · 2026-10-18 · kein Fund — `removePendingUserTurn` entfernt nur eine offene Nutzerzeile am Ende, `messagesWithPendingUserTurn` kopiert, `snapshot` lässt eine leere Systemnachricht weg, `turnsSnapshot`/`resetTurns` arbeiten auf Kopien (Pair ist unveränderlich). - `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` ↔ `server_metrics_to_json` (`json_helpers.hpp`) · `2c10c02` · 2026-10-18 · kein Fund — alle 22 gelesenen Schlüssel werden auf der C++-Seite geschrieben (Abgleich per grep), Zeiten in Millisekunden (`to_ms` aus Mikrosekunden), Raten und `getDraftAcceptanceRate` gegen Division durch 0 geschützt, Argumentreihenfolge des `Timings`-Konstruktors stimmt. - `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` ↔ `jllama_engine.cpp` (Modell-Meta-JSON) · `e9aa330` · 2026-10-18 · kein Fund — alle 21 gelesenen Schlüssel/Pfade werden nativ geschrieben (`modalities.{vision,audio,video}`, `special_tokens.{bos,eos,eot}`, `metadata`, Ein-/Ausgabemodalitäten), Vorgaben für fehlende Felder passen zum Vocab-only-Zweig (der nur `vocab_type`, `n_vocab`, `special_tokens` liefert). `vocab_type` wird nativ als int gecastet (common_json-Falle, CLAUDE.md). Außerhalb des Bereichs bemerkt (C++, nicht geändert): Metadaten-Schlüssel landen in einem festen 256-Byte-Puffer, längere würden abgeschnitten. +- `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` (zweite Durchsicht) · `e167fa7` · 2026-10-18 · kein Fund — MIME/Format aus der Endung ohne Locale-Falle (`Locale.ROOT`), Data-URL `data:;base64,`, Audioformat auf `wav`/`mp3` normalisiert; `@EqualsAndHashCode` (Lombok) trägt die Wertgleichheit, die `ChatRequest` verspricht. +- `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` (zweite Durchsicht) · `453a951` · 2026-10-18 · kein Fund — Teile und Tool-Calls defensiv kopiert und unveränderlich zurückgegeben, `content` aus den Textteilen mit Zeilenumbruch verbunden, leere Teil-Liste abgewiesen. Ein `null`-Element in der Teil-Liste ergäbe eine NullPointerException beim Zusammenfügen (Aufruferfehler, nicht verfolgt). From fd8a17b004dab379f21e1c631d2742e5ef2c51fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:58:44 +0000 Subject: [PATCH 271/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 313aadd64..806705cc1 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -288,3 +288,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` ↔ `jllama_engine.cpp` (Modell-Meta-JSON) · `e9aa330` · 2026-10-18 · kein Fund — alle 21 gelesenen Schlüssel/Pfade werden nativ geschrieben (`modalities.{vision,audio,video}`, `special_tokens.{bos,eos,eot}`, `metadata`, Ein-/Ausgabemodalitäten), Vorgaben für fehlende Felder passen zum Vocab-only-Zweig (der nur `vocab_type`, `n_vocab`, `special_tokens` liefert). `vocab_type` wird nativ als int gecastet (common_json-Falle, CLAUDE.md). Außerhalb des Bereichs bemerkt (C++, nicht geändert): Metadaten-Schlüssel landen in einem festen 256-Byte-Puffer, längere würden abgeschnitten. - `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` (zweite Durchsicht) · `e167fa7` · 2026-10-18 · kein Fund — MIME/Format aus der Endung ohne Locale-Falle (`Locale.ROOT`), Data-URL `data:;base64,`, Audioformat auf `wav`/`mp3` normalisiert; `@EqualsAndHashCode` (Lombok) trägt die Wertgleichheit, die `ChatRequest` verspricht. - `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` (zweite Durchsicht) · `453a951` · 2026-10-18 · kein Fund — Teile und Tool-Calls defensiv kopiert und unveränderlich zurückgegeben, `content` aus den Textteilen mit Zeilenumbruch verbunden, leere Teil-Liste abgewiesen. Ein `null`-Element in der Teil-Liste ergäbe eine NullPointerException beim Zusammenfügen (Aufruferfehler, nicht verfolgt). +- `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` ↔ `LlamaLoader.selectModules` · `a15ed7b` · 2026-10-19 · kein Fund — `namedBy` teilt und trimmt den Filter wie der Loader (Groß-/Kleinschreibung in beiden exakt), der Hinweis auf ungenutzte Opt-in-Module stimmt auch bei leerem Filter (der Loader behandelt ihn als unset). Commit-Kürzung nur für volle 40-Zeichen-Hashes, `-dirty` bleibt erhalten; jeder Prüfschritt fängt RuntimeException/LinkageError ab, ein Bericht scheitert also nie an einer fehlenden nativen Bibliothek. From 6d7cc3f973567cfb33a7a23a6b5cf4a36b4b94c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:05:40 +0000 Subject: [PATCH 272/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 806705cc1..c3dc7d7ef 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -289,3 +289,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` (zweite Durchsicht) · `e167fa7` · 2026-10-18 · kein Fund — MIME/Format aus der Endung ohne Locale-Falle (`Locale.ROOT`), Data-URL `data:;base64,`, Audioformat auf `wav`/`mp3` normalisiert; `@EqualsAndHashCode` (Lombok) trägt die Wertgleichheit, die `ChatRequest` verspricht. - `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` (zweite Durchsicht) · `453a951` · 2026-10-18 · kein Fund — Teile und Tool-Calls defensiv kopiert und unveränderlich zurückgegeben, `content` aus den Textteilen mit Zeilenumbruch verbunden, leere Teil-Liste abgewiesen. Ein `null`-Element in der Teil-Liste ergäbe eine NullPointerException beim Zusammenfügen (Aufruferfehler, nicht verfolgt). - `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` ↔ `LlamaLoader.selectModules` · `a15ed7b` · 2026-10-19 · kein Fund — `namedBy` teilt und trimmt den Filter wie der Loader (Groß-/Kleinschreibung in beiden exakt), der Hinweis auf ungenutzte Opt-in-Module stimmt auch bei leerem Filter (der Loader behandelt ihn als unset). Commit-Kürzung nur für volle 40-Zeichen-Hashes, `-dirty` bleibt erhalten; jeder Prüfschritt fängt RuntimeException/LinkageError ab, ein Bericht scheitert also nie an einer fehlenden nativen Bibliothek. +- `llama/src/main/java/net/ladenthin/llama/server/ServerLauncher.java` (zweite Durchsicht) · `d8433a0` · 2026-10-19 · kein Fund — der Marker wird an jeder Stelle und mehrfach entfernt, sonst gehen die Argumente unverändert an `NativeServer.main`. +- `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` (zweite Durchsicht) · `8327d64` · 2026-10-19 · kein Fund — `Locale.ROOT`-Formatierung, leere Timings (0/0) und `null` werden übersprungen, Draft-Teil nur bei spekulativer Dekodierung. From aa52fce9194ea439cb56db34bae706e7a2118a75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:07:37 +0000 Subject: [PATCH 273/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index c3dc7d7ef..08a622f34 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -291,3 +291,5 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` ↔ `LlamaLoader.selectModules` · `a15ed7b` · 2026-10-19 · kein Fund — `namedBy` teilt und trimmt den Filter wie der Loader (Groß-/Kleinschreibung in beiden exakt), der Hinweis auf ungenutzte Opt-in-Module stimmt auch bei leerem Filter (der Loader behandelt ihn als unset). Commit-Kürzung nur für volle 40-Zeichen-Hashes, `-dirty` bleibt erhalten; jeder Prüfschritt fängt RuntimeException/LinkageError ab, ein Bericht scheitert also nie an einer fehlenden nativen Bibliothek. - `llama/src/main/java/net/ladenthin/llama/server/ServerLauncher.java` (zweite Durchsicht) · `d8433a0` · 2026-10-19 · kein Fund — der Marker wird an jeder Stelle und mehrfach entfernt, sonst gehen die Argumente unverändert an `NativeServer.main`. - `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` (zweite Durchsicht) · `8327d64` · 2026-10-19 · kein Fund — `Locale.ROOT`-Formatierung, leere Timings (0/0) und `null` werden übersprungen, Draft-Teil nur bei spekulativer Dekodierung. +- `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` (zweite Durchsicht) · `2448442` · 2026-10-19 · kein Fund — Array/Objekt/fehlend wie dokumentiert, unlesbarer oder leerer Umschlag beendet den Strom (verhindert eine Endlosschleife). +- `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` ↔ `rerank_results_to_json` · `2591bb0` · 2026-10-19 · kein Fund — `document` und `score` werden nativ geschrieben. Kleinigkeit: `parse(String)` liefert bei unlesbarem JSON eine unveränderliche, `parse(JsonNode)` bei leerem Array eine veränderliche Liste (der Kommentar dort nennt gerade diese Einheitlichkeit als Grund). From 017ac951bfc1de1647aaf740c0f6d92dc9efdede Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:14:16 +0000 Subject: [PATCH 274/589] Poll the router at least once in awaitModelLoaded A timeout of 0, or one that elapsed before the first poll, skipped the loop entirely and reported a timeout with no status for a model that was already loaded. The deadline is now checked after each poll. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + BUGHUNT-FOUND.md | 13 +++++++++++++ .../net/ladenthin/llama/server/RouterClient.java | 7 ++++++- .../ladenthin/llama/server/RouterClientTest.java | 12 ++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 08a622f34..999d2fa88 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -293,3 +293,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` (zweite Durchsicht) · `8327d64` · 2026-10-19 · kein Fund — `Locale.ROOT`-Formatierung, leere Timings (0/0) und `null` werden übersprungen, Draft-Teil nur bei spekulativer Dekodierung. - `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` (zweite Durchsicht) · `2448442` · 2026-10-19 · kein Fund — Array/Objekt/fehlend wie dokumentiert, unlesbarer oder leerer Umschlag beendet den Strom (verhindert eine Endlosschleife). - `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` ↔ `rerank_results_to_json` · `2591bb0` · 2026-10-19 · kein Fund — `document` und `score` werden nativ geschrieben. Kleinigkeit: `parse(String)` liefert bei unlesbarem JSON eine unveränderliche, `parse(JsonNode)` bei leerem Array eine veränderliche Liste (der Kommentar dort nennt gerade diese Einheitlichkeit als Grund). +- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`, Fristgrenzen) · `f23050e` · 2026-10-19 · Fund F143 — bei Frist 0 keine einzige Abfrage. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f76b24906..7eaad6152 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -101,6 +101,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F134](#f134) — mittel — *gefixt* - [F137](#f137) — niedrig — *gefixt* - [F138](#f138) — niedrig — *gefixt* +- [F143](#f143) — niedrig — *gefixt* ## Funde @@ -1148,6 +1149,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6a13092` + +### F143 – java-llama.cpp: `RouterClient.awaitModelLoaded(id, 0)` meldet für ein geladenes Modell einen Timeout, ohne abzufragen +- **Zeit:** 2026-10-19 01:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java:210` (`awaitModelLoaded`) +- **Schwere:** niedrig +- **Beschreibung:** Die Schleife `while (System.currentTimeMillis() < deadline)` prüft die Frist vor der ersten Abfrage. Mit `timeoutMillis` 0 (die naheliegende Frage „ist es jetzt geladen?“) oder einer Frist, die vor der ersten Abfrage abläuft, wird `GET /models` nie gefragt: der Aufruf wirft „did not reach LOADED within 0 ms (last status: null)“ für ein geladenes Modell, und auch die dokumentierten Sofort-Fehler (Modell nicht gelistet, Worker gescheitert) treten nie ein. Dieselbe Klasse wie der schon behobene Überlauf bei `Long.MAX_VALUE` (Test `awaitModelLoaded_longMaxValueTimeoutStillPolls`: „would skip polling and report a timeout for a loaded model“). +- **Fix:** Die Methode fragt mindestens einmal ab und prüft die Frist erst nach einer Abfrage, vor der Pause. +- **Reproduktion:** `RouterClientTest#awaitModelLoaded_zeroTimeoutStillPollsOnce` +- **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception thrown: java.lang.IllegalStateException: Model 'qwen' did not reach LOADED within 0 ms (last status: null)“), mit Fix 20/20 (skipped 0, die Timeout- und Sofort-Fehler-Tests unverändert grün), Gegenprobe per `git stash` rot, danach wieder grün. `LlamaArchitectureTest` 13/13; `spotless:check` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index 5396c0a71..f23050e83 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -207,7 +207,9 @@ public RouterModel awaitModelLoaded(String modelId, long timeoutMillis) throws I // into a deadline in the past and time out before the first poll. long deadline = timeoutMillis > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + timeoutMillis; RouterModel last = null; - while (System.currentTimeMillis() < deadline) { + // Polls at least once, then until the deadline: a timeout of 0 (or one that elapses before the + // first poll) would otherwise report a timeout for a model that is already loaded. + while (true) { Optional model = findModel(modelId); if (!model.isPresent()) { throw new IllegalStateException("Router does not list model '" + modelId @@ -225,6 +227,9 @@ public RouterModel awaitModelLoaded(String modelId, long timeoutMillis) throws I if (last.getStatus() == RouterModel.Status.LOADED || last.getStatus() == RouterModel.Status.SLEEPING) { return last; } + if (System.currentTimeMillis() >= deadline) { + break; + } // Interruptible pause between polls; a latch that is never counted down is the // project's Thread.sleep-free wait primitive (see LlamaArchitectureTest#noThreadSleep). if (new CountDownLatch(1).await(POLL_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java index cb690bc21..c6d06e031 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java @@ -7,6 +7,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import com.sun.net.httpserver.HttpExchange; @@ -197,6 +198,17 @@ public void awaitModelLoaded_longMaxValueTimeoutStillPolls() throws Exception { assertThat(loaded.getStatus(), is(RouterModel.Status.LOADED)); } + @Test + public void awaitModelLoaded_zeroTimeoutStillPollsOnce() throws Exception { + // A timeout of 0 asks "is it loaded now?"; the deadline is already reached before the first + // poll, which skipped polling and reported a timeout ("last status: null") for a loaded model. + modelsBody.set("{\"data\":[" + entry("qwen", "loaded") + "]}"); + + RouterModel loaded = assertDoesNotThrow(() -> client.awaitModelLoaded("qwen", 0L)); + + assertThat(loaded.getStatus(), is(RouterModel.Status.LOADED)); + } + @Test public void ipv6LiteralHostIsBracketedInTheRequestUrl() { // A router started with --host ::1 is reached as http://[::1]:port. An unbracketed literal is From 975e2dfbe647d33508c37da25b6af5d20cdce32a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:14:16 +0000 Subject: [PATCH 275/589] Record the commit of F143 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7eaad6152..3becb4b7a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1159,7 +1159,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `RouterClientTest#awaitModelLoaded_zeroTimeoutStillPollsOnce` - **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception thrown: java.lang.IllegalStateException: Model 'qwen' did not reach LOADED within 0 ms (last status: null)“), mit Fix 20/20 (skipped 0, die Timeout- und Sofort-Fehler-Tests unverändert grün), Gegenprobe per `git stash` rot, danach wieder grün. `LlamaArchitectureTest` 13/13; `spotless:check` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `058153f` ## Offene Verdachte ohne Fund-ID From ba0e1635d7ede29837f65e9aaf5f45addb62e614 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:18:30 +0000 Subject: [PATCH 276/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 999d2fa88..654219fdd 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -294,3 +294,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` (zweite Durchsicht) · `2448442` · 2026-10-19 · kein Fund — Array/Objekt/fehlend wie dokumentiert, unlesbarer oder leerer Umschlag beendet den Strom (verhindert eine Endlosschleife). - `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` ↔ `rerank_results_to_json` · `2591bb0` · 2026-10-19 · kein Fund — `document` und `score` werden nativ geschrieben. Kleinigkeit: `parse(String)` liefert bei unlesbarem JSON eine unveränderliche, `parse(JsonNode)` bei leerem Array eine veränderliche Liste (der Kommentar dort nennt gerade diese Einheitlichkeit als Grund). - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`, Fristgrenzen) · `f23050e` · 2026-10-19 · Fund F143 — bei Frist 0 keine einzige Abfrage. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (dritte Durchsicht) · `e5eb40e` · 2026-10-19 · kein Fund — `ensureUsageCachedTokens` ergänzt nur fehlendes `cached_tokens` und gibt jede andere Form (`usage` null, mit Leerzeichen, als Text im Inhalt, kein Objekt, unlesbar) unverändert weiter; SSE-Rahmen mit Leerzeile, Fehlerobjekt mit `code` null. From ae61ecadc9757c14268a9e014f4be0b6f0fc6a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:22:42 +0000 Subject: [PATCH 277/589] Update the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 654219fdd..ba8a0cf62 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -295,3 +295,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` ↔ `rerank_results_to_json` · `2591bb0` · 2026-10-19 · kein Fund — `document` und `score` werden nativ geschrieben. Kleinigkeit: `parse(String)` liefert bei unlesbarem JSON eine unveränderliche, `parse(JsonNode)` bei leerem Array eine veränderliche Liste (der Kommentar dort nennt gerade diese Einheitlichkeit als Grund). - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`, Fristgrenzen) · `f23050e` · 2026-10-19 · Fund F143 — bei Frist 0 keine einzige Abfrage. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (dritte Durchsicht) · `e5eb40e` · 2026-10-19 · kein Fund — `ensureUsageCachedTokens` ergänzt nur fehlendes `cached_tokens` und gibt jede andere Form (`usage` null, mit Leerzeichen, als Text im Inhalt, kein Objekt, unlesbar) unverändert weiter; SSE-Rahmen mit Leerzeile, Fehlerobjekt mit `code` null. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` · `37473c2` · 2026-10-19 · kein Fund — Werte werden über `index` (Eingabeposition) zurückgeordnet, ohne `index` über die Reihenfolge; außerhalb liegende Indizes verworfen, doppelte Dokumente bleiben getrennt. Nicht verfolgt: `scoreAll` mit leerer Liste reicht ein leeres Dokument-Array an `handleRerank` weiter (native Seite, ohne Bibliothek nicht messbar). From cb8a3eaa4d3bc7c4ee12be5e6c26f073770c6573 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:55:42 +0000 Subject: [PATCH 278/589] Correct the documentation of the LoRA cache and of n_predict setLoraAdapters promised that the KV caches are cleared when the adapter set changes; upstream's POST /lora-adapters path only replaces the scales, so cached prefixes keep the old adapter. The Javadoc and README now say so and name eraseSlot and withCachePrompt(false). withNPredict documented -2 as "until context filled", which the request schema rejects (hard limits -1 .. INT32_MAX). ModelParameters.setPredict documented the same value, which is a setting of the completion tool; in the server a slot counts only -1 as unlimited and stops after the first token with -2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 37 ++++++++++++------- README.md | 6 ++- .../java/net/ladenthin/llama/LlamaModel.java | 9 ++++- .../llama/parameters/InferenceParameters.java | 3 +- .../llama/parameters/ModelParameters.java | 9 ++++- 5 files changed, 43 insertions(+), 21 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3becb4b7a..6727327ff 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -46,9 +46,9 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F63](#f63) — niedrig — *gefixt* - [F64](#f64) — mittel — *gefixt* - [F65](#f65) — niedrig — *gefixt* -- [F67](#f67) — niedrig — *nur beschrieben* (CI-Bedarf: Modell + LoRA-Adapter) +- [F67](#f67) — niedrig — *gefixt* (Dokumentation) - [F68](#f68) — niedrig — *gefixt* -- [F69](#f69) — niedrig — *nur beschrieben* (CI-Bedarf: modellgestützter Test) +- [F69](#f69) — niedrig — *gefixt* (Dokumentation) - [F70](#f70) — mittel — *gefixt* - [F71](#f71) — niedrig — *gefixt* - [F72](#f72) — niedrig — *gefixt* @@ -102,6 +102,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F137](#f137) — niedrig — *gefixt* - [F138](#f138) — niedrig — *gefixt* - [F143](#f143) — niedrig — *gefixt* +- [F158](#f158) — niedrig — *Doku gefixt*, Verhalten offen (Entscheidung des Nutzers) ## Funde @@ -484,11 +485,11 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java:1124` (Javadoc `setLoraAdapters`), ebenso `README.md:833` - **Schwere:** niedrig (Dokumentation; das Verhalten selbst ist upstreams) - **Beschreibung:** Javadoc und README sagen: „The native side clears affected KV caches when the effective adapter set changes.“ `setLoraAdapters` geht über `jllama_set_lora_adapters` → `engine::set_lora_adapters` → `SERVER_TASK_TYPE_SET_LORA`; upstream (`server-context.cpp`, b11639) setzt dort nur `params_base.lora_adapters = construct_lora_list(...)`. Eine spätere Anfrage ohne eigene `lora`-Angabe übernimmt in `launch_slot_with_task` im `else`-Zweig `slot.lora = params_base.lora_adapters`, **ohne** `slot.prompt.clear()` – den Cache leert upstream nur im Zweig für LoRA pro Anfrage (`lora_should_clear_cache`). Eine Anfrage, deren Prompt-Präfix noch im Slot liegt, rechnet also mit KV-Einträgen weiter, die unter den alten Adapter-Skalen entstanden sind, und liefert stillschweigend eine Mischung aus altem und neuem Adapter. Wer sich auf die Javadoc verlässt, rechnet damit nicht. -- **Fix:** keiner (Nutzerregel: kein Fix ohne roten Test). Möglich wäre, die Doku zu korrigieren (Hinweis auf `eraseSlot`/`cache_prompt=false` nach einem Wechsel) oder in `engine::set_lora_adapters` nach dem Task die Slots zu leeren (C++, außerhalb des Ziels). +- **Fix:** Entscheidung des Nutzers: Dokumentation korrigieren (ohne Test, Nutzerregel für Doku-Korrekturen). Javadoc von `LlamaModel.setLoraAdapters` und README sagen jetzt, dass die KV-Caches auf diesem Weg **nicht** geleert werden, und nennen `eraseSlot(id)` bzw. `withCachePrompt(false)`. Vor der Änderung am Quelltext von b11639 nachgeprüft: `SERVER_TASK_TYPE_SET_LORA` (`server-context.cpp:3077`) setzt nur `params_base.lora_adapters`, `launch_slot_with_task` (`:1868`) übernimmt sie im `else`-Zweig ohne `prompt.clear()`. Das Leeren in `engine::set_lora_adapters` (C++) bleibt als Möglichkeit, ist aber nicht beschlossen. - **Reproduktion:** keine lauffähige: es braucht ein Modell und einen dazu passenden LoRA-Adapter (nicht in `models.csv`), und in dieser Sandbox gibt es keine native Bibliothek und kein `gguf-py`. Ein Test würde denselben Prompt vor und nach `setLoraAdapter(0, 0f)` mit Greedy-Sampling schicken und mit einem frisch geladenen Modell ohne Adapter vergleichen. -- **CI-Bedarf:** ein kleines Modell mit LoRA-Adapter im Modellsatz (oder ein zur Laufzeit erzeugtes Zufallsmodell samt Adapter, wie `test_c_api.cpp` es für das Modell macht). -- **Status:** nur beschrieben -- **Commit:** – +- **CI-Bedarf:** ein kleines Modell mit LoRA-Adapter im Modellsatz (oder ein zur Laufzeit erzeugtes Zufallsmodell samt Adapter, wie `test_c_api.cpp` es für das Modell macht) – nur, falls das Verhalten geändert werden soll. +- **Status:** gefixt (Dokumentation) +- **Commit:** (folgt) ### F68 – java-llama.cpp: `RouterClient.awaitModelLoaded` läuft bei einem schlafenden Modell in den Timeout @@ -507,12 +508,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Zeit:** 2026-10-18 00:05 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java:197` (Javadoc `withNPredict`) - **Schwere:** niedrig (Dokumentation; der Aufrufer bekommt eine Ausnahme, kein stilles Fehlverhalten) -- **Beschreibung:** Die Javadoc nennt für den Anfragewert `n_predict` „-1 = infinity, -2 = until context filled“. Upstream (`tools/server/server-schema.cpp:44`, b11639) deklariert das Feld mit `set_hard_limits(-1, INT32_MAX)`, und `is_hard_limit` heißt laut `server-schema.h:52` „throw error if the value is invalid“: `withNPredict(-2)` endet also in einer 400 bzw. `InvalidRequestException`, statt bis zum Kontextende zu generieren. `-2` gibt es nur für die Startoption `-n` (`ModelParameters.setNPredict`, dort korrekt dokumentiert), nicht pro Anfrage. -- **Fix:** keiner (Nutzerregel: kein Fix ohne roten Test; der Test braucht ein geladenes Modell). Möglich: Javadoc auf „-1 = unbegrenzt, 0 = nur Prompt auswerten“ (upstreams `set_desc`) korrigieren oder `-2` in Java mit klarer Meldung abweisen. +- **Beschreibung:** Die Javadoc nennt für den Anfragewert `n_predict` „-1 = infinity, -2 = until context filled“. Upstream (`tools/server/server-schema.cpp:44`, b11639) deklariert das Feld mit `set_hard_limits(-1, INT32_MAX)`, und `is_hard_limit` heißt laut `server-schema.h:52` „throw error if the value is invalid“: `withNPredict(-2)` endet also in einer 400 bzw. `InvalidRequestException`, statt bis zum Kontextende zu generieren. `-2` gibt es nur für die Startoption `-n` – **Nachtrag:** dort ist es für den Server ebenfalls falsch dokumentiert, siehe F158. +- **Fix:** Entscheidung des Nutzers: Dokumentation korrigieren (ohne Test). Die Javadoc von `withNPredict` sagt jetzt: `-1` übernimmt die `--predict`-Einstellung des Modells (selbst standardmäßig unbegrenzt, `server-context.cpp:1964`), der Server nimmt `-1` und größer an, `-2` lehnt er als ungültige Anfrage ab. Upstreams Beschreibung für `0` („no tokens will be generated but the prompt is evaluated“) ist bewusst **nicht** übernommen: im Quelltext findet sich dafür kein eigener Zweig, `has_budget()` stoppt erst nach dem ersten Token; ohne Messung bleibt die Aussage draußen. - **Reproduktion:** keine lauffähige hier (kein Modell, keine native Bibliothek). Ein Test in einer modellgestützten Klasse: `model.complete(InferenceParameters.empty().withPrompt("x").withNPredict(-2))` → `InvalidRequestException` mit `n_predict` in der Meldung. -- **CI-Bedarf:** ein modellgestützter Java-Test (jedes Modell aus `models.csv` genügt). -- **Status:** nur beschrieben -- **Commit:** – +- **CI-Bedarf:** ein modellgestützter Java-Test (jedes Modell aus `models.csv` genügt), falls `-2` zusätzlich in Java abgewiesen werden soll. +- **Status:** gefixt (Dokumentation) +- **Commit:** (folgt) ### F70 – java-llama.cpp: `OpenAiServerCli` bewirbt mit `--parallel N` das ganze `-c` statt des Kontexts je Slot @@ -1161,6 +1162,17 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `058153f` + +### F158 – java-llama.cpp: `ModelParameters.setPredict(-2)` verspricht „until context filled“, der Server erzeugt damit je Anfrage nur ein Token +- **Zeit:** 2026-10-19 00:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java:202` (Javadoc `setPredict`) +- **Schwere:** niedrig bis mittel (still: keine Fehlermeldung, nur abgeschnittene Antworten) +- **Beschreibung:** Gefunden beim Nachprüfen von F69. Upstreams `-n` (`common/arg.cpp:1661`, b11639) nennt „-2 = until context filled“ **nur** für `LLAMA_EXAMPLE_COMPLETION`, für den Server lautet die Beschreibung „-1 = infinity“; der Parser übernimmt jeden Wert. Im Server übernimmt ein Slot `params_base.n_predict`, wenn die Anfrage `-1` sendet oder das Feld weglässt (`server-context.cpp:1964`), und `has_budget()` (`:495`) zählt nur `-1` als unbegrenzt: bei `-2` ist `n_remaining()` negativ, und die Grenzprüfung (`:2040`, `n_gen > 0 && !has_budget()`) beendet die Erzeugung nach dem ersten Token mit `STOP_TYPE_LIMIT`. Aus dem Quelltext abgeleitet, **nicht gemessen**. +- **Fix:** Dokumentation korrigiert (ohne Test, Nutzerregel): Javadoc nennt nur noch `-1 = infinity` und erklärt, dass `-2` eine Einstellung des Completion-Tools ist und hier nach dem ersten Token stoppt. **Offen, Entscheidung des Nutzers:** `-2` in `ModelParameters.setPredict` mit `IllegalArgumentException` abweisen – modellfrei testbar, ändert aber das Verhalten eines öffentlichen Setters. +- **CI-Bedarf:** Messung mit einem Modell aus `models.csv`: `new ModelParameters().setPredict(-2)` laden, eine Anfrage ohne `n_predict`, Anzahl der erzeugten Tokens (erwartet nach Quelltext: 1). +- **Status:** Doku gefixt, Verhalten offen +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -1171,13 +1183,10 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. -- *(erledigt durch F96, 2026-10-18)* Anthropic-Rest nach F76: upstreams `convert_anthropic_to_oai` setzt `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Protokoll verlangt das Feld, Clients senden es), und reicht `chat_template_kwargs` durch, das wir verwerfen. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. -- *(erledigt durch F97, 2026-10-18)* Ollama-Antworten tragen den Denkinhalt nicht: Ollama legt ihn in `message.thinking` ab (blockierend und je Stream-Zeile), `OllamaApiSupport` übernimmt aus OpenAIs Antwort nur `content` und `tool_calls`, `reasoning_content` geht verloren. Ein Client mit `think: true` sieht keine Gedanken. Eine Ergänzung der Antwortform, kein Fehlverhalten der Anfrage; daher nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). -- *(erledigt durch F106, 2026-10-18)* Responses-Felder: upstreams `convert_responses_to_chatcmpl` kopiert den **ganzen** Körper in die Chat-Anfrage (`chatcmpl_body = response_body`, nur `input`/`instructions`/`max_output_tokens` umbenannt bzw. entfernt), sodass `top_k`, `seed`, `chat_template_kwargs`, `min_p`, `repeat_penalty` & Co. ankommen. `ResponsesApiSupport.toOpenAiChatRequest` übernimmt nur `temperature`, `top_p`, `max_output_tokens` und `reasoning.effort`; der Rest geht still verloren (Muster F74/F104). Nächste Runde: die Felder, die der Mapper liest (F95), aus der Responses-Anfrage übernehmen und je Feld einen Test schreiben. - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `LlamaModel.java:194` (`completeBatch`, ebenso `completeBatchWithStats`, `chatBatch`): Die Javadoc sagt „Each request is sent immediately; the native scheduler dispatches tasks across whatever slot count `setParallel` was configured with“. Tatsächlich startet jede Anfrage als `CompletableFuture.supplyAsync` auf dem gemeinsamen `ForkJoinPool`, dessen Parallelität `Runtime.availableProcessors() - 1` ist, und der blockierende native Aufruf läuft ohne `ManagedBlocker`. Auf einem Rechner mit 4 Kernen und `setParallel(8)` laufen so höchstens 3 Anfragen gleichzeitig, die übrigen warten in der Pool-Warteschlange, bevor sie überhaupt übergeben werden; bei 2 Kernen (Parallelität 1) nimmt `CompletableFuture` statt des Pools einen Thread je Aufgabe, dort stimmt die Aussage. Ein Fix ohne Pool: alle Aufgaben auf dem Aufrufer-Thread mit `requestCompletion` übergeben, dann der Reihe nach `receiveCompletionJson`, bei einem Übergabefehler die bereits übergebenen mit `cancelCompletion` freigeben. Beweisen ließe sich das nur mit nativer Bibliothek und Modell (Zahl gleichzeitig belegter Slots über `getMetricsTyped`), daher kein Commit. Nebenbei: `rerank(String, String...)` legt die Ergebnisse in eine `HashMap` nach Dokumenttext, zwei gleiche Dokumente fallen dort zu einem Eintrag zusammen (die Überladung mit `reRank` liefert eine Liste und ist davon nicht betroffen). diff --git a/README.md b/README.md index 3ddf3be8a..bb949db19 100644 --- a/README.md +++ b/README.md @@ -830,8 +830,10 @@ try (LlamaModel model = new LlamaModel(modelParams)) { ``` Per the upstream contract, a scale update lists the adapters to keep active — any adapter missing -from the map is set to scale `0` (disabled). The native side clears affected KV caches when the -effective adapter set changes. +from the map is set to scale `0` (disabled). The KV caches are **not** cleared on this path (upstream's +`POST /lora-adapters` only replaces the scales): a later request whose prompt prefix is still cached in +a slot reuses KV entries computed under the previous scales. After a change, clear the slots with +`eraseSlot(id)` or send such requests with `withCachePrompt(false)`. ### Text-to-Speech diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java index 96eafab0e..a0c99c80b 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java @@ -1120,8 +1120,13 @@ public List getLoraAdapters() { * Set the scales of the loaded LoRA adapters at runtime, without reloading the model. * Mirrors the upstream {@code POST /lora-adapters} contract: adapters present in * {@code scales} get the given scale, all other adapters are set to {@code 0} - * (disabled). Ids not corresponding to a loaded adapter are ignored. The native side - * clears affected KV caches when the effective adapter set changes. + * (disabled). Ids not corresponding to a loaded adapter are ignored. + * + *

The KV caches are not cleared on this path (upstream's {@code POST /lora-adapters} + * only replaces the scales). A later request whose prompt prefix is still cached in a slot + * reuses KV entries computed under the previous scales and silently mixes old and new adapter. + * After a change, clear the slots with {@link #eraseSlot(int)} or send such requests with + * {@code withCachePrompt(false)}. * * @param scales adapter id (see {@link net.ladenthin.llama.value.LoraAdapter#getId()}) to * new scale; an empty map disables every adapter diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java index 386d73f2a..9ff36301a 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java @@ -194,7 +194,8 @@ public InferenceParameters withSlotId(int slotId) { /** * Returns a new request with the number of tokens to predict replaced - * (default: -1, -1 = infinity, -2 = until context filled). + * (default: -1 = the model's {@code --predict} setting, which itself defaults to unlimited). The + * server accepts {@code -1} and above; {@code -2} is rejected as an invalid request. * * @param nPredict tokens to predict * @return a new instance; this instance is unchanged diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index a84e7da73..f40ade91f 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -199,9 +199,14 @@ public ModelParameters setCtxSize(int ctxSize) { } /** - * Set the number of tokens to predict (default: -1 = infinity, -2 = until context filled). + * Set the number of tokens to predict per request when the request does not set its own + * (default: -1 = infinity). * - * @param nPredict the maximum number of tokens to predict (-1 = infinity, -2 = until context filled) + *

{@code -2} ("until context filled") is a setting of llama.cpp's completion tool, not of the + * server this library runs. There a slot counts only {@code -1} as unlimited, so with {@code -2} + * a request stops after its first token. + * + * @param nPredict the maximum number of tokens to predict (-1 = infinity) * @return this builder */ public ModelParameters setPredict(int nPredict) { From dfbf19cb03be11f317bbac3e2302faa11bc2a0f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:55:42 +0000 Subject: [PATCH 279/589] Record the commits of F67, F69 and F158 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6727327ff..013607a8a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -489,7 +489,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** keine lauffähige: es braucht ein Modell und einen dazu passenden LoRA-Adapter (nicht in `models.csv`), und in dieser Sandbox gibt es keine native Bibliothek und kein `gguf-py`. Ein Test würde denselben Prompt vor und nach `setLoraAdapter(0, 0f)` mit Greedy-Sampling schicken und mit einem frisch geladenen Modell ohne Adapter vergleichen. - **CI-Bedarf:** ein kleines Modell mit LoRA-Adapter im Modellsatz (oder ein zur Laufzeit erzeugtes Zufallsmodell samt Adapter, wie `test_c_api.cpp` es für das Modell macht) – nur, falls das Verhalten geändert werden soll. - **Status:** gefixt (Dokumentation) -- **Commit:** (folgt) +- **Commit:** a977a1e ### F68 – java-llama.cpp: `RouterClient.awaitModelLoaded` läuft bei einem schlafenden Modell in den Timeout @@ -513,7 +513,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** keine lauffähige hier (kein Modell, keine native Bibliothek). Ein Test in einer modellgestützten Klasse: `model.complete(InferenceParameters.empty().withPrompt("x").withNPredict(-2))` → `InvalidRequestException` mit `n_predict` in der Meldung. - **CI-Bedarf:** ein modellgestützter Java-Test (jedes Modell aus `models.csv` genügt), falls `-2` zusätzlich in Java abgewiesen werden soll. - **Status:** gefixt (Dokumentation) -- **Commit:** (folgt) +- **Commit:** a977a1e ### F70 – java-llama.cpp: `OpenAiServerCli` bewirbt mit `--parallel N` das ganze `-c` statt des Kontexts je Slot @@ -1171,7 +1171,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Dokumentation korrigiert (ohne Test, Nutzerregel): Javadoc nennt nur noch `-1 = infinity` und erklärt, dass `-2` eine Einstellung des Completion-Tools ist und hier nach dem ersten Token stoppt. **Offen, Entscheidung des Nutzers:** `-2` in `ModelParameters.setPredict` mit `IllegalArgumentException` abweisen – modellfrei testbar, ändert aber das Verhalten eines öffentlichen Setters. - **CI-Bedarf:** Messung mit einem Modell aus `models.csv`: `new ModelParameters().setPredict(-2)` laden, eine Anfrage ohne `n_predict`, Anzahl der erzeugten Tokens (erwartet nach Quelltext: 1). - **Status:** Doku gefixt, Verhalten offen -- **Commit:** (folgt) +- **Commit:** a977a1e ## Offene Verdachte ohne Fund-ID From 6731f22f4effc00d13a6cebbc8982267de342a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:58:40 +0000 Subject: [PATCH 280/589] Carry the open points of the bug hunt into TODO.md Everything the bug hunt left open - decided but not implemented, waiting for a decision, and the CI and hardware runs - is listed in TODO.md with a pointer to its ledger entry, so it survives the end of the session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- TODO.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/TODO.md b/TODO.md index 3a440c34c..073a12e0a 100644 --- a/TODO.md +++ b/TODO.md @@ -507,6 +507,85 @@ Kept as a list because each entry names the machine it needs -- the harness is ## Open — jllama-specific +### Open points from the bug hunt of 2026-10-16 … 19 (details: `BUGHUNT-FOUND.md`) + +Everything the bug hunt left open, so nothing is lost when the session ends. The ledger entry +named in each item holds the analysis, the upstream references (b11639) and the reproduction; +delete an item here when it is done. + +**Waiting for the owner's decision** (findings with a proposed fix): + +- **F57** `ServerMetrics.getCumulativeUsage()` returns a `Usage` that breaks `Usage`'s own contract. + Proposal: `new Usage(processed + cached, predicted, cached)`; changes documented behaviour and + `ServerMetricsTest`. +- **F62** The LangChain4j adapter concatenates several `TextContent` without a separator. Proposal: + join with `\n` as F60/F61 do (or pass them on as parts); the test that pins today's output changes. +- **F158** `ModelParameters.setPredict(-2)`: documentation fixed; behaviour open. Proposal: reject + `-2` with an `IllegalArgumentException` (model-free test possible), since the server stops every + request after its first token with it. + +**CI runs** (the owner runs these together): + +- **F59** `FfmNativeServer.isRunning()` may read freed memory after a concurrent `close()`. Proposed + fix: a private lock around `isRunning()` and `close()`, as in `NativeServer`; the red test needs the + JDK 22+ job `test-java-llama-ffm` (no JDK 22 in the sandbox). Same job for the open suspicion on + `JllamaNative.takeStrings` below. +- **F158** measure: load with `setPredict(-2)`, one request without `n_predict`, count the generated + tokens (expected from the source: 1). +- Model-backed confirmations of fixed findings, none a precondition: **F64** (upstream's 400 for an + Ollama tool call without `type`, `OpenAiCompatServerIntegrationTest`), **F71** (fine-tuning text with + an emoji, `LlamaTrainerIntegrationTest` with `stories260K.gguf`), **F77** (`reasoning_effort: "none"` + really suppresses the thinking block), **F90** (`cached_tokens` > 0 in the second round), + **F93** (token usage of a streamed LangChain4j answer), **F128** + (`JllamaToolCallingIntegrationTest#streamedRequiredToolCallComesBackAsToolExecutionRequest` in + `test-java-llama-langchain4j-integration`, not a release gate: look at it in the next run). +- Only if the behaviour is to change: **F67** (clear the slots in `engine::set_lora_adapters`; needs a + model with a LoRA adapter) and **F69** (reject `withNPredict(-2)` in Java; any model). + +**Open suspicions without a finding id** (each needs the owner's decision or a model to decide): + +1. `llama-ffm` `JllamaNative.takeStrings` leaks the array for `count == 0` (8 bytes per call; + practically unreachable). Fix: always call `STRINGS_FREE` for a non-NULL pointer; test in + `test-java-llama-ffm`. +2. Ollama `done_reason`: `tool_calls` is passed through (Ollama reports `stop`), the streamed paths + always report `stop` even when cut off by `num_predict`, and the streamed final line has no + `prompt_eval_count`/`eval_count` (`withUsageChunk` not requested). +3. `OpenAiCompatServer` sends `200` + SSE headers before the backend runs, so a rejected streaming + request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by + `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; + changing it means sending the headers with the first chunk on all five streaming routes. +4. `RouterClient.findModel`/`awaitModelLoaded` compare `id` only; a model reached by an alias is + reported as not listed (`RouterModel` would need `aliases`). +5. `OpenAiCompatServer.main` without `-c` advertises 8192 + 2048 tokens whatever the model's window + is; the loaded model's slot `n_ctx` could be read instead (needs a model). +6. JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified + UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI + aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. +7. Empty tool-call arguments in the history: upstream parses `arguments` with `json::parse` for + templates with object arguments, so `""` fails the whole request. `""` can arise on the Java side + (`ChatResponseParser`, the delta accumulators, `ResponsesApiSupport.java:126`, + `AnthropicApiSupport.java:172`). Cheap guard: write `"{}"` for empty arguments in + `ParameterJsonSerializer.buildMessages`. Whether a model really emits `""` needs a model. +8. Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. +9. `/props` reports `total_slots` as 1 instead of `n_parallel` (needs a config field). +10. Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` + (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently + gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep + the remaining fields. +11. Responses `refusal` parts of assistant messages in the history are dropped; upstream turns them + into a part its own parser rejects with 400. Taking them as text is the most useful reading. +12. Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. +13. Continue decides FIM support from `.Suffix` in `/api/show`'s `template`; ours is `""`. A + placeholder template would be invented. +14. Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a + `prompt` given as an array is rejected with 400 when streamed, accepted when not. +15. `completeBatch`/`completeBatchWithStats`/`chatBatch` run on the common `ForkJoinPool` + (`availableProcessors() - 1`), so fewer requests run at once than `setParallel` allows; the + Javadoc says otherwise. Fix: submit all on the caller's thread, then receive. Also: + `rerank(String, String...)` collapses equal documents in its `HashMap`. +16. Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat + template, ours always completes raw. + ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) **Nothing has been published since 5.1.0 on 2026-08-29.** `pom.xml` is `6.0.0-SNAPSHOT`, and From faa6d0284de08278dad1f8b15bbdafb133a779b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:37:07 +0000 Subject: [PATCH 281/589] Add the measurement brief for the open points of the bug hunt Every measurement and CI or hardware run the bug hunt left open, with the commands, the hypothesis, what the result decides and an answer field, so an agent can run them all at once and answer in the repository. TODO.md points to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-MEASUREMENTS.md | 244 ++++++++++++++++++++++++++++++++++++++++ TODO.md | 3 +- 2 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 BUGHUNT-MEASUREMENTS.md diff --git a/BUGHUNT-MEASUREMENTS.md b/BUGHUNT-MEASUREMENTS.md new file mode 100644 index 000000000..e0e949415 --- /dev/null +++ b/BUGHUNT-MEASUREMENTS.md @@ -0,0 +1,244 @@ + + +# Messauftrag zum Bug-Hunt 2026-10-16 … 19 — java-llama.cpp + +Dieser Auftrag sammelt jede Messung und jeden modell- oder CI-gestützten Lauf, den der Bug-Hunt offen +gelassen hat. Der Hintergrund steht in `BUGHUNT-FOUND.md` (Fund-IDs, offene Verdachte) und in `TODO.md` +(„Open points from the bug hunt“). + +**Regeln für den ausführenden Agenten** + +- **Nur messen und berichten.** Keine Produktionsänderung, keine Fixes. Wegwerf-Proben (Testklassen, + kleine `main`-Programme) nach dem Lauf wieder entfernen; committet wird nur diese Datei mit den + Ergebnissen. Wo ein Punkt ausdrücklich einen Rot-Test verlangt (J7, J8), den Test schreiben, laufen + lassen, das Ergebnis eintragen und den Test **nicht** committen; der Quelltext des Tests gehört dann + ins Ergebnis. +- Arbeitsstand: Branch `ccr-90fc5f6a-vs4s0f`. Pin: llama.cpp b11639. +- Dateien aus `.github/shared-files.sha256` nicht verändern. +- Jeder Punkt wird beantwortet, auch wenn er nicht ausführbar war (mit Grund). Zu jedem Ergebnis die + Umgebung (CPU, Kerne, RAM, GPU, OS, JDK) und die wörtlichen Ausgaben, die das Ergebnis tragen. +- Ergebnis unter **Ergebnis:**, darunter **Folgerung:** (was es für die genannte Entscheidung heißt). + +**Gemeinsame Vorbereitung** + +```bash +# JDK: Temurin 21 (nicht Oracle 21, siehe CLAUDE.md); für J7/J8 zusätzlich ein JDK 22+ in ~/.m2/toolchains.xml +mvn -B -q -f llama/pom.xml compile +cd llama && cmake -B build && cmake --build build --config Release -j"$(nproc)" && cd .. +# Modelle aus .github/models.csv nach models/ (die Tests finden sie dort): +mkdir -p models +while IFS=, read -r f url; do case "$f" in \#*|"") continue;; esac + [ -f "models/$f" ] || curl -L --fail -o "models/$f" "$url"; done < .github/models.csv +# Auf einem Rechner ohne GPU alle Läufe mit -Dnet.ladenthin.llama.test.ngl=0 +``` + +Für die kleinen Messprogramme unten genügt eine Wegwerf-Testklasse unter +`llama/src/test/java/net/ladenthin/llama/` (dann gilt der Test-Klassenpfad mit `src/main/natives/`), gestartet +mit `mvn -B -q -f llama/pom.xml test -Dtest=`. Pfade über `TestConstants` (`DRAFT_MODEL_PATH`, +`REASONING_MODEL_PATH`, `DEFAULT_TOOL_MODEL_PATH`). + +--- + +## J1 F158: `ModelParameters.setPredict(-2)` — wie viele Tokens erzeugt der Server? + +**Ziel.** Aus dem Quelltext von b11639 abgeleitet (`server-context.cpp:1964`, `:495`, `:2040`), nicht gemessen: +mit `-n -2` beendet ein Slot jede Anfrage nach dem ersten Token, weil nur `-1` als unbegrenzt gilt. + +```java +try (LlamaModel model = new LlamaModel(new ModelParameters() + .setModel(TestConstants.DRAFT_MODEL_PATH).setGpuLayers(0).setPredict(-2))) { + CompletionResult r = model.completeWithStats(InferenceParameters.empty().withPrompt("def fib(n):")); + System.out.println("completion_tokens=" + r.getUsage().getCompletionTokens() + + " stop=" + r.getStopReason() + " text=" + r.getText()); +} +``` + +Dasselbe zum Vergleich mit `setPredict(-1)` und mit `setPredict(16)`. + +**Hypothese.** `-2`: 1 Token, Stopp-Grund Limit; `-1`: bis EOS oder Kontextende; `16`: 16. +**Entscheidet.** Ob `setPredict(-2)` in Java mit `IllegalArgumentException` abgelehnt werden soll. + +**Ergebnis:** + +**Folgerung:** + +--- + +## J2 F69 / Upstream-Doku: `withNPredict(-2)` und `withNPredict(0)` + +**Ziel.** (a) Bestätigen, dass der Server `n_predict: -2` pro Anfrage ablehnt (harte Grenze `-1 … INT32_MAX`, +`server-schema.cpp:44`). (b) Upstream beschreibt `0` als „no tokens will be generated but the prompt is +evaluated into the cache“; im Quelltext fand sich dafür kein eigener Zweig (`has_budget()` stoppt erst nach +dem ersten Token), deshalb steht die Aussage nicht in unserer Javadoc. + +```java +// (a) erwartet: InvalidRequestException, Meldung nennt n_predict +model.complete(InferenceParameters.empty().withPrompt("x").withNPredict(-2)); +// (b) Anzahl erzeugter Tokens bei 0 +CompletionResult r = model.completeWithStats(InferenceParameters.empty().withPrompt("def fib(n):").withNPredict(0)); +System.out.println(r.getUsage().getCompletionTokens() + " / " + r.getStopReason() + " / '" + r.getText() + "'"); +``` + +**Entscheidet.** (a) bestätigt die korrigierte Javadoc; (b) ob die Javadoc von `withNPredict` „0 = nur den +Prompt auswerten“ nennen darf (0 Tokens) oder ob upstreams Beschreibung falsch ist (1 Token). + +**Ergebnis:** + +**Folgerung:** + +--- + +## J3 Offener Verdacht 5: `OpenAiCompatServer` ohne `-c` bewirbt 8192 + 2048 Tokens + +**Ziel.** Ohne `-c` meldet der Server in `/props` und `/api/show` eine Kontextlänge aus den festen Vorgaben +(`DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048), unabhängig vom Modell. + +```bash +java -cp "llama/target/classes:llama/src/main/natives:$(mvn -q -f llama/pom.xml dependency:build-classpath -Dmdep.outputFile=/dev/stdout)" \ + net.ladenthin.llama.server.OpenAiCompatServer -m models/AMD-Llama-135m-code.Q2_K.gguf --port 18080 & +sleep 20 +curl -s localhost:18080/props | head -c 2000; echo +curl -s -X POST localhost:18080/api/show -d '{"model":"x"}' | head -c 2000; echo +kill %1 +``` + +Dazu die tatsächliche Slot-Größe: `model.getMetricsTyped()` bzw. `/slots` → `n_ctx`, und die Trainingslänge +des Modells (`n_ctx_train` im Ladelog). + +**Hypothese.** Beworben 10240, tatsächlich das Fenster des Modells (beim Draft-Modell kleiner). +**Entscheidet.** Ob der Starter das echte `n_ctx` des geladenen Modells bewerben soll. + +**Ergebnis:** + +**Folgerung:** + +--- + +## J4 Offener Verdacht 7: liefert ein Modell leere Werkzeug-Argumente `""`? + +**Ziel.** Upstream parst `function.arguments` im Verlauf mit `json::parse` (`common/chat.cpp:1026`); ein +leerer String lässt die ganze Folgeanfrage scheitern („Failed to parse tool call arguments as JSON“). Offen +ist, ob ein Modell für ein Werkzeug **ohne Parameter** `""` statt `"{}"` liefert. + +Mit `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` (`--jinja`): ein Werkzeug `get_time` mit leerem Parameter-Schema +(`{"type":"object","properties":{}}`), `tool_choice: "required"`, Prompt „What time is it?“. Zehnmal +blockierend und zehnmal gestreamt (`streamChatCompletion`), jeweils `tool_calls[0].function.arguments` +wörtlich protokollieren. Danach den Verlauf mit `arguments: ""` als Assistenten-Nachricht zurückschicken +und die Antwort des Servers protokollieren (blockierend; zur Not mit `ToolCallingIntegrationTest` als Vorlage). + +**Hypothese.** Das Modell liefert `"{}"`; mit `""` im Verlauf antwortet der Server mit 400. +**Entscheidet.** Ob `ParameterJsonSerializer.buildMessages` leere Argumente als `"{}"` schreiben soll. + +**Ergebnis:** + +**Folgerung:** + +--- + +## J5 Offener Verdacht 15: wie viele Anfragen laufen bei `completeBatch` gleichzeitig? + +**Ziel.** `completeBatch` (ebenso `completeBatchWithStats`, `chatBatch`) startet jede Anfrage über +`CompletableFuture.supplyAsync` auf dem gemeinsamen `ForkJoinPool` (Parallelität +`availableProcessors() - 1`), die Javadoc verspricht dagegen die mit `setParallel` eingestellte Slot-Zahl. + +```java +int cores = Runtime.getRuntime().availableProcessors(); +try (LlamaModel model = new LlamaModel(new ModelParameters() + .setModel(TestConstants.DRAFT_MODEL_PATH).setGpuLayers(0).setParallel(8).setCtxSize(8 * 512))) { + List reqs = new ArrayList<>(); + for (int i = 0; i < 8; i++) reqs.add(InferenceParameters.empty().withPrompt("def f" + i + "(x):").withNPredict(256)); + AtomicInteger maxBusy = new AtomicInteger(); + Thread poller = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { + maxBusy.accumulateAndGet(model.getMetricsTyped().getProcessingSlots(), Math::max); + try { Thread.sleep(20); } catch (InterruptedException e) { return; } } }); + poller.start(); + model.completeBatch(reqs); + poller.interrupt(); + System.out.println("cores=" + cores + " commonPool=" + ForkJoinPool.commonPool().getParallelism() + + " maxProcessingSlots=" + maxBusy.get()); +} +``` + +Auf einem Rechner mit 4 Kernen (bzw. mit `-Djava.util.concurrent.ForkJoinPool.common.parallelism=3`) und +auf einem mit vielen Kernen. + +**Hypothese.** `maxProcessingSlots` ≈ min(8, Pool-Parallelität), also weniger als 8 bei wenigen Kernen. +**Entscheidet.** Ob `completeBatch` auf „alle im Aufrufer-Thread übergeben, dann empfangen“ umgebaut wird. + +**Ergebnis:** + +**Folgerung:** + +--- + +## J6 Modellgestützte Bestätigungen bereits gefixter Funde + +Jeweils den genannten Test bzw. Ablauf laufen lassen und Zahl/Ausgabe eintragen; keiner ist Voraussetzung, +jeder bestätigt einen Fix, der ohne Modell nur bis zur Schnittstelle geprüft war. + +| Fund | Lauf | Erwartung | +|---|---|---| +| F64 | `OpenAiCompatServerIntegrationTest` (Ollama-Werkzeugaufruf im Verlauf ohne `type`) | keine 400 von upstream mehr | +| F71 | `LlamaTrainerIntegrationTest` mit einem Emoji im Trainingstext (`stories260K.gguf`) | Feintuning läuft durch | +| F77 | `OpenAiCompatServerIntegrationTest` mit dem Reasoning-Modell, Anfrage mit `reasoning_effort: "none"` | kein Denkblock in der Antwort | +| F90 | zwei Runden mit Claude Codes wechselndem `cch`-Stempel im System-Prompt | `cached_tokens` > 0 in Runde 2 | +| F93 | gestreamte LangChain4j-Antwort (`test-java-llama-langchain4j-integration`) | Token-Nutzung gesetzt | +| F128 | `JllamaToolCallingIntegrationTest#streamedRequiredToolCallComesBackAsToolExecutionRequest` | grün | + +```bash +mvn -B -f llama/pom.xml test -Dtest='OpenAiCompatServerIntegrationTest,LlamaTrainerIntegrationTest' -Dnet.ladenthin.llama.test.ngl=0 +mvn -B -q -pl llama -am -DskipTests install && mvn -B -f llama-langchain4j/pom.xml verify \ + -Dnet.ladenthin.llama.lib.path="$PWD/llama/src/main/natives/net/ladenthin/llama/Linux/x86_64/cpu" +``` + +**Ergebnis:** + +**Folgerung:** + +--- + +## J7 F59: `FfmNativeServer.isRunning()` gegen ein paralleles `close()` (JDK 22+) + +**Ziel.** `isRunning()` kann nach einem parallelen `close()` freigegebenen Speicher lesen. Vorgeschlagener +Rot-Test: paketinterne Naht für die beiden Method-Handles, `isRunning` in einem Latch festhalten, `close()` +parallel; Erwartung nach dem Fix: `close()` wartet. Lauf im Modul `llama-ffm` (JDK 22+, wie Job +`test-java-llama-ffm`): `mvn -B -f llama-ffm/pom.xml verify -Dnet.ladenthin.llama.lib.path=…`. + +**Entscheidet.** Ob das Lock aus `NativeServer` übernommen wird (rot = ja). + +**Ergebnis:** + +**Folgerung:** + +--- + +## J8 Offener Verdacht 1: `JllamaNative.takeStrings` gibt bei `count == 0` nicht frei (JDK 22+) + +**Ziel.** Bei `count == 0` kehrt `takeStrings` vor dem `try` zurück, `STRINGS_FREE` wird für den +Nicht-NULL-Zeiger nie gerufen (8 Byte je Aufruf; praktisch unerreichbar). Vorgeschlagener Test: +`takeStrings` mit der Freigabefunktion als Parameter, Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung +genau ein Freigabeaufruf. + +**Entscheidet.** Ob der Fix („bei Nicht-NULL immer freigeben“) umgesetzt wird. + +**Ergebnis:** + +**Folgerung:** + +--- + +## J9 Nur falls das Verhalten geändert werden soll: F67 (LoRA-Cache) + +Braucht ein kleines Modell mit passendem LoRA-Adapter (nicht in `models.csv`). Ablauf: denselben Prompt +mit Greedy-Sampling vor und nach `setLoraAdapter(0, 0f)` senden und mit einem frisch geladenen Modell ohne +Adapter vergleichen; dann dasselbe nach `eraseSlot(0)`. Ohne Adapter: als „nicht ausführbar“ beantworten. + +**Entscheidet.** Ob `engine::set_lora_adapters` nach dem Wechsel die Slots leeren soll. + +**Ergebnis:** + +**Folgerung:** diff --git a/TODO.md b/TODO.md index 073a12e0a..559e87ba2 100644 --- a/TODO.md +++ b/TODO.md @@ -524,7 +524,8 @@ delete an item here when it is done. `-2` with an `IllegalArgumentException` (model-free test possible), since the server stops every request after its first token with it. -**CI runs** (the owner runs these together): +**CI runs** (the owner runs these together). **The complete measurement brief for an agent, with +commands, hypotheses and answer fields, is `BUGHUNT-MEASUREMENTS.md` (J1–J9).** - **F59** `FfmNativeServer.isRunning()` may read freed memory after a concurrent `close()`. Proposed fix: a private lock around `isRunning()` and `close()`, as in `NativeServer`; the red test needs the From 4c58e8faf2ec986b48ae62712b43100317b7e3ee Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Mon, 19 Oct 2026 13:30:25 +0200 Subject: [PATCH 282/589] Record the results of the bug-hunt measurement brief All nine points are measured on a 16-core CPU machine (no GPU). - J1: setPredict(-2) stops every request after one token. - J2: withNPredict(-2) is rejected with 400; withNPredict(0) generates one token, not zero. - J3: without -c the server advertises 10240 while the slot has 2048. - J4: the model always sends "{}", but "" in the history fails the request. - J5: completeBatch runs min(slots, common pool) requests: 3 of 8 on 4 cores. - J6: F64, F71, F77, F90, F93 and F128 confirmed with models. - J7: red through a throwaway seam: close() frees while isRunning() runs. - J8: takeStrings never calls the free function when count is 0. - J9: F67 confirmed with llama.cpp's own LoRA test pair. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015bvDWEYcnzbZvnHgzS2KuX --- BUGHUNT-MEASUREMENTS.md | 212 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 194 insertions(+), 18 deletions(-) diff --git a/BUGHUNT-MEASUREMENTS.md b/BUGHUNT-MEASUREMENTS.md index e0e949415..13f9894b3 100644 --- a/BUGHUNT-MEASUREMENTS.md +++ b/BUGHUNT-MEASUREMENTS.md @@ -43,6 +43,20 @@ mit `mvn -B -q -f llama/pom.xml test -Dtest=`. Pfade über `TestConstant --- +## Messumgebung (Ergebnisse vom 2026-10-19) + +**Umgebung aller Läufe:** Vultr `voc-c-16c-32gb-300s-amd` (Frankfurt, dedizierte Kerne), AMD EPYC-Milan, 16 vCPU, +31 GB RAM, **keine GPU**, Ubuntu 24.04.5 LTS (Kernel 6.8.0-146-generic), Temurin 21.0.12.1+1 (Maven), Temurin +25.0.4.1+1 als Toolchain für `llama-ffm` (J7/J8), Maven 3.9.11, GCC 13.3.0, CMake aus Ubuntu 24.04. Branch +`ccr-90fc5f6a-vs4s0f` (`55f53d7a`), llama.cpp b11639 (`a0e6dc7384edb8f5d927155e1d26c10b4a310e6d`), statischer +CPU-Build nach der gemeinsamen Vorbereitung (`cmake -B build && cmake --build build --config Release -j16`), +Modelle aus `.github/models.csv`. Alle Läufe CPU-only (`-Dnet.ladenthin.llama.test.ngl=0`, `setDevices("none")`). +Die Proben J1–J5, J9 und F71 lagen in einer Wegwerf-Klasse `llama/src/test/java/net/ladenthin/llama/BughuntProbeTest.java`, +J7/J8 in `llama-ffm/src/test/java/net/ladenthin/llama/ffm/BughuntFfmProbeTest.java`, F93 in +`llama-langchain4j/src/test/java/…/BughuntLc4jProbeTest.java` — nur auf der VM, nicht committet, VM danach gelöscht. + +--- + ## J1 F158: `ModelParameters.setPredict(-2)` — wie viele Tokens erzeugt der Server? **Ziel.** Aus dem Quelltext von b11639 abgeleitet (`server-context.cpp:1964`, `:495`, `:2040`), nicht gemessen: @@ -62,9 +76,21 @@ Dasselbe zum Vergleich mit `setPredict(-1)` und mit `setPredict(16)`. **Hypothese.** `-2`: 1 Token, Stopp-Grund Limit; `-1`: bis EOS oder Kontextende; `16`: 16. **Entscheidet.** Ob `setPredict(-2)` in Java mit `IllegalArgumentException` abgelehnt werden soll. -**Ergebnis:** +**Ergebnis:** Probe wie im Brief (`DRAFT_MODEL_PATH` = `AMD-Llama-135m-code.Q2_K.gguf`, `completeWithStats`, +Prompt `def fib(n):`), zusätzlich die Stopp-Felder der Roh-JSON: +``` +J1 setPredict(-2) completion_tokens=1 stop=MAX_TOKENS text=" Quant" +J1 setPredict(-2) raw stop fields: stop_type="limit" ... tokens_predicted=1 n_predict(gen_settings)=-2 +J1 setPredict(-1) completion_tokens=2042 stop=MAX_TOKENS text="arginargs net used Net net ..." (gekürzt) +J1 setPredict(-1) raw stop fields: stop_type="limit" ... tokens_predicted=2042 n_predict(gen_settings)=-1 +J1 setPredict(16) completion_tokens=16 stop=MAX_TOKENS text=" $ NCargin Net used Net Net Net Net Net Net Net Net Net Net Net" +J1 setPredict(16) raw stop fields: stop_type="limit" ... tokens_predicted=16 n_predict(gen_settings)=16 +``` +(`-1`: 2042 Tokens = Kontext 2048 minus 6 Prompt-Tokens, also bis Kontextende; das Modell erreichte kein EOS.) -**Folgerung:** +**Folgerung:** Die Ableitung aus dem Quelltext stimmt: `-2` beendet jede Anfrage nach **einem** Token mit +Stopp-Grund Limit, still und ohne Fehler. `setPredict(-2)` sollte in Java mit `IllegalArgumentException` +abgelehnt werden (gültig: `-1` und `>= 0`). --- @@ -86,9 +112,16 @@ System.out.println(r.getUsage().getCompletionTokens() + " / " + r.getStopReason( **Entscheidet.** (a) bestätigt die korrigierte Javadoc; (b) ob die Javadoc von `withNPredict` „0 = nur den Prompt auswerten“ nennen darf (0 Tokens) oder ob upstreams Beschreibung falsch ist (1 Token). -**Ergebnis:** +**Ergebnis:** ``` +J2a withNPredict(-2) threw net.ladenthin.llama.exception.InvalidRequestException: Field 'n_predict': Value must be between -1 <= value <= 2147483647, but got -2 +J2b withNPredict(0) 1 / MAX_TOKENS / ' args' prompt_tokens=6 +J2b raw stop fields: stop_type="limit" ... tokens_predicted=1 n_predict(gen_settings)=0 +``` -**Folgerung:** +**Folgerung:** (a) Die korrigierte Javadoc ist bestätigt: der Server lehnt `-2` pro Anfrage mit 400 +(`InvalidRequestException`) und genau dieser Meldung ab. (b) `0` erzeugt **ein** Token, nicht null — +upstreams Beschreibung („no tokens will be generated“) ist für b11639 falsch. Die Javadoc von `withNPredict` darf +„0 = nur den Prompt auswerten“ **nicht** nennen; richtig ist „0 verhält sich wie 1“. --- @@ -112,9 +145,21 @@ des Modells (`n_ctx_train` im Ladelog). **Hypothese.** Beworben 10240, tatsächlich das Fenster des Modells (beim Draft-Modell kleiner). **Entscheidet.** Ob der Starter das echte `n_ctx` des geladenen Modells bewerben soll. -**Ergebnis:** +**Ergebnis:** Befehl aus dem Brief (`OpenAiCompatServer -m models/AMD-Llama-135m-code.Q2_K.gguf --port 18080`, kein `-c`): +``` +--- /props +{"default_generation_settings":{"n_ctx":10240,"model":"AMD-Llama-135m-code.Q2_K.gguf"},"total_slots":1,"model_alias":"AMD-Llama-135m-code.Q2_K.gguf","chat_template":"","modalities":{"vision":false,"audio":false,"video":false}} +--- /api/show +{"license":"","modelfile":"","parameters":"num_ctx 10240","template":"","details":{"family":"llama","families":["llama"],"parameter_size":"","quantization_level":""},"model_info":{"general.architecture":"llama","llama.context_length":10240},"capabilities":["completion","tools","insert"],"model":"AMD-Llama-135m-code.Q2_K.gguf"} +--- /slots +[{"id":0,"n_ctx":2048,"speculative":false,"is_processing":false}] +``` +Ladelog: `srv load_model: initializing, n_slots = 1, n_ctx_slot = 2048, kv_unified = 'false'`. +`getMetricsTyped()`/`getModelMeta()` am selben Modell: `J3 slot id=0 n_ctx=2048`, `"n_ctx_train":2048`. -**Folgerung:** +**Folgerung:** Bestätigt: beworben werden 10240 (= 8192 + 2048), tatsächlich hat der Slot 2048 (die +Trainingslänge des Modells). Ein Client, der sich auf `/props`/`/api/show` verlässt, schickt bis zu 5× zu viel +Kontext. Der Starter sollte das echte `n_ctx` des geladenen Modells bewerben. --- @@ -133,9 +178,22 @@ und die Antwort des Servers protokollieren (blockierend; zur Not mit `ToolCallin **Hypothese.** Das Modell liefert `"{}"`; mit `""` im Verlauf antwortet der Server mit 400. **Entscheidet.** Ob `ParameterJsonSerializer.buildMessages` leere Argumente als `"{}"` schreiben soll. -**Ergebnis:** +**Ergebnis:** `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`, `enableJinja()`, Kontext 4096. Rohe OpenAI-JSON über +`handleChatCompletions` (damit `arguments` wörtlich ankommt), Werkzeug `get_time` mit +`{"type":"object","properties":{}}`, `tool_choice: "required"`, Prompt „What time is it?“, Standard-Sampling. +``` +J4 blocking#0 arguments(literal JSON string)="{}" name="get_time" finish="tool_calls" +... (blocking#1 bis #9 identisch: "{}", get_time, tool_calls) +J4 streamed#0 arguments(concatenated)="{}" pieces=["{","}"] +... (streamed#1 bis #9 identisch) +J4 history arguments="" -> net.ladenthin.llama.exception.InvalidRequestException: Failed to parse tool call arguments as JSON: [json.exception.parse_error.101] parse error at line 1, column 1: attempting to parse an empty input; check that your input string or stream contains the expected JSON +J4 history arguments="{}" -> OK: {"role":"assistant","content":"The current time is 12:00."} +``` -**Folgerung:** +**Folgerung:** Dieses Modell liefert 20/20 `"{}"`, nie `""`. Ein `""` im Verlauf lässt die ganze Folgeanfrage +aber mit 400 scheitern, `"{}"` geht durch. Der Fall ist mit diesem Modell nicht erreichbar, der Schaden bei einem +Modell oder Client, der `""` schreibt, ist jedoch total. `ParameterJsonSerializer.buildMessages` sollte leere +Argumente als `"{}"` schreiben (billig, ohne Nebenwirkung). --- @@ -169,9 +227,19 @@ auf einem mit vielen Kernen. **Hypothese.** `maxProcessingSlots` ≈ min(8, Pool-Parallelität), also weniger als 8 bei wenigen Kernen. **Entscheidet.** Ob `completeBatch` auf „alle im Aufrufer-Thread übergeben, dann empfangen“ umgebaut wird. -**Ergebnis:** +**Ergebnis:** Probe wie im Brief (Draft-Modell, `setParallel(8)`, `setCtxSize(8*512)`, 8 × 256 Tokens, Abtastung von +`getProcessingSlots()` alle 20 ms): +``` +16 Kerne (Standard): J5 cores=16 commonPool=15 maxProcessingSlots=8 batchMillis=3225 +4 Kerne (taskset -c 0-3): J5 cores=4 commonPool=3 maxProcessingSlots=3 batchMillis=14067 +16 Kerne, -Djava.util.concurrent.ForkJoinPool.common.parallelism=3: + J5 cores=16 commonPool=3 maxProcessingSlots=3 batchMillis=3262 +``` -**Folgerung:** +**Folgerung:** Bestätigt: Die Gleichzeitigkeit ist min(Slots, Parallelität des gemeinsamen `ForkJoinPool`) — auf +4 Kernen nur 3 von 8 Slots, unabhängig von `setParallel`. Die Javadoc-Zusage stimmt nur auf Rechnern mit mehr +als `setParallel` Kernen. Umbau auf „alle im Aufrufer-Thread übergeben, dann empfangen“ empfohlen; er entkoppelt +außerdem `completeBatch` vom gemeinsamen Pool der Anwendung. --- @@ -195,9 +263,32 @@ mvn -B -q -pl llama -am -DskipTests install && mvn -B -f llama-langchain4j/pom.x -Dnet.ladenthin.llama.lib.path="$PWD/llama/src/main/natives/net/ladenthin/llama/Linux/x86_64/cpu" ``` -**Ergebnis:** +**Ergebnis:** Die beiden Befehle aus dem Brief: +``` +mvn -B -f llama/pom.xml test -Dtest='OpenAiCompatServerIntegrationTest,LlamaTrainerIntegrationTest' -Dnet.ladenthin.llama.test.ngl=0 +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 -- in net.ladenthin.llama.server.OpenAiCompatServerIntegrationTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in net.ladenthin.llama.LlamaTrainerIntegrationTest +mvn -B -f llama-langchain4j/pom.xml verify -Dnet.ladenthin.llama.lib.path=… (+ model/embedding/rerank-Eigenschaften) +[INFO] Tests run: 55, Failures: 0, Errors: 0, Skipped: 3 (die 3 = JllamaToolCallingIntegrationTest, Eigenschaft fehlte) +[INFO] BUILD SUCCESS +``` +Die vorhandenen Tests spielen die Szenarien der Tabelle nicht nach (kein Werkzeugaufruf im Ollama-Verlauf, kein +`reasoning_effort`, kein `cch`-Stempel, kein Emoji im Trainingstext). Deshalb jedes Szenario einzeln: -**Folgerung:** +| Fund | Lauf | Ausgabe | +|---|---|---| +| F64 | `OpenAiCompatServer --jinja` mit Qwen2.5-1.5B, `POST /api/chat`, Verlauf `assistant.tool_calls=[{"function":{"name":"get_time","arguments":{}}}]` (ohne `type`) + `tool`-Antwort | `HTTP 200`, `{"message":{"role":"assistant","content":"The current time is 12:00."},…,"done":true,"done_reason":"stop"}` | +| F71 | Feintuning wie `LlamaTrainerIntegrationTest` (`stories260K.gguf`, nCtx 128, ADAMW), Korpus mit 🦊 und 🐶 (128 Zeichen außerhalb der BMP) | `J6/F71 finetune OK: input=1185376 B, output=1201728 B, weights changed=true` | +| F77 | `OpenAiCompatServer --jinja` mit Qwen3-0.6B, `/v1/chat/completions` „What is 2+2?“ mit `reasoning_effort: "none"` gegen ohne | mit `none`: `reasoning_content_len=0 think_in_content=False completion_tokens=9 content='2 + 2 = 4.'`; ohne: `reasoning_content_len=459 completion_tokens=133` | +| F90 | `/v1/messages` (Anthropic), System-Prompt `x-anthropic-billing-header: cc_version=2.1.3.abc; cc_entrypoint=cli; cch=…;` + ~880 Token, drei Runden | `cch=a5145`: `cache_read_input_tokens 3`; **`cch=b7731`: `input_tokens 1, cache_read_input_tokens 885`**; Gegenprobe mit geändertem `cc_version`: `cache_read_input_tokens 18` | +| F93 | `JllamaStreamingChatModel` und `JllamaChatModel`, Qwen2.5-1.5B, „Reply with the single word: ok“ | gestreamt: `tokenUsage=TokenUsage { inputTokenCount = 36, outputTokenCount = 2, totalTokenCount = 38 } finishReason=STOP`; blockierend identisch | +| F128 | `JllamaToolCallingIntegrationTest` mit `-Dnet.ladenthin.llama.langchain4j.tool.model=…/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | `Tests run: 3, Failures: 0, Errors: 0, Skipped: 0` (enthält `streamedRequiredToolCallComesBackAsToolExecutionRequest`) | + +**Folgerung:** Alle sechs Fixes sind mit Modell bestätigt: F64 kein 400 mehr, F71 Feintuning läuft mit Emoji +durch und ändert die Gewichte, F77 unterdrückt den Denkblock (9 statt 133 Tokens), F90 trifft den Präfix-Cache +in Runde 2 (885 von 886 Tokens aus dem Cache; die Gegenprobe zeigt, dass ohne die Normalisierung nur der +Anfang vor der Änderung getroffen würde), F93 setzt die Token-Nutzung im Stream, F128 grün. Hinweis für CI: der +Integrationsjob sollte `net.ladenthin.llama.langchain4j.tool.model` setzen, sonst bleibt F128 dort übersprungen. --- @@ -210,9 +301,52 @@ parallel; Erwartung nach dem Fix: `close()` wartet. Lauf im Modul `llama-ffm` (J **Entscheidet.** Ob das Lock aus `NativeServer` übernommen wird (rot = ja). -**Ergebnis:** +**Ergebnis:** `mvn -B -f llama-ffm/pom.xml test -Dtest=BughuntFfmProbeTest#… -Dnet.ladenthin.llama.lib.path=…` +(Toolchain: `Found matching toolchain for type jdk: JDK[/opt/jdk-25.0.4.1+1]`). + +1. **Rot-Test mit paketinterner Naht** (nur auf der VM): `FfmNativeServer` bekam zwei statische Felder + `isRunningHandle`/`stopHandle` (vorbelegt mit `SERVER_IS_RUNNING`/`SERVER_STOP`) statt der direkten Verweise. + Der Test ersetzt sie durch Java-Handles: `isRunning` hält in einem Latch fest, `close()` läuft parallel. + ``` + J7 seam events: [isRunning:native-entered, close:native-stop (frees the handle), close() RETURNED after 2 s, isRunning:native-returning] + J7 seam verdict: close() did NOT wait for the isRunning() native call (RED: stop/free happened while it was inside) + Tests run: 1, Failures: 1 (AssertionError "J7 RED: close() returned while isRunning() was inside the native call") + ``` +2. **Ohne Naht, echter Server** (Router-Modus über ein leeres `--models-dir`, 30 Runden, ein Thread ruft + `isRunning()` in einer Schleife, der Haupt-Thread `close()`): + ``` + J7 real: rounds=30 isRunning calls=862476291 true-after-close=0 (JVM survived) + ``` +Quelltext des Rot-Tests (Auszug; Naht: `static MethodHandle isRunningHandle = JllamaNative.SERVER_IS_RUNNING;`, +`static MethodHandle stopHandle = JllamaNative.SERVER_STOP;`, in `isRunning()`/`close()` statt der Konstanten): +```java +static boolean fakeIsRunning(MemorySegment handle) throws InterruptedException { + EVENTS.add("isRunning:native-entered"); ENTERED.countDown(); RELEASE.await(); + EVENTS.add("isRunning:native-returning"); return true; +} +static int fakeStop(MemorySegment handle) { EVENTS.add("close:native-stop (frees the handle)"); return 0; } + +@Test public void j7Seam() throws Exception { + MethodHandles.Lookup l = MethodHandles.lookup(); + FfmNativeServer.isRunningHandle = l.findStatic(BughuntFfmProbeTest.class, "fakeIsRunning", + MethodType.methodType(boolean.class, MemorySegment.class)); + FfmNativeServer.stopHandle = l.findStatic(BughuntFfmProbeTest.class, "fakeStop", + MethodType.methodType(int.class, MemorySegment.class)); + Constructor c = FfmNativeServer.class.getDeclaredConstructor(MemorySegment.class); + c.setAccessible(true); + FfmNativeServer server = c.newInstance(MemorySegment.NULL); + Thread reader = new Thread(server::isRunning); reader.start(); ENTERED.await(); + Thread closer = new Thread(server::close); closer.start(); closer.join(2000); + boolean closeReturnedWhileIsRunningInside = !closer.isAlive(); + RELEASE.countDown(); reader.join(); closer.join(); + if (closeReturnedWhileIsRunningInside) throw new AssertionError("J7 RED: close() returned while isRunning() was inside the native call"); +} +``` -**Folgerung:** +**Folgerung:** Rot: `close()` gibt den Handle frei, während `isRunning()` noch im nativen Aufruf auf ihm arbeitet. +Das Fenster ist im Normalbetrieb schmal (862 Mio. Aufrufe ohne sichtbaren Schaden), ein Use-after-free bleibt +es trotzdem. Das Lock aus `NativeServer` sollte übernommen werden (nach dem Fix muss der Test grün werden: +`close()` wartet). --- @@ -225,9 +359,35 @@ genau ein Freigabeaufruf. **Entscheidet.** Ob der Fix („bei Nicht-NULL immer freigeben“) umgesetzt wird. -**Ergebnis:** +**Ergebnis:** `takeStrings` hat keine Naht für die Freigabefunktion (`STRINGS_FREE` ist `static final`), der +vorgeschlagene Test bräuchte also eine Produktionsänderung. Stattdessen deterministisch über glibc: ein +**falsch ausgerichteter** Nicht-NULL-Zeiger, auf dem `free()` sofort mit `free(): invalid pointer` abbricht. +``` +mode=control (jllama_strings_free(p, 0) direkt): mvn exit=1 Process Exit Code: 134 free(): invalid pointer +mode=takeStrings (JllamaNative.takeStrings(p, 0)): mvn exit=0 Tests run: 1, Failures: 0 + J8 mode=takeStrings: calling with misaligned non-NULL pointer 0x755a0072b991 and count 0 + J8 mode=takeStrings: returned [] and the JVM survived -> the free function was NOT called +``` +Probe: +```java +@Test public void j8InvalidPointer() throws Throwable { + String mode = System.getProperty("j8.mode", "takeStrings"); + try (Arena arena = Arena.ofConfined()) { + MemorySegment p = MemorySegment.ofAddress(arena.allocate(64, 16).address() + 1); + MemorySegment out = arena.allocate(ADDRESS); MemorySegment len = arena.allocate(JAVA_LONG); + out.set(ADDRESS, 0, p); len.set(JAVA_LONG, 0, 0L); + if (mode.equals("takeStrings")) JllamaNative.takeStrings(out, len); + else JllamaNative.invoke(JllamaNative.STRINGS_FREE, p, 0L); + } +} +``` +(Zwei vorherige Versuche über den Heap-Zähler `mallinfo2().uordblks` und über die tcache-Double-Free-Erkennung +waren nicht aussagekräftig — die JVM allokiert dazwischen selbst, die Gegenprobe schlug nicht an — und sind +deshalb nicht Teil des Ergebnisses.) -**Folgerung:** +**Folgerung:** Bestätigt: Bei `count == 0` ruft `takeStrings` die Freigabe nie auf, ein Nicht-NULL-Array leckt +(laut Verdacht 8 Byte je Aufruf); `jllama_strings_free(p, 0)` würde es freigeben. Der Fix „bei Nicht-NULL immer +freigeben“ ist richtig und gefahrlos. --- @@ -239,6 +399,22 @@ Adapter vergleichen; dann dasselbe nach `eraseSlot(0)`. Ohne Adapter: als „nic **Entscheidet.** Ob `engine::set_lora_adapters` nach dem Wechsel die Slots leeren soll. -**Ergebnis:** +**Ergebnis:** Kein Adapter in `models.csv`; genutzt wurde das Paar aus llama.cpps eigenen Server-Tests +(`tools/server/tests/unit/test_lora.py`): `ggml-org/stories15M_MOE` → `stories15M_MOE-F16.gguf` mit +`moe_shakespeare15M.gguf`. Greedy (`temperature 0, top_k 1, seed 42`, `cache_prompt true`, Slot 0, `setParallel(1)`, +Kontext 512), Prompt „Look in thy glass“, 32 Tokens: +``` +J9 fresh model, no adapter: " was a little girl. She was only three years old and she was three years old. She was three years old. She was three years old and she was" +J9 adapters at load: [LoraAdapter(id=0, path=…/moe_shakespeare15M.gguf, scale=1.0, …)] +J9 adapter scale 1 (cold slot): " in love of the eye:\nThat's when when the eye see thy on the sun's shining bright.\nThat's why those flowers" +J9 adapter scale 1 (warm slot): " in love of the eye:\nThat's when when the eye see thy on the sun's shining bright.\nThat's why those flowers" +J9 adapters after setLoraAdapter(0,0): [LoraAdapter(id=0, …, scale=0.0, …)] +J9 scale 0, warm slot (cache kept): ". He saw a picture of a bird and he wanted to know what it was. He asked his mom, \"What is it?\"\n\"It's" equalsFresh=false +J9 eraseSlot(0): {"id_slot":0,"n_erased":36} +J9 scale 0 after eraseSlot(0): " was a little girl. She was only three years old and she was three years old. She was three years old. She was three years old and she was" equalsFresh=true +``` -**Folgerung:** +**Folgerung:** F67 ist bestätigt: Nach `setLoraAdapter(0, 0f)` rechnet der Slot mit dem unter Skala 1 +berechneten Prompt-Cache weiter und liefert eine dritte, weder dem Adapter noch dem Grundmodell entsprechende +Ausgabe. Erst `eraseSlot(0)` stellt das Verhalten eines frischen Modells her. `engine::set_lora_adapters` sollte +nach einem Wechsel die Slots leeren, wenn das Verhalten geändert werden soll. From be4d4a263a37640c3babadbf063145e7d678d7e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:54:16 +0000 Subject: [PATCH 283/589] Reject a predict setting below -1 instead of stopping after one token The server counts only -1 as unlimited, so a model loaded with -n -2 answered every request with one token and stop reason limit, without an error (measured at b11639). withNPredict documents that 0 behaves like 1, which the measurement showed as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 8 ++++--- TODO.md | 5 ----- .../llama/parameters/InferenceParameters.java | 3 +++ .../llama/parameters/ModelParameters.java | 9 +++++++- .../ModelParametersExtendedTest.java | 21 ++++++++++++++++--- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 013607a8a..88f73f6d9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1169,9 +1169,11 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** niedrig bis mittel (still: keine Fehlermeldung, nur abgeschnittene Antworten) - **Beschreibung:** Gefunden beim Nachprüfen von F69. Upstreams `-n` (`common/arg.cpp:1661`, b11639) nennt „-2 = until context filled“ **nur** für `LLAMA_EXAMPLE_COMPLETION`, für den Server lautet die Beschreibung „-1 = infinity“; der Parser übernimmt jeden Wert. Im Server übernimmt ein Slot `params_base.n_predict`, wenn die Anfrage `-1` sendet oder das Feld weglässt (`server-context.cpp:1964`), und `has_budget()` (`:495`) zählt nur `-1` als unbegrenzt: bei `-2` ist `n_remaining()` negativ, und die Grenzprüfung (`:2040`, `n_gen > 0 && !has_budget()`) beendet die Erzeugung nach dem ersten Token mit `STOP_TYPE_LIMIT`. Aus dem Quelltext abgeleitet, **nicht gemessen**. - **Fix:** Dokumentation korrigiert (ohne Test, Nutzerregel): Javadoc nennt nur noch `-1 = infinity` und erklärt, dass `-2` eine Einstellung des Completion-Tools ist und hier nach dem ersten Token stoppt. **Offen, Entscheidung des Nutzers:** `-2` in `ModelParameters.setPredict` mit `IllegalArgumentException` abweisen – modellfrei testbar, ändert aber das Verhalten eines öffentlichen Setters. -- **CI-Bedarf:** Messung mit einem Modell aus `models.csv`: `new ModelParameters().setPredict(-2)` laden, eine Anfrage ohne `n_predict`, Anzahl der erzeugten Tokens (erwartet nach Quelltext: 1). -- **Status:** Doku gefixt, Verhalten offen -- **Commit:** a977a1e +- **Messung (J1, `BUGHUNT-MEASUREMENTS.md`):** bestätigt – `-2`: 1 Token, `stop_type="limit"`; `-1`: 2042 Tokens (bis Kontextende); `16`: 16. Dazu J2: `withNPredict(0)` erzeugt ebenfalls 1 Token, nicht 0. +- **Nachtrag (Entscheidung des Nutzers nach der Messung):** `setPredict` lehnt Werte unter `-1` mit `IllegalArgumentException` ab; Javadoc von `setPredict` und `withNPredict` sagt „`0` verhält sich wie `1`“. Test `ModelParametersExtendedTest#testSetPredictBelowMinusOneIsRejected` (`-2`, `-3`, `Integer.MIN_VALUE`) ersetzt `testSetPredictFillContext`, dazu `testSetPredictZero`. +- **Rot-Grün-Nachweis:** ohne die Prüfung rot (3 von 3), mit ihr grün (`ModelParametersExtendedTest`, `ModelParametersTest`, `WireNameRegistryTest`: 252 Tests). +- **Status:** gefixt +- **Commit:** a977a1e (Doku), (folgt) (Verhalten) ## Offene Verdachte ohne Fund-ID diff --git a/TODO.md b/TODO.md index 559e87ba2..861879be3 100644 --- a/TODO.md +++ b/TODO.md @@ -520,9 +520,6 @@ delete an item here when it is done. `ServerMetricsTest`. - **F62** The LangChain4j adapter concatenates several `TextContent` without a separator. Proposal: join with `\n` as F60/F61 do (or pass them on as parts); the test that pins today's output changes. -- **F158** `ModelParameters.setPredict(-2)`: documentation fixed; behaviour open. Proposal: reject - `-2` with an `IllegalArgumentException` (model-free test possible), since the server stops every - request after its first token with it. **CI runs** (the owner runs these together). **The complete measurement brief for an agent, with commands, hypotheses and answer fields, is `BUGHUNT-MEASUREMENTS.md` (J1–J9).** @@ -531,8 +528,6 @@ commands, hypotheses and answer fields, is `BUGHUNT-MEASUREMENTS.md` (J1–J9).* fix: a private lock around `isRunning()` and `close()`, as in `NativeServer`; the red test needs the JDK 22+ job `test-java-llama-ffm` (no JDK 22 in the sandbox). Same job for the open suspicion on `JllamaNative.takeStrings` below. -- **F158** measure: load with `setPredict(-2)`, one request without `n_predict`, count the generated - tokens (expected from the source: 1). - Model-backed confirmations of fixed findings, none a precondition: **F64** (upstream's 400 for an Ollama tool call without `type`, `OpenAiCompatServerIntegrationTest`), **F71** (fine-tuning text with an emoji, `LlamaTrainerIntegrationTest` with `stories260K.gguf`), **F77** (`reasoning_effort: "none"` diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java index 9ff36301a..8b5d0478e 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java @@ -197,6 +197,9 @@ public InferenceParameters withSlotId(int slotId) { * (default: -1 = the model's {@code --predict} setting, which itself defaults to unlimited). The * server accepts {@code -1} and above; {@code -2} is rejected as an invalid request. * + *

{@code 0} behaves like {@code 1}: one token is generated (measured at b11639). Upstream's + * description of {@code 0} as "only evaluate the prompt" does not hold for the server. + * * @param nPredict tokens to predict * @return a new instance; this instance is unchanged */ diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index f40ade91f..48592499f 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -204,12 +204,19 @@ public ModelParameters setCtxSize(int ctxSize) { * *

{@code -2} ("until context filled") is a setting of llama.cpp's completion tool, not of the * server this library runs. There a slot counts only {@code -1} as unlimited, so with {@code -2} - * a request stops after its first token. + * every request stopped after its first token without an error (measured at b11639); it is + * rejected here instead. {@code 0} behaves like {@code 1}. * * @param nPredict the maximum number of tokens to predict (-1 = infinity) * @return this builder + * @throws IllegalArgumentException if {@code nPredict} is below {@code -1} */ public ModelParameters setPredict(int nPredict) { + if (nPredict < -1) { + throw new IllegalArgumentException("Invalid predict value: " + nPredict + + " (must be -1 = unlimited or >= 0; -2 is a setting of llama.cpp's completion tool," + + " the server would stop every request after one token)"); + } return putScalar(ModelOption.PREDICT, nPredict); } diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java index d2c2d1410..8a24f4787 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersExtendedTest.java @@ -29,6 +29,8 @@ import net.ladenthin.llama.args.RopeScalingType; import net.ladenthin.llama.value.RpcEndpoint; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Extended tests for {@link ModelParameters} covering CLI argument serialization @@ -83,9 +85,22 @@ public void testSetPredictInfinity() { } @Test - public void testSetPredictFillContext() { - ModelParameters p = new ModelParameters().setPredict(-2); - assertThat(p.parameters.get("--predict"), is("-2")); + public void testSetPredictZero() { + ModelParameters p = new ModelParameters().setPredict(0); + assertThat(p.parameters.get("--predict"), is("0")); + } + + /** + * {@code -2} ("until context filled") is a setting of llama.cpp's completion tool. The server + * counts only {@code -1} as unlimited, so a model loaded with {@code -2} answered every request + * with exactly one token and stop reason "limit", without an error (measured at b11639). + */ + @ParameterizedTest + @ValueSource(ints = {-2, -3, Integer.MIN_VALUE}) + public void testSetPredictBelowMinusOneIsRejected(int nPredict) { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> new ModelParameters().setPredict(nPredict)); + assertThat(e.getMessage(), containsString(String.valueOf(nPredict))); } @Test From 80f70d763ad9a870cc528a33b0eca08cf9a4cb95 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:54:17 +0000 Subject: [PATCH 284/589] Record the commit of the F158 follow-up in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 88f73f6d9..96d53a9e8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1173,7 +1173,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag (Entscheidung des Nutzers nach der Messung):** `setPredict` lehnt Werte unter `-1` mit `IllegalArgumentException` ab; Javadoc von `setPredict` und `withNPredict` sagt „`0` verhält sich wie `1`“. Test `ModelParametersExtendedTest#testSetPredictBelowMinusOneIsRejected` (`-2`, `-3`, `Integer.MIN_VALUE`) ersetzt `testSetPredictFillContext`, dazu `testSetPredictZero`. - **Rot-Grün-Nachweis:** ohne die Prüfung rot (3 von 3), mit ihr grün (`ModelParametersExtendedTest`, `ModelParametersTest`, `WireNameRegistryTest`: 252 Tests). - **Status:** gefixt -- **Commit:** a977a1e (Doku), (folgt) (Verhalten) +- **Commit:** a977a1e (Doku), ae07a53 (Verhalten) ## Offene Verdachte ohne Fund-ID From 490f43bfe7b10388502eb9180a2c3f120e22627a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:59:03 +0000 Subject: [PATCH 285/589] Advertise the loaded model's slot context in the OpenAI-compatible server Without -c the server advertised the fixed 8192 + 2048 tokens whatever the model loaded with: a slot of 2048 tokens appeared as 10240 in /props and /api/show (measured), and a client sizing its prompt from that sends up to five times too much. main now passes n_ctx of the first slot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++++- TODO.md | 4 +-- .../llama/server/OpenAiCompatServer.java | 16 ++++++++++- .../llama/server/OpenAiServerCli.java | 28 +++++++++++++++++-- .../llama/server/OpenAiServerCliTest.java | 27 ++++++++++++++++++ 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 96d53a9e8..0c78a6530 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1175,13 +1175,24 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** a977a1e (Doku), ae07a53 (Verhalten) +### F165 – java-llama.cpp: `OpenAiCompatServer` ohne `-c` bewirbt 8192 + 2048 Tokens statt des Fensters des geladenen Modells +- **Zeit:** 2026-10-19 14:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`), `OpenAiCompatServer.java` (`main`) +- **Schwere:** mittel (ein Client, der seinen Prompt nach `/props`/`/api/show` bemisst, schickt bis zu 5× zu viel Kontext) +- **Historie:** Die Budgets kamen nur aus `-c` (seit F70 als ganzes Slot-Fenster aufgeteilt); ohne `-c` blieben die festen Vorgaben stehen. +- **Messung (J3, `BUGHUNT-MEASUREMENTS.md`):** Draft-Modell ohne `-c`: `/props` `n_ctx` 10240, `/api/show` `num_ctx 10240`, `/slots` `n_ctx` 2048 (= `n_ctx_train`). +- **Fix:** Entscheidung des Nutzers: das echte `n_ctx` bewerben. Neue Überladung `toServerConfig(supportsVision, modelFtype, slotContextSize)`; `main` übergibt `n_ctx` des ersten Slots aus `getMetricsTyped()`. Ein bekanntes Slot-Fenster geht vor `-c` (die Last kann kleiner ausfallen, etwa durch `--fit`), sonst gilt wie bisher `-c` bzw. die Vorgabe. +- **Tests:** `OpenAiServerCliTest#loadedSlotContextIsAdvertisedWithoutContextSize`, `#loadedSlotContextWinsOverTheContextSizeArgument`, `#unknownSlotContextFallsBackToTheContextSizeArgument`. +- **Rot-Grün-Nachweis:** mit der alten Auswahl (nur `-c`) rot (2 von 3, der Rückfalltest ist beidseitig grün), mit dem Fix grün (`OpenAiServerCliTest`, `OpenAiCompatServer*Test`: 114 Tests). Die Verdrahtung in `main` braucht ein Modell und ist hier nur übersetzt; Prüfung mit dem Befehl aus J3 (erwartet: 2048 in `/props` und `/api/show`). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. -- *(niedrig, nur beschrieben, CI-Bedarf: Modell, 2026-10-18, Musterdurchsicht nach F70)* `OpenAiCompatServer.main` → `OpenAiServerCli.toServerConfig(...)` ohne `-c`: beworben werden die festen Vorgaben `DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048 (Kontextlänge 10240 in `/api/show`/`/props`), unabhängig vom geladenen Modell. Upstreams Vorgabe für `-c` ist 0 = Trainingskontext des Modells (ggf. von `common_fit_params` verkleinert); ein Modell mit kleinerem Fenster (z. B. 2048) wird so zu groß beworben. Der Starter hält das geladene Modell schon in der Hand (Zeile 1102) und könnte das echte Slot-Fenster lesen (`getMetricsTyped()` → `SlotMetrics.n_ctx`); ohne Modell hier nicht testbar. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. diff --git a/TODO.md b/TODO.md index 861879be3..f56f33d9c 100644 --- a/TODO.md +++ b/TODO.md @@ -552,8 +552,8 @@ commands, hypotheses and answer fields, is `BUGHUNT-MEASUREMENTS.md` (J1–J9).* changing it means sending the headers with the first chunk on all five streaming routes. 4. `RouterClient.findModel`/`awaitModelLoaded` compare `id` only; a model reached by an alias is reported as not listed (`RouterModel` would need `aliases`). -5. `OpenAiCompatServer.main` without `-c` advertises 8192 + 2048 tokens whatever the model's window - is; the loaded model's slot `n_ctx` could be read instead (needs a model). +5. *Fixed as F165:* `OpenAiCompatServer.main` advertises the loaded model's slot `n_ctx` (measured + before: 10240 advertised, 2048 real). 6. JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index a6ed18795..8985ffd46 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -19,6 +19,7 @@ import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -30,6 +31,7 @@ import java.util.concurrent.atomic.AtomicReference; import net.ladenthin.llama.LlamaModel; import net.ladenthin.llama.exception.InvalidRequestException; +import net.ladenthin.llama.value.SlotMetrics; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -1142,7 +1144,8 @@ public static void main(String[] args) throws IOException { "jllama-openai-shutdown")); try (LlamaModel model = new LlamaModel(options.toModelParameters())) { - OpenAiServerConfig config = options.toServerConfig(model.supportsVision(), model.getModelFtype()); + OpenAiServerConfig config = + options.toServerConfig(model.supportsVision(), model.getModelFtype(), slotContextSize(model)); try (OpenAiCompatServer server = new OpenAiCompatServer(model, config)) { server.start(); printReady(config, server.getPort()); @@ -1157,6 +1160,17 @@ public static void main(String[] args) throws IOException { } } + /** + * The context window one slot of the loaded model has, for the advertised token budgets. + * + * @param model the loaded model + * @return {@code n_ctx} of the first slot, or {@code 0} if the model reports none + */ + private static int slotContextSize(LlamaModel model) { + final List slots = model.getMetricsTyped().getSlotMetrics(); + return slots.isEmpty() ? 0 : slots.get(0).getContextSize(); + } + private static void printReady(OpenAiServerConfig config, int port) { String url = "http://" + config.getHost() + ":" + port + PATH_CHAT_COMPLETIONS; System.out.println(); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index c9677a3fa..9738fc1c6 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -722,6 +722,23 @@ public OpenAiServerConfig toServerConfig(boolean supportsVision) { * @return the server configuration */ public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFtype) { + return toServerConfig(supportsVision, modelFtype, 0); + } + + /** + * Build the server configuration with the context window a slot of the loaded model really + * has. Without it the advertised window comes from {@code -c} or, without {@code -c}, from the + * fixed defaults (8192 + 2048), whatever the model loaded with: measured at b11639, a model + * whose slots hold 2048 tokens was advertised with 10240 in {@code /props} and + * {@code /api/show}, and a client sizing its prompt from that sends up to five times too much. + * + * @param supportsVision whether the loaded model reports usable vision input + * @param modelFtype the model's file-type (quantization) label, or {@code ""} if unknown + * @param slotContextSize the context size of one slot of the loaded model ({@code n_ctx} of + * its slot metrics), or {@code 0} if unknown + * @return the server configuration + */ + public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFtype, int slotContextSize) { final OpenAiServerConfig.Builder builder = OpenAiServerConfig.builder() .host(host) .port(port) @@ -731,10 +748,17 @@ public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFty if (apiKey != null) { builder.apiKey(apiKey); } - if (ctxSize > 0) { + final int slotCtx; + if (slotContextSize > 0) { + slotCtx = slotContextSize; + } else if (ctxSize > 0) { // An explicit --parallel N splits the context: llama.cpp unifies the KV cache only when // the slot count is "auto", so each request gets n_ctx / N (what upstream's /props reports). - final int slotCtx = parallel > 1 ? Math.max(1, ctxSize / parallel) : ctxSize; + slotCtx = parallel > 1 ? Math.max(1, ctxSize / parallel) : ctxSize; + } else { + slotCtx = 0; + } + if (slotCtx > 0) { final int maxOutputTokens = Math.min(OpenAiServerConfig.DEFAULT_MAX_OUTPUT_TOKENS, Math.max(1, slotCtx / 2)); builder.maxOutputTokens(maxOutputTokens); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index 0bf9c715d..e9bf9b0da 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -295,6 +295,33 @@ public void contextSizeDerivesAdvertisedTokenBudgets() { assertThat(config.getMaxInputTokens(), is(6144)); } + @Test + public void loadedSlotContextIsAdvertisedWithoutContextSize() { + // Without -c the advertised window was the fixed 8192 + 2048, whatever the model loaded with: + // a slot of 2048 tokens was advertised as 10240 (measured at b11639). + OpenAiServerConfig config = OpenAiServerCli.parse("-m", "m.gguf").toServerConfig(false, "", 2048); + assertThat(config.getMaxOutputTokens(), is(1024)); + assertThat(config.getMaxInputTokens() + config.getMaxOutputTokens(), is(2048)); + } + + @Test + public void loadedSlotContextWinsOverTheContextSizeArgument() { + // -c 16384 with 4 slots would be 4096 per slot, but the load can end with less (llama.cpp's + // --fit shrinks the context to the device memory); what the slot holds is what a request gets. + OpenAiServerConfig config = OpenAiServerCli.parse("-m", "m.gguf", "-c", "16384", "--parallel", "4") + .toServerConfig(false, "", 3000); + assertThat(config.getMaxOutputTokens(), is(1500)); + assertThat(config.getMaxInputTokens(), is(1500)); + } + + @Test + public void unknownSlotContextFallsBackToTheContextSizeArgument() { + OpenAiServerConfig config = + OpenAiServerCli.parse("-m", "m.gguf", "-c", "8192").toServerConfig(false, "", 0); + assertThat(config.getMaxOutputTokens(), is(2048)); + assertThat(config.getMaxInputTokens(), is(6144)); + } + @Test public void smallContextSizeSplitsTheWholeWindowBetweenInputAndOutput() { // Below twice the output default the output budget is ctx/2. The input budget must be the rest From fe22b34da2b0594eca7171ccc121f88edd5a9fcd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:59:03 +0000 Subject: [PATCH 286/589] Record the commit of F165 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0c78a6530..bc0090a86 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1185,7 +1185,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiServerCliTest#loadedSlotContextIsAdvertisedWithoutContextSize`, `#loadedSlotContextWinsOverTheContextSizeArgument`, `#unknownSlotContextFallsBackToTheContextSizeArgument`. - **Rot-Grün-Nachweis:** mit der alten Auswahl (nur `-c`) rot (2 von 3, der Rückfalltest ist beidseitig grün), mit dem Fix grün (`OpenAiServerCliTest`, `OpenAiCompatServer*Test`: 114 Tests). Die Verdrahtung in `main` braucht ein Modell und ist hier nur übersetzt; Prüfung mit dem Befehl aus J3 (erwartet: 2048 in `/props` und `/api/show`). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** 62413a9 ## Offene Verdachte ohne Fund-ID From 7f194503ca7bad9b15282645de6d58a924a33074 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:02:05 +0000 Subject: [PATCH 287/589] Write empty tool-call arguments of the history as an empty object Upstream parses every function.arguments of the history with json::parse, so an empty string failed the whole follow-up request with 400 (measured); "{}" goes through. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +++++++++- TODO.md | 9 +++--- .../parameters/ParameterJsonSerializer.java | 5 +++- .../json/ParameterJsonSerializerTest.java | 28 +++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index bc0090a86..af45d54c6 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1187,6 +1187,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** 62413a9 +### F166 – java-llama.cpp: Leere Werkzeug-Argumente im Verlauf lassen die ganze Folgeanfrage mit 400 scheitern +- **Zeit:** 2026-10-19 15:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages(List)`) +- **Schwere:** niedrig (mit dem gemessenen Modell nicht erreichbar, im Eintrittsfall aber Totalausfall der Folgeanfrage) +- **Historie:** `buildMessages` schreibt `ToolCall.getArgumentsJson()` seit den Werkzeugaufrufen im Verlauf wörtlich. +- **Messung (J4, `BUGHUNT-MEASUREMENTS.md`):** Qwen2.5-1.5B liefert für ein Werkzeug ohne Parameter 20/20 `"{}"`, nie `""`; mit `""` im Verlauf antwortet der Server `InvalidRequestException: Failed to parse tool call arguments as JSON … attempting to parse an empty input`, mit `"{}"` normal. +- **Fix:** Entscheidung des Nutzers: leere (auch nur aus Leerraum bestehende) Argumente als `"{}"` schreiben, alles andere unverändert. +- **Tests:** `ParameterJsonSerializerTest#testBuildMessages_emptyToolCallArguments_areWrittenAsAnEmptyObject` (`""`, `" "`, `"\n"`), `#testBuildMessages_toolCallArguments_arePassedOnVerbatim`. +- **Rot-Grün-Nachweis:** ohne den Fix rot (3 von 3), mit ihm grün (186 Tests in den Serializer-, Multimodal-, `InferenceParameters`- und `ChatRequest`-Klassen). +- **Nicht abgedeckt (offen):** Die Übersetzungen des Servers bauen den Verlauf ohne `buildMessages`: `ResponsesApiSupport.java:218` (`asText("")`) und `AnthropicApiSupport.java:229` (`input` fehlt → `""`) können weiterhin `""` schreiben. +- **Status:** gefixt (Java-API-Pfad) +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -1194,7 +1207,6 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. -- *(niedrig, unsicher, CI-Bedarf: Modell mit Werkzeugaufruf ohne Parameter, 2026-10-18)* Leere Werkzeug-Argumente im Gesprächsverlauf: Für Vorlagen mit `supports_object_arguments` (Qwen, Llama 3 u. a.) parst upstream jedes `function.arguments` mit `json::parse` (`workaround::func_args_not_string`, `common/chat.cpp:1026`, b11639), ein leerer String lässt die ganze Anfrage mit „Failed to parse tool call arguments as JSON“ scheitern. Auf Java-Seite kann `""` entstehen: `ChatResponseParser.parseToolCalls` übernimmt ein textuelles `""` unverändert (und ein fehlendes `arguments` würde `MissingNode.toString()`), `ToolCallDeltaAccumulator`/`StreamingChunkAssembler` setzen `arguments` nur aus den Deltas zusammen, und `LangChain4jMapping.toJllamaAssistantMessage` ersetzt nur `null` durch `"{}"`, nicht `""`. Ob upstream für einen parameterlosen Aufruf `""` statt `"{}"` liefert, hängt vom Parser des Modells ab und ist ohne Modell nicht prüfbar. Billige Absicherung: `ParameterJsonSerializer.buildMessages` schreibt bei leerem `getArgumentsJson()` `"{}"`. Nachtrag (Durchsicht der Übersetzungsschichten): auch auf dem Hinweg kann `""` entstehen – `ResponsesApiSupport.java:126` übernimmt das `arguments` eines `function_call`-Eingabeelements per `asText("")`, `AnthropicApiSupport.java:172` macht aus einem `tool_use`-Block ohne `input` `MissingNode.toString()` = `""` (gemessen); der Rückweg zu Ollama und Anthropic fängt `""` dagegen ab (F37/F38). Eine Normalisierung `""` → `"{}"` an einer Stelle vor upstream (OpenAI-Kern) würde alle Wege decken. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. diff --git a/TODO.md b/TODO.md index f56f33d9c..5bcdd3760 100644 --- a/TODO.md +++ b/TODO.md @@ -557,11 +557,10 @@ commands, hypotheses and answer fields, is `BUGHUNT-MEASUREMENTS.md` (J1–J9).* 6. JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. -7. Empty tool-call arguments in the history: upstream parses `arguments` with `json::parse` for - templates with object arguments, so `""` fails the whole request. `""` can arise on the Java side - (`ChatResponseParser`, the delta accumulators, `ResponsesApiSupport.java:126`, - `AnthropicApiSupport.java:172`). Cheap guard: write `"{}"` for empty arguments in - `ParameterJsonSerializer.buildMessages`. Whether a model really emits `""` needs a model. +7. *Fixed as F166 for the Java API:* `ParameterJsonSerializer.buildMessages` writes `"{}"` for empty + tool-call arguments (measured: `""` in the history fails the request with 400). Still open: the + server's own translations, `ResponsesApiSupport.java:218` and `AnthropicApiSupport.java:229` + (`input` missing), build the history without it and can still write `""`. 8. Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. 9. `/props` reports `total_slots` as 1 instead of `n_parallel` (needs a config field). 10. Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java index 408b4aeb2..391dad4b0 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java @@ -158,7 +158,10 @@ public ArrayNode buildMessages(List messages) { entry.put("type", "function"); ObjectNode function = entry.putObject("function"); function.put("name", call.getName()); - function.put("arguments", call.getArgumentsJson()); + // Upstream parses the arguments of the history with json::parse, so an empty + // string fails the whole request with 400; a call without arguments is "{}". + final String arguments = call.getArgumentsJson(); + function.put("arguments", arguments.trim().isEmpty() ? "{}" : arguments); } } arr.add(msg); diff --git a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java index f923cc6e2..247bfa731 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java @@ -19,8 +19,12 @@ import java.util.Map; import net.ladenthin.llama.args.Sampler; import net.ladenthin.llama.parameters.ParameterJsonSerializer; +import net.ladenthin.llama.value.ChatMessage; import net.ladenthin.llama.value.Pair; +import net.ladenthin.llama.value.ToolCall; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Unit tests for {@link ParameterJsonSerializer}. @@ -78,6 +82,30 @@ public void testToJsonString_unicode() { // buildMessages // ------------------------------------------------------------------ + /** + * Upstream parses every {@code function.arguments} of the history with {@code json::parse} + * (templates with object arguments, Qwen and Llama 3 among them), so an empty string failed the + * whole follow-up request with 400 "Failed to parse tool call arguments as JSON" (measured at + * b11639); {@code "{}"} goes through. An empty string can come from a client or from an + * accumulator that saw no argument delta. + */ + @ParameterizedTest + @ValueSource(strings = {"", " ", "\n"}) + public void testBuildMessages_emptyToolCallArguments_areWrittenAsAnEmptyObject(String arguments) { + ArrayNode arr = serializer.buildMessages(Collections.singletonList(ChatMessage.assistantToolCalls( + "", Collections.singletonList(new ToolCall("call_1", "get_time", arguments))))); + JsonNode function = arr.get(0).get("tool_calls").get(0).get("function"); + assertThat(function.get("arguments").asText(), is("{}")); + } + + @Test + public void testBuildMessages_toolCallArguments_arePassedOnVerbatim() { + ArrayNode arr = serializer.buildMessages(Collections.singletonList(ChatMessage.assistantToolCalls( + "", Collections.singletonList(new ToolCall("call_1", "get_weather", "{\"city\":\"Berlin\"}"))))); + JsonNode function = arr.get(0).get("tool_calls").get(0).get("function"); + assertThat(function.get("arguments").asText(), is("{\"city\":\"Berlin\"}")); + } + @Test public void testBuildMessages_withSystemMessage() { List> msgs = Collections.singletonList(new Pair<>("user", "Hello")); From 3f7e3bc6dc290745305dd0d7515dee904a5092e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:02:05 +0000 Subject: [PATCH 288/589] Record the commit of F166 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index af45d54c6..40948b011 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1198,7 +1198,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne den Fix rot (3 von 3), mit ihm grün (186 Tests in den Serializer-, Multimodal-, `InferenceParameters`- und `ChatRequest`-Klassen). - **Nicht abgedeckt (offen):** Die Übersetzungen des Servers bauen den Verlauf ohne `buildMessages`: `ResponsesApiSupport.java:218` (`asText("")`) und `AnthropicApiSupport.java:229` (`input` fehlt → `""`) können weiterhin `""` schreiben. - **Status:** gefixt (Java-API-Pfad) -- **Commit:** (folgt) +- **Commit:** afc40be ## Offene Verdachte ohne Fund-ID From 81304631efb664432531e7562e9e4d0c4a740974 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:11:11 +0000 Subject: [PATCH 289/589] Hand batch requests to the native scheduler without the common ForkJoinPool completeBatch and completeBatchWithStats ran each request through supplyAsync, so no more were in flight than the common pool has threads: 3 of 8 slots on a 4-core machine (measured). They now submit every request on the caller's thread, release the submitted ones when a submission fails, and then receive in order; chatBatch, whose native call submits and waits in one, runs each request on a thread of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +- TODO.md | 8 +- llama/spotbugs-exclude.xml | 41 +++- .../net/ladenthin/llama/BatchDispatch.java | 156 +++++++++++++++ .../java/net/ladenthin/llama/LlamaModel.java | 87 ++------- .../ladenthin/llama/BatchDispatchTest.java | 179 ++++++++++++++++++ 6 files changed, 403 insertions(+), 81 deletions(-) create mode 100644 llama/src/main/java/net/ladenthin/llama/BatchDispatch.java create mode 100644 llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 40948b011..38f63300a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1200,6 +1200,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt (Java-API-Pfad) - **Commit:** afc40be +### F167 – java-llama.cpp: `completeBatch`/`chatBatch` laufen auf dem gemeinsamen `ForkJoinPool` und nutzen auf wenigen Kernen nicht alle Slots +- **Zeit:** 2026-10-19 15:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`completeBatch`, `completeBatchWithStats`, `chatBatch`), neu `BatchDispatch.java` +- **Schwere:** niedrig bis mittel (Durchsatz; die Javadoc versprach die `setParallel`-Slots) +- **Historie:** Jede Anfrage lief über `CompletableFuture.supplyAsync`, also auf dem gemeinsamen Pool mit `availableProcessors() - 1` Threads, den sie zudem mit der Anwendung teilte. +- **Messung (J5, `BUGHUNT-MEASUREMENTS.md`):** 8 Slots, 8 Anfragen: 16 Kerne → 8 gleichzeitig (3225 ms); 4 Kerne → 3 (14067 ms); 16 Kerne mit Pool-Parallelität 3 → 3. +- **Fix:** Entscheidung des Nutzers: `completeBatch`/`completeBatchWithStats` übergeben alle Anfragen im Aufrufer-Thread (`requestCompletion`) und empfangen dann der Reihe nach; scheitert eine Übergabe, werden die bereits übergebenen freigegeben (`releaseTask`). Für `chatBatch` gibt es keine Trennung von Übergabe und Warten (`handleChatCompletions` wartet in einem nativen Aufruf), daher läuft dort jede Anfrage auf einem eigenen Daemon-Thread eines Executors, der genau so groß ist wie der Batch und mit dem Aufruf endet. Beides in `BatchDispatch` (ohne native Bibliothek testbar, `LlamaModel` lädt sie im statischen Initialisierer). Die SpotBugs-Ausnahme für den bewussten Rethrow ist von `LlamaModel` dorthin gezogen, zwei neue mit Begründung (`HES_LOCAL_EXECUTOR_SERVICE`, `ITC_INHERITANCE_TYPE_CHECKING`). +- **Tests:** `BatchDispatchTest` (8): alle Übergaben vor dem ersten Empfang, Übergabe im Aufrufer-Thread, Freigabe nach gescheiterter Übergabe, Empfang aller trotz Fehler, mehr gleichzeitige Anfragen als der gemeinsame Pool Threads hat (Latch über `commonPool().getParallelism() + 2`), Reihenfolge, Fehler nach den anderen, leere Eingabe. +- **Rot-Grün-Nachweis:** mit dem alten Mechanismus (`supplyAsync` je Anfrage, keine Freigabe) rot (4 von 8; die übrigen 4 prüfen unverändertes Verhalten), mit dem Fix grün. Ein modellgestützter Lauf (J5-Probe) steht aus: erwartet `maxProcessingSlots=8` auch auf 4 Kernen für `completeBatch`. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(unsicher/niedrig, nur beschrieben, 06:38 UTC)* java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber immer auf das CPU-Gerät zurück, leer wird die Liste nur ohne registriertes CPU-Backend (kaputte Installation) – praktisch unerreichbar. Fix wäre: bei Nicht-NULL-Zeiger immer `STRINGS_FREE` aufrufen. Nicht ohne native Bibliothek + JDK-22-Toolchain testbar, daher kein Commit. Alle übrigen Freigabe-Funktionen (`takeBytesAsString` → `jllama_string_free` laut Header) stimmen mit `jllama_api.h` überein. **Blocker (Stand 2026-10-17):** Der Rot-Grün-Zyklus braucht ein JDK 22+ (Toolchain des Moduls) und ist in dieser Sandbox nicht herstellbar: GitHub-Releases (Adoptium) und api.adoptium.net sind über den Proxy gesperrt. Vorschlag für den Test: `takeStrings` mit der Freigabefunktion als Parameter (paketintern), Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung genau ein Freigabeaufruf; läuft dann im CI-Job `test-java-llama-ffm`. @@ -1214,5 +1226,4 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). -- *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `LlamaModel.java:194` (`completeBatch`, ebenso `completeBatchWithStats`, `chatBatch`): Die Javadoc sagt „Each request is sent immediately; the native scheduler dispatches tasks across whatever slot count `setParallel` was configured with“. Tatsächlich startet jede Anfrage als `CompletableFuture.supplyAsync` auf dem gemeinsamen `ForkJoinPool`, dessen Parallelität `Runtime.availableProcessors() - 1` ist, und der blockierende native Aufruf läuft ohne `ManagedBlocker`. Auf einem Rechner mit 4 Kernen und `setParallel(8)` laufen so höchstens 3 Anfragen gleichzeitig, die übrigen warten in der Pool-Warteschlange, bevor sie überhaupt übergeben werden; bei 2 Kernen (Parallelität 1) nimmt `CompletableFuture` statt des Pools einen Thread je Aufgabe, dort stimmt die Aussage. Ein Fix ohne Pool: alle Aufgaben auf dem Aufrufer-Thread mit `requestCompletion` übergeben, dann der Reihe nach `receiveCompletionJson`, bei einem Übergabefehler die bereits übergebenen mit `cancelCompletion` freigeben. Beweisen ließe sich das nur mit nativer Bibliothek und Modell (Zahl gleichzeitig belegter Slots über `getMetricsTyped`), daher kein Commit. Nebenbei: `rerank(String, String...)` legt die Ergebnisse in eine `HashMap` nach Dokumenttext, zwei gleiche Dokumente fallen dort zu einem Eintrag zusammen (die Überladung mit `reRank` liefert eine Liste und ist davon nicht betroffen). - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* java-llama.cpp `server/OllamaApiSupport.java:481` (`toOpenAiCompletionRequest`): Ollamas `/api/generate` wendet ohne `"raw": true` die Chat-Vorlage des Modells an (und eine `system`-Angabe); erst `raw: true` schickt den Prompt unverändert. Unsere Route liest weder `raw` noch `system` und macht aus jedem `/api/generate` ohne `suffix` eine rohe Vervollständigung. Continue (`Ollama.ts` `_getGenerateOptions`) setzt `raw: options.raw`, also nur, wenn der Aufrufer es verlangt – für Nicht-Autocomplete-Aufrufe bekommt ein Instruktionsmodell dann einen Text ohne Rollenmarker und antwortet als Fortsetzung statt als Assistent; `system` geht ganz verloren. Ein Fix hieße, nicht-rohe Anfragen über den Chat-Pfad zu schicken (`messages` aus `system` + `prompt`) – eine Verhaltensänderung der Route, die README und Javadoc als „prompt completion / FIM“ beschreiben, daher Entscheidung des Nutzers. diff --git a/TODO.md b/TODO.md index 5bcdd3760..beb9eec8f 100644 --- a/TODO.md +++ b/TODO.md @@ -574,10 +574,10 @@ commands, hypotheses and answer fields, is `BUGHUNT-MEASUREMENTS.md` (J1–J9).* placeholder template would be invented. 14. Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. -15. `completeBatch`/`completeBatchWithStats`/`chatBatch` run on the common `ForkJoinPool` - (`availableProcessors() - 1`), so fewer requests run at once than `setParallel` allows; the - Javadoc says otherwise. Fix: submit all on the caller's thread, then receive. Also: - `rerank(String, String...)` collapses equal documents in its `HashMap`. +15. *Fixed as F167:* `completeBatch`/`completeBatchWithStats` submit every request on the caller's + thread and then receive, `chatBatch` gives each request a thread of its own (measured before: 3 + of 8 slots on 4 cores). Still open: `rerank(String, String...)` collapses equal documents in its + `HashMap`. 16. Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat template, ours always completes raw. diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml index 0514d12f9..2e177e03b 100644 --- a/llama/spotbugs-exclude.xml +++ b/llama/spotbugs-exclude.xml @@ -397,22 +397,45 @@ SPDX-License-Identifier: MIT - + - - - + + + + + + + + + + + + + + + + + - -# Messauftrag zum Bug-Hunt 2026-10-16 … 19 — java-llama.cpp - -Dieser Auftrag sammelt jede Messung und jeden modell- oder CI-gestützten Lauf, den der Bug-Hunt offen -gelassen hat. Der Hintergrund steht in `BUGHUNT-FOUND.md` (Fund-IDs, offene Verdachte) und in `TODO.md` -(„Open points from the bug hunt“). - -**Regeln für den ausführenden Agenten** - -- **Nur messen und berichten.** Keine Produktionsänderung, keine Fixes. Wegwerf-Proben (Testklassen, - kleine `main`-Programme) nach dem Lauf wieder entfernen; committet wird nur diese Datei mit den - Ergebnissen. Wo ein Punkt ausdrücklich einen Rot-Test verlangt (J7, J8), den Test schreiben, laufen - lassen, das Ergebnis eintragen und den Test **nicht** committen; der Quelltext des Tests gehört dann - ins Ergebnis. -- Arbeitsstand: Branch `ccr-90fc5f6a-vs4s0f`. Pin: llama.cpp b11639. -- Dateien aus `.github/shared-files.sha256` nicht verändern. -- Jeder Punkt wird beantwortet, auch wenn er nicht ausführbar war (mit Grund). Zu jedem Ergebnis die - Umgebung (CPU, Kerne, RAM, GPU, OS, JDK) und die wörtlichen Ausgaben, die das Ergebnis tragen. -- Ergebnis unter **Ergebnis:**, darunter **Folgerung:** (was es für die genannte Entscheidung heißt). - -**Gemeinsame Vorbereitung** - -```bash -# JDK: Temurin 21 (nicht Oracle 21, siehe CLAUDE.md); für J7/J8 zusätzlich ein JDK 22+ in ~/.m2/toolchains.xml -mvn -B -q -f llama/pom.xml compile -cd llama && cmake -B build && cmake --build build --config Release -j"$(nproc)" && cd .. -# Modelle aus .github/models.csv nach models/ (die Tests finden sie dort): -mkdir -p models -while IFS=, read -r f url; do case "$f" in \#*|"") continue;; esac - [ -f "models/$f" ] || curl -L --fail -o "models/$f" "$url"; done < .github/models.csv -# Auf einem Rechner ohne GPU alle Läufe mit -Dnet.ladenthin.llama.test.ngl=0 -``` - -Für die kleinen Messprogramme unten genügt eine Wegwerf-Testklasse unter -`llama/src/test/java/net/ladenthin/llama/` (dann gilt der Test-Klassenpfad mit `src/main/natives/`), gestartet -mit `mvn -B -q -f llama/pom.xml test -Dtest=`. Pfade über `TestConstants` (`DRAFT_MODEL_PATH`, -`REASONING_MODEL_PATH`, `DEFAULT_TOOL_MODEL_PATH`). - ---- - -## Messumgebung (Ergebnisse vom 2026-10-19) - -**Umgebung aller Läufe:** Vultr `voc-c-16c-32gb-300s-amd` (Frankfurt, dedizierte Kerne), AMD EPYC-Milan, 16 vCPU, -31 GB RAM, **keine GPU**, Ubuntu 24.04.5 LTS (Kernel 6.8.0-146-generic), Temurin 21.0.12.1+1 (Maven), Temurin -25.0.4.1+1 als Toolchain für `llama-ffm` (J7/J8), Maven 3.9.11, GCC 13.3.0, CMake aus Ubuntu 24.04. Branch -`ccr-90fc5f6a-vs4s0f` (`55f53d7a`), llama.cpp b11639 (`a0e6dc7384edb8f5d927155e1d26c10b4a310e6d`), statischer -CPU-Build nach der gemeinsamen Vorbereitung (`cmake -B build && cmake --build build --config Release -j16`), -Modelle aus `.github/models.csv`. Alle Läufe CPU-only (`-Dnet.ladenthin.llama.test.ngl=0`, `setDevices("none")`). -Die Proben J1–J5, J9 und F71 lagen in einer Wegwerf-Klasse `llama/src/test/java/net/ladenthin/llama/BughuntProbeTest.java`, -J7/J8 in `llama-ffm/src/test/java/net/ladenthin/llama/ffm/BughuntFfmProbeTest.java`, F93 in -`llama-langchain4j/src/test/java/…/BughuntLc4jProbeTest.java` — nur auf der VM, nicht committet, VM danach gelöscht. - ---- - -## J1 F158: `ModelParameters.setPredict(-2)` — wie viele Tokens erzeugt der Server? - -**Ziel.** Aus dem Quelltext von b11639 abgeleitet (`server-context.cpp:1964`, `:495`, `:2040`), nicht gemessen: -mit `-n -2` beendet ein Slot jede Anfrage nach dem ersten Token, weil nur `-1` als unbegrenzt gilt. - -```java -try (LlamaModel model = new LlamaModel(new ModelParameters() - .setModel(TestConstants.DRAFT_MODEL_PATH).setGpuLayers(0).setPredict(-2))) { - CompletionResult r = model.completeWithStats(InferenceParameters.empty().withPrompt("def fib(n):")); - System.out.println("completion_tokens=" + r.getUsage().getCompletionTokens() - + " stop=" + r.getStopReason() + " text=" + r.getText()); -} -``` - -Dasselbe zum Vergleich mit `setPredict(-1)` und mit `setPredict(16)`. - -**Hypothese.** `-2`: 1 Token, Stopp-Grund Limit; `-1`: bis EOS oder Kontextende; `16`: 16. -**Entscheidet.** Ob `setPredict(-2)` in Java mit `IllegalArgumentException` abgelehnt werden soll. - -**Ergebnis:** Probe wie im Brief (`DRAFT_MODEL_PATH` = `AMD-Llama-135m-code.Q2_K.gguf`, `completeWithStats`, -Prompt `def fib(n):`), zusätzlich die Stopp-Felder der Roh-JSON: -``` -J1 setPredict(-2) completion_tokens=1 stop=MAX_TOKENS text=" Quant" -J1 setPredict(-2) raw stop fields: stop_type="limit" ... tokens_predicted=1 n_predict(gen_settings)=-2 -J1 setPredict(-1) completion_tokens=2042 stop=MAX_TOKENS text="arginargs net used Net net ..." (gekürzt) -J1 setPredict(-1) raw stop fields: stop_type="limit" ... tokens_predicted=2042 n_predict(gen_settings)=-1 -J1 setPredict(16) completion_tokens=16 stop=MAX_TOKENS text=" $ NCargin Net used Net Net Net Net Net Net Net Net Net Net Net" -J1 setPredict(16) raw stop fields: stop_type="limit" ... tokens_predicted=16 n_predict(gen_settings)=16 -``` -(`-1`: 2042 Tokens = Kontext 2048 minus 6 Prompt-Tokens, also bis Kontextende; das Modell erreichte kein EOS.) - -**Folgerung:** Die Ableitung aus dem Quelltext stimmt: `-2` beendet jede Anfrage nach **einem** Token mit -Stopp-Grund Limit, still und ohne Fehler. `setPredict(-2)` sollte in Java mit `IllegalArgumentException` -abgelehnt werden (gültig: `-1` und `>= 0`). - ---- - -## J2 F69 / Upstream-Doku: `withNPredict(-2)` und `withNPredict(0)` - -**Ziel.** (a) Bestätigen, dass der Server `n_predict: -2` pro Anfrage ablehnt (harte Grenze `-1 … INT32_MAX`, -`server-schema.cpp:44`). (b) Upstream beschreibt `0` als „no tokens will be generated but the prompt is -evaluated into the cache“; im Quelltext fand sich dafür kein eigener Zweig (`has_budget()` stoppt erst nach -dem ersten Token), deshalb steht die Aussage nicht in unserer Javadoc. - -```java -// (a) erwartet: InvalidRequestException, Meldung nennt n_predict -model.complete(InferenceParameters.empty().withPrompt("x").withNPredict(-2)); -// (b) Anzahl erzeugter Tokens bei 0 -CompletionResult r = model.completeWithStats(InferenceParameters.empty().withPrompt("def fib(n):").withNPredict(0)); -System.out.println(r.getUsage().getCompletionTokens() + " / " + r.getStopReason() + " / '" + r.getText() + "'"); -``` - -**Entscheidet.** (a) bestätigt die korrigierte Javadoc; (b) ob die Javadoc von `withNPredict` „0 = nur den -Prompt auswerten“ nennen darf (0 Tokens) oder ob upstreams Beschreibung falsch ist (1 Token). - -**Ergebnis:** ``` -J2a withNPredict(-2) threw net.ladenthin.llama.exception.InvalidRequestException: Field 'n_predict': Value must be between -1 <= value <= 2147483647, but got -2 -J2b withNPredict(0) 1 / MAX_TOKENS / ' args' prompt_tokens=6 -J2b raw stop fields: stop_type="limit" ... tokens_predicted=1 n_predict(gen_settings)=0 -``` - -**Folgerung:** (a) Die korrigierte Javadoc ist bestätigt: der Server lehnt `-2` pro Anfrage mit 400 -(`InvalidRequestException`) und genau dieser Meldung ab. (b) `0` erzeugt **ein** Token, nicht null — -upstreams Beschreibung („no tokens will be generated“) ist für b11639 falsch. Die Javadoc von `withNPredict` darf -„0 = nur den Prompt auswerten“ **nicht** nennen; richtig ist „0 verhält sich wie 1“. - ---- - -## J3 Offener Verdacht 5: `OpenAiCompatServer` ohne `-c` bewirbt 8192 + 2048 Tokens - -**Ziel.** Ohne `-c` meldet der Server in `/props` und `/api/show` eine Kontextlänge aus den festen Vorgaben -(`DEFAULT_MAX_INPUT_TOKENS` 8192 + `DEFAULT_MAX_OUTPUT_TOKENS` 2048), unabhängig vom Modell. - -```bash -java -cp "llama/target/classes:llama/src/main/natives:$(mvn -q -f llama/pom.xml dependency:build-classpath -Dmdep.outputFile=/dev/stdout)" \ - net.ladenthin.llama.server.OpenAiCompatServer -m models/AMD-Llama-135m-code.Q2_K.gguf --port 18080 & -sleep 20 -curl -s localhost:18080/props | head -c 2000; echo -curl -s -X POST localhost:18080/api/show -d '{"model":"x"}' | head -c 2000; echo -kill %1 -``` - -Dazu die tatsächliche Slot-Größe: `model.getMetricsTyped()` bzw. `/slots` → `n_ctx`, und die Trainingslänge -des Modells (`n_ctx_train` im Ladelog). - -**Hypothese.** Beworben 10240, tatsächlich das Fenster des Modells (beim Draft-Modell kleiner). -**Entscheidet.** Ob der Starter das echte `n_ctx` des geladenen Modells bewerben soll. - -**Ergebnis:** Befehl aus dem Brief (`OpenAiCompatServer -m models/AMD-Llama-135m-code.Q2_K.gguf --port 18080`, kein `-c`): -``` ---- /props -{"default_generation_settings":{"n_ctx":10240,"model":"AMD-Llama-135m-code.Q2_K.gguf"},"total_slots":1,"model_alias":"AMD-Llama-135m-code.Q2_K.gguf","chat_template":"","modalities":{"vision":false,"audio":false,"video":false}} ---- /api/show -{"license":"","modelfile":"","parameters":"num_ctx 10240","template":"","details":{"family":"llama","families":["llama"],"parameter_size":"","quantization_level":""},"model_info":{"general.architecture":"llama","llama.context_length":10240},"capabilities":["completion","tools","insert"],"model":"AMD-Llama-135m-code.Q2_K.gguf"} ---- /slots -[{"id":0,"n_ctx":2048,"speculative":false,"is_processing":false}] -``` -Ladelog: `srv load_model: initializing, n_slots = 1, n_ctx_slot = 2048, kv_unified = 'false'`. -`getMetricsTyped()`/`getModelMeta()` am selben Modell: `J3 slot id=0 n_ctx=2048`, `"n_ctx_train":2048`. - -**Folgerung:** Bestätigt: beworben werden 10240 (= 8192 + 2048), tatsächlich hat der Slot 2048 (die -Trainingslänge des Modells). Ein Client, der sich auf `/props`/`/api/show` verlässt, schickt bis zu 5× zu viel -Kontext. Der Starter sollte das echte `n_ctx` des geladenen Modells bewerben. - ---- - -## J4 Offener Verdacht 7: liefert ein Modell leere Werkzeug-Argumente `""`? - -**Ziel.** Upstream parst `function.arguments` im Verlauf mit `json::parse` (`common/chat.cpp:1026`); ein -leerer String lässt die ganze Folgeanfrage scheitern („Failed to parse tool call arguments as JSON“). Offen -ist, ob ein Modell für ein Werkzeug **ohne Parameter** `""` statt `"{}"` liefert. - -Mit `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` (`--jinja`): ein Werkzeug `get_time` mit leerem Parameter-Schema -(`{"type":"object","properties":{}}`), `tool_choice: "required"`, Prompt „What time is it?“. Zehnmal -blockierend und zehnmal gestreamt (`streamChatCompletion`), jeweils `tool_calls[0].function.arguments` -wörtlich protokollieren. Danach den Verlauf mit `arguments: ""` als Assistenten-Nachricht zurückschicken -und die Antwort des Servers protokollieren (blockierend; zur Not mit `ToolCallingIntegrationTest` als Vorlage). - -**Hypothese.** Das Modell liefert `"{}"`; mit `""` im Verlauf antwortet der Server mit 400. -**Entscheidet.** Ob `ParameterJsonSerializer.buildMessages` leere Argumente als `"{}"` schreiben soll. - -**Ergebnis:** `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`, `enableJinja()`, Kontext 4096. Rohe OpenAI-JSON über -`handleChatCompletions` (damit `arguments` wörtlich ankommt), Werkzeug `get_time` mit -`{"type":"object","properties":{}}`, `tool_choice: "required"`, Prompt „What time is it?“, Standard-Sampling. -``` -J4 blocking#0 arguments(literal JSON string)="{}" name="get_time" finish="tool_calls" -... (blocking#1 bis #9 identisch: "{}", get_time, tool_calls) -J4 streamed#0 arguments(concatenated)="{}" pieces=["{","}"] -... (streamed#1 bis #9 identisch) -J4 history arguments="" -> net.ladenthin.llama.exception.InvalidRequestException: Failed to parse tool call arguments as JSON: [json.exception.parse_error.101] parse error at line 1, column 1: attempting to parse an empty input; check that your input string or stream contains the expected JSON -J4 history arguments="{}" -> OK: {"role":"assistant","content":"The current time is 12:00."} -``` - -**Folgerung:** Dieses Modell liefert 20/20 `"{}"`, nie `""`. Ein `""` im Verlauf lässt die ganze Folgeanfrage -aber mit 400 scheitern, `"{}"` geht durch. Der Fall ist mit diesem Modell nicht erreichbar, der Schaden bei einem -Modell oder Client, der `""` schreibt, ist jedoch total. `ParameterJsonSerializer.buildMessages` sollte leere -Argumente als `"{}"` schreiben (billig, ohne Nebenwirkung). - ---- - -## J5 Offener Verdacht 15: wie viele Anfragen laufen bei `completeBatch` gleichzeitig? - -**Ziel.** `completeBatch` (ebenso `completeBatchWithStats`, `chatBatch`) startet jede Anfrage über -`CompletableFuture.supplyAsync` auf dem gemeinsamen `ForkJoinPool` (Parallelität -`availableProcessors() - 1`), die Javadoc verspricht dagegen die mit `setParallel` eingestellte Slot-Zahl. - -```java -int cores = Runtime.getRuntime().availableProcessors(); -try (LlamaModel model = new LlamaModel(new ModelParameters() - .setModel(TestConstants.DRAFT_MODEL_PATH).setGpuLayers(0).setParallel(8).setCtxSize(8 * 512))) { - List reqs = new ArrayList<>(); - for (int i = 0; i < 8; i++) reqs.add(InferenceParameters.empty().withPrompt("def f" + i + "(x):").withNPredict(256)); - AtomicInteger maxBusy = new AtomicInteger(); - Thread poller = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { - maxBusy.accumulateAndGet(model.getMetricsTyped().getProcessingSlots(), Math::max); - try { Thread.sleep(20); } catch (InterruptedException e) { return; } } }); - poller.start(); - model.completeBatch(reqs); - poller.interrupt(); - System.out.println("cores=" + cores + " commonPool=" + ForkJoinPool.commonPool().getParallelism() - + " maxProcessingSlots=" + maxBusy.get()); -} -``` - -Auf einem Rechner mit 4 Kernen (bzw. mit `-Djava.util.concurrent.ForkJoinPool.common.parallelism=3`) und -auf einem mit vielen Kernen. - -**Hypothese.** `maxProcessingSlots` ≈ min(8, Pool-Parallelität), also weniger als 8 bei wenigen Kernen. -**Entscheidet.** Ob `completeBatch` auf „alle im Aufrufer-Thread übergeben, dann empfangen“ umgebaut wird. - -**Ergebnis:** Probe wie im Brief (Draft-Modell, `setParallel(8)`, `setCtxSize(8*512)`, 8 × 256 Tokens, Abtastung von -`getProcessingSlots()` alle 20 ms): -``` -16 Kerne (Standard): J5 cores=16 commonPool=15 maxProcessingSlots=8 batchMillis=3225 -4 Kerne (taskset -c 0-3): J5 cores=4 commonPool=3 maxProcessingSlots=3 batchMillis=14067 -16 Kerne, -Djava.util.concurrent.ForkJoinPool.common.parallelism=3: - J5 cores=16 commonPool=3 maxProcessingSlots=3 batchMillis=3262 -``` - -**Folgerung:** Bestätigt: Die Gleichzeitigkeit ist min(Slots, Parallelität des gemeinsamen `ForkJoinPool`) — auf -4 Kernen nur 3 von 8 Slots, unabhängig von `setParallel`. Die Javadoc-Zusage stimmt nur auf Rechnern mit mehr -als `setParallel` Kernen. Umbau auf „alle im Aufrufer-Thread übergeben, dann empfangen“ empfohlen; er entkoppelt -außerdem `completeBatch` vom gemeinsamen Pool der Anwendung. - ---- - -## J6 Modellgestützte Bestätigungen bereits gefixter Funde - -Jeweils den genannten Test bzw. Ablauf laufen lassen und Zahl/Ausgabe eintragen; keiner ist Voraussetzung, -jeder bestätigt einen Fix, der ohne Modell nur bis zur Schnittstelle geprüft war. - -| Fund | Lauf | Erwartung | -|---|---|---| -| F64 | `OpenAiCompatServerIntegrationTest` (Ollama-Werkzeugaufruf im Verlauf ohne `type`) | keine 400 von upstream mehr | -| F71 | `LlamaTrainerIntegrationTest` mit einem Emoji im Trainingstext (`stories260K.gguf`) | Feintuning läuft durch | -| F77 | `OpenAiCompatServerIntegrationTest` mit dem Reasoning-Modell, Anfrage mit `reasoning_effort: "none"` | kein Denkblock in der Antwort | -| F90 | zwei Runden mit Claude Codes wechselndem `cch`-Stempel im System-Prompt | `cached_tokens` > 0 in Runde 2 | -| F93 | gestreamte LangChain4j-Antwort (`test-java-llama-langchain4j-integration`) | Token-Nutzung gesetzt | -| F128 | `JllamaToolCallingIntegrationTest#streamedRequiredToolCallComesBackAsToolExecutionRequest` | grün | - -```bash -mvn -B -f llama/pom.xml test -Dtest='OpenAiCompatServerIntegrationTest,LlamaTrainerIntegrationTest' -Dnet.ladenthin.llama.test.ngl=0 -mvn -B -q -pl llama -am -DskipTests install && mvn -B -f llama-langchain4j/pom.xml verify \ - -Dnet.ladenthin.llama.lib.path="$PWD/llama/src/main/natives/net/ladenthin/llama/Linux/x86_64/cpu" -``` - -**Ergebnis:** Die beiden Befehle aus dem Brief: -``` -mvn -B -f llama/pom.xml test -Dtest='OpenAiCompatServerIntegrationTest,LlamaTrainerIntegrationTest' -Dnet.ladenthin.llama.test.ngl=0 -[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 -- in net.ladenthin.llama.server.OpenAiCompatServerIntegrationTest -[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in net.ladenthin.llama.LlamaTrainerIntegrationTest -mvn -B -f llama-langchain4j/pom.xml verify -Dnet.ladenthin.llama.lib.path=… (+ model/embedding/rerank-Eigenschaften) -[INFO] Tests run: 55, Failures: 0, Errors: 0, Skipped: 3 (die 3 = JllamaToolCallingIntegrationTest, Eigenschaft fehlte) -[INFO] BUILD SUCCESS -``` -Die vorhandenen Tests spielen die Szenarien der Tabelle nicht nach (kein Werkzeugaufruf im Ollama-Verlauf, kein -`reasoning_effort`, kein `cch`-Stempel, kein Emoji im Trainingstext). Deshalb jedes Szenario einzeln: - -| Fund | Lauf | Ausgabe | -|---|---|---| -| F64 | `OpenAiCompatServer --jinja` mit Qwen2.5-1.5B, `POST /api/chat`, Verlauf `assistant.tool_calls=[{"function":{"name":"get_time","arguments":{}}}]` (ohne `type`) + `tool`-Antwort | `HTTP 200`, `{"message":{"role":"assistant","content":"The current time is 12:00."},…,"done":true,"done_reason":"stop"}` | -| F71 | Feintuning wie `LlamaTrainerIntegrationTest` (`stories260K.gguf`, nCtx 128, ADAMW), Korpus mit 🦊 und 🐶 (128 Zeichen außerhalb der BMP) | `J6/F71 finetune OK: input=1185376 B, output=1201728 B, weights changed=true` | -| F77 | `OpenAiCompatServer --jinja` mit Qwen3-0.6B, `/v1/chat/completions` „What is 2+2?“ mit `reasoning_effort: "none"` gegen ohne | mit `none`: `reasoning_content_len=0 think_in_content=False completion_tokens=9 content='2 + 2 = 4.'`; ohne: `reasoning_content_len=459 completion_tokens=133` | -| F90 | `/v1/messages` (Anthropic), System-Prompt `x-anthropic-billing-header: cc_version=2.1.3.abc; cc_entrypoint=cli; cch=…;` + ~880 Token, drei Runden | `cch=a5145`: `cache_read_input_tokens 3`; **`cch=b7731`: `input_tokens 1, cache_read_input_tokens 885`**; Gegenprobe mit geändertem `cc_version`: `cache_read_input_tokens 18` | -| F93 | `JllamaStreamingChatModel` und `JllamaChatModel`, Qwen2.5-1.5B, „Reply with the single word: ok“ | gestreamt: `tokenUsage=TokenUsage { inputTokenCount = 36, outputTokenCount = 2, totalTokenCount = 38 } finishReason=STOP`; blockierend identisch | -| F128 | `JllamaToolCallingIntegrationTest` mit `-Dnet.ladenthin.llama.langchain4j.tool.model=…/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | `Tests run: 3, Failures: 0, Errors: 0, Skipped: 0` (enthält `streamedRequiredToolCallComesBackAsToolExecutionRequest`) | - -**Folgerung:** Alle sechs Fixes sind mit Modell bestätigt: F64 kein 400 mehr, F71 Feintuning läuft mit Emoji -durch und ändert die Gewichte, F77 unterdrückt den Denkblock (9 statt 133 Tokens), F90 trifft den Präfix-Cache -in Runde 2 (885 von 886 Tokens aus dem Cache; die Gegenprobe zeigt, dass ohne die Normalisierung nur der -Anfang vor der Änderung getroffen würde), F93 setzt die Token-Nutzung im Stream, F128 grün. Hinweis für CI: der -Integrationsjob sollte `net.ladenthin.llama.langchain4j.tool.model` setzen, sonst bleibt F128 dort übersprungen. - ---- - -## J7 F59: `FfmNativeServer.isRunning()` gegen ein paralleles `close()` (JDK 22+) - -**Ziel.** `isRunning()` kann nach einem parallelen `close()` freigegebenen Speicher lesen. Vorgeschlagener -Rot-Test: paketinterne Naht für die beiden Method-Handles, `isRunning` in einem Latch festhalten, `close()` -parallel; Erwartung nach dem Fix: `close()` wartet. Lauf im Modul `llama-ffm` (JDK 22+, wie Job -`test-java-llama-ffm`): `mvn -B -f llama-ffm/pom.xml verify -Dnet.ladenthin.llama.lib.path=…`. - -**Entscheidet.** Ob das Lock aus `NativeServer` übernommen wird (rot = ja). - -**Ergebnis:** `mvn -B -f llama-ffm/pom.xml test -Dtest=BughuntFfmProbeTest#… -Dnet.ladenthin.llama.lib.path=…` -(Toolchain: `Found matching toolchain for type jdk: JDK[/opt/jdk-25.0.4.1+1]`). - -1. **Rot-Test mit paketinterner Naht** (nur auf der VM): `FfmNativeServer` bekam zwei statische Felder - `isRunningHandle`/`stopHandle` (vorbelegt mit `SERVER_IS_RUNNING`/`SERVER_STOP`) statt der direkten Verweise. - Der Test ersetzt sie durch Java-Handles: `isRunning` hält in einem Latch fest, `close()` läuft parallel. - ``` - J7 seam events: [isRunning:native-entered, close:native-stop (frees the handle), close() RETURNED after 2 s, isRunning:native-returning] - J7 seam verdict: close() did NOT wait for the isRunning() native call (RED: stop/free happened while it was inside) - Tests run: 1, Failures: 1 (AssertionError "J7 RED: close() returned while isRunning() was inside the native call") - ``` -2. **Ohne Naht, echter Server** (Router-Modus über ein leeres `--models-dir`, 30 Runden, ein Thread ruft - `isRunning()` in einer Schleife, der Haupt-Thread `close()`): - ``` - J7 real: rounds=30 isRunning calls=862476291 true-after-close=0 (JVM survived) - ``` -Quelltext des Rot-Tests (Auszug; Naht: `static MethodHandle isRunningHandle = JllamaNative.SERVER_IS_RUNNING;`, -`static MethodHandle stopHandle = JllamaNative.SERVER_STOP;`, in `isRunning()`/`close()` statt der Konstanten): -```java -static boolean fakeIsRunning(MemorySegment handle) throws InterruptedException { - EVENTS.add("isRunning:native-entered"); ENTERED.countDown(); RELEASE.await(); - EVENTS.add("isRunning:native-returning"); return true; -} -static int fakeStop(MemorySegment handle) { EVENTS.add("close:native-stop (frees the handle)"); return 0; } - -@Test public void j7Seam() throws Exception { - MethodHandles.Lookup l = MethodHandles.lookup(); - FfmNativeServer.isRunningHandle = l.findStatic(BughuntFfmProbeTest.class, "fakeIsRunning", - MethodType.methodType(boolean.class, MemorySegment.class)); - FfmNativeServer.stopHandle = l.findStatic(BughuntFfmProbeTest.class, "fakeStop", - MethodType.methodType(int.class, MemorySegment.class)); - Constructor c = FfmNativeServer.class.getDeclaredConstructor(MemorySegment.class); - c.setAccessible(true); - FfmNativeServer server = c.newInstance(MemorySegment.NULL); - Thread reader = new Thread(server::isRunning); reader.start(); ENTERED.await(); - Thread closer = new Thread(server::close); closer.start(); closer.join(2000); - boolean closeReturnedWhileIsRunningInside = !closer.isAlive(); - RELEASE.countDown(); reader.join(); closer.join(); - if (closeReturnedWhileIsRunningInside) throw new AssertionError("J7 RED: close() returned while isRunning() was inside the native call"); -} -``` - -**Folgerung:** Rot: `close()` gibt den Handle frei, während `isRunning()` noch im nativen Aufruf auf ihm arbeitet. -Das Fenster ist im Normalbetrieb schmal (862 Mio. Aufrufe ohne sichtbaren Schaden), ein Use-after-free bleibt -es trotzdem. Das Lock aus `NativeServer` sollte übernommen werden (nach dem Fix muss der Test grün werden: -`close()` wartet). - ---- - -## J8 Offener Verdacht 1: `JllamaNative.takeStrings` gibt bei `count == 0` nicht frei (JDK 22+) - -**Ziel.** Bei `count == 0` kehrt `takeStrings` vor dem `try` zurück, `STRINGS_FREE` wird für den -Nicht-NULL-Zeiger nie gerufen (8 Byte je Aufruf; praktisch unerreichbar). Vorgeschlagener Test: -`takeStrings` mit der Freigabefunktion als Parameter, Aufruf mit Nicht-NULL-Zeiger und Anzahl 0, Erwartung -genau ein Freigabeaufruf. - -**Entscheidet.** Ob der Fix („bei Nicht-NULL immer freigeben“) umgesetzt wird. - -**Ergebnis:** `takeStrings` hat keine Naht für die Freigabefunktion (`STRINGS_FREE` ist `static final`), der -vorgeschlagene Test bräuchte also eine Produktionsänderung. Stattdessen deterministisch über glibc: ein -**falsch ausgerichteter** Nicht-NULL-Zeiger, auf dem `free()` sofort mit `free(): invalid pointer` abbricht. -``` -mode=control (jllama_strings_free(p, 0) direkt): mvn exit=1 Process Exit Code: 134 free(): invalid pointer -mode=takeStrings (JllamaNative.takeStrings(p, 0)): mvn exit=0 Tests run: 1, Failures: 0 - J8 mode=takeStrings: calling with misaligned non-NULL pointer 0x755a0072b991 and count 0 - J8 mode=takeStrings: returned [] and the JVM survived -> the free function was NOT called -``` -Probe: -```java -@Test public void j8InvalidPointer() throws Throwable { - String mode = System.getProperty("j8.mode", "takeStrings"); - try (Arena arena = Arena.ofConfined()) { - MemorySegment p = MemorySegment.ofAddress(arena.allocate(64, 16).address() + 1); - MemorySegment out = arena.allocate(ADDRESS); MemorySegment len = arena.allocate(JAVA_LONG); - out.set(ADDRESS, 0, p); len.set(JAVA_LONG, 0, 0L); - if (mode.equals("takeStrings")) JllamaNative.takeStrings(out, len); - else JllamaNative.invoke(JllamaNative.STRINGS_FREE, p, 0L); - } -} -``` -(Zwei vorherige Versuche über den Heap-Zähler `mallinfo2().uordblks` und über die tcache-Double-Free-Erkennung -waren nicht aussagekräftig — die JVM allokiert dazwischen selbst, die Gegenprobe schlug nicht an — und sind -deshalb nicht Teil des Ergebnisses.) - -**Folgerung:** Bestätigt: Bei `count == 0` ruft `takeStrings` die Freigabe nie auf, ein Nicht-NULL-Array leckt -(laut Verdacht 8 Byte je Aufruf); `jllama_strings_free(p, 0)` würde es freigeben. Der Fix „bei Nicht-NULL immer -freigeben“ ist richtig und gefahrlos. - ---- - -## J9 Nur falls das Verhalten geändert werden soll: F67 (LoRA-Cache) - -Braucht ein kleines Modell mit passendem LoRA-Adapter (nicht in `models.csv`). Ablauf: denselben Prompt -mit Greedy-Sampling vor und nach `setLoraAdapter(0, 0f)` senden und mit einem frisch geladenen Modell ohne -Adapter vergleichen; dann dasselbe nach `eraseSlot(0)`. Ohne Adapter: als „nicht ausführbar“ beantworten. - -**Entscheidet.** Ob `engine::set_lora_adapters` nach dem Wechsel die Slots leeren soll. - -**Ergebnis:** Kein Adapter in `models.csv`; genutzt wurde das Paar aus llama.cpps eigenen Server-Tests -(`tools/server/tests/unit/test_lora.py`): `ggml-org/stories15M_MOE` → `stories15M_MOE-F16.gguf` mit -`moe_shakespeare15M.gguf`. Greedy (`temperature 0, top_k 1, seed 42`, `cache_prompt true`, Slot 0, `setParallel(1)`, -Kontext 512), Prompt „Look in thy glass“, 32 Tokens: -``` -J9 fresh model, no adapter: " was a little girl. She was only three years old and she was three years old. She was three years old. She was three years old and she was" -J9 adapters at load: [LoraAdapter(id=0, path=…/moe_shakespeare15M.gguf, scale=1.0, …)] -J9 adapter scale 1 (cold slot): " in love of the eye:\nThat's when when the eye see thy on the sun's shining bright.\nThat's why those flowers" -J9 adapter scale 1 (warm slot): " in love of the eye:\nThat's when when the eye see thy on the sun's shining bright.\nThat's why those flowers" -J9 adapters after setLoraAdapter(0,0): [LoraAdapter(id=0, …, scale=0.0, …)] -J9 scale 0, warm slot (cache kept): ". He saw a picture of a bird and he wanted to know what it was. He asked his mom, \"What is it?\"\n\"It's" equalsFresh=false -J9 eraseSlot(0): {"id_slot":0,"n_erased":36} -J9 scale 0 after eraseSlot(0): " was a little girl. She was only three years old and she was three years old. She was three years old. She was three years old and she was" equalsFresh=true -``` - -**Folgerung:** F67 ist bestätigt: Nach `setLoraAdapter(0, 0f)` rechnet der Slot mit dem unter Skala 1 -berechneten Prompt-Cache weiter und liefert eine dritte, weder dem Adapter noch dem Grundmodell entsprechende -Ausgabe. Erst `eraseSlot(0)` stellt das Verhalten eines frischen Modells her. `engine::set_lora_adapters` sollte -nach einem Wechsel die Slots leeren, wenn das Verhalten geändert werden soll. diff --git a/TODO.md b/TODO.md index 2a62ef603..4c5647e19 100644 --- a/TODO.md +++ b/TODO.md @@ -520,15 +520,8 @@ delete an item here when it is done. `ServerMetricsTest`. - **F62** The LangChain4j adapter concatenates several `TextContent` without a separator. Proposal: join with `\n` as F60/F61 do (or pass them on as parts); the test that pins today's output changes. - -**Measured (`BUGHUNT-MEASUREMENTS.md`, J1–J9) and acted on:** J1/J2 → F158 follow-up (`setPredict` -below -1 rejected; `0` behaves like `1`), J3 → F165, J4 → F166, J5 → F167, J7 → F59, J8 → F168. -J6 confirmed all six fixes with a model (F64, F71, F77, F90, F93, F128); the CI job -`test-java-llama-langchain4j-integration` already passes `net.ladenthin.llama.langchain4j.tool.model`, -so F128 runs there too (the property was missing only from the measurement's local command line). -Still the owner's decision: - -- **F67** confirmed (J9): after `setLoraAdapter(0, 0f)` a slot keeps computing on the prompt cache +- **F67** confirmed (J9; the measurement brief J1–J9 and its results are `BUGHUNT-MEASUREMENTS.md` in + commit `b689d7b`, removed since): after `setLoraAdapter(0, 0f)` a slot keeps computing on the prompt cache built under scale 1 and answers neither like the adapter nor like the base model; only `eraseSlot(0)` restores a fresh model's behaviour. Proposal: clear the slots in `engine::set_lora_adapters` after a change. @@ -536,46 +529,41 @@ Still the owner's decision: 'n_predict': Value must be between -1 <= value <= 2147483647", J2a), so a Java-side check would only move the same error earlier. -**Open suspicions without a finding id** (each needs the owner's decision or a model to decide): - -1. *Fixed as F168:* `JllamaNative.takeStrings` frees a non-NULL array also when it is empty. -2. Ollama `done_reason`: `tool_calls` is passed through (Ollama reports `stop`), the streamed paths - always report `stop` even when cut off by `num_predict`, and the streamed final line has no - `prompt_eval_count`/`eval_count` (`withUsageChunk` not requested). -3. `OpenAiCompatServer` sends `200` + SSE headers before the backend runs, so a rejected streaming - request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by - `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; - changing it means sending the headers with the first chunk on all five streaming routes. -4. `RouterClient.findModel`/`awaitModelLoaded` compare `id` only; a model reached by an alias is - reported as not listed (`RouterModel` would need `aliases`). -5. *Fixed as F165:* `OpenAiCompatServer.main` advertises the loaded model's slot `n_ctx` (measured - before: 10240 advertised, 2048 real). -6. JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified - UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI - aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. -7. *Fixed as F166 for the Java API:* `ParameterJsonSerializer.buildMessages` writes `"{}"` for empty - tool-call arguments (measured: `""` in the history fails the request with 400). Still open: the - server's own translations, `ResponsesApiSupport.java:218` and `AnthropicApiSupport.java:229` - (`input` missing), build the history without it and can still write `""`. -8. Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. -9. `/props` reports `total_slots` as 1 instead of `n_parallel` (needs a config field). -10. Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` - (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently - gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep - the remaining fields. -11. Responses `refusal` parts of assistant messages in the history are dropped; upstream turns them - into a part its own parser rejects with 400. Taking them as text is the most useful reading. -12. Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. -13. Continue decides FIM support from `.Suffix` in `/api/show`'s `template`; ours is `""`. A - placeholder template would be invented. -14. Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a - `prompt` given as an array is rejected with 400 when streamed, accepted when not. -15. *Fixed as F167:* `completeBatch`/`completeBatchWithStats` submit every request on the caller's - thread and then receive, `chatBatch` gives each request a thread of its own (measured before: 3 - of 8 slots on 4 cores). Still open: `rerank(String, String...)` collapses equal documents in its - `HashMap`. -16. Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat - template, ours always completes raw. +**Open suspicions without a finding id** (each needs the owner's decision or a model to decide; +numbered for reference, the numbers stay when an item is done): + +- **(2)** Ollama `done_reason`: `tool_calls` is passed through (Ollama reports `stop`), the streamed paths + always report `stop` even when cut off by `num_predict`, and the streamed final line has no + `prompt_eval_count`/`eval_count` (`withUsageChunk` not requested). +- **(3)** `OpenAiCompatServer` sends `200` + SSE headers before the backend runs, so a rejected streaming + request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by + `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; + changing it means sending the headers with the first chunk on all five streaming routes. +- **(4)** `RouterClient.findModel`/`awaitModelLoaded` compare `id` only; a model reached by an alias is + reported as not listed (`RouterModel` would need `aliases`). +- **(6)** JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified + UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI + aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. +- **(7)** Empty tool-call arguments (rest of F166): the server's own translations, + `ResponsesApiSupport.java:218` and `AnthropicApiSupport.java:229` (`input` missing), build the + history without `buildMessages` and can still write `""`, which upstream rejects with 400. +- **(8)** Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. +- **(9)** `/props` reports `total_slots` as 1 instead of `n_parallel` (needs a config field). +- **(10)** Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` + (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently + gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep + the remaining fields. +- **(11)** Responses `refusal` parts of assistant messages in the history are dropped; upstream turns them + into a part its own parser rejects with 400. Taking them as text is the most useful reading. +- **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. +- **(13)** Continue decides FIM support from `.Suffix` in `/api/show`'s `template`; ours is `""`. A + placeholder template would be invented. +- **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a + `prompt` given as an array is rejected with 400 when streamed, accepted when not. +- **(15)** `rerank(String, String...)` collapses equal documents in its `HashMap` (the batch part was + fixed as F167). +- **(16)** Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat + template, ours always completes raw. ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) From b89fe4c3b0219106c340d14167149c274c263d37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:31:19 +0000 Subject: [PATCH 297/589] Mark F62 as the duplicate of F94, fixed in ee99d85 The LangChain4j adapter has joined the text parts of a user message with a newline since F94; F62 described the same finding and stayed open in the ledger and in TODO.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 6 +++--- TODO.md | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0a22275b2..7abff8704 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -42,7 +42,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) - [F60](#f60) — niedrig — *gefixt* - [F61](#f61) — niedrig — *gefixt* -- [F62](#f62) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) +- [F62](#f62) — niedrig — *gefixt* (Duplikat von F94) - [F63](#f63) — niedrig — *gefixt* - [F64](#f64) — mittel — *gefixt* - [F65](#f65) — niedrig — *gefixt* @@ -439,8 +439,8 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix (Vorschlag, nicht umgesetzt):** wie F60/F61 mit `\n` verbinden (oder die Teile als Parts weiterreichen); dabei den festschreibenden Test anpassen. Entscheidung des Nutzers, weil der Test das Verhalten bewusst zu wählen scheint. - **Reproduktion:** `UserMessage.from(TextContent.from("Hello"), TextContent.from("world"))` → `getContent()` = `"Helloworld"`. - **Rot-Grün-Nachweis:** keiner (kein Fix ohne Entscheidung). -- **Status:** nur beschrieben -- **Commit:** — +- **Status:** gefixt als F94 (Duplikat: derselbe Befund wurde in der Musterdurchsicht nach F60/F61 noch einmal aufgenommen und dort behoben; Nachtrag 2026-10-19) +- **Commit:** java-llama.cpp `ee99d85` (F94) ### F63 – java-llama.cpp: Responses-API-Objekte tragen `created_at: 0` diff --git a/TODO.md b/TODO.md index 4c5647e19..fb40f4590 100644 --- a/TODO.md +++ b/TODO.md @@ -518,8 +518,6 @@ delete an item here when it is done. - **F57** `ServerMetrics.getCumulativeUsage()` returns a `Usage` that breaks `Usage`'s own contract. Proposal: `new Usage(processed + cached, predicted, cached)`; changes documented behaviour and `ServerMetricsTest`. -- **F62** The LangChain4j adapter concatenates several `TextContent` without a separator. Proposal: - join with `\n` as F60/F61 do (or pass them on as parts); the test that pins today's output changes. - **F67** confirmed (J9; the measurement brief J1–J9 and its results are `BUGHUNT-MEASUREMENTS.md` in commit `b689d7b`, removed since): after `setLoraAdapter(0, 0f)` a slot keeps computing on the prompt cache built under scale 1 and answers neither like the adapter nor like the base model; only `eraseSlot(0)` From 685346477e3b7a931143db5ff2485e756a9c9a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:38:28 +0000 Subject: [PATCH 298/589] Count the cached prompt tokens in ServerMetrics.getCumulativeUsage (F57) Usage's contract is that prompt tokens include the cached ones and the cached ones are their subset. getCumulativeUsage filled promptTokens with the evaluated tokens only and cachedTokens with 0, citing an upstream counter that did not exist; n_prompt_tokens_cached_total has been part of getMetrics() since the b11538 bump. The cumulative Usage is now evaluated + cached, with the cached tokens as their subset, read the same way as the usage of a response. getWindowUsage documents why it has no cached part: upstream keeps no cache count for the window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++------ CHANGELOG.md | 10 ++++++++++ TODO.md | 3 --- .../ladenthin/llama/value/ServerMetrics.java | 18 ++++++++++++------ .../llama/value/ServerMetricsTest.java | 17 +++++++++++++++-- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7abff8704..2654cf1cb 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -38,7 +38,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F54](#f54) — niedrig — *gefixt* - [F55](#f55) — niedrig — *gefixt* - [F56](#f56) — niedrig — *gefixt* -- [F57](#f57) — niedrig — *nur beschrieben* (Entscheidung des Nutzers) +- [F57](#f57) — niedrig — *gefixt* - [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) - [F60](#f60) — niedrig — *gefixt* - [F61](#f61) — niedrig — *gefixt* @@ -385,11 +385,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java:203` (`getCumulativeUsage`) - **Schwere:** niedrig - **Beschreibung:** `Usage.getPromptTokens()` ist dokumentiert als „logical prompt token count, including cached tokens“, `getCachedTokens()` als dessen gecachte Teilmenge. `getCumulativeUsage()` füllt `promptTokens` aber mit `n_prompt_tokens_processed_total` (nur ausgewertete Tokens, ohne Cache-Treffer) und `cachedTokens` mit 0. Die Javadoc der Methode sagt das zwar, begründet es aber mit „upstream does not currently expose a cumulative logical or cached prompt total“ – das stimmt nicht mehr: `n_prompt_tokens_cached_total` kommt seit der `getMetrics()`-Zusammenführung mit und hat einen eigenen Getter (`getCumulativeCachedPromptTokens`). Ein Aufrufer, der `Usage` wie bei einer Chat-Antwort liest (`getPromptTokens()` gesamt, `getProcessedPromptTokens()` ausgewertet), bekommt hier zu kleine Gesamtzahlen. -- **Fix (Vorschlag, nicht umgesetzt):** `new Usage(processed + cached, predicted, cached)`; dann stimmen `getPromptTokens()`, `getCachedTokens()` und `getProcessedPromptTokens()` mit dem `Usage`-Vertrag überein. Das ändert dokumentiertes Verhalten (und `ServerMetricsTest`), daher Entscheidung des Nutzers. `getWindowUsage()` ist nicht betroffen: für das Fenster sendet upstream keinen Cache-Zähler. -- **Reproduktion:** ein Test mit `n_prompt_tokens_processed_total=10`, `n_prompt_tokens_cached_total=4` erwartet `getPromptTokens()==14` und `getCachedTokens()==4` – heute 10 und 0. Nicht geschrieben, solange die Richtung offen ist. -- **Rot-Grün-Nachweis:** keiner (kein Fix ohne Entscheidung). -- **Status:** nur beschrieben -- **Commit:** — +- **Historie:** Die Begründung „upstream does not currently expose a cumulative logical or cached prompt total“ steht in der erreichbaren Historie schon vor #494 (2026-10-09, flacher Klon, älter nicht sichtbar); `getCumulativeCachedPromptTokens()` und die Weitergabe von `n_prompt_tokens_cached_total` kamen mit `1eef547` (Bump auf b11538, 2026-10-09) – seitdem ist die Begründung überholt, `getCumulativeUsage()` wurde aber nicht nachgezogen. Bei b11639 nachgeprüft: upstream zählt in `prompt` nur ausgewertete Tokens und die Cache-Treffer getrennt (`server-common.h`, „cached ones are counted separately below“). +- **Fix:** Entscheidung des Nutzers: `new Usage(processed + cached, predicted, cached)`; Javadoc korrigiert, `getWindowUsage()` dokumentiert, warum dort kein Cache-Anteil steht (upstream führt für das Fenster keinen). CHANGELOG unter „Fixed“ mit dem Hinweis für Aufrufer, die `getPromptTokens()` als „ausgewertet“ gelesen haben. +- **Tests:** `ServerMetricsTest#cumulativeUsage` (schrieb das alte Verhalten fest, jetzt 800/700/100/1000 aus 100 ausgewerteten + 700 gecachten) und neu `#cumulativeUsage_readsLikeTheUsageOfAChatResponse` (10 + 4 → `new Usage(14, 3, 4)`). +- **Rot-Grün-Nachweis:** ohne Fix rot (2 von 23), mit Fix grün (23 von 23). +- **Status:** gefixt +- **Commit:** (folgt) ### F59 – java-llama.cpp: `FfmNativeServer.isRunning()` kann nach einem parallelen `close()` freigegebenen Speicher lesen diff --git a/CHANGELOG.md b/CHANGELOG.md index 347431003..33a41af4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,16 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **`ServerMetrics.getCumulativeUsage()` counts the cached prompt tokens.** It returned + `Usage(processed, predicted)`, so `getPromptTokens()` was the evaluated tokens only and + `getCachedTokens()` always `0`, against `Usage`'s own contract (prompt tokens include the cached + ones, which are their subset). The method's Javadoc gave the reason "upstream exposes no cumulative + cached total", which stopped being true when `getMetrics()` started carrying + `n_prompt_tokens_cached_total`. Now `getPromptTokens()` is evaluated + cached, + `getCachedTokens()` the cached ones and `getProcessedPromptTokens()` the evaluated ones -- read the + same way as the `usage` of a response. A caller who read `getPromptTokens()` as "evaluated" reads + `getProcessedPromptTokens()` or `getCumulativeProcessedPromptTokens()` now. `getWindowUsage()` is + unchanged: upstream keeps no cache count for the window. - **The GitHub `snapshot` pre-release and the versioned releases carry what Maven Central carries, and the `snapshot` release no longer accumulates.** The collect steps copied `*.jar` / `*.jar.asc` and the bare AARs, so every `.pom` with its `.pom.asc` was missing, `llama-platform` (a pom-only diff --git a/TODO.md b/TODO.md index fb40f4590..9395a628c 100644 --- a/TODO.md +++ b/TODO.md @@ -515,9 +515,6 @@ delete an item here when it is done. **Waiting for the owner's decision** (findings with a proposed fix): -- **F57** `ServerMetrics.getCumulativeUsage()` returns a `Usage` that breaks `Usage`'s own contract. - Proposal: `new Usage(processed + cached, predicted, cached)`; changes documented behaviour and - `ServerMetricsTest`. - **F67** confirmed (J9; the measurement brief J1–J9 and its results are `BUGHUNT-MEASUREMENTS.md` in commit `b689d7b`, removed since): after `setLoraAdapter(0, 0f)` a slot keeps computing on the prompt cache built under scale 1 and answers neither like the adapter nor like the base model; only `eraseSlot(0)` diff --git a/llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java b/llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java index 2c10c0257..eff25770e 100644 --- a/llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java +++ b/llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java @@ -194,16 +194,20 @@ public List getDraftAcceptedPerPosition() { } /** - * Cumulative server-wide compute counters since startup. The prompt value is - * the number actually evaluated by the model, excluding cache hits; upstream - * does not currently expose a cumulative logical or cached prompt total. + * Cumulative server-wide token counters since startup, read like the {@code usage} of a + * response: {@link Usage#getPromptTokens()} counts every prompt token, evaluated or served + * from the KV cache, {@link Usage#getCachedTokens()} the cached ones and + * {@link Usage#getProcessedPromptTokens()} the ones the model evaluated. Upstream counts the + * evaluated and the cached prompt tokens separately ({@code n_prompt_tokens_processed_total}, + * {@code n_prompt_tokens_cached_total}); the prompt total is their sum. * * @return cumulative {@link Usage} across all completions since server start */ public Usage getCumulativeUsage() { + final long processed = node.path("n_prompt_tokens_processed_total").asLong(0L); + final long cached = getCumulativeCachedPromptTokens(); return new Usage( - node.path("n_prompt_tokens_processed_total").asLong(0L), - node.path("n_tokens_predicted_total").asLong(0L)); + processed + cached, node.path("n_tokens_predicted_total").asLong(0L), cached); } /** @@ -226,7 +230,9 @@ public long getCumulativeGeneratedTokens() { /** * Usage counters from the most recent measurement window (current bucket) — - * {@code n_prompt_tokens_processed} and {@code n_tokens_predicted}. + * {@code n_prompt_tokens_processed} and {@code n_tokens_predicted}. Upstream keeps no cache + * count for the window, so the prompt value is the evaluated tokens only and + * {@link Usage#getCachedTokens()} is {@code 0}, unlike {@link #getCumulativeUsage()}. * * @return per-window {@link Usage} since the previous metrics emission */ diff --git a/llama/src/test/java/net/ladenthin/llama/value/ServerMetricsTest.java b/llama/src/test/java/net/ladenthin/llama/value/ServerMetricsTest.java index f7f226c38..ea244439d 100644 --- a/llama/src/test/java/net/ladenthin/llama/value/ServerMetricsTest.java +++ b/llama/src/test/java/net/ladenthin/llama/value/ServerMetricsTest.java @@ -63,13 +63,26 @@ public void totalsAndMaxTokens() throws Exception { public void cumulativeUsage() throws Exception { ServerMetrics m = parse(SAMPLE); Usage u = m.getCumulativeUsage(); - assertEquals(100L, u.getPromptTokens()); + // Usage's contract: prompt tokens include the cached ones (100 evaluated + 700 from the cache), + // the cached ones are their subset, and the evaluated ones are the difference. + assertEquals(800L, u.getPromptTokens()); + assertEquals(700L, u.getCachedTokens()); + assertEquals(100L, u.getProcessedPromptTokens()); assertEquals(200L, u.getCompletionTokens()); - assertEquals(300L, u.getTotalTokens()); + assertEquals(1000L, u.getTotalTokens()); assertEquals(100L, m.getCumulativeProcessedPromptTokens()); assertEquals(200L, m.getCumulativeGeneratedTokens()); } + @Test + public void cumulativeUsage_readsLikeTheUsageOfAChatResponse() throws Exception { + // The same counts as a response's usage block (prompt_tokens includes + // prompt_tokens_details.cached_tokens) must give the same Usage. + ServerMetrics m = parse("{\"n_prompt_tokens_processed_total\":10," + + "\"n_prompt_tokens_cached_total\":4,\"n_tokens_predicted_total\":3}"); + assertEquals(new Usage(14L, 3L, 4L), m.getCumulativeUsage()); + } + @Test public void windowUsage() throws Exception { ServerMetrics m = parse(SAMPLE); From fccc1e1c005d010e015e63b45fa4ecb3e8fd2e2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:38:28 +0000 Subject: [PATCH 299/589] Record the commit of F57 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2654cf1cb..68db0ca61 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -390,7 +390,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ServerMetricsTest#cumulativeUsage` (schrieb das alte Verhalten fest, jetzt 800/700/100/1000 aus 100 ausgewerteten + 700 gecachten) und neu `#cumulativeUsage_readsLikeTheUsageOfAChatResponse` (10 + 4 → `new Usage(14, 3, 4)`). - **Rot-Grün-Nachweis:** ohne Fix rot (2 von 23), mit Fix grün (23 von 23). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `a529c5c` ### F59 – java-llama.cpp: `FfmNativeServer.isRunning()` kann nach einem parallelen `close()` freigegebenen Speicher lesen From c9e0b80a5da0bf9eb973c5bec2465643f5a37ea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:41:33 +0000 Subject: [PATCH 300/589] Pin the server's rejection of n_predict -2 per request (F69) F69's documentation fix said the server refuses -2 as an invalid request; J2a measured it. A Java-side check would only move the same error earlier and keep upstream's limit in a second place, so the decision is to pin the behaviour instead: ErrorHandlingTest asserts that a request with withNPredict(-2) throws InvalidRequestException naming n_predict. Verified locally on a random two-layer GGUF, red with -1 as the counter-check; CI runs it with the regular model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + TODO.md | 3 --- .../ladenthin/llama/ErrorHandlingTest.java | 19 +++++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 68db0ca61..aaeec1ae7 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -513,6 +513,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: Dokumentation korrigieren (ohne Test). Die Javadoc von `withNPredict` sagt jetzt: `-1` übernimmt die `--predict`-Einstellung des Modells (selbst standardmäßig unbegrenzt, `server-context.cpp:1964`), der Server nimmt `-1` und größer an, `-2` lehnt er als ungültige Anfrage ab. Upstreams Beschreibung für `0` („no tokens will be generated but the prompt is evaluated“) ist bewusst **nicht** übernommen: im Quelltext findet sich dafür kein eigener Zweig, `has_budget()` stoppt erst nach dem ersten Token; ohne Messung bleibt die Aussage draußen. - **Reproduktion:** keine lauffähige hier (kein Modell, keine native Bibliothek). Ein Test in einer modellgestützten Klasse: `model.complete(InferenceParameters.empty().withPrompt("x").withNPredict(-2))` → `InvalidRequestException` mit `n_predict` in der Meldung. - **CI-Bedarf:** ein modellgestützter Java-Test (jedes Modell aus `models.csv` genügt), falls `-2` zusätzlich in Java abgewiesen werden soll. +- **Nachtrag 2026-10-19 (geschlossen):** J2a hat die Ablehnung gemessen (`InvalidRequestException`, „Field 'n_predict': Value must be between -1 <= value <= 2147483647“). Entscheidung des Nutzers: kein Java-Check (er würde denselben Fehler nur früher melden und upstreams Grenze ein zweites Mal pflegen), dafür die Ablehnung modellgestützt festgeschrieben: `ErrorHandlingTest#testNPredictMinusTwoPerRequestIsAnInvalidRequest` (läuft in jedem `test-java-*`-Job mit dem CI-Modell). Lokal mit einem zufälligen Zwei-Schicht-GGUF geprüft: mit `-2` grün, als Gegenprobe mit `-1` rot („Expected InvalidRequestException to be thrown, but nothing was thrown“). - **Status:** gefixt (Dokumentation) - **Commit:** a977a1e diff --git a/TODO.md b/TODO.md index 9395a628c..0cae461c8 100644 --- a/TODO.md +++ b/TODO.md @@ -520,9 +520,6 @@ delete an item here when it is done. built under scale 1 and answers neither like the adapter nor like the base model; only `eraseSlot(0)` restores a fresh model's behaviour. Proposal: clear the slots in `engine::set_lora_adapters` after a change. -- **F69**: the server rejects `withNPredict(-2)` per request with `InvalidRequestException` ("Field - 'n_predict': Value must be between -1 <= value <= 2147483647", J2a), so a Java-side check would only - move the same error earlier. **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; numbered for reference, the numbers stay when an item is done): diff --git a/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java b/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java index 701f40dfa..6c97df68a 100644 --- a/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java +++ b/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java @@ -9,6 +9,7 @@ import java.io.File; import net.ladenthin.llama.exception.InvalidRequestException; import net.ladenthin.llama.exception.LlamaException; +import net.ladenthin.llama.parameters.InferenceParameters; import net.ladenthin.llama.parameters.ModelParameters; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; @@ -201,6 +202,24 @@ public void testHandleInfillMissingInputSuffix() { } } + // ------------------------------------------------------------------------- + // n_predict per request (F69) + // ------------------------------------------------------------------------- + + @Test + public void testNPredictMinusTwoPerRequestIsAnInvalidRequest() { + // -2 ("until context filled") exists only for the completion tool's -n option. The server's + // request schema declares n_predict with the hard limits [-1, INT32_MAX] (server-schema.cpp, + // b11639), so a request with -2 is refused as the caller's fault, with the field named -- + // what withNPredict's Javadoc promises. + InferenceParameters request = + InferenceParameters.empty().withPrompt("def f():").withNPredict(-2); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> modelNoEmbed.complete(request)); + + assertTrue(e.getMessage().contains("n_predict"), "Error should name n_predict but was: " + e.getMessage()); + } + // ------------------------------------------------------------------------- // configureParallelInference validation // ------------------------------------------------------------------------- From e5a3efcf9b69f5ccea4b47b96dc501ba48d28cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:52:46 +0000 Subject: [PATCH 301/589] Clear a slot's cache when the server's LoRA adapters change (F67, patch 0021) launch_slot_with_task() clears a slot's prompt cache when a request names its own adapters and they differ from the slot's, but a request naming none takes params_base.lora_adapters without that check. The cache thus survived SET_LORA (setLoraAdapter(s), upstream's POST /lora-adapters) and the return to the server's adapters after a request with its own, and the slot answered like neither adapter set. J9 measured it at b11639 with llama.cpp's own LoRA test pair. patches/0021 applies the per-request rule (lora_should_clear_cache) in the else branch too; equal sets keep the cache. The guard is test_lora_cache.cpp: a random llama model and a random LoRA adapter on K, V and the FFN output, written by the test, one slot, greedy; after each kind of change the warm slot must answer like an emptied one. Red without the patch (3 of 4), green with it; the whole C++ suite 633/633. The model writer moved into tiny_llama_gguf.hpp, shared with test_c_api.cpp. The setLoraAdapters Javadoc and the README no longer tell callers to erase the slots and name the cache that still does not record adapters, the host-memory prompt cache. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 8 +- CHANGELOG.md | 8 + CLAUDE.md | 4 +- README.md | 9 +- TODO.md | 8 - docs/patches.md | 49 ++++- llama/CMakeLists.txt | 1 + ...ache-when-the-server-adapters-change.patch | 59 ++++++ .../java/net/ladenthin/llama/LlamaModel.java | 12 +- llama/src/test/cpp/test_c_api.cpp | 133 +------------ llama/src/test/cpp/test_lora_cache.cpp | 185 ++++++++++++++++++ llama/src/test/cpp/tiny_llama_gguf.hpp | 157 +++++++++++++++ 12 files changed, 481 insertions(+), 152 deletions(-) create mode 100644 llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch create mode 100644 llama/src/test/cpp/test_lora_cache.cpp create mode 100644 llama/src/test/cpp/tiny_llama_gguf.hpp diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index aaeec1ae7..ec5210434 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -46,7 +46,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F63](#f63) — niedrig — *gefixt* - [F64](#f64) — mittel — *gefixt* - [F65](#f65) — niedrig — *gefixt* -- [F67](#f67) — niedrig — *gefixt* (Dokumentation) +- [F67](#f67) — niedrig — *gefixt* (Patch 0021) - [F68](#f68) — niedrig — *gefixt* - [F69](#f69) — niedrig — *gefixt* (Dokumentation) - [F70](#f70) — mittel — *gefixt* @@ -489,8 +489,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: Dokumentation korrigieren (ohne Test, Nutzerregel für Doku-Korrekturen). Javadoc von `LlamaModel.setLoraAdapters` und README sagen jetzt, dass die KV-Caches auf diesem Weg **nicht** geleert werden, und nennen `eraseSlot(id)` bzw. `withCachePrompt(false)`. Vor der Änderung am Quelltext von b11639 nachgeprüft: `SERVER_TASK_TYPE_SET_LORA` (`server-context.cpp:3077`) setzt nur `params_base.lora_adapters`, `launch_slot_with_task` (`:1868`) übernimmt sie im `else`-Zweig ohne `prompt.clear()`. Das Leeren in `engine::set_lora_adapters` (C++) bleibt als Möglichkeit, ist aber nicht beschlossen. - **Reproduktion:** keine lauffähige: es braucht ein Modell und einen dazu passenden LoRA-Adapter (nicht in `models.csv`), und in dieser Sandbox gibt es keine native Bibliothek und kein `gguf-py`. Ein Test würde denselben Prompt vor und nach `setLoraAdapter(0, 0f)` mit Greedy-Sampling schicken und mit einem frisch geladenen Modell ohne Adapter vergleichen. - **CI-Bedarf:** ein kleines Modell mit LoRA-Adapter im Modellsatz (oder ein zur Laufzeit erzeugtes Zufallsmodell samt Adapter, wie `test_c_api.cpp` es für das Modell macht) – nur, falls das Verhalten geändert werden soll. -- **Status:** gefixt (Dokumentation) +- **Status:** gefixt (zuerst Dokumentation, seit 2026-10-19 Patch 0021, siehe Nachtrag) - **Commit:** a977a1e +- **Nachtrag 2026-10-19 (Verhalten geändert):** J9 hat F67 bestätigt (`b689d7b`: nach `setLoraAdapter(0, 0f)` lieferte der warme Slot „. He saw a picture of a bird …“ statt „ was a little girl …“ wie ein geleerter Slot). Entscheidung des Nutzers: upstream-fähiger Patch `0021` statt eines Leerens in `engine::set_lora_adapters`, weil upstreams eigene HTTP-Route `POST /lora-adapters` denselben Fehler hat. Der `else`-Zweig von `launch_slot_with_task` wendet jetzt dieselbe Regel an wie LoRA pro Anfrage (`lora_should_clear_cache`, aLoRA behält den Cache). Das deckt auch den zweiten Weg ab, den die Durchsicht fand: eine Anfrage mit eigenen Adaptern füllt den Cache, die nächste ohne läuft mit den Server-Adaptern darauf weiter. Nicht abgedeckt, in Javadoc/README genannt: der Host-RAM-Prompt-Cache (`--cache-ram`, standardmäßig an) speichert Slot-Zustände ohne ihre Adapter. Javadoc `setLoraAdapters`, README, CHANGELOG, CLAUDE.md (Patch- und Testtabelle), `docs/patches.md`. Upstream-master bewusst nicht geprüft (Entscheidung des Nutzers: zeigt sich beim nächsten Bump über den Drop-Check). +- **Tests (Patch):** `src/test/cpp/test_lora_cache.cpp` (4): Zufallsmodell + Zufalls-LoRA auf K, V und FFN-Ausgang, ein Slot, `--cache-ram 0`, greedy; nach Skala 1→0, 0→1 und nach einer Anfrage mit eigenen Adaptern muss der warme Slot wie ein geleerter antworten; ein vierter Fall schließt das leere Bestehen aus (Adapter ändert die Antwort, Wiederholung nutzt den Cache). Das Modellschreiben liegt jetzt in `tiny_llama_gguf.hpp`, gemeinsam mit `test_c_api.cpp`. +- **Rot-Grün-Nachweis (Patch):** ohne Patch rot (3 von 4; z. B. „mYmq5*mq“ statt „C+oM~kM~“), mit Patch grün (4 von 4, drei Läufe); ganze C++-Suite 633/633; alle 21 Patches wenden sich der Reihe nach sauber auf b11639 an; `check-patches.py` 0 Probleme. +- **Commit (Patch):** (folgt) ### F68 – java-llama.cpp: `RouterClient.awaitModelLoaded` läuft bei einem schlafenden Modell in den Timeout diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a41af4a..48a90080b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,14 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **A LoRA adapter change no longer answers from the old cache** (`patches/0021`). After + `setLoraAdapter(s)` -- and after a request with its own adapters -- a slot reused the prompt cache it + had computed under the previous scales and answered like neither adapter set (measured with + llama.cpp's own LoRA test pair: a third, unrelated text until the slot was erased). Upstream clears + the cache when a request names its own adapters but not when the server's adapters change; the patch + applies the same rule there, which also fixes upstream's `POST /lora-adapters` on the embedded + server. `setLoraAdapters`' Javadoc no longer tells callers to erase the slots; it names the one cache + that still does not record adapters, the host-memory prompt cache (`setCacheRamMib(0)` disables it). - **`ServerMetrics.getCumulativeUsage()` counts the cached prompt tokens.** It returned `Usage(processed, predicted)`, so `getPromptTokens()` was the evaluated tokens only and `getCachedTokens()` always `0`, against `Usage`'s own contract (prompt tokens include the cached diff --git a/CLAUDE.md b/CLAUDE.md index 7e3429d5f..4001adcc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1453,6 +1453,7 @@ Current patches: | `0018-ggml-amx-free-the-aligned-buffer.patch` | **The AMX weight buffer is freed with `free()` but allocated with `_aligned_malloc()` on Windows -- `STATUS_HEAP_CORRUPTION` (0xC0000374) the moment a model is unloaded.** `ggml_backend_amx_buffer_type_alloc_buffer()` (`ggml/src/ggml-cpu/amx/amx.cpp`) allocates with `ggml_aligned_malloc()`, which is `_aligned_malloc()` under `_MSC_VER` (plain clang on a `*-windows-msvc` target defines it too); `ggml_backend_amx_buffer_free_buffer()` called `free()` on it. Fine on Linux (`posix_memalign` + `free`), fatal on Windows, and only there. The patch frees with `ggml_aligned_free(buffer->context, buffer->size)`, exactly what `ggml_backend_cpu_buffer_free_buffer()` does; on Linux that is `free()`, so nothing changes there. **Reached by** the `sapphirerapids` CPU module (the only variant built with `AMX_TILE AMX_INT8`, which `ggml_backend_load_best` picks on Sapphire/Emerald/Granite Rapids; `ggml_amx_init()` is `return true` on `_WIN32`) and a model whose weights have AMX kernels (Q4_0, Q4_1, Q8_0, Q4_K, Q5_K, Q6_K, IQ4_XS -- i.e. every CI model). **Found by the second full run of the modular natives** (38069595101): `Java Tests Windows 2025 x86_64 (clang)` landed on an `Intel(R) Xeon(R) 6973P-C` runner (Granite Rapids), loaded `ggml-cpu-sapphirerapids.dll`, ran `GrammarExample`'s two generations fine and died with exit code -1073740940 at `LlamaModel.close()`; the first run (38065017595) had the same job green on an AMD EPYC 7763 (no AVX-512, `haswell`). **The Windows runners are a CPU lottery**, so which CPU module a Windows job exercises differs per run; before the modular natives the Windows jar was a static Haswell-level build and this code never ran. **Of the six callers of `ggml_aligned_malloc` in the tree this was the only mismatched pair** (`ggml-backend.cpp`, `ggml-cpu.c` twice, `ggml-openvino.cpp`, `ggml-zdnn.cpp` and `ggml.c` all pair correctly), and `ggml-backend.cpp:2464` is the line the fix copies. **Reproduced deliberately rather than waiting for the lottery again**: on a rented Granite Rapids machine, one CPU module staged at a time with the same model and the DLLs CI itself built, `sapphirerapids` crashed **6 of 6** runs -- including at **one** thread, which rules out a data race -- while `cooperlake`, `icelake` and `haswell` were clean 6 of 6, and on Linux with the same CPU all six exit 0 with byte-identical output. The crash lands *after* the inference prints its timings, in cleanup: the signature of a heap corrupted earlier and detected at `free`. With the patch applied the same matrix on the same machine is **clean in all twelve runs**. **The guard is a configure-time check, not a `jllama_test` case**: the AMX code exists only in that one variant of a `GGML_CPU_ALL_VARIANTS` build and executes only on AMX hardware, which the static test build, the local agent's Zen 3 and the cloud sandboxes lack -- so **`llama/cmake/check-aligned-free-pairing.cmake`** fails the configure on **every** platform when a ggml source calls `ggml_aligned_malloc` and never `ggml_aligned_free` (verified red against the unpatched source, naming `amx.cpp`, and green with this patch). A run-time test cannot replace it: without an AMX CPU there is nothing to observe, which is exactly how this shipped. What exercises it: the Windows x86-64 Java test job and the `windows-x86-64` smoke row whenever GitHub hands them an Intel AMX runner; the patch itself is held by `check-patches.py`, applies at b11639, and the patched file compiles with the variant's flags (GCC 13, `-mamx-tile -mamx-int8 -mavx512vnni ...`). Upstream master still carried the `free()` on 2026-10-10; **not yet filed upstream** (nothing before the release). **Drop-check on every bump, note the inversion:** `grep -n "free(buffer->context)" ggml/src/ggml-cpu/amx/amx.cpp` at the new tag -- a **hit** means the bare `free()` is still there and the patch is still needed; **gone** means upstream fixed it, drop the patch. Measured at b11639 (`a0e6dc738`): one hit, line 46. | | `0019-ggml-cpu-select-the-neon-fp32-to-fp16-conversion.patch` | **Every ARM build converts fp32 to fp16 in software, ours and upstream's.** The `__ARM_NEON` branch of `simd-mappings.h` defines both `GGML_CPU_COMPUTE_*` macros and selects only `FP16_TO_FP32`, so `GGML_CPU_FP32_TO_FP16` falls through to `GGML_COMPUTE_FP32_TO_FP16` -- ggml-base's deliberately portable software bit manipulation -- while `neon_compute_fp32_to_fp16`, one `fcvt`, stays defined and referenced by nothing. The `GGML_CPU_*` pair exists to be the *accelerated* variant; on ARM it was defined to be the unaccelerated one. `__POWER9_VECTOR__` and `__riscv_zfhmin` select both, and the comment on that fallback has always promised NEON does too. **Not a regression** -- absent in all 25 most recent commits touching the file, back to 2025-04-10. **Why only ARM feels it:** `ggml_cpu_fp32_to_fp16`, the `from_float` of `GGML_TYPE_F16`, has vector paths for F16C, RISC-V and s390x (the last from #30157) and **none** for NEON, so there the whole function is the scalar loop through the macro. **In the shipped artifacts:** no `fcvt` of any form in that function in any of this project's 17 ARM libraries (`cpu-linux-aarch64` 8 variants, `cpu-android-aarch64` 7, `cpu-windows-aarch64`, `metal-macos-aarch64`) nor in upstream's own `bin-ubuntu-arm64` / `bin-macos-arm64`; a plain `gcc` with no flags defines `__ARM_NEON` and `__ARM_FP16_FORMAT_IEEE` on aarch64, so nothing can miss it. **Measured 2.45x-13.3x** on five ARM core types, three operating systems and nine compiler builds, with the opposite direction in the same run as a control (within 3.2 % everywhere); **bit-exact over all 2^32 float inputs**, differing only in NaN payload, where x86 F16C gives the same numbers -- so it makes ARM agree with what x86 already ships. **Not an inference speed-up:** `perf` puts 83.9 % of an F16 `pp512` run in tinyBLAS's gemm, which converts in its own microkernel, and 0.03 % here. The patch also replaces the stale comment above the fallback with an `#error` pair that refuses a branch defining a COMPUTE macro without selecting it -- its position chosen by a mutation measurement, not by taste (4 of 4 correctness cases caught before the fallback against 3 of 4 directly after the chain). **The `FP16_TO_FP32` half carries `&& !defined(__F16C__)`, and that carve-out is load-bearing since the b11639 bump:** upstream #30343 (`685f70894` = b11635, merged 2026-10-14) puts that one direction back on the lookup table deliberately -- clang lowers the F16C intrinsic into the live accumulator register of the quantized dot products and serialises the loop, 45-65 % slower Q8_0/Q4_0 `MUL_MAT_ID` -- so without it the guard fires on **11 of the 14 x86-64 CPU variant modules**. Up to b11632 #30343 was absent, the `__F16C__` block still selected both directions and the carve-out was inert; from b11639 on the pin carries #30343, so x86 FP16->FP32 is on the lookup table exactly as upstream ships it, and the carve-out is what keeps the guard quiet there (`gcc -E` of the patched b11639 header silent for `-march=x86-64`, `-msse4.2`, `-march=haswell`, `-march=sapphirerapids`, `-march=znver4` and `-mavx -mf16c`). A **first** version of this patch also moved #30270's two F16C selections into the `__F16C__` branch; that was dropped when #30343 turned the block into a documented exception with an issue link, which is also why this diff now applies **both** at the pin and on upstream master (verified with `git apply --check` against each, and `clang -E` silent in all five configurations of the patched pinned source). Full record: [`docs/benchmarks/arm-fp16-conversion.md`](docs/benchmarks/arm-fp16-conversion.md). **Submitted upstream as [ggml-org/llama.cpp#30505](https://github.com/ggml-org/llama.cpp/pull/30505)** (open, rebased onto master 2026-10-14 at the maintainer's request), byte-identical to this diff -- the diff body of the patch file and `git diff` of the PR commit hash the same. **Drop-check on every bump, and its output must be read rather than tested:** `git show :ggml/src/ggml-cpu/simd-mappings.h \| sed -n '/^#if defined(__ARM_NEON)/,/^#elif/p' \| grep -n GGML_CPU_FP32_TO_FP16` prints **nothing** while the patch is needed and one line once upstream merged it -- then **drop this patch rather than refreshing it**. **Do not reduce the looser `git grep -n "GGML_CPU_FP32_TO_FP16(x) GGML_CPU_COMPUTE_FP32_TO_FP16"` over that file to a true/false:** it returns **three** hits at every tag so far (`__POWER9_VECTOR__`, `__riscv_zfhmin` and the out-of-chain `__F16C__` block, the first two with the NEON line's own four-space indent), so a `grep -q` reports "upstream fixed it" at every tag and deletes a fix that is still needed -- measured at b11639 (`a0e6dc738`): lines 71, 114 and 150, none in the `__ARM_NEON` branch, while `git apply --check` of the patch is clean. | | `0020-ggml-alloc-validate-leafs-before-reusing-an-allocation.patch` | **An `--embedding` model that is asked to generate crashes the process in `ggml_gallocr_alloc_graph`** (SIGSEGV on Linux, 0xC0000005 with both Windows toolchains), since upstream #30445 (`cb55ffe6e`, "graph: skip the LM head for pooled embeddings", b11574..b11612). `ggml_gallocr_alloc_graph()` assigns every tensor by **position** from the graph it was reserved with, and `ggml_gallocr_needs_realloc()` validates every node and every node's sources but of the **leafs only the count**. Since #30445 the LM head is expanded by `build_logits()` *after* `build_pooling()`, so an embeddings context reserves a graph that expands `t_embd` first and `t_logits` second, while a generation step on it (the server calls `llama_set_embeddings(ctx, false)` for a slot that samples) expands only `t_logits`, whose first source is `output.weight`: same node and leaf counts, every leaf after it shifted by one. An input leaf then lands on the slot of a weight (`buffer_id -1`) and, with the asserts compiled out, `ggml_vbuffer_tensor_alloc()` indexes `galloc->buffers[-1]`; a leaf landing on another input's slot is not size-checked either. The patch validates the leafs exactly as the nodes are validated, so an invalid leaf makes the allocator reserve again (one buffer) or return false, on which `ggml_backend_sched` reserves the graph itself. **Found by** Publish run 38648257387 (2026-10-14, the first full run since b11558): `ErrorHandlingTest` loads its model with `enableEmbedding()` and posts an infill request to it -- its first test in JUnit's order -- and the fork died in all three Java test jobs (`ggml_gallocr_alloc_graph <- ggml_backend_sched_alloc_graph <- llama_context::process_ubatch <- decode <- server_context_impl::update_slots`). **Reproduced locally without a downloaded model** (a random 2-layer llama GGUF with FIM tokens, context 128): infill crashes with `--embedding` and works without; a debug print showed leaf 31 `out_ids` arriving at a slot with no allocation; reverting `cb55ffe6e` makes it pass, and so does this patch -- with greedy sampling the `--embedding` model then returns the same tokens as the model without, run after run. Upstream master (`a0e6dc738`) still lacks the check; **not yet filed upstream**. **Runnable guard: `src/test/cpp/test_gallocr_leafs.cpp`** (two graphs with the same counts and the leafs swapped). **Drop-check on every bump:** read `ggml_gallocr_needs_realloc()` in `ggml/src/ggml-alloc.c` at the new tag -- once it walks `graph->leafs` itself, drop this patch rather than refreshing it. | +| `0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch` | **A slot's prompt cache survived a change of the LoRA adapters it was computed with.** `launch_slot_with_task()` (`tools/server/server-context.cpp`) clears the cache when a request names its own adapters and they differ from the slot's (`lora_should_clear_cache`, which keeps it only for aLoRA), but a request that names none takes `params_base.lora_adapters` in the `else` branch without that check. So the cache survived `SET_LORA` (`LlamaModel.setLoraAdapter(s)`, upstream's `POST /lora-adapters`) and the return to the server's adapters after a request with its own, and the slot answered like neither adapter set, silently. **Measured** (J9 of the bug-hunt brief, b11639, upstream's own LoRA test pair `stories15M_MOE-F16.gguf` + `moe_shakespeare15M.gguf`, greedy, one slot): after `setLoraAdapter(0, 0f)` the warm slot answered ". He saw a picture of a bird ..." where an emptied slot with the same scale answers " was a little girl ..."; only `eraseSlot(0)` restored it (F67). The patch applies the per-request rule in the `else` branch too; equal sets keep the cache as before. Upstream has the same defect on its HTTP route, so the patch is upstream-shaped; **not yet filed upstream**. Not covered, and said in `setLoraAdapters`' Javadoc: the host-memory prompt cache (`--cache-ram`, on by default) stores slot states without their adapters. **Runnable guard: `src/test/cpp/test_lora_cache.cpp`** (a random llama model and a random LoRA adapter on K, V and the FFN output, written by the test; four cases). **Drop-check on every bump:** read the `else` branch of the "process per-request lora adapters" block in `launch_slot_with_task()` at the new tag -- once it compares `params_base.lora_adapters` with `slot.lora` and clears the prompt (or `SET_LORA` clears the slots), drop this patch rather than refreshing it. | **Dropped patches** (`0004`, `0005`, `0009`, `0010`, `0011`, `0012`, `0013` -- upstream fixed each defect itself) are recorded in [`docs/history/dropped-llama-patches.md`](docs/history/dropped-llama-patches.md): @@ -2616,8 +2617,9 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_c_api.cpp` | 27 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | +| `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 629 tests (all passing).** +**Current total: 633 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/README.md b/README.md index bb949db19..39bf57322 100644 --- a/README.md +++ b/README.md @@ -830,10 +830,11 @@ try (LlamaModel model = new LlamaModel(modelParams)) { ``` Per the upstream contract, a scale update lists the adapters to keep active — any adapter missing -from the map is set to scale `0` (disabled). The KV caches are **not** cleared on this path (upstream's -`POST /lora-adapters` only replaces the scales): a later request whose prompt prefix is still cached in -a slot reuses KV entries computed under the previous scales. After a change, clear the slots with -`eraseSlot(id)` or send such requests with `withCachePrompt(false)`. +from the map is set to scale `0` (disabled). A slot whose cache was computed under other scales clears +it at its next request, so no answer mixes old and new adapters (upstream's `POST /lora-adapters` +path does not; `patches/0021` applies upstream's rule for per-request adapters there too). The +host-memory prompt cache (`--cache-ram`, on by default) does not record the adapters and can still load a +state saved under other scales; disable it (`setCacheRamMib(0)`) where that matters. ### Text-to-Speech diff --git a/TODO.md b/TODO.md index 0cae461c8..4a3e1d6f9 100644 --- a/TODO.md +++ b/TODO.md @@ -513,14 +513,6 @@ Everything the bug hunt left open, so nothing is lost when the session ends. The named in each item holds the analysis, the upstream references (b11639) and the reproduction; delete an item here when it is done. -**Waiting for the owner's decision** (findings with a proposed fix): - -- **F67** confirmed (J9; the measurement brief J1–J9 and its results are `BUGHUNT-MEASUREMENTS.md` in - commit `b689d7b`, removed since): after `setLoraAdapter(0, 0f)` a slot keeps computing on the prompt cache - built under scale 1 and answers neither like the adapter nor like the base model; only `eraseSlot(0)` - restores a fresh model's behaviour. Proposal: clear the slots in `engine::set_lora_adapters` after a - change. - **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; numbered for reference, the numbers stay when an item is done): diff --git a/docs/patches.md b/docs/patches.md index 3871c1569..4014573ac 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -18,7 +18,7 @@ upstream-shaped, and dropped here the moment upstream carries the fix (the drop- in its `jllama-build.txt` (`patches=`) and a hash over the set (`patches.sha256=`), so a consumer can match a binary to this page. -**13 patches.** Ids as `jllama-build.txt` lists them under `patches=`. +**14 patches.** Ids as `jllama-build.txt` lists them under `patches=`. | Id | Patch | Title | Files | Hunks | |---|---|---|---:|---:| @@ -35,6 +35,7 @@ match a binary to this page. | 0018 | [`0018-ggml-amx-free-the-aligned-buffer.patch`](#0018-ggml-amx-free-the-aligned-bufferpatch) | ggml-cpu(amx): free the AMX weight buffer with ggml_aligned_free(), not free() | 1 | 1 | | 0019 | [`0019-ggml-cpu-select-the-neon-fp32-to-fp16-conversion.patch`](#0019-ggml-cpu-select-the-neon-fp32-to-fp16-conversionpatch) | ggml-cpu : select the NEON fp32->fp16 conversion | 1 | 2 | | 0020 | [`0020-ggml-alloc-validate-leafs-before-reusing-an-allocation.patch`](#0020-ggml-alloc-validate-leafs-before-reusing-an-allocationpatch) | ggml-alloc : validate the leafs before reusing a graph's allocation | 1 | 1 | +| 0021 | [`0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch`](#0021-server-clear-the-slot-cache-when-the-server-adapters-changepatch) | server : clear a slot's cache when the server's LoRA adapters change | 1 | 1 | ## 0001-win32-arg-parse-embed-guard.patch @@ -604,3 +605,49 @@ and only the leafs moved (it dies with SIGSEGV without this patch), reserve with Drop-check on every bump: read ggml_gallocr_needs_realloc() in ggml/src/ggml-alloc.c at the new tag -- once it walks graph->leafs itself, drop this patch rather than refreshing it. ``` + +## 0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch + +**Files (1):** `tools/server/server-context.cpp` + +The patch's own header, verbatim: + +```text +server : clear a slot's cache when the server's LoRA adapters change + +A slot's prompt cache holds KV entries computed under the LoRA adapters the slot ran with. +launch_slot_with_task() respects that for a request that names its own adapters: when they differ +from the slot's, lora_should_clear_cache() decides, and the cache is cleared unless only aLoRA +adapters are involved. A request that names none takes params_base.lora_adapters in the else +branch -- without that check. So the cache survives two changes of the adapters it was computed +with: + + - SET_LORA (POST /lora-adapters, and the embedding API's set-adapters call) replaces + params_base.lora_adapters, and the next request on a warm slot reuses its prefix; + - a request with its own adapters fills the cache, and the next request without any runs with + the server's adapters on top of it. + +The slot then goes on with KV entries computed under the old scales and answers like neither the +old nor the new adapter set, silently. Measured at b11639 with the pair from the server's own LoRA +test (stories15M_MOE-F16.gguf + moe_shakespeare15M.gguf, greedy, one slot, prompt "Look in thy +glass"): after setting the adapter's scale from 1 to 0, the warm slot answered ". He saw a picture +of a bird ..." where the same model with the adapter at 0 on an emptied slot answers " was a little +girl. She was only three years old ..." -- and erasing the slot was the only way back. + +The patch applies the per-request rule in the else branch too: when params_base.lora_adapters +differ from the slot's, lora_should_clear_cache() decides, the same trace lines are logged, and +the slot takes the server's adapters. Equal sets keep the cache exactly as before. + +Not covered: the host-memory prompt cache (--cache-ram) stores slot states without the adapters +they were computed with, so a state saved under other adapters can still be loaded into a slot; +that holds for per-request adapters as much as for these. + +The guard is src/test/cpp/test_lora_cache.cpp: a random llama model and a random LoRA adapter on +K, V and the FFN output, greedy, one slot; after each kind of change the warm slot must answer +like an emptied one. + +Drop-check on every bump: read the else branch of the "process per-request lora adapters" block of +launch_slot_with_task() in tools/server/server-context.cpp at the new tag -- once it compares +params_base.lora_adapters with slot.lora and clears the prompt (or SET_LORA clears the slots), +drop this patch rather than refreshing it. +``` diff --git a/llama/CMakeLists.txt b/llama/CMakeLists.txt index d51118bd4..668b0b50a 100644 --- a/llama/CMakeLists.txt +++ b/llama/CMakeLists.txt @@ -1350,6 +1350,7 @@ if(BUILD_TESTING) src/test/cpp/test_wire_contracts.cpp src/test/cpp/test_rpc.cpp src/test/cpp/test_gallocr_leafs.cpp + src/test/cpp/test_lora_cache.cpp ${llama.cpp_SOURCE_DIR}/tools/server/server-common.cpp ${llama.cpp_SOURCE_DIR}/tools/server/server-chat.cpp ${llama.cpp_SOURCE_DIR}/tools/server/server-context.cpp diff --git a/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch b/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch new file mode 100644 index 000000000..e2cf77742 --- /dev/null +++ b/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch @@ -0,0 +1,59 @@ +server : clear a slot's cache when the server's LoRA adapters change + +A slot's prompt cache holds KV entries computed under the LoRA adapters the slot ran with. +launch_slot_with_task() respects that for a request that names its own adapters: when they differ +from the slot's, lora_should_clear_cache() decides, and the cache is cleared unless only aLoRA +adapters are involved. A request that names none takes params_base.lora_adapters in the else +branch -- without that check. So the cache survives two changes of the adapters it was computed +with: + + - SET_LORA (POST /lora-adapters, and the embedding API's set-adapters call) replaces + params_base.lora_adapters, and the next request on a warm slot reuses its prefix; + - a request with its own adapters fills the cache, and the next request without any runs with + the server's adapters on top of it. + +The slot then goes on with KV entries computed under the old scales and answers like neither the +old nor the new adapter set, silently. Measured at b11639 with the pair from the server's own LoRA +test (stories15M_MOE-F16.gguf + moe_shakespeare15M.gguf, greedy, one slot, prompt "Look in thy +glass"): after setting the adapter's scale from 1 to 0, the warm slot answered ". He saw a picture +of a bird ..." where the same model with the adapter at 0 on an emptied slot answers " was a little +girl. She was only three years old ..." -- and erasing the slot was the only way back. + +The patch applies the per-request rule in the else branch too: when params_base.lora_adapters +differ from the slot's, lora_should_clear_cache() decides, the same trace lines are logged, and +the slot takes the server's adapters. Equal sets keep the cache exactly as before. + +Not covered: the host-memory prompt cache (--cache-ram) stores slot states without the adapters +they were computed with, so a state saved under other adapters can still be loaded into a slot; +that holds for per-request adapters as much as for these. + +The guard is src/test/cpp/test_lora_cache.cpp: a random llama model and a random LoRA adapter on +K, V and the FFN output, greedy, one slot; after each kind of change the warm slot must answer +like an emptied one. + +Drop-check on every bump: read the else branch of the "process per-request lora adapters" block of +launch_slot_with_task() in tools/server/server-context.cpp at the new tag -- once it compares +params_base.lora_adapters with slot.lora and clears the prompt (or SET_LORA clears the slots), +drop this patch rather than refreshing it. + +diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp +index 22a7805aa..e33a01cbd 100644 +--- a/tools/server/server-context.cpp ++++ b/tools/server/server-context.cpp +@@ -1882,7 +1882,15 @@ + } + slot.lora = task_loras; + } +- } else { ++ } else if (!are_lora_equal(params_base.lora_adapters, slot.lora)) { ++ // the server's adapters (changed by SET_LORA, or restored after a request that named its ++ // own) differ from the ones the slot's cache was computed with: same rule as above ++ if (lora_should_clear_cache(slot.lora, params_base.lora_adapters)) { ++ SLT_TRC(slot, "clearing cache for lora change. %zu loras -> %zu loras\n", slot.lora.size(), params_base.lora_adapters.size()); ++ slot.prompt.clear(); ++ } else { ++ SLT_TRC(slot, "keeping cache for alora. %zu target loras\n", params_base.lora_adapters.size()); ++ } + slot.lora = params_base.lora_adapters; + } + diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java index 5c361d7db..2984d32d5 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java @@ -1075,11 +1075,13 @@ public List getLoraAdapters() { * {@code scales} get the given scale, all other adapters are set to {@code 0} * (disabled). Ids not corresponding to a loaded adapter are ignored. * - *

The KV caches are not cleared on this path (upstream's {@code POST /lora-adapters} - * only replaces the scales). A later request whose prompt prefix is still cached in a slot - * reuses KV entries computed under the previous scales and silently mixes old and new adapter. - * After a change, clear the slots with {@link #eraseSlot(int)} or send such requests with - * {@code withCachePrompt(false)}. + *

A slot whose cache was computed under other scales clears it at its next request, so no + * answer mixes the old and the new adapters (the rule upstream applies to adapters named per + * request; upstream's {@code POST /lora-adapters} path itself does not, which + * {@code patches/0021} fixes). One cache does not record the adapters: the host-memory prompt + * cache ({@code --cache-ram}, on by default) can load a state saved under other scales into a + * slot; disable it with {@link ModelParameters#setCacheRamMib(int) setCacheRamMib(0)} where + * adapters change while that matters. * * @param scales adapter id (see {@link net.ladenthin.llama.value.LoraAdapter#getId()}) to * new scale; an empty map disables every adapter diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index 3df2441ea..60a127b99 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -13,6 +13,7 @@ // pinned here is the behaviour LlamaModel has. #include "jllama_api.h" +#include "tiny_llama_gguf.hpp" #include "build-info.h" #include "ggml.h" @@ -41,137 +42,7 @@ namespace { using nlohmann::json; -constexpr int N_VOCAB = 256; // one GPT-2 byte token per byte -constexpr int N_EMBD = 32; -constexpr int N_HEAD = 4; -constexpr int N_HEAD_KV = 4; -constexpr int HEAD_DIM = 8; -constexpr int N_FF = 64; -constexpr int N_LAYER = 2; -constexpr int N_CTX_TRAIN = 128; - -// The 256 printable code points GPT-2's byte-level BPE maps the bytes to, as UTF-8. -std::vector byte_tokens() { - std::vector bs; - for (int b = '!'; b <= '~'; ++b) - bs.push_back(b); - for (int b = 0xA1; b <= 0xAC; ++b) - bs.push_back(b); - for (int b = 0xAE; b <= 0xFF; ++b) - bs.push_back(b); - std::vector cs = bs; - int extra = 0; - for (int b = 0; b < 256; ++b) { - if (std::find(bs.begin(), bs.end(), b) == bs.end()) { - bs.push_back(b); - cs.push_back(256 + extra++); - } - } - std::vector tokens(256); - for (size_t i = 0; i < bs.size(); ++i) { - const int cp = cs[i]; - std::string s; - if (cp < 0x80) { - s += (char)cp; - } else { - s += (char)(0xC0 | (cp >> 6)); - s += (char)(0x80 | (cp & 0x3F)); - } - tokens[bs[i]] = s; - } - return tokens; -} - -ggml_tensor *add_random_tensor(gguf_context *g, ggml_context *ctx, std::mt19937 &rng, const std::string &name, - std::initializer_list ne, float scale) { - const std::vector d(ne); - ggml_tensor *t = d.size() == 1 ? ggml_new_tensor_1d(ctx, GGML_TYPE_F32, d[0]) - : ggml_new_tensor_2d(ctx, GGML_TYPE_F32, d[0], d[1]); - std::normal_distribution dist(0.0f, scale); - auto *data = (float *)t->data; - for (int64_t i = 0; i < ggml_nelements(t); ++i) { - data[i] = d.size() == 1 ? 1.0f : dist(rng); // norms are ones, weights small noise - } - ggml_set_name(t, name.c_str()); - gguf_add_tensor(g, t); - return t; -} - -// Keep the generated tokens printable ASCII (never BOS/EOS): the output rows of every other -// byte are scaled to almost nothing, so greedy sampling over random logits lands on an ASCII -// byte with overwhelming probability. That matters for the streaming test -- a byte >= 0x80 is -// an incomplete UTF-8 sequence, which the server holds back instead of sending as a partial -// result, and a random model that happens to emit only those streams no partial chunk at all. -void confine_output_to_ascii(ggml_tensor *output) { - auto *data = (float *)output->data; - for (int64_t tok = 0; tok < output->ne[1]; ++tok) { - const bool printable = tok >= ' ' && tok < 0x7F && tok != '<' && tok != '>'; - if (!printable) { - for (int64_t i = 0; i < output->ne[0]; ++i) { - data[tok * output->ne[0] + i] *= 1e-3f; - } - } - } -} - -// A two-layer llama with random weights and the GPT-2 byte tokenizer: enough for the server to -// load it, apply its fallback chat template and generate tokens. -std::string write_tiny_llama_gguf() { - static std::atomic counter{0}; - static const unsigned run_id = std::random_device{}(); - const std::string path = (std::filesystem::temp_directory_path() / - ("jllama-c-api-" + std::to_string(run_id) + "-" + std::to_string(counter++) + ".gguf")) - .string(); - std::mt19937 rng(42); - gguf_context *g = gguf_init_empty(); - const std::string a = "llama"; - gguf_set_val_str(g, "general.architecture", a.c_str()); - gguf_set_val_u32(g, (a + ".context_length").c_str(), N_CTX_TRAIN); - gguf_set_val_u32(g, (a + ".embedding_length").c_str(), N_EMBD); - gguf_set_val_u32(g, (a + ".block_count").c_str(), N_LAYER); - gguf_set_val_u32(g, (a + ".feed_forward_length").c_str(), N_FF); - gguf_set_val_u32(g, (a + ".attention.head_count").c_str(), N_HEAD); - gguf_set_val_u32(g, (a + ".attention.head_count_kv").c_str(), N_HEAD_KV); - gguf_set_val_f32(g, (a + ".attention.layer_norm_rms_epsilon").c_str(), 1e-5f); - gguf_set_val_u32(g, (a + ".rope.dimension_count").c_str(), HEAD_DIM); - - const std::vector tokens = byte_tokens(); - std::vector token_ptrs; - for (const std::string &s : tokens) { - token_ptrs.push_back(s.c_str()); - } - std::vector token_types(N_VOCAB, 1); // LLAMA_TOKEN_TYPE_NORMAL - const char *no_merges[] = {"Ġ Ġ"}; - gguf_set_val_str(g, "tokenizer.ggml.model", "gpt2"); - gguf_set_val_str(g, "tokenizer.ggml.pre", "default"); - gguf_set_arr_str(g, "tokenizer.ggml.tokens", token_ptrs.data(), token_ptrs.size()); - gguf_set_arr_data(g, "tokenizer.ggml.token_type", GGUF_TYPE_INT32, token_types.data(), token_types.size()); - gguf_set_arr_str(g, "tokenizer.ggml.merges", no_merges, 1); - gguf_set_val_u32(g, "tokenizer.ggml.bos_token_id", '<'); - gguf_set_val_u32(g, "tokenizer.ggml.eos_token_id", '>'); - - ggml_init_params ip = {16u * 1024 * 1024, nullptr, false}; - ggml_context *ctx = ggml_init(ip); - add_random_tensor(g, ctx, rng, "token_embd.weight", {N_EMBD, N_VOCAB}, 0.1f); - add_random_tensor(g, ctx, rng, "output_norm.weight", {N_EMBD}, 1.0f); - confine_output_to_ascii(add_random_tensor(g, ctx, rng, "output.weight", {N_EMBD, N_VOCAB}, 0.1f)); - for (int il = 0; il < N_LAYER; ++il) { - const std::string p = "blk." + std::to_string(il) + "."; - add_random_tensor(g, ctx, rng, p + "attn_norm.weight", {N_EMBD}, 1.0f); - add_random_tensor(g, ctx, rng, p + "attn_q.weight", {N_EMBD, N_HEAD * HEAD_DIM}, 0.1f); - add_random_tensor(g, ctx, rng, p + "attn_k.weight", {N_EMBD, N_HEAD_KV * HEAD_DIM}, 0.1f); - add_random_tensor(g, ctx, rng, p + "attn_v.weight", {N_EMBD, N_HEAD_KV * HEAD_DIM}, 0.1f); - add_random_tensor(g, ctx, rng, p + "attn_output.weight", {N_HEAD * HEAD_DIM, N_EMBD}, 0.1f); - add_random_tensor(g, ctx, rng, p + "ffn_norm.weight", {N_EMBD}, 1.0f); - add_random_tensor(g, ctx, rng, p + "ffn_gate.weight", {N_EMBD, N_FF}, 0.1f); - add_random_tensor(g, ctx, rng, p + "ffn_up.weight", {N_EMBD, N_FF}, 0.1f); - add_random_tensor(g, ctx, rng, p + "ffn_down.weight", {N_FF, N_EMBD}, 0.1f); - } - const bool ok = gguf_write_to_file(g, path.c_str(), false); - gguf_free(g); - ggml_free(ctx); - return ok ? path : std::string(); -} +using namespace jllama_test; struct Owned { char *s = nullptr; diff --git a/llama/src/test/cpp/test_lora_cache.cpp b/llama/src/test/cpp/test_lora_cache.cpp new file mode 100644 index 000000000..e7d599d1b --- /dev/null +++ b/llama/src/test/cpp/test_lora_cache.cpp @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +// The runnable guard for patches/0021: a slot's prompt cache is computed under the LoRA adapters +// the slot ran with, so it must not survive a change of those adapters. Upstream clears it when a +// request names its own adapters (lora_should_clear_cache) but not when the adapters come from +// params_base -- changed by SET_LORA (LlamaModel.setLoraAdapter, POST /lora-adapters) or restored +// after a request with its own adapters. The slot then went on with KV entries computed under the +// old scales and answered like neither model: measured at b11639 with stories15M_MOE and its +// Shakespeare adapter (J9 of the bug-hunt measurement brief, F67). +// +// Hermetic: a tiny random llama model and a random LoRA adapter written here with the public gguf +// API. The adapter touches K, V and the FFN output of every layer, so the KV cache depends on it. +// Greedy decoding makes every answer deterministic, and the reference for "the model with these +// adapters" is the same request on an emptied slot. + +#include "jllama_api.h" +#include "tiny_llama_gguf.hpp" + +#include "ggml.h" +#include "gguf.h" + +#include + +#include + +#include +#include +#include +#include + +namespace { + +using nlohmann::json; +using namespace jllama_test; + +constexpr int LORA_RANK = 4; + +// A LoRA adapter for write_tiny_llama_gguf()'s model. lora_a is {in, rank}, lora_b {rank, out} in +// ggml order (llama-adapter.cpp validates exactly that); alpha = rank keeps the scale at 1. +std::string write_tiny_lora_gguf() { + static const unsigned run_id = std::random_device{}(); + const std::string path = + (std::filesystem::temp_directory_path() / ("jllama-lora-cache-adapter-" + std::to_string(run_id) + ".gguf")) + .string(); + std::mt19937 rng(7); + gguf_context *g = gguf_init_empty(); + gguf_set_val_str(g, "general.type", "adapter"); + gguf_set_val_str(g, "general.architecture", "llama"); + gguf_set_val_str(g, "adapter.type", "lora"); + gguf_set_val_f32(g, "adapter.lora.alpha", static_cast(LORA_RANK)); + + ggml_init_params ip = {4u * 1024 * 1024, nullptr, false}; + ggml_context *ctx = ggml_init(ip); + for (int il = 0; il < N_LAYER; ++il) { + const std::string p = "blk." + std::to_string(il) + "."; + for (const char *name : {"attn_k", "attn_v"}) { + add_random_tensor(g, ctx, rng, p + name + ".weight.lora_a", {N_EMBD, LORA_RANK}, 1.0f); + add_random_tensor(g, ctx, rng, p + name + ".weight.lora_b", {LORA_RANK, N_HEAD_KV * HEAD_DIM}, 1.0f); + } + add_random_tensor(g, ctx, rng, p + "ffn_down.weight.lora_a", {N_FF, LORA_RANK}, 1.0f); + add_random_tensor(g, ctx, rng, p + "ffn_down.weight.lora_b", {LORA_RANK, N_EMBD}, 1.0f); + } + const bool ok = gguf_write_to_file(g, path.c_str(), false); + gguf_free(g); + ggml_free(ctx); + return ok ? path : std::string(); +} + +struct Owned { + char *s = nullptr; + ~Owned() { jllama_string_free(s); } + std::string str() const { return s != nullptr ? std::string(s) : std::string(); } +}; + +// One slot, so every request lands on the same cache; long enough a prompt that most of it is +// reused from the cache when it is sent again. +const char *PROMPT = "The quick brown fox jumps over the lazy dog while the cache keeps every token"; + +class LoraCache : public ::testing::Test { + protected: + static void SetUpTestSuite() { + model_path_ = write_tiny_llama_gguf("jllama-lora-cache-model"); + adapter_path_ = write_tiny_lora_gguf(); + ASSERT_FALSE(model_path_.empty()); + ASSERT_FALSE(adapter_path_.empty()); + // --cache-ram 0: no host-memory prompt cache, so the slot's own KV cache is the only one. + const std::vector args = {"jllama", "-m", model_path_, "--lora", adapter_path_, "-c", + "128", "-np", "1", "-ngl", "0", "-t", + "2", "--cache-ram", "0", "--no-warmup"}; + std::vector argv; + for (const auto &a : args) { + argv.push_back(a.c_str()); + } + ASSERT_EQ(jllama_model_load(static_cast(argv.size()), argv.data(), nullptr, nullptr, &model_), + JLLAMA_STATUS_OK) + << jllama_last_error(); + } + static void TearDownTestSuite() { + jllama_model_free(model_); + model_ = nullptr; + std::error_code ignored; + std::filesystem::remove(model_path_, ignored); + std::filesystem::remove(adapter_path_, ignored); + } + void SetUp() override { + ASSERT_NE(model_, nullptr); + set_scale(1.0f); + erase_slot(); + } + + static json complete(const json &extra = json::object()) { + json request = {{"prompt", PROMPT}, {"n_predict", 8}, {"temperature", 0}, + {"top_k", 1}, {"cache_prompt", true}, {"id_slot", 0}}; + request.update(extra); + Owned response; + EXPECT_EQ(jllama_completions(model_, request.dump().c_str(), &response.s), JLLAMA_STATUS_OK) + << jllama_last_error(); + return json::parse(response.str()); + } + static void set_scale(float scale) { + Owned response; + const json adapters = json::array({{{"id", 0}, {"scale", scale}}}); + ASSERT_EQ(jllama_set_lora_adapters(model_, adapters.dump().c_str(), &response.s), JLLAMA_STATUS_OK) + << jllama_last_error(); + } + static void erase_slot() { + Owned response; + ASSERT_EQ(jllama_slot_action(model_, JLLAMA_SLOT_ERASE, 0, nullptr, &response.s), JLLAMA_STATUS_OK) + << jllama_last_error(); + } + // The answer of the model with the current adapters, computed on an empty slot. + static std::string fresh_answer() { + erase_slot(); + return complete().at("content").get(); + } + + static std::string model_path_; + static std::string adapter_path_; + static jllama_model *model_; +}; +std::string LoraCache::model_path_; +std::string LoraCache::adapter_path_; +jllama_model *LoraCache::model_ = nullptr; + +} // namespace + +// Without these two, the tests below could pass for the wrong reason: an adapter that changes +// nothing, or a slot that never reuses its cache. +TEST_F(LoraCache, TheAdapterChangesTheAnswerAndTheSlotReusesItsCache) { + const std::string with_adapter = fresh_answer(); + const json again = complete(); + EXPECT_GT(again.at("tokens_cached").get(), 0) << again.dump(); + EXPECT_EQ(again.at("content").get(), with_adapter); + + set_scale(0.0f); + EXPECT_NE(fresh_answer(), with_adapter); +} + +TEST_F(LoraCache, ASetLoraChangeIsNotAnsweredFromTheOldCache) { + complete(); // the slot's cache is now computed with the adapter at scale 1 + set_scale(0.0f); + const std::string after_change = complete().at("content").get(); + + EXPECT_EQ(after_change, fresh_answer()); +} + +TEST_F(LoraCache, ASetLoraChangeBackIsNotAnsweredFromTheOldCache) { + set_scale(0.0f); + complete(); // cache computed without the adapter + set_scale(1.0f); + const std::string after_change = complete().at("content").get(); + + EXPECT_EQ(after_change, fresh_answer()); +} + +TEST_F(LoraCache, TheServerAdaptersAfterARequestWithItsOwnAreNotAnsweredFromItsCache) { + // A request with its own adapters (scale 0) fills the cache; the next request names none, so it + // runs with the server's adapters (scale 1) and must not reuse what the first one computed. + complete({{"lora", json::array({{{"id", 0}, {"scale", 0.0}}})}}); + const std::string after_change = complete().at("content").get(); + + EXPECT_EQ(after_change, fresh_answer()); +} diff --git a/llama/src/test/cpp/tiny_llama_gguf.hpp b/llama/src/test/cpp/tiny_llama_gguf.hpp new file mode 100644 index 000000000..ee2fd78bc --- /dev/null +++ b/llama/src/test/cpp/tiny_llama_gguf.hpp @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +// A tiny random llama-architecture GGUF written with the public gguf API, shared by the tests that +// drive a real model without downloading one (test_c_api.cpp, test_lora_cache.cpp). The output of +// such a model is noise; it is deterministic for a given seed, which is all a contract test needs. + +#pragma once + +#include "ggml.h" +#include "gguf.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace jllama_test { + +constexpr int N_VOCAB = 256; // one GPT-2 byte token per byte +constexpr int N_EMBD = 32; +constexpr int N_HEAD = 4; +constexpr int N_HEAD_KV = 4; +constexpr int HEAD_DIM = 8; +constexpr int N_FF = 64; +constexpr int N_LAYER = 2; +constexpr int N_CTX_TRAIN = 128; + +// The 256 printable code points GPT-2's byte-level BPE maps the bytes to, as UTF-8. +inline std::vector byte_tokens() { + std::vector bs; + for (int b = '!'; b <= '~'; ++b) + bs.push_back(b); + for (int b = 0xA1; b <= 0xAC; ++b) + bs.push_back(b); + for (int b = 0xAE; b <= 0xFF; ++b) + bs.push_back(b); + std::vector cs = bs; + int extra = 0; + for (int b = 0; b < 256; ++b) { + if (std::find(bs.begin(), bs.end(), b) == bs.end()) { + bs.push_back(b); + cs.push_back(256 + extra++); + } + } + std::vector tokens(256); + for (size_t i = 0; i < bs.size(); ++i) { + const int cp = cs[i]; + std::string s; + if (cp < 0x80) { + s += (char)cp; + } else { + s += (char)(0xC0 | (cp >> 6)); + s += (char)(0x80 | (cp & 0x3F)); + } + tokens[bs[i]] = s; + } + return tokens; +} + +inline ggml_tensor *add_random_tensor(gguf_context *g, ggml_context *ctx, std::mt19937 &rng, const std::string &name, + std::initializer_list ne, float scale) { + const std::vector d(ne); + ggml_tensor *t = d.size() == 1 ? ggml_new_tensor_1d(ctx, GGML_TYPE_F32, d[0]) + : ggml_new_tensor_2d(ctx, GGML_TYPE_F32, d[0], d[1]); + std::normal_distribution dist(0.0f, scale); + auto *data = (float *)t->data; + for (int64_t i = 0; i < ggml_nelements(t); ++i) { + data[i] = d.size() == 1 ? 1.0f : dist(rng); // norms are ones, weights small noise + } + ggml_set_name(t, name.c_str()); + gguf_add_tensor(g, t); + return t; +} + +// Keep the generated tokens printable ASCII (never BOS/EOS): the output rows of every other +// byte are scaled to almost nothing, so greedy sampling over random logits lands on an ASCII +// byte with overwhelming probability. That matters for the streaming test -- a byte >= 0x80 is +// an incomplete UTF-8 sequence, which the server holds back instead of sending as a partial +// result, and a random model that happens to emit only those streams no partial chunk at all. +inline void confine_output_to_ascii(ggml_tensor *output) { + auto *data = (float *)output->data; + for (int64_t tok = 0; tok < output->ne[1]; ++tok) { + const bool printable = tok >= ' ' && tok < 0x7F && tok != '<' && tok != '>'; + if (!printable) { + for (int64_t i = 0; i < output->ne[0]; ++i) { + data[tok * output->ne[0] + i] *= 1e-3f; + } + } + } +} + +// A two-layer llama with random weights and the GPT-2 byte tokenizer: enough for the server to +// load it, apply its fallback chat template and generate tokens. +inline std::string write_tiny_llama_gguf(const std::string &prefix = "jllama-c-api") { + static std::atomic counter{0}; + static const unsigned run_id = std::random_device{}(); + const std::string path = (std::filesystem::temp_directory_path() / + (prefix + "-" + std::to_string(run_id) + "-" + std::to_string(counter++) + ".gguf")) + .string(); + std::mt19937 rng(42); + gguf_context *g = gguf_init_empty(); + const std::string a = "llama"; + gguf_set_val_str(g, "general.architecture", a.c_str()); + gguf_set_val_u32(g, (a + ".context_length").c_str(), N_CTX_TRAIN); + gguf_set_val_u32(g, (a + ".embedding_length").c_str(), N_EMBD); + gguf_set_val_u32(g, (a + ".block_count").c_str(), N_LAYER); + gguf_set_val_u32(g, (a + ".feed_forward_length").c_str(), N_FF); + gguf_set_val_u32(g, (a + ".attention.head_count").c_str(), N_HEAD); + gguf_set_val_u32(g, (a + ".attention.head_count_kv").c_str(), N_HEAD_KV); + gguf_set_val_f32(g, (a + ".attention.layer_norm_rms_epsilon").c_str(), 1e-5f); + gguf_set_val_u32(g, (a + ".rope.dimension_count").c_str(), HEAD_DIM); + + const std::vector tokens = byte_tokens(); + std::vector token_ptrs; + for (const std::string &s : tokens) { + token_ptrs.push_back(s.c_str()); + } + std::vector token_types(N_VOCAB, 1); // LLAMA_TOKEN_TYPE_NORMAL + const char *no_merges[] = {"Ġ Ġ"}; + gguf_set_val_str(g, "tokenizer.ggml.model", "gpt2"); + gguf_set_val_str(g, "tokenizer.ggml.pre", "default"); + gguf_set_arr_str(g, "tokenizer.ggml.tokens", token_ptrs.data(), token_ptrs.size()); + gguf_set_arr_data(g, "tokenizer.ggml.token_type", GGUF_TYPE_INT32, token_types.data(), token_types.size()); + gguf_set_arr_str(g, "tokenizer.ggml.merges", no_merges, 1); + gguf_set_val_u32(g, "tokenizer.ggml.bos_token_id", '<'); + gguf_set_val_u32(g, "tokenizer.ggml.eos_token_id", '>'); + + ggml_init_params ip = {16u * 1024 * 1024, nullptr, false}; + ggml_context *ctx = ggml_init(ip); + add_random_tensor(g, ctx, rng, "token_embd.weight", {N_EMBD, N_VOCAB}, 0.1f); + add_random_tensor(g, ctx, rng, "output_norm.weight", {N_EMBD}, 1.0f); + confine_output_to_ascii(add_random_tensor(g, ctx, rng, "output.weight", {N_EMBD, N_VOCAB}, 0.1f)); + for (int il = 0; il < N_LAYER; ++il) { + const std::string p = "blk." + std::to_string(il) + "."; + add_random_tensor(g, ctx, rng, p + "attn_norm.weight", {N_EMBD}, 1.0f); + add_random_tensor(g, ctx, rng, p + "attn_q.weight", {N_EMBD, N_HEAD * HEAD_DIM}, 0.1f); + add_random_tensor(g, ctx, rng, p + "attn_k.weight", {N_EMBD, N_HEAD_KV * HEAD_DIM}, 0.1f); + add_random_tensor(g, ctx, rng, p + "attn_v.weight", {N_EMBD, N_HEAD_KV * HEAD_DIM}, 0.1f); + add_random_tensor(g, ctx, rng, p + "attn_output.weight", {N_HEAD * HEAD_DIM, N_EMBD}, 0.1f); + add_random_tensor(g, ctx, rng, p + "ffn_norm.weight", {N_EMBD}, 1.0f); + add_random_tensor(g, ctx, rng, p + "ffn_gate.weight", {N_EMBD, N_FF}, 0.1f); + add_random_tensor(g, ctx, rng, p + "ffn_up.weight", {N_EMBD, N_FF}, 0.1f); + add_random_tensor(g, ctx, rng, p + "ffn_down.weight", {N_FF, N_EMBD}, 0.1f); + } + const bool ok = gguf_write_to_file(g, path.c_str(), false); + gguf_free(g); + ggml_free(ctx); + return ok ? path : std::string(); +} + +} // namespace jllama_test From f5ae9d4571cca041d0e1c12a4edc47cd035972bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:52:46 +0000 Subject: [PATCH 302/589] Record the commit of patch 0021 (F67) in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ec5210434..40b8a948b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -494,7 +494,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag 2026-10-19 (Verhalten geändert):** J9 hat F67 bestätigt (`b689d7b`: nach `setLoraAdapter(0, 0f)` lieferte der warme Slot „. He saw a picture of a bird …“ statt „ was a little girl …“ wie ein geleerter Slot). Entscheidung des Nutzers: upstream-fähiger Patch `0021` statt eines Leerens in `engine::set_lora_adapters`, weil upstreams eigene HTTP-Route `POST /lora-adapters` denselben Fehler hat. Der `else`-Zweig von `launch_slot_with_task` wendet jetzt dieselbe Regel an wie LoRA pro Anfrage (`lora_should_clear_cache`, aLoRA behält den Cache). Das deckt auch den zweiten Weg ab, den die Durchsicht fand: eine Anfrage mit eigenen Adaptern füllt den Cache, die nächste ohne läuft mit den Server-Adaptern darauf weiter. Nicht abgedeckt, in Javadoc/README genannt: der Host-RAM-Prompt-Cache (`--cache-ram`, standardmäßig an) speichert Slot-Zustände ohne ihre Adapter. Javadoc `setLoraAdapters`, README, CHANGELOG, CLAUDE.md (Patch- und Testtabelle), `docs/patches.md`. Upstream-master bewusst nicht geprüft (Entscheidung des Nutzers: zeigt sich beim nächsten Bump über den Drop-Check). - **Tests (Patch):** `src/test/cpp/test_lora_cache.cpp` (4): Zufallsmodell + Zufalls-LoRA auf K, V und FFN-Ausgang, ein Slot, `--cache-ram 0`, greedy; nach Skala 1→0, 0→1 und nach einer Anfrage mit eigenen Adaptern muss der warme Slot wie ein geleerter antworten; ein vierter Fall schließt das leere Bestehen aus (Adapter ändert die Antwort, Wiederholung nutzt den Cache). Das Modellschreiben liegt jetzt in `tiny_llama_gguf.hpp`, gemeinsam mit `test_c_api.cpp`. - **Rot-Grün-Nachweis (Patch):** ohne Patch rot (3 von 4; z. B. „mYmq5*mq“ statt „C+oM~kM~“), mit Patch grün (4 von 4, drei Läufe); ganze C++-Suite 633/633; alle 21 Patches wenden sich der Reihe nach sauber auf b11639 an; `check-patches.py` 0 Probleme. -- **Commit (Patch):** (folgt) +- **Commit (Patch):** java-llama.cpp `c4a23c5` ### F68 – java-llama.cpp: `RouterClient.awaitModelLoaded` läuft bei einem schlafenden Modell in den Timeout From ce6e5a79988684b60d143b489e2001094b69632c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:06:12 +0000 Subject: [PATCH 303/589] Write empty tool-call arguments of translated histories as "{}" (F172) The Responses and Anthropic translations build the chat history themselves, so F166's rule did not reach them: a function_call without arguments and a tool_use without (or with a null) input became "" or "null", and upstream json::parse()s every arguments string of the history, failing the follow-up request with 400. Both now write "{}". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 16 ++++++++++++-- TODO.md | 3 --- .../llama/server/AnthropicApiSupport.java | 5 ++++- .../llama/server/ResponsesApiSupport.java | 5 ++++- .../llama/server/AnthropicApiSupportTest.java | 20 ++++++++++++++++++ .../llama/server/ResponsesApiSupportTest.java | 21 +++++++++++++++++++ 6 files changed, 63 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 40b8a948b..66e59c6f8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1202,8 +1202,8 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: leere (auch nur aus Leerraum bestehende) Argumente als `"{}"` schreiben, alles andere unverändert. - **Tests:** `ParameterJsonSerializerTest#testBuildMessages_emptyToolCallArguments_areWrittenAsAnEmptyObject` (`""`, `" "`, `"\n"`), `#testBuildMessages_toolCallArguments_arePassedOnVerbatim`. - **Rot-Grün-Nachweis:** ohne den Fix rot (3 von 3), mit ihm grün (186 Tests in den Serializer-, Multimodal-, `InferenceParameters`- und `ChatRequest`-Klassen). -- **Nicht abgedeckt (offen):** Die Übersetzungen des Servers bauen den Verlauf ohne `buildMessages`: `ResponsesApiSupport.java:218` (`asText("")`) und `AnthropicApiSupport.java:229` (`input` fehlt → `""`) können weiterhin `""` schreiben. -- **Status:** gefixt (Java-API-Pfad) +- **Nicht abgedeckt (offen):** Die Übersetzungen des Servers bauen den Verlauf ohne `buildMessages`: `ResponsesApiSupport.java:218` (`asText("")`) und `AnthropicApiSupport.java:229` (`input` fehlt → `""`) können weiterhin `""` schreiben. **Nachtrag 2026-10-19:** behoben als F172. +- **Status:** gefixt (Java-API-Pfad; Server-Übersetzungen als F172) - **Commit:** afc40be ### F167 – java-llama.cpp: `completeBatch`/`chatBatch` laufen auf dem gemeinsamen `ForkJoinPool` und nutzen auf wenigen Kernen nicht alle Slots @@ -1230,6 +1230,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** 8269149 +### F172 – java-llama.cpp: Responses- und Anthropic-Übersetzung schreiben leere Tool-Argumente in den Verlauf +- **Zeit:** 2026-10-19 21:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`function_call`), `AnthropicApiSupport.java` (`tool_use`) +- **Schwere:** niedrig (laut: die Folgeanfrage scheitert mit 400) +- **Historie:** Rest von F166, dort als „Nicht abgedeckt“ vermerkt: F166 behob den Java-API-Pfad (`buildMessages`), die beiden Übersetzungen bauen den Verlauf selbst. +- **Beschreibung:** Ein `function_call` ohne `arguments` (Responses) und ein `tool_use` ohne `input` (Anthropic, `MissingNode.toString()` ist `""`; `input: null` ergab `"null"`) landeten als `""` bzw. `"null"` in `function.arguments`. Upstream parst jedes Argument des Verlaufs mit `json::parse` und lehnt `""` mit 400 ab (J4: „attempting to parse an empty input“). +- **Fix:** Entscheidung des Nutzers: dieselbe Regel wie F166 – leere oder nur aus Leerraum bestehende Argumente (Responses) bzw. fehlender oder `null`-`input` (Anthropic) werden `"{}"`. +- **Tests:** `ResponsesApiSupportTest#functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject` (fehlend, `""`, Leerraum), `AnthropicApiSupportTest#toolUseWithoutInputBecomesAnEmptyArgumentsObject` (fehlend, `null`). +- **Rot-Grün-Nachweis:** ohne den Fix rot (beide, `was ""`), mit ihm grün (75 Tests der beiden Klassen). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. diff --git a/TODO.md b/TODO.md index 4a3e1d6f9..ef918ae3f 100644 --- a/TODO.md +++ b/TODO.md @@ -528,9 +528,6 @@ numbered for reference, the numbers stay when an item is done): - **(6)** JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. -- **(7)** Empty tool-call arguments (rest of F166): the server's own translations, - `ResponsesApiSupport.java:218` and `AnthropicApiSupport.java:229` (`input` missing), build the - history without `buildMessages` and can still write `""`, which upstream rejects with 400. - **(8)** Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. - **(9)** `/props` reports `total_slots` as 1 instead of `n_parallel` (needs a config field). - **(10)** Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 6ae6dd66d..4c7ab1c8e 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -226,7 +226,10 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag toolCall.put("type", "function"); ObjectNode function = toolCall.putObject("function"); function.put("name", block.path("name").asText("")); - function.put("arguments", block.path("input").toString()); + // A missing or null input is no arguments; upstream json::parse()s every arguments + // string of the history, so an empty one fails the whole request (F166). + JsonNode input = block.path("input"); + function.put("arguments", input.isMissingNode() || input.isNull() ? "{}" : input.toString()); break; case "tool_result": // A user-message tool_result becomes a separate OpenAI role:"tool" message. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 12c684668..009b43d8a 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -215,7 +215,10 @@ private static void appendInput(ArrayNode messages, JsonNode input) { toolCall.put("type", "function"); ObjectNode function = toolCall.putObject("function"); function.put("name", item.path("name").asText("")); - function.put("arguments", item.path("arguments").asText("")); + // upstream json::parse()s every arguments string of the history, so an empty one + // fails the whole request; a call without arguments is "{}" (F166) + String arguments = item.path("arguments").asText(""); + function.put("arguments", arguments.trim().isEmpty() ? "{}" : arguments); break; case "function_call_output": ObjectNode toolMessage = messages.addObject(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index eaf4743f6..94958f9e9 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -225,6 +225,26 @@ public void requestFlattensToolUseAndToolResultBlocks() throws IOException { assertThat(toolMessage.path("content").asText(), is("sunny")); } + @Test + public void toolUseWithoutInputBecomesAnEmptyArgumentsObject() throws IOException { + // Upstream parses every function.arguments of the history with json::parse, so an empty string + // fails the whole request with 400; a tool call without arguments is "{}" (F166). + for (String block : new String[] { + "{\"type\":\"tool_use\",\"id\":\"c1\",\"name\":\"now\"}", + "{\"type\":\"tool_use\",\"id\":\"c1\",\"name\":\"now\",\"input\":null}" + }) { + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"assistant\",\"content\":[" + block + "]}]}")); + JsonNode arguments = openAi.path("messages") + .get(0) + .path("tool_calls") + .get(0) + .path("function") + .path("arguments"); + assertThat(block, arguments.asText(), is("{}")); + } + } + @Test public void requestConcatenatesSystemBlocksAndMapsStopSequences() throws IOException { JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest( diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 60b08ded2..d53abccb6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -83,6 +83,27 @@ public void reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream() assertThat(messages.path(3).path("content").asText(), is("42")); } + @Test + public void functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject() throws IOException { + // Upstream parses every function.arguments of the history with json::parse, so an empty string + // fails the whole request with 400; a call without arguments is "{}" (F166). + for (String arguments : new String[] {"", ",\"arguments\":\"\"", ",\"arguments\":\" \""}) { + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"now\"" + + arguments + "}]}")) + .path("messages"); + assertThat( + arguments, + messages.path(0) + .path("tool_calls") + .path(0) + .path("function") + .path("arguments") + .asText(), + is("{}")); + } + } + @Test public void functionCallOutputArrayKeepsItsPartsLikeUpstream() throws IOException { // A tool result may be an array of input_text / input_image parts; upstream's From 56e7dc5f45a248488ceb17dbfc2499e32a75e489 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:06:12 +0000 Subject: [PATCH 304/589] Record the commit of F172 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 66e59c6f8..2d4bbbff9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1240,7 +1240,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject` (fehlend, `""`, Leerraum), `AnthropicApiSupportTest#toolUseWithoutInputBecomesAnEmptyArgumentsObject` (fehlend, `null`). - **Rot-Grün-Nachweis:** ohne den Fix rot (beide, `was ""`), mit ihm grün (75 Tests der beiden Klassen). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `f1bf93b` ## Offene Verdachte ohne Fund-ID From 2db54c9b47f116c2e894039b88bec410c6cdbc46 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:08:23 +0000 Subject: [PATCH 305/589] Keep refusal parts of a Responses history as text (F173) A refusal part carries its text under "refusal", not "text", so the translation dropped it and an assistant turn that only refused became empty. Upstream turns it into a part of type refusal, which its own chat parser rejects with 400; as text it is what the assistant said. One helper reads a part's text for both content paths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++- TODO.md | 2 -- .../llama/server/ResponsesApiSupport.java | 31 +++++++++++++---- .../llama/server/ResponsesApiSupportTest.java | 33 +++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2d4bbbff9..fbb5db4a9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1242,6 +1242,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f1bf93b` +### F173 – java-llama.cpp: Responses-Übersetzung verwirft `refusal`-Teile von Assistenten-Nachrichten im Verlauf +- **Zeit:** 2026-10-19 21:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`inputContentText`, `inputContentWithImages`) +- **Schwere:** niedrig (still: der Verlauf verliert, dass das Modell abgelehnt hat) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (11)). Die Übersetzung las Textteile nur über `text`; ein `refusal`-Teil trägt seinen Text unter `refusal`. +- **Beschreibung:** Ein Responses-Client schickt die eigene frühere Antwort mit `{"type":"refusal","refusal":"…"}` zurück. Der Teil fiel weg, eine Nachricht nur aus einer Ablehnung wurde leer. Upstream übersetzt ihn zu einem Teil vom Typ `refusal`, den der eigene Chat-Parser mit 400 ablehnt (`common/chat.cpp`, nur Text und Medien). +- **Fix:** Entscheidung des Nutzers: als Text übernehmen. Neuer Helfer `partText` (Text aus `text`, bei Typ `refusal` aus `refusal`), genutzt von beiden Lesewegen; neben einem Bild wird er ein `text`-Teil. +- **Tests:** `ResponsesApiSupportTest#refusalPartsOfTheHistoryAreKeptAsText` (Text + Ablehnung, nur Ablehnung), `#refusalPartsNextToAnImageAreKeptAsTextParts`. +- **Rot-Grün-Nachweis:** ohne den Fix rot (2 von 54: `was "a"`, `was ""`), mit ihm grün (54 von 54). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. @@ -1251,7 +1263,6 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. -- *(niedrig, nur beschrieben, 2026-10-18)* Responses-`refusal`-Teile in Assistenten-Nachrichten des Verlaufs verwirft `ResponsesApiSupport` (der Teil hat `refusal`, kein `text`). Upstream übersetzt sie zu einem Teil vom Typ `refusal`, den sein eigener Chat-Parser dann mit 400 abweist (`common/chat.cpp:373`, nur `text`/`media_marker`). Beides ist unbefriedigend; als Text zu übernehmen wäre die nützlichste Lesart. Entscheidung des Nutzers. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). diff --git a/TODO.md b/TODO.md index ef918ae3f..8346941da 100644 --- a/TODO.md +++ b/TODO.md @@ -534,8 +534,6 @@ numbered for reference, the numbers stay when an item is done): (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep the remaining fields. -- **(11)** Responses `refusal` parts of assistant messages in the history are dropped; upstream turns them - into a part its own parser rejects with 400. Taking them as text is the most useful reading. - **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. - **(13)** Continue decides FIM support from `.Suffix` in `/api/show`'s `template`; ours is `""`. A placeholder template would be invented. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 009b43d8a..eb3478d7d 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -279,10 +279,11 @@ private static void appendInput(ArrayNode messages, JsonNode input) { .put("url", url); hasImage = true; } - } else if (part.path("text").isTextual()) { - parts.addObject() - .put("type", "text") - .put("text", part.path("text").asText()); + } else { + String text = partText(part); + if (text != null) { + parts.addObject().put("type", "text").put("text", text); + } } } return hasImage ? parts : null; @@ -407,6 +408,23 @@ private static void appendReasoningItem(ArrayNode messages, JsonNode item) { assistant.put("reasoning_content", reasoning); } + /** + * The text of a content part, or {@code null} for a part without one. A {@code refusal} part of an + * assistant message carries its text under {@code refusal}; upstream turns it into a part of type + * {@code refusal}, which its own chat parser then rejects with 400 (it accepts text and media only), + * so as text it is what the assistant said. + */ + private static @Nullable String partText(JsonNode part) { + if (part.path("text").isTextual()) { + return part.path("text").asText(); + } + if ("refusal".equals(part.path("type").asText("")) + && part.path("refusal").isTextual()) { + return part.path("refusal").asText(); + } + return null; + } + private static String inputContentText(JsonNode content) { if (content.isTextual()) { return content.asText(); @@ -414,12 +432,13 @@ private static String inputContentText(JsonNode content) { if (content.isArray()) { StringBuilder sb = new StringBuilder(); for (JsonNode part : content) { - if (part.path("text").isTextual()) { + String text = partText(part); + if (text != null) { // Joined with a newline, as llama.cpp's concat_content_parts joins text parts. if (sb.length() > 0) { sb.append('\n'); } - sb.append(part.path("text").asText()); + sb.append(text); } } return sb.toString(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index d53abccb6..17c1f6777 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -104,6 +104,39 @@ public void functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject() throws I } } + @Test + public void refusalPartsOfTheHistoryAreKeptAsText() throws IOException { + // A refusal part carries "refusal", not "text"; it was dropped, so the model never saw that it + // had refused. Upstream turns it into a part its own parser rejects with 400 (common/chat.cpp + // accepts only text and media markers); as text it is what the assistant said. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q\"}," + + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[" + + "{\"type\":\"output_text\",\"text\":\"a\"}," + + "{\"type\":\"refusal\",\"refusal\":\"no\"}]}," + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q2\"}," + + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[" + + "{\"type\":\"refusal\",\"refusal\":\"never\"}]}]}")) + .path("messages"); + + assertThat(messages.path(1).path("content").asText(), is("a\nno")); + assertThat(messages.path(3).path("content").asText(), is("never")); + } + + @Test + public void refusalPartsNextToAnImageAreKeptAsTextParts() throws IOException { + JsonNode content = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[" + + "{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,AA==\"}," + + "{\"type\":\"refusal\",\"refusal\":\"no\"}]}]}")) + .path("messages") + .path(0) + .path("content"); + + assertThat(content.path(1).path("type").asText(), is("text")); + assertThat(content.path(1).path("text").asText(), is("no")); + } + @Test public void functionCallOutputArrayKeepsItsPartsLikeUpstream() throws IOException { // A tool result may be an array of input_text / input_image parts; upstream's From 3d00996cadcb1a959707ec856ebc966c30c0c5ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:08:23 +0000 Subject: [PATCH 306/589] Record the commit of F173 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index fbb5db4a9..914dc7df4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1252,7 +1252,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#refusalPartsOfTheHistoryAreKeptAsText` (Text + Ablehnung, nur Ablehnung), `#refusalPartsNextToAnImageAreKeptAsTextParts`. - **Rot-Grün-Nachweis:** ohne den Fix rot (2 von 54: `was "a"`, `was ""`), mit ihm grün (54 von 54). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `0fd166f` ## Offene Verdachte ohne Fund-ID From cb21ecf018aec280b2888501666eab4a5f47bee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:14:15 +0000 Subject: [PATCH 307/589] Report the slot count in /props instead of a constant 1 (F174) Upstream's /props reports n_parallel; ours said total_slots 1 whatever the model was loaded with, because only the CLI knew --parallel. The server configuration carries the slot count now: the launcher takes it from the loaded model's slot metrics (as it already does for the per-slot context), falls back to --parallel and then to 1, and /props reports it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++++- TODO.md | 1 - .../llama/server/OpenAiCompatServer.java | 13 +++++---- .../llama/server/OpenAiServerCli.java | 20 ++++++++++++- .../llama/server/OpenAiServerConfig.java | 28 +++++++++++++++++++ .../llama/server/OpenAiSseFormatter.java | 5 ++-- .../server/OpenAiCompatServerHttpTest.java | 17 +++++++++++ .../llama/server/OpenAiServerCliTest.java | 21 ++++++++++++++ .../llama/server/OpenAiServerConfigTest.java | 13 +++++++++ .../llama/server/OpenAiSseFormatterTest.java | 3 +- 10 files changed, 122 insertions(+), 12 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 914dc7df4..f93b9d51e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1254,6 +1254,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0fd166f` +### F174 – java-llama.cpp: `/props` meldet `total_slots` fest als 1 +- **Zeit:** 2026-10-19 22:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`), `OpenAiServerConfig`, `OpenAiServerCli` (`toServerConfig`), `OpenAiCompatServer` (`main`, `handleProps`) +- **Schwere:** niedrig (still falsch; ein Client, der Anfragen auf die Slots verteilt, sieht einen Slot) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (9)): `propsJson` setzte `total_slots` seit seiner Einführung als Konstante; upstreams `/props` meldet `params.n_parallel` (`get_res_props`). +- **Beschreibung:** Mit `--parallel 4` – oder ohne die Option, wenn llama.cpp die Slotzahl selbst wählt – meldete `/props` einen Slot. Die Zahl kannte nur die CLI, nicht `OpenAiServerConfig`. +- **Fix:** Entscheidung des Nutzers: `OpenAiServerConfig.totalSlots` (Builder, mindestens 1, Vorgabe 1) wird in `/props` gemeldet. Der Starter nimmt die Slotzahl des geladenen Modells (`getMetricsTyped().getSlotMetrics().size()`, wie schon das Kontextfenster je Slot, F70/J3); ohne sie entscheidet `--parallel`, ohne das 1. Neue Überladung `toServerConfig(vision, ftype, slotContext, totalSlots)`; die Metriken werden in `main` nur noch einmal abgefragt. +- **Tests:** `OpenAiCompatServerHttpTest#propsEndpointReportsTheSlotCountOfTheConfiguration` (über HTTP), `OpenAiSseFormatterTest#propsJsonReportsContextLengthAndModalities` (ergänzt), `OpenAiServerConfigTest#totalSlotsDefaultsToOneAndKeepsTheConfiguredCount`, `#totalSlotsBelowOneIsRejected`, `OpenAiServerCliTest#slotCountOfTheLoadedModelIsAdvertised`, `#unknownSlotCountFallsBackToTheParallelArgumentAndThenToOne`. +- **Rot-Grün-Nachweis:** mit einem Platzhalter, der das alte Verhalten behält (Getter liefert 1, `propsJson` ignoriert den Parameter), rot (5 Tests), mit dem Fix grün (129 Tests der vier Klassen); SpotBugs grün. Der Starter selbst (`main`) braucht ein Modell und ist nur übersetzt. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. @@ -1261,7 +1273,6 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. -- *(niedrig, nur beschrieben, 2026-10-18)* `/props` (`OpenAiSseFormatter.propsJson`) meldet `total_slots` fest als 1; upstream (`get_res_props`, `server-context.cpp:5141`) meldet `params.n_parallel`. Mit `--parallel 4` sieht ein Client einen Slot, obwohl vier Anfragen gleichzeitig laufen können. Die Slotzahl kennt nur `OpenAiServerCli`, nicht `OpenAiServerConfig`; ein Fix bräuchte ein neues Konfigurationsfeld. Kein bekannter Client richtet sich danach, deshalb nur notiert. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. diff --git a/TODO.md b/TODO.md index 8346941da..56b0bb6b8 100644 --- a/TODO.md +++ b/TODO.md @@ -529,7 +529,6 @@ numbered for reference, the numbers stay when an item is done): UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. - **(8)** Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. -- **(9)** `/props` reports `total_slots` as 1 instead of `n_parallel` (needs a config field). - **(10)** Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 8985ffd46..4cbc3d9ef 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -885,7 +885,8 @@ private void handleProps(HttpExchange exchange) throws IOException { sendJson( exchange, HTTP_OK, - OpenAiSseFormatter.propsJson(config.getModelId(), contextLength, config.isSupportsVision())); + OpenAiSseFormatter.propsJson( + config.getModelId(), contextLength, config.isSupportsVision(), config.getTotalSlots())); } finally { exchange.close(); } @@ -1144,8 +1145,9 @@ public static void main(String[] args) throws IOException { "jllama-openai-shutdown")); try (LlamaModel model = new LlamaModel(options.toModelParameters())) { - OpenAiServerConfig config = - options.toServerConfig(model.supportsVision(), model.getModelFtype(), slotContextSize(model)); + final List slots = model.getMetricsTyped().getSlotMetrics(); + OpenAiServerConfig config = options.toServerConfig( + model.supportsVision(), model.getModelFtype(), slotContextSize(slots), slots.size()); try (OpenAiCompatServer server = new OpenAiCompatServer(model, config)) { server.start(); printReady(config, server.getPort()); @@ -1163,11 +1165,10 @@ public static void main(String[] args) throws IOException { /** * The context window one slot of the loaded model has, for the advertised token budgets. * - * @param model the loaded model + * @param slots the slot metrics of the loaded model * @return {@code n_ctx} of the first slot, or {@code 0} if the model reports none */ - private static int slotContextSize(LlamaModel model) { - final List slots = model.getMetricsTyped().getSlotMetrics(); + private static int slotContextSize(List slots) { return slots.isEmpty() ? 0 : slots.get(0).getContextSize(); } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index 9738fc1c6..015adc1ae 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -739,12 +739,30 @@ public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFty * @return the server configuration */ public OpenAiServerConfig toServerConfig(boolean supportsVision, String modelFtype, int slotContextSize) { + return toServerConfig(supportsVision, modelFtype, slotContextSize, 0); + } + + /** + * Build the server configuration with the context window and the slot count the loaded model + * really has. The slot count is advertised in {@code GET /props} ({@code total_slots}, upstream's + * {@code n_parallel}); without it {@code --parallel} decides, and without that {@code 1}. + * + * @param supportsVision whether the loaded model reports usable vision input + * @param modelFtype the model's file-type (quantization) label, or {@code ""} if unknown + * @param slotContextSize the context size of one slot of the loaded model ({@code n_ctx} of + * its slot metrics), or {@code 0} if unknown + * @param totalSlots the number of slots of the loaded model, or {@code 0} if unknown + * @return the server configuration + */ + public OpenAiServerConfig toServerConfig( + boolean supportsVision, String modelFtype, int slotContextSize, int totalSlots) { final OpenAiServerConfig.Builder builder = OpenAiServerConfig.builder() .host(host) .port(port) .modelId(getModelId()) .supportsVision(supportsVision) - .modelFtype(modelFtype); + .modelFtype(modelFtype) + .totalSlots(totalSlots > 0 ? totalSlots : Math.max(1, parallel)); if (apiKey != null) { builder.apiKey(apiKey); } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java index 190803beb..e95d6abae 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java @@ -56,6 +56,7 @@ public final class OpenAiServerConfig { private final boolean supportsVision; private final int maxRequestBodyBytes; private final String modelFtype; + private final int totalSlots; private OpenAiServerConfig(Builder builder) { this.host = builder.host; @@ -69,6 +70,7 @@ private OpenAiServerConfig(Builder builder) { this.supportsVision = builder.supportsVision; this.maxRequestBodyBytes = builder.maxRequestBodyBytes; this.modelFtype = builder.modelFtype; + this.totalSlots = builder.totalSlots; } /** @@ -182,6 +184,16 @@ public String getModelFtype() { return modelFtype; } + /** + * The number of inference slots of the served model, advertised in {@code GET /props} + * ({@code total_slots}) as upstream's server does ({@code n_parallel}). + * + * @return the slot count, at least {@code 1} + */ + public int getTotalSlots() { + return totalSlots; + } + /** * Whether bearer-token authentication is enabled (an API key is configured). * @@ -231,6 +243,7 @@ public static final class Builder { private boolean supportsVision; private int maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES; private String modelFtype = ""; + private int totalSlots = 1; private Builder() {} @@ -351,6 +364,21 @@ public Builder modelFtype(@Nullable String modelFtype) { return this; } + /** + * Sets the number of inference slots of the served model, advertised in {@code GET /props}. + * + * @param totalSlots the slot count, at least {@code 1} + * @return this builder + * @throws IllegalArgumentException if {@code totalSlots} is less than {@code 1} + */ + public Builder totalSlots(int totalSlots) { + if (totalSlots < 1) { + throw new IllegalArgumentException("totalSlots must be at least 1 but was " + totalSlots); + } + this.totalSlots = totalSlots; + return this; + } + /** * Sets the maximum accepted request-body size in bytes. Bodies larger than this are rejected * with HTTP 413 before being buffered. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java index e5eb40ec8..153d90a2d 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java @@ -197,14 +197,15 @@ static String completionChunk(String id, long created, String model, String text * @param modelId the served model id * @param nCtx the advertised context length * @param vision whether image input is supported + * @param totalSlots the number of inference slots ({@code total_slots}, upstream's {@code n_parallel}) * @return the props object serialized as JSON */ - static String propsJson(String modelId, int nCtx, boolean vision) { + static String propsJson(String modelId, int nCtx, boolean vision, int totalSlots) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); ObjectNode defaults = root.putObject("default_generation_settings"); defaults.put("n_ctx", nCtx); defaults.put("model", modelId); - root.put("total_slots", 1); + root.put("total_slots", totalSlots); root.put("model_alias", modelId); root.put("chat_template", ""); ObjectNode modalities = root.putObject("modalities"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index c115875f9..201ef6f2b 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -342,6 +342,23 @@ public void propsEndpointReportsContextLength() throws IOException { } } + @Test + public void propsEndpointReportsTheSlotCountOfTheConfiguration() throws IOException { + // total_slots was a constant 1; upstream's /props reports n_parallel, and a client that + // spreads its requests over the slots needs the real count. + OpenAiServerConfig cfg = OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .modelId("test-model") + .totalSlots(4) + .build(); + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), cfg).start()) { + Response response = get(server.getPort(), "/props", ""); + assertThat(response.code, is(200)); + assertThat(response.body, containsString("\"total_slots\":4")); + } + } + @Test public void ollamaGenerateNonStreamingReturnsResponse() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index e9bf9b0da..fdeb575b8 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -314,6 +314,27 @@ public void loadedSlotContextWinsOverTheContextSizeArgument() { assertThat(config.getMaxInputTokens(), is(1500)); } + @Test + public void slotCountOfTheLoadedModelIsAdvertised() { + // Without --parallel llama.cpp picks the slot count itself, so the loaded model is the only + // source; /props reported a constant 1. + assertThat( + OpenAiServerCli.parse("-m", "m.gguf") + .toServerConfig(false, "", 0, 4) + .getTotalSlots(), + is(4)); + } + + @Test + public void unknownSlotCountFallsBackToTheParallelArgumentAndThenToOne() { + assertThat( + OpenAiServerCli.parse("-m", "m.gguf", "--parallel", "3") + .toServerConfig() + .getTotalSlots(), + is(3)); + assertThat(OpenAiServerCli.parse("-m", "m.gguf").toServerConfig().getTotalSlots(), is(1)); + } + @Test public void unknownSlotContextFallsBackToTheContextSizeArgument() { OpenAiServerConfig config = diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java index 371444618..e7feaa144 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java @@ -95,4 +95,17 @@ public void heartbeatIntervalOfZeroIsRejected() { IllegalArgumentException.class, () -> OpenAiServerConfig.builder().heartbeatMillis(0L)); } + + @Test + public void totalSlotsDefaultsToOneAndKeepsTheConfiguredCount() { + assertThat(OpenAiServerConfig.builder().build().getTotalSlots(), is(1)); + assertThat(OpenAiServerConfig.builder().totalSlots(4).build().getTotalSlots(), is(4)); + } + + @Test + public void totalSlotsBelowOneIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> OpenAiServerConfig.builder().totalSlots(0)); + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java index 3af5b3875..138465302 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiSseFormatterTest.java @@ -142,7 +142,8 @@ public void modelsJsonIncludesFtypeWhenKnownAndOmitsWhenBlank() throws IOExcepti @Test public void propsJsonReportsContextLengthAndModalities() throws IOException { - JsonNode root = MAPPER.readTree(OpenAiSseFormatter.propsJson("local", 8192, true)); + JsonNode root = MAPPER.readTree(OpenAiSseFormatter.propsJson("local", 8192, true, 3)); + assertThat(root.path("total_slots").asInt(), is(3)); assertThat(root.path("default_generation_settings").path("n_ctx").asInt(), is(8192)); assertThat(root.path("model_alias").asText(), is("local")); assertThat(root.path("modalities").path("vision").asBoolean(), is(true)); From ed318ce9ed43e3a1883e8ce6f4352852a9b98357 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:14:16 +0000 Subject: [PATCH 308/589] Record the commit of F174 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f93b9d51e..cda3f3412 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1264,7 +1264,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#propsEndpointReportsTheSlotCountOfTheConfiguration` (über HTTP), `OpenAiSseFormatterTest#propsJsonReportsContextLengthAndModalities` (ergänzt), `OpenAiServerConfigTest#totalSlotsDefaultsToOneAndKeepsTheConfiguredCount`, `#totalSlotsBelowOneIsRejected`, `OpenAiServerCliTest#slotCountOfTheLoadedModelIsAdvertised`, `#unknownSlotCountFallsBackToTheParallelArgumentAndThenToOne`. - **Rot-Grün-Nachweis:** mit einem Platzhalter, der das alte Verhalten behält (Getter liefert 1, `propsJson` ignoriert den Parameter), rot (5 Tests), mit dem Fix grün (129 Tests der vier Klassen); SpotBugs grün. Der Starter selbst (`main`) braucht ein Modell und ist nur übersetzt. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `569415d` ## Offene Verdachte ohne Fund-ID From 0dd5e4142f695aaaea22ad43890e63cde11309f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:19:08 +0000 Subject: [PATCH 309/589] Resolve router model names through their aliases (F175) The router serves a model under its preset's --alias names too and lists them under "aliases" in GET /models, but RouterModel never read the field, so findModel("") found nothing and awaitModelLoaded timed out with "not listed" while requests under that name were served. RouterModel carries the aliases and answers isNamed(id or alias), the parser reads them, and findModel resolves through isNamed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++- README.md | 3 + TODO.md | 2 - .../json/RouterModelsResponseParser.java | 6 +- .../ladenthin/llama/server/RouterClient.java | 6 +- .../ladenthin/llama/value/RouterModel.java | 60 +++++++++++++++++++ .../json/RouterModelsResponseParserTest.java | 10 ++++ .../llama/server/RouterClientTest.java | 13 ++++ .../llama/value/RouterModelTest.java | 31 ++++++++++ 9 files changed, 136 insertions(+), 8 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cda3f3412..175682aa1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1266,11 +1266,22 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `569415d` +### F175 – java-llama.cpp: `RouterClient.findModel`/`awaitModelLoaded` erkennen einen Alias nicht +- **Zeit:** 2026-10-19 22:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`findModel`), `value/RouterModel.java`, `json/RouterModelsResponseParser.java` +- **Schwere:** niedrig (laut, aber falsch: Timeout bzw. „not listed“, obwohl der Router das Modell bedient) +- **Historie:** Offener Verdacht vom 2026-10-17 (TODO (4)). Upstream löst Modellnamen über Aliase auf (`--alias` im Preset, `server_models::get_meta`) und listet sie in `GET /models` unter `aliases` (`server-models.cpp:2141`, b11639); `RouterModel` las das Feld nie. +- **Beschreibung:** `awaitModelLoaded("")` meldete nach der vollen Wartezeit „Router does not list model“, `findModel("")` lieferte nichts – während eine Anfrage unter demselben Namen bedient wurde. +- **Fix:** Entscheidung des Nutzers (API-Erweiterung): `RouterModel.getAliases()` und `isNamed(name)` (Id oder Alias, groß/klein genau wie upstream), ein neuer Konstruktor mit Aliasen (die bisherigen delegieren mit leerer Liste), der Parser liest `aliases`, `findModel` vergleicht über `isNamed`. README-Hinweis. +- **Tests:** `RouterClientTest#findModel_matchesByAlias` (Stub-Router, auch `awaitModelLoaded` über den Alias), `RouterModelsResponseParserTest#parsesAliasesAndDefaultsThemToEmpty`, `RouterModelTest#aliasesDefaultToEmptyAndAreKeptUnmodifiable`, `#isNamed_matchesTheIdAndEveryAliasAndNothingElse`. +- **Rot-Grün-Nachweis:** mit Platzhaltern (`getAliases()` leer, `isNamed` nur Id) rot (4 Tests), mit dem Fix grün (54 Tests, 2 modellgestützte übersprungen); PIT auf `RouterModel` 18/18 getötet; SpotBugs grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. -- *(niedrig, nur beschrieben, 2026-10-17)* `RouterClient.findModel`/`awaitModelLoaded` vergleichen nur `id`. Upstream löst Modellnamen auch über Aliase auf (`--alias` im Preset, `server_models::get_meta`, `server-models.cpp:1084`) und listet sie unter `aliases`; `awaitModelLoaded("")` meldet deshalb „Router does not list model“, obwohl der Router das Modell unter diesem Namen lädt und bedient. `RouterModel` liest `aliases` nicht ein; ein Fix bräuchte das Feld (API-Erweiterung), daher hier nur notiert. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. diff --git a/README.md b/README.md index 39bf57322..a2dc564bc 100644 --- a/README.md +++ b/README.md @@ -1201,6 +1201,9 @@ RouterClient client = new RouterClient(8080, System.getenv("LLAMA_API_KEY")); > `dedup-cache-models` still loads and still serves by name, but never appears. For those, skip the > await and issue the request directly; with autoload the router waits for the worker itself. +`findModel` and `awaitModelLoaded` accept a model's aliases (a preset's `--alias`) as well as its id, as +the router itself does; `RouterModel.getAliases()` lists them. + ### Distributed inference over RPC llama.cpp's RPC backend spreads one model over the devices of several machines: every machine diff --git a/TODO.md b/TODO.md index 56b0bb6b8..a4d75185b 100644 --- a/TODO.md +++ b/TODO.md @@ -523,8 +523,6 @@ numbered for reference, the numbers stay when an item is done): request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; changing it means sending the headers with the first chunk on all five streaming routes. -- **(4)** `RouterClient.findModel`/`awaitModelLoaded` compare `id` only; a model reached by an alias is - reported as not listed (`RouterModel` would need `aliases`). - **(6)** JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. diff --git a/llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java b/llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java index fd64a6998..cb49f91f1 100644 --- a/llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java +++ b/llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java @@ -67,7 +67,8 @@ public List parse(String json) { * from {@code status.failed} / {@code status.exit_code}, the modalities from * {@code architecture.input_modalities} / {@code architecture.output_modalities} (empty when * absent, as from a server before llama.cpp b11429), and the trained context length from - * {@code context_length} ({@code 0} when absent, as from a server before b11538). + * {@code context_length} ({@code 0} when absent, as from a server before b11538) and the + * aliases from {@code aliases} (empty when absent). * * @param root pre-parsed router {@code GET /models} response * @return list of models; empty list when no entry array is present @@ -94,7 +95,8 @@ public List parse(JsonNode root) { status.path("exit_code").asInt(0), strings(architecture.path("input_modalities")), strings(architecture.path("output_modalities")), - entry.path("context_length").asInt(0))); + entry.path("context_length").asInt(0), + strings(entry.path("aliases")))); } return models; } diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index f23050e83..92476b4f0 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -138,15 +138,15 @@ public List listModels() throws IOException { } /** - * Look up a single model by identifier. + * Look up a single model by identifier or alias, as the router resolves a model name. * - * @param modelId the model identifier (as listed by {@link #listModels()}) + * @param modelId the model identifier (as listed by {@link #listModels()}) or one of its aliases * @return the entry, or {@link Optional#empty()} when the router does not list it * @throws IOException if the request fails or the router answers non-2xx */ public Optional findModel(String modelId) throws IOException { for (RouterModel model : listModels()) { - if (model.getId().equals(modelId)) { + if (model.isNamed(modelId)) { return Optional.of(model); } } diff --git a/llama/src/main/java/net/ladenthin/llama/value/RouterModel.java b/llama/src/main/java/net/ladenthin/llama/value/RouterModel.java index 878a72210..12d852c7d 100644 --- a/llama/src/main/java/net/ladenthin/llama/value/RouterModel.java +++ b/llama/src/main/java/net/ladenthin/llama/value/RouterModel.java @@ -88,6 +88,7 @@ public static Status fromValue(String value) { private final List inputModalities; private final List outputModalities; private final int contextLength; + private final List aliases; /** * Construct a router model entry. @@ -157,6 +158,44 @@ public RouterModel( Collection inputModalities, Collection outputModalities, int contextLength) { + this( + id, + status, + statusValue, + failed, + exitCode, + inputModalities, + outputModalities, + contextLength, + Collections.emptyList()); + } + + /** + * Construct a router model entry with every field the router reports, including the aliases. + * + * @param id the model identifier + * @param status the parsed lifecycle status + * @param statusValue the raw {@code status.value} string as emitted by the server + * @param failed whether the router flagged the model's worker as failed + * @param exitCode the failed worker's exit code; {@code 0} when not failed + * @param inputModalities {@code architecture.input_modalities}; empty when the server omits it + * @param outputModalities {@code architecture.output_modalities}; empty when the server omits it + * @param contextLength {@code context_length}, the trained context in tokens; {@code 0} when + * the server omits it + * @param aliases {@code aliases}, the other names the router serves this model under + * (a preset's {@code --alias}); empty when the server omits it + */ + public RouterModel( + String id, + Status status, + String statusValue, + boolean failed, + int exitCode, + Collection inputModalities, + Collection outputModalities, + int contextLength, + Collection aliases) { + this.aliases = Collections.unmodifiableList(new ArrayList<>(aliases)); this.id = id; this.status = status; this.statusValue = statusValue; @@ -253,6 +292,27 @@ public int getContextLength() { return contextLength; } + /** + * The other names the router serves this model under (a preset's {@code --alias}); a request, + * {@code /models/load} and {@code /models/unload} accept any of them in place of the id. + * + * @return immutable list of aliases, empty when the model has none or the server omits them + */ + public List getAliases() { + return aliases; + } + + /** + * Whether {@code name} names this model: its id or one of its aliases, as the router resolves a + * model name ({@code server_models::get_meta}). + * + * @param name the name to resolve + * @return {@code true} if {@code name} is the id or an alias of this model + */ + public boolean isNamed(String name) { + return id.equals(name) || aliases.contains(name); + } + @Override public String toString() { if (failed) { diff --git a/llama/src/test/java/net/ladenthin/llama/json/RouterModelsResponseParserTest.java b/llama/src/test/java/net/ladenthin/llama/json/RouterModelsResponseParserTest.java index cf9e57d93..dd09503c7 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/RouterModelsResponseParserTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/RouterModelsResponseParserTest.java @@ -41,6 +41,16 @@ public void parsesUpstreamShape() { assertThat(models.get(1).getStatus(), is(RouterModel.Status.UNLOADED)); } + @Test + public void parsesAliasesAndDefaultsThemToEmpty() { + String json = "{\"data\":[{\"id\":\"qwen\",\"aliases\":[\"coder\",\"chat\"]},{\"id\":\"llama\"}]}"; + + List models = parser.parse(json); + + assertThat(models.get(0).getAliases(), contains("coder", "chat")); + assertThat(models.get(1).getAliases(), is(empty())); + } + @Test public void parsesFailedWorkerMarker() { String json = "{\"data\":[{\"id\":\"broken\"," diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java index c6d06e031..1bc06178f 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java @@ -128,6 +128,19 @@ public void findModel_matchesById() throws IOException { assertThat(client.findModel("absent").isPresent(), is(false)); } + @Test + public void findModel_matchesByAlias() throws Exception { + // The router serves a model under its preset's --alias names too (server_models::get_meta); + // awaitModelLoaded("") reported "not listed" although the router loaded it under that name. + modelsBody.set("{\"data\":[{\"id\":\"qwen\",\"aliases\":[\"coder\",\"chat\"]," + + "\"status\":{\"value\":\"loaded\"}}]}"); + + assertThat(client.findModel("coder").get().getId(), is("qwen")); + assertThat(client.findModel("chat").isPresent(), is(true)); + assertThat(client.findModel("absent").isPresent(), is(false)); + assertThat(client.awaitModelLoaded("coder", 2_000L).getId(), is("qwen")); + } + @Test public void loadModel_postsModelFieldToLoadEndpoint() throws IOException { client.loadModel("qwen"); diff --git a/llama/src/test/java/net/ladenthin/llama/value/RouterModelTest.java b/llama/src/test/java/net/ladenthin/llama/value/RouterModelTest.java index 21fb69b66..b842b2c06 100644 --- a/llama/src/test/java/net/ladenthin/llama/value/RouterModelTest.java +++ b/llama/src/test/java/net/ladenthin/llama/value/RouterModelTest.java @@ -40,6 +40,37 @@ public void gettersRoundTrip() { assertThat(model.getExitCode(), is(0)); } + @Test + public void aliasesDefaultToEmptyAndAreKeptUnmodifiable() { + assertThat(sample().getAliases(), is(empty())); + RouterModel aliased = withAliases("coder", "chat"); + assertThat(aliased.getAliases(), contains("coder", "chat")); + assertThrows( + UnsupportedOperationException.class, () -> aliased.getAliases().add("x")); + } + + @Test + public void isNamed_matchesTheIdAndEveryAliasAndNothingElse() { + RouterModel aliased = withAliases("coder"); + assertThat(aliased.isNamed("qwen"), is(true)); + assertThat(aliased.isNamed("coder"), is(true)); + assertThat(aliased.isNamed("Coder"), is(false)); + assertThat(aliased.isNamed("other"), is(false)); + } + + private static RouterModel withAliases(String... aliases) { + return new RouterModel( + "qwen", + RouterModel.Status.LOADED, + "loaded", + false, + 0, + Collections.emptyList(), + Collections.emptyList(), + 0, + Arrays.asList(aliases)); + } + @Test public void statusFromValue_mapsEveryUpstreamString() { // The exact strings emitted by upstream server_model_status_to_string (server-models.h). From 0aeb6378f6918aadfc9515d724e8eb9c24270352 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:19:08 +0000 Subject: [PATCH 310/589] Record the commit of F175 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 175682aa1..040bb593b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1276,7 +1276,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `RouterClientTest#findModel_matchesByAlias` (Stub-Router, auch `awaitModelLoaded` über den Alias), `RouterModelsResponseParserTest#parsesAliasesAndDefaultsThemToEmpty`, `RouterModelTest#aliasesDefaultToEmptyAndAreKeptUnmodifiable`, `#isNamed_matchesTheIdAndEveryAliasAndNothingElse`. - **Rot-Grün-Nachweis:** mit Platzhaltern (`getAliases()` leer, `isNamed` nur Id) rot (4 Tests), mit dem Fix grün (54 Tests, 2 modellgestützte übersprungen); PIT auf `RouterModel` 18/18 getötet; SpotBugs grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `11c7477` ## Offene Verdachte ohne Fund-ID From ef443b044e31a8c8ce0c46c04247e9ec178398a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:25:30 +0000 Subject: [PATCH 311/589] Return rerank(String, String...) scores in document order (F176) The map form collected the results into a HashMap keyed by document text, so the documents came back in hash order. It is a LinkedHashMap in result order now, built by RerankResponseParser.toDocumentScores, which a test can reach without the native library. The Javadoc says that equal documents share one entry and points to rerank(boolean, ...) for a list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++++++ TODO.md | 2 -- .../java/net/ladenthin/llama/LlamaModel.java | 14 ++++----- .../llama/json/RerankResponseParser.java | 19 ++++++++++++ .../llama/json/RerankResponseParserTest.java | 30 +++++++++++++++++++ 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 040bb593b..c764105f0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1278,6 +1278,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `11c7477` +### F176 – java-llama.cpp: `rerank(String, String...)` liefert die Dokumente in Hash-Reihenfolge +- **Zeit:** 2026-10-19 22:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`rerank(String, String...)`), `json/RerankResponseParser.java` (`toDocumentScores`) +- **Schwere:** niedrig (still: Reihenfolge zufällig; gleiche Dokumente teilen einen Eintrag) +- **Historie:** Rest von F167 (TODO (15)): F167 behob den Batch-Teil, die Map-Form blieb. Sie sammelte seit jeher in einer `HashMap`, nach Dokumenttext. +- **Beschreibung:** Die Ergebnisse kommen in Dokumentreihenfolge aus dem Server; die `HashMap` gab sie in Hash-Reihenfolge zurück (gemessen: `zeta, alpha, mid, b, a` → `mid` an zweiter Stelle). Gleiche Dokumente fallen auf einen Eintrag zusammen – ohne Datenverlust, weil gleicher Text gleich bewertet wird, aber undokumentiert. +- **Fix:** Entscheidung des Nutzers: `LinkedHashMap` in Ergebnisreihenfolge, herausgelöst als `RerankResponseParser.toDocumentScores` (ohne native Bibliothek testbar); Javadoc von `rerank(String, String...)` nennt das Zusammenfallen und verweist für eine Liste auf `rerank(boolean, …)`. Die Signatur bleibt (`LlamaOutput` nimmt eine Map). +- **Tests:** `RerankResponseParserTest#toDocumentScores_keepsTheOrderOfTheDocuments`, `#toDocumentScores_equalDocumentsShareOneEntryAtTheirFirstPosition`. +- **Rot-Grün-Nachweis:** mit der `HashMap` rot (1 von 14, `item 1: was "mid"`), mit der `LinkedHashMap` grün (14 von 14); PIT auf `RerankResponseParser` 6/6 getötet; SpotBugs grün (Parameter `Collection`, vorbelegte Größe). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. diff --git a/TODO.md b/TODO.md index a4d75185b..10df0833b 100644 --- a/TODO.md +++ b/TODO.md @@ -536,8 +536,6 @@ numbered for reference, the numbers stay when an item is done): placeholder template would be invented. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. -- **(15)** `rerank(String, String...)` collapses equal documents in its `HashMap` (the batch part was - fixed as F167). - **(16)** Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat template, ours always completes raw. diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java index 2984d32d5..a8309c99b 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java @@ -6,7 +6,6 @@ import java.lang.annotation.Native; import java.nio.charset.StandardCharsets; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -556,20 +555,17 @@ public List> rerank(boolean reRank, String query, String... /** * Rerank the given documents against the query, returning a {@link net.ladenthin.llama.value.LlamaOutput} with scored documents - * in the probabilities map. + * in the probabilities map. The map is keyed by document text in the order of the documents, so + * equal documents share one entry (they score the same); {@link #rerank(boolean, String, String...)} + * returns one pair per document. * * @param query the query string * @param documents the documents to rank - * @return a LlamaOutput with document/score pairs in the probabilities map + * @return a LlamaOutput with document/score pairs in the probabilities map, in document order */ public LlamaOutput rerank(String query, String... documents) { String json = handleRerank(query, documents); - List> results = rerankParser.parse(json); - Map probabilities = new HashMap<>(results.size()); - for (Pair pair : results) { - probabilities.put(pair.getKey(), pair.getValue()); - } - return new LlamaOutput(query, probabilities, true, StopReason.EOS); + return new LlamaOutput(query, rerankParser.toDocumentScores(rerankParser.parse(json)), true, StopReason.EOS); } /** diff --git a/llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java b/llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java index 2591bb045..675daa035 100644 --- a/llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java +++ b/llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java @@ -8,8 +8,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import net.ladenthin.llama.value.Pair; /** @@ -72,4 +75,20 @@ public List> parse(JsonNode arr) { } return results; } + + /** + * The document/score pairs of {@link #parse(String)} as a map from document to score, in the + * order of the results (the input order of the documents). Equal documents score the same and + * share one entry, at the position of their first occurrence; the list form keeps every entry. + * + * @param results the parsed rerank results + * @return a map from document text to score, in result order + */ + public Map toDocumentScores(Collection> results) { + Map scores = new LinkedHashMap(results.size()); + for (Pair pair : results) { + scores.put(pair.getKey(), pair.getValue()); + } + return scores; + } } diff --git a/llama/src/test/java/net/ladenthin/llama/json/RerankResponseParserTest.java b/llama/src/test/java/net/ladenthin/llama/json/RerankResponseParserTest.java index da703575b..1fd244638 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/RerankResponseParserTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/RerankResponseParserTest.java @@ -5,6 +5,7 @@ package net.ladenthin.llama.json; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; @@ -13,6 +14,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; +import java.util.Map; import net.ladenthin.llama.value.Pair; import org.junit.jupiter.api.Test; @@ -137,4 +139,32 @@ public void testParseNode_missingDocument_defaultsToEmpty() throws Exception { assertThat(result, hasSize(1)); assertThat(result.get(0).getKey(), is("")); } + + @Test + public void toDocumentScores_keepsTheOrderOfTheDocuments() { + // rerank(String, String...) collected into a HashMap, so the documents came back in hash + // order; the results are in document order and the map keeps it. + String json = "[{\"document\":\"zeta\",\"index\":0,\"score\":0.1}," + + "{\"document\":\"alpha\",\"index\":1,\"score\":0.2}," + + "{\"document\":\"mid\",\"index\":2,\"score\":0.3}," + + "{\"document\":\"b\",\"index\":3,\"score\":0.4}," + + "{\"document\":\"a\",\"index\":4,\"score\":0.5}]"; + + Map scores = parser.toDocumentScores(parser.parse(json)); + + assertThat(scores.keySet(), contains("zeta", "alpha", "mid", "b", "a")); + assertThat(scores.get("mid"), is(0.3f)); + } + + @Test + public void toDocumentScores_equalDocumentsShareOneEntryAtTheirFirstPosition() { + String json = "[{\"document\":\"x\",\"index\":0,\"score\":0.7}," + + "{\"document\":\"y\",\"index\":1,\"score\":0.2}," + + "{\"document\":\"x\",\"index\":2,\"score\":0.7}]"; + + Map scores = parser.toDocumentScores(parser.parse(json)); + + assertThat(scores.keySet(), contains("x", "y")); + assertThat(scores.get("x"), is(0.7f)); + } } From 29d9e2952a9758fe422eb7b22818e77eaa78e5a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:25:30 +0000 Subject: [PATCH 312/589] Record the commit of F176 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c764105f0..a8db0d59e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1288,7 +1288,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `RerankResponseParserTest#toDocumentScores_keepsTheOrderOfTheDocuments`, `#toDocumentScores_equalDocumentsShareOneEntryAtTheirFirstPosition`. - **Rot-Grün-Nachweis:** mit der `HashMap` rot (1 von 14, `item 1: was "mid"`), mit der `LinkedHashMap` grün (14 von 14); PIT auf `RerankResponseParser` 6/6 getötet; SpotBugs grün (Parameter `Collection`, vorbelegte Größe). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `3299e6a` ## Offene Verdachte ohne Fund-ID From 68f81b6964620d5a748ed387f4096c5cfdcd4109 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:32:01 +0000 Subject: [PATCH 313/589] Follow upstream's top_n and usage in /v1/rerank (F177) Upstream keeps min(top_n, n) results, so top_n 0 keeps none, and always reports usage as the sum of the tokens evaluated per document. Ours kept every result for top_n <= 0 and had no usage at all. top_n 0 now keeps none in both the OpenAI and the TEI form, a negative top_n is refused as an invalid request before the model runs (upstream fails inside the server on it), and rerank_results_to_json passes tokens_evaluated on so the OpenAI answer can carry usage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++- CLAUDE.md | 4 +- TODO.md | 1 - llama/src/main/cpp/json_helpers.hpp | 10 ++++- .../java/net/ladenthin/llama/LlamaModel.java | 6 +-- .../llama/server/LlamaModelBackend.java | 1 + .../llama/server/OaiRerankSupport.java | 37 +++++++++++++++---- llama/src/test/cpp/test_json_helpers.cpp | 25 +++++++++++++ .../llama/server/OaiRerankSupportTest.java | 36 ++++++++++++++++++ 9 files changed, 117 insertions(+), 16 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a8db0d59e..520f2de32 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1290,12 +1290,23 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3299e6a` +### F177 – java-llama.cpp: `/v1/rerank`: `top_n: 0` liefert alle Ergebnisse, ein negatives `top_n` wird durchgereicht, `usage` fehlt +- **Zeit:** 2026-10-19 22:55 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java`, `LlamaModelBackend.java` (`rerank`), `src/main/cpp/json_helpers.hpp` (`rerank_results_to_json`) +- **Schwere:** niedrig (still abweichend von upstream) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (8)). `OaiRerankSupport` behandelte `top_n <= 0` seit seiner Einführung als „alle“. Upstreams `format_response_rerank` (`server-common.cpp:1535`, b11639) behält `min(top_n, n)` Einträge – bei 0 keinen, bei einem negativen Wert ein `resize` auf eine riesige Größe – und setzt immer `usage` aus der Summe von `tokens_evaluated`. Das native Array (`rerank_results_to_json`) reichte `tokens_evaluated` nicht weiter. +- **Beschreibung:** `top_n: 0` lieferte alle Dokumente statt keines, `top_n: -1` ebenfalls alle (upstream scheitert intern), und `usage` fehlte in der OpenAI-förmigen Antwort ganz. +- **Fix:** Entscheidung des Nutzers: `top_n: 0` behält keinen Eintrag (OpenAI- und TEI-Form); ein negatives `top_n` ist eine ungültige Anfrage (400), geprüft in `LlamaModelBackend.rerank` vor dem nativen Aufruf; `rerank_results_to_json` reicht `tokens_evaluated` weiter (0, wenn das Ergebnis es nicht trägt), und `toOaiResponse` setzt `usage.prompt_tokens`/`total_tokens` auf die Summe. Javadoc von `handleRerank` nennt das neue Feld. +- **Tests:** C++: `RerankResultsToJson.TokensEvaluatedArePassedOnAndDefaultToZero`. Java: `OaiRerankSupportTest#negativeTopNIsAnInvalidRequest`, `#topNZeroReturnsNoResultsLikeUpstream`, `#toOaiResponseCarriesTheUsageOfTheEvaluatedTokens`, `#readTopNReturnsValueOrNegativeOne` (um 0 ergänzt). +- **Rot-Grün-Nachweis:** C++ ohne den Fix rot (`Which is: -1`), mit ihm grün (9/9 der Suite); Java ohne den Fix rot (3 von 16), mit ihm grün (16/16, mit `OpenAiCompatServerHttpTest` 71); SpotBugs grün. Ende zu Ende mit einem Reranker (CI: `OpenAiServerRerankIntegrationTest`) nicht lokal gelaufen. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. -- *(niedrig, nur beschrieben, 2026-10-18)* Rerank gegen upstream: `OaiRerankSupport.toOaiResponse` liefert bei `top_n: 0` alle Ergebnisse, upstream (`format_response_rerank`, `elements.resize(min(top_n, size))`) keines; ein negatives `top_n` ergibt bei uns ebenfalls alle. Und `usage` (`prompt_tokens`/`total_tokens` aus `tokens_evaluated`) fehlt in unserer Antwort ganz, upstream setzt es immer. Kein Absturz und keine falsche Rangfolge, daher nur notiert; `top_n: 0` als „alle“ ist eine vertretbare Lesart, die Abweichung sollte aber bewusst entschieden werden. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. diff --git a/CLAUDE.md b/CLAUDE.md index 4001adcc4..fea3e0173 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2602,7 +2602,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" |------|-------|-------| | `src/test/cpp/test_utils.cpp` | 168 | Upstream helpers: `server_tokens`, `server_grammar_trigger`, `gen_tool_call_id`, `json_value`, `json_get_nested_values`, UTF-8 helpers, `format_response_rerank`, `format_embeddings_response_oaicompat`, `oaicompat_completion_params_parse`, `oaicompat_chat_params_parse`, `are_lora_equal`, `strip_flag_from_argv`, `token_piece_value`, `json_is_array_and_contains_numbers`, `format_oai_sse`, `format_oai_resp_sse`, `format_anthropic_sse`, `parse_lora_request`, `common_chat_parse` over malformed UTF-8 (the `ContentOnlyParseUtf8` guard, which pins upstream #29161's one-U+FFFD-per-invalid-run contract — formerly the guard for the dropped `patches/0011`) | | `src/test/cpp/test_server.cpp` | 206 | Upstream result types: `server_slot_stats` (the `timings` JSON payload; replaced `result_timings` in b10408), `task_params::to_json()` (incl. `dry_sequence_breakers`, `preserved_tokens`, `timings_per_token`), `completion_token_output`, `server_task_result_cmpl_partial` (non-oaicompat + `to_json_oaicompat` + logprobs + `to_json_oaicompat_chat` + `to_json_anthropic` + dispatcher), `server_task_result_cmpl_final` (non-oaicompat + `to_json_oaicompat` + `to_json_oaicompat_chat` + `to_json_oaicompat_chat_stream` + `to_json_anthropic` + `to_json_anthropic_stream` + tool_calls + dispatcher), `server_task_result_embd`, `server_task_result_rerank`, `server_task_result_metrics` (`to_metrics()` = the `/metrics` Prometheus exposition text; its `to_json()` has been unused since b10519 and returns `json{}` = JSON null), `server_task_result_slots` (`to_json()` = the `/slots` array, fed by the b10519 `SERVER_TASK_TYPE_SLOT_GET` task), `server_task_result_slot_save_load`, `server_task_result_slot_erase`, `server_task_result_apply_lora`, `server_task_result_get_lora`, `server_task_result_error`, `format_error_response`, `server_task::need_sampling()`, `server_task::n_tokens()`, `server_schema::eval_llama_cmpl_schema()` (parsing pipeline + grammar routing + error paths + per-request `dry_*` and `sse_ping_interval` field round-trips incl. hard-limit + server-default inheritance), `response_fields` projection | -| `src/test/cpp/test_json_helpers.cpp` | 71 | All functions in `json_helpers.hpp`: `get_result_error_message`, `get_result_error_code` (the HTTP code of a failed task result, from which `jllama.cpp` picks `InvalidRequestException` or `LlamaException`), `results_to_json`, `rerank_results_to_json` (incl. missing/out-of-range `index` rejection), `parse_encoding_format`, `extract_embedding_prompt`, `is_infill_request`, `parse_slot_prompt_similarity`, `parse_positive_int_config`, `wrap_stream_chunk`, `server_metrics_to_json`, `route_error_message` | +| `src/test/cpp/test_json_helpers.cpp` | 72 | All functions in `json_helpers.hpp`: `get_result_error_message`, `get_result_error_code` (the HTTP code of a failed task result, from which `jllama.cpp` picks `InvalidRequestException` or `LlamaException`), `results_to_json`, `rerank_results_to_json` (incl. missing/out-of-range `index` rejection and the `tokens_evaluated` pass-through), `parse_encoding_format`, `extract_embedding_prompt`, `is_infill_request`, `parse_slot_prompt_similarity`, `parse_positive_int_config`, `wrap_stream_chunk`, `server_metrics_to_json`, `route_error_message` | | `src/test/cpp/test_log_helpers.cpp` | 13 | All functions in `log_helpers.hpp`: `log_level_name`, `format_log_as_json` | | `src/test/cpp/test_common_log_callback.cpp` | 7 | **The runnable guard for `patches/0014`**: `common_log_set_callback()` on a private `common_log_init()` instance (never `common_log_main()`, so the process-wide logger the other tests print through is untouched) — delivery of level + bare text, no prefix/timestamp even when both are on (what `common_init()` does), clearing stops delivery, a swap drains queued entries to the *previous* sink (the property behind `LlamaModel.setLogger(format, null)` being a synchronous flush), a `--log-file` keeps being written alongside the sink, every `ggml_log_level` passes through unchanged, and the sink is not gated on upstream's `common_log_set_stdo()` console switch (b11612: entries reach the callback with stdout/stderr output off). The Java half (`LlamaLoggerTest`, model-free) proves the JNI trampoline on top of it. | | `src/test/cpp/test_jni_helpers.cpp` | 70 | All functions in `jni_helpers.hpp` using a zero-filled `JNINativeInterface_` mock (incl. the `utf8_to_jstring_impl` byte-array string path: emoji byte-preservation, truncated-UTF-8 replace-not-throw). Seven of them pin `jni_guard_impl` — the JNI exception boundary every `Java_*` entry point runs inside — including the `catch (...)` arm that is the only backstop for a non-`std::exception` type, and its two refusals (never `ThrowNew` over a pending Java exception, never with a null class). | @@ -2619,7 +2619,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 633 tests (all passing).** +**Current total: 634 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/TODO.md b/TODO.md index 10df0833b..f09a076a3 100644 --- a/TODO.md +++ b/TODO.md @@ -526,7 +526,6 @@ numbered for reference, the numbers stay when an item is done): - **(6)** JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. -- **(8)** Rerank: `top_n: 0` (and negative) returns all results, upstream none; `usage` is missing. - **(10)** Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep diff --git a/llama/src/main/cpp/json_helpers.hpp b/llama/src/main/cpp/json_helpers.hpp index edca72ef0..3c00ecb62 100644 --- a/llama/src/main/cpp/json_helpers.hpp +++ b/llama/src/main/cpp/json_helpers.hpp @@ -85,7 +85,8 @@ // // Converts a collected vector of rerank task results to a JSON array. // Each element contains the original document text (looked up via the -// result's "index" field), the index, and the relevance score. +// result's "index" field), the index, the relevance score and the number of +// tokens evaluated for it (0 when the result does not say). // --------------------------------------------------------------------------- [[nodiscard]] inline json rerank_results_to_json(const std::vector &results, const std::vector &documents) { @@ -102,7 +103,12 @@ throw std::invalid_argument("rerank result index " + std::to_string(index) + " out of range"); } float score = out["score"].get(); - arr.push_back({{"document", documents[index]}, {"index", index}, {"score", score}}); + // the evaluated tokens, which the OpenAI-shaped answer sums into usage as upstream does + const int tokens_evaluated = out.contains("tokens_evaluated") ? out["tokens_evaluated"].get() : 0; + arr.push_back({{"document", documents[index]}, + {"index", index}, + {"score", score}, + {"tokens_evaluated", tokens_evaluated}}); } return arr; } diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java index a8309c99b..829848782 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java @@ -570,15 +570,15 @@ public LlamaOutput rerank(String query, String... documents) { /** * Rerank {@code documents} against {@code query} and return the raw native JSON array of results. - * Each element carries {@code "document"}, {@code "index"} (the position in the input array) and - * {@code "score"}. This is the JSON-in/JSON-out form behind + * Each element carries {@code "document"}, {@code "index"} (the position in the input array), + * {@code "score"} and {@code "tokens_evaluated"} (the tokens the model evaluated for it). This is the JSON-in/JSON-out form behind * {@link #rerank(boolean, String, String...)}; the OpenAI-compatible server reshapes it into the * {@code POST /v1/rerank} response. Requires the model loaded in reranking mode * ({@link net.ladenthin.llama.parameters.ModelParameters#enableReranking()}). * * @param query the query string * @param documents the documents to score against the query - * @return a JSON array of {@code {document, index, score}} objects + * @return a JSON array of {@code {document, index, score, tokens_evaluated}} objects */ public native String handleRerank(String query, String... documents); diff --git a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java index 1e8895872..8c2371511 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java @@ -137,6 +137,7 @@ public String infill(JsonNode request) { public String rerank(JsonNode request) { final String query = OaiRerankSupport.readQuery(request); final String[] documents = OaiRerankSupport.readDocuments(request); + OaiRerankSupport.readTopN(request); // a negative top_n is refused before anything is evaluated final String requestModel = request.path("model").asText(""); final String nativeJson = model.handleRerank(query, documents); return OaiRerankSupport.toResponse(request, nativeJson, requestModel); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java index b3c1c3130..d51bebe10 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java @@ -72,14 +72,29 @@ static String[] readDocuments(JsonNode request) { } /** - * Read the optional {@code top_n} cap. + * Read the optional {@code top_n} cap. {@code 0} keeps no result, as upstream's {@code + * format_response_rerank} does ({@code min(top_n, n)} entries). * * @param request the parsed rerank request * @return the requested cap, or {@code -1} when absent or not an integer + * @throws IllegalArgumentException if {@code top_n} is negative (upstream resizes its result vector + * to {@code min(top_n, n)}, a huge size for a negative value, and fails inside the server) */ static int readTopN(JsonNode request) { JsonNode topN = request.path("top_n"); - return topN.isInt() ? topN.asInt() : -1; + if (!topN.isInt()) { + return -1; + } + final int value = topN.asInt(); + if (value < 0) { + throw new IllegalArgumentException("'top_n' must not be negative but was " + value); + } + return value; + } + + /** The number of entries a {@code top_n} keeps: all for {@code -1} (absent), else at most it. */ + private static int limit(int topN, int size) { + return topN < 0 ? size : Math.min(topN, size); } /** @@ -106,7 +121,8 @@ static String toResponse(JsonNode request, String nativeArrayJson, String model) * {@code text} when {@code return_text} is set. * * @param nativeArrayJson the native {@code [{document,index,score}]} array as JSON - * @param topN keep only the top-N highest-scoring entries, or {@code <= 0} to keep all + * @param topN keep only the top-N highest-scoring entries ({@code 0} keeps none), or {@code -1} to + * keep all * @param returnText whether to include each document's text * @return the TEI rerank response JSON */ @@ -132,7 +148,7 @@ static String toTeiResponse(String nativeArrayJson, int topN, boolean returnText entries.sort((a, b) -> Double.compare(b.path("score").asDouble(), a.path("score").asDouble())); final ArrayNode results = OBJECT_MAPPER.createArrayNode(); - final int limit = topN > 0 ? Math.min(topN, entries.size()) : entries.size(); + final int limit = limit(topN, entries.size()); for (int i = 0; i < limit; i++) { results.add(entries.get(i)); } @@ -144,11 +160,14 @@ static String toTeiResponse(String nativeArrayJson, int topN, boolean returnText * * @param nativeArrayJson the native {@code [{document,index,score}]} array as JSON * @param model the model id to echo (omitted when empty) - * @param topN keep only the top-N highest-scoring entries, or {@code <= 0} to keep all - * @return the rerank response JSON with sorted {@code results} and a {@code data} alias + * @param topN keep only the top-N highest-scoring entries ({@code 0} keeps none), or {@code -1} to + * keep all + * @return the rerank response JSON with sorted {@code results}, a {@code data} alias and the + * {@code usage} of the evaluated tokens */ static String toOaiResponse(String nativeArrayJson, String model, int topN) { final List entries = new ArrayList<>(); + long promptTokens = 0; try { JsonNode arr = OBJECT_MAPPER.readTree(nativeArrayJson); if (arr.isArray()) { @@ -157,17 +176,19 @@ static String toOaiResponse(String nativeArrayJson, String model, int topN) { result.put("index", entry.path("index").asInt()); result.put("relevance_score", entry.path("score").asDouble()); entries.add(result); + promptTokens += entry.path("tokens_evaluated").asLong(0L); } } } catch (IOException e) { // The native call already succeeded; an unexpected body just yields empty results. entries.clear(); + promptTokens = 0; } entries.sort((a, b) -> Double.compare( b.path("relevance_score").asDouble(), a.path("relevance_score").asDouble())); final ArrayNode results = OBJECT_MAPPER.createArrayNode(); - final int limit = topN > 0 ? Math.min(topN, entries.size()) : entries.size(); + final int limit = limit(topN, entries.size()); for (int i = 0; i < limit; i++) { results.add(entries.get(i)); } @@ -177,6 +198,8 @@ static String toOaiResponse(String nativeArrayJson, String model, int topN) { if (!model.isEmpty()) { root.put("model", model); } + // upstream's usage: every token evaluated for a document is a prompt token + root.putObject("usage").put("prompt_tokens", promptTokens).put("total_tokens", promptTokens); root.set("results", results); root.set("data", results.deepCopy()); // alias for Continue (#6478), independent copy return root.toString(); diff --git a/llama/src/test/cpp/test_json_helpers.cpp b/llama/src/test/cpp/test_json_helpers.cpp index 33148a0d8..e7f8e63be 100644 --- a/llama/src/test/cpp/test_json_helpers.cpp +++ b/llama/src/test/cpp/test_json_helpers.cpp @@ -189,6 +189,31 @@ TEST(RerankResultsToJson, TwoResults_CorrectShape) { EXPECT_EQ(out[1].value("document", ""), "doc B"); } +namespace { +// The shape upstream's server_task_result_rerank::to_json emits, with the evaluated tokens. +struct fake_rerank_result_with_tokens : server_task_result { + int index; + float score; + int n_tokens; + fake_rerank_result_with_tokens(int idx, float sc, int n) : index(idx), score(sc), n_tokens(n) {} + json to_json() override { return {{"index", index}, {"score", score}, {"tokens_evaluated", n_tokens}}; } +}; +} // namespace + +TEST(RerankResultsToJson, TokensEvaluatedArePassedOnAndDefaultToZero) { + // The OpenAI-shaped /v1/rerank answer carries usage (prompt_tokens = the sum of the evaluated + // tokens, as upstream's format_response_rerank computes it), so the array has to carry them. + std::vector results; + results.push_back(std::make_unique(0, 0.9f, 7)); + results.push_back(make_rerank(2, 1, 0.4f)); // a result without the field + std::vector docs = {"doc A", "doc B"}; + + json out = rerank_results_to_json(results, docs); + + EXPECT_EQ(out[0].value("tokens_evaluated", -1), 7); + EXPECT_EQ(out[1].value("tokens_evaluated", -1), 0); +} + TEST(RerankResultsToJson, EmptyResults_ReturnsEmptyArray) { std::vector results; json out = rerank_results_to_json(results, {}); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java index 7674ce7a3..34eb4aa37 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java @@ -5,6 +5,7 @@ package net.ladenthin.llama.server; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -71,6 +72,41 @@ public void readDocumentsThrowsOnUnsupportedEntry() throws IOException { public void readTopNReturnsValueOrNegativeOne() throws IOException { assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":3}")), is(3)); assertThat(OaiRerankSupport.readTopN(read("{}")), is(-1)); + assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":0}")), is(0)); + } + + @Test + public void negativeTopNIsAnInvalidRequest() throws IOException { + // Upstream resizes its result vector to min(top_n, n), which for a negative top_n is a huge + // size_t: the request fails inside the server rather than as the caller's fault. + JsonNode request = read("{\"top_n\":-1}"); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> OaiRerankSupport.readTopN(request)); + assertThat(e.getMessage(), containsString("top_n")); + } + + @Test + public void topNZeroReturnsNoResultsLikeUpstream() throws IOException { + // format_response_rerank keeps min(top_n, n) entries, so 0 keeps none; ours kept all. + String nativeJson = "[{\"index\":0,\"score\":0.2},{\"index\":1,\"score\":0.9}]"; + assertThat( + read(OaiRerankSupport.toOaiResponse(nativeJson, "", 0)) + .path("results") + .size(), + is(0)); + assertThat(read(OaiRerankSupport.toTeiResponse(nativeJson, 0, false)).size(), is(0)); + } + + @Test + public void toOaiResponseCarriesTheUsageOfTheEvaluatedTokens() throws IOException { + // Upstream's answer always has usage, prompt_tokens and total_tokens both the sum of the + // tokens evaluated per document; ours had none. + String nativeJson = "[{\"index\":0,\"score\":0.2,\"tokens_evaluated\":5}," + + "{\"index\":1,\"score\":0.9,\"tokens_evaluated\":7}]"; + JsonNode usage = + read(OaiRerankSupport.toOaiResponse(nativeJson, "", -1)).path("usage"); + assertThat(usage.path("prompt_tokens").asInt(-1), is(12)); + assertThat(usage.path("total_tokens").asInt(-1), is(12)); } @Test From f49dba49e2c40e5b33ab8853a1243abca169fc93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:32:01 +0000 Subject: [PATCH 314/589] Record the commit of F177 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 520f2de32..43bdaeb32 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1300,7 +1300,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++: `RerankResultsToJson.TokensEvaluatedArePassedOnAndDefaultToZero`. Java: `OaiRerankSupportTest#negativeTopNIsAnInvalidRequest`, `#topNZeroReturnsNoResultsLikeUpstream`, `#toOaiResponseCarriesTheUsageOfTheEvaluatedTokens`, `#readTopNReturnsValueOrNegativeOne` (um 0 ergänzt). - **Rot-Grün-Nachweis:** C++ ohne den Fix rot (`Which is: -1`), mit ihm grün (9/9 der Suite); Java ohne den Fix rot (3 von 16), mit ihm grün (16/16, mit `OpenAiCompatServerHttpTest` 71); SpotBugs grün. Ende zu Ende mit einem Reranker (CI: `OpenAiServerRerankIntegrationTest`) nicht lokal gelaufen. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `232985f` ## Offene Verdachte ohne Fund-ID From 887eb93f6b30bc522f338fb2c57779d242955f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:39:36 +0000 Subject: [PATCH 315/589] Report Ollama's done_reason and the generate token counts (F178) Ollama says "length" for an answer cut off by num_predict and "stop" otherwise, tool calls included. The blocking /api/chat passed OpenAI's finish_reason through ("tool_calls" too), and the streamed /api/chat and both /api/generate forms always said "stop". One mapping now serves all four: the stream remembers the finish_reason of its last chunk, and /api/generate reads finish_reason and usage from the completion, or stop_type and the token counts from the native infill answer, so its done line also carries prompt_eval_count and eval_count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +- TODO.md | 3 - .../llama/server/OllamaApiSupport.java | 176 +++++++++++++----- .../llama/server/OpenAiCompatServer.java | 20 +- .../llama/server/OllamaApiSupportTest.java | 29 ++- .../server/OpenAiCompatServerHttpTest.java | 93 +++++++++ 6 files changed, 273 insertions(+), 61 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 43bdaeb32..a043284c5 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1302,9 +1302,20 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `232985f` +### F178 – java-llama.cpp: Ollama-Routen melden `done_reason` falsch und `/api/generate` keine Tokenzahlen +- **Zeit:** 2026-10-19 23:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java`, `OpenAiCompatServer.java` (`streamOllamaChat`, `handleOllamaGenerate`) +- **Schwere:** niedrig (still: ein Client erkennt abgeschnittene Antworten im Stream und bei `/api/generate` nie) +- **Historie:** Offener Verdacht vom 2026-10-17 (TODO (2)); die Tokenzahlen der gestreamten `/api/chat`-Zeile kamen schon mit F98. Ollama meldet `done_reason` `"length"` für eine durch `num_predict` abgeschnittene Antwort und sonst `"stop"`, auch bei Werkzeugaufrufen. +- **Beschreibung:** Blockierendes `/api/chat` reichte OpenAIs `finish_reason` durch, also auch `"tool_calls"`, das Ollama nicht kennt; gestreamtes `/api/chat` und beide `/api/generate`-Formen meldeten immer `"stop"`. `/api/generate` meldete außerdem weder `prompt_eval_count` noch `eval_count`, obwohl die Antwort des Backends sie trägt. +- **Fix:** Entscheidung des Nutzers: eine Abbildung `toOllamaDoneReason` (`length` → `length`, alles andere → `stop`) für alle vier Wege. Der Stream merkt sich den `finish_reason` des Endchunks (`finishReasonOf`) für die Abschlusszeile; `/api/generate` baut aus der Antwort des Backends ein `Generated` (Text, Grund, Zählungen): aus der OpenAI-Completion `finish_reason` und `usage`, aus der nativen Infill-Antwort `stop_type` (`limit` → `length`) und `tokens_evaluated`/`tokens_predicted`. Fehlen die Zählungen, bleiben die Felder weg. `extractCompletionText`/`extractInfillContent` entfielen (nur noch von Tests benutzt). +- **Tests:** `OpenAiCompatServerHttpTest#ollamaChatDoneReasonIsLengthWhenCutOffAndStopForToolCalls` (blockierend und gestreamt, `length`/`tool_calls`/`stop`), `#ollamaGenerateDoneReasonAndCountsFollowTheCompletion` (blockierend und gestreamt), `#ollamaGenerateWithSuffixReadsTheInfillStopType`; `OllamaApiSupportTest#generateTextExtractors` (umgestellt), `#generateWithoutCountsOmitsThemAndToolCallsAreStop`. +- **Rot-Grün-Nachweis:** ohne den Fix rot (3 HTTP-Tests, `was "stop"`), mit ihm grün (94 Tests der beiden Klassen; Pakete `server`, `json`, `value` 417 grün); SpotBugs grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID -- *(niedrig, unsicher, nur beschrieben, 2026-10-17)* java-llama.cpp `server/OllamaApiSupport`: `done_reason` ist zwischen den Pfaden uneinheitlich. Nicht gestreamtes `/api/chat` reicht OpenAIs `finish_reason` durch (`stop`, `length`, aber auch `tool_calls`, das Ollama selbst nicht kennt – es meldet bei Werkzeugaufrufen `stop`). Gestreamtes `/api/chat` (`toOllamaDoneLine`) und beide `/api/generate`-Formen melden immer `stop`, auch wenn `num_predict` die Antwort abgeschnitten hat. Ein Client, der über `done_reason == "length"` Abschneiden erkennt, sieht es im Streaming nie. Ob das relevant ist, hängt vom Client ab; Vorschlag: `tool_calls` → `stop` abbilden und den `finish_reason` des letzten Stream-Chunks an `toOllamaDoneLine` durchreichen. Kein Fix ohne Entscheidung. Nachtrag 2026-10-18 (Tokenzahlen erledigt durch F98, `done_reason` bleibt offen): Die gestreamte Abschlusszeile (`toOllamaDoneLine`) trägt außerdem weder `prompt_eval_count` noch `eval_count`, die nicht gestreamte Antwort schon (`OllamaApiSupport.java:261`). Anders als die Anthropic- und Responses-Routen (`withUsageChunk`, `OpenAiCompatServer.java:653`/`:741`) fordert `streamOllamaChat` den Usage-Chunk auch nicht an; ein Fix wäre `withUsageChunk` plus Erfassen der Zählung im Akkumulator, Clients, die Token/s aus `eval_count` berechnen, sehen sonst nichts. - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. diff --git a/TODO.md b/TODO.md index f09a076a3..1f0973059 100644 --- a/TODO.md +++ b/TODO.md @@ -516,9 +516,6 @@ delete an item here when it is done. **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; numbered for reference, the numbers stay when an item is done): -- **(2)** Ollama `done_reason`: `tool_calls` is passed through (Ollama reports `stop`), the streamed paths - always report `stop` even when cut off by `num_predict`, and the streamed final line has no - `prompt_eval_count`/`eval_count` (`withUsageChunk` not requested). - **(3)** `OpenAiCompatServer` sends `200` + SSE headers before the backend runs, so a rejected streaming request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 73f11e11e..f4df2cb3b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -314,9 +314,7 @@ static String toOllamaChatResponse(String openAiCompletionJson, String model) { if (ollamaToolCalls.size() > 0) { message.set("tool_calls", ollamaToolCalls); } - if (choice.path("finish_reason").isTextual()) { - doneReason = choice.path("finish_reason").asText(); - } + doneReason = toOllamaDoneReason(choice.path("finish_reason").asText("")); JsonNode usage = completion.path("usage"); if (usage.isObject()) { root.put("prompt_eval_count", usage.path("prompt_tokens").asLong(0)); @@ -387,6 +385,25 @@ static String toOllamaDoneLine(String model, ToolCallDeltaAccumulator accumulato * @return the final Ollama NDJSON line (with trailing newline) */ static String toOllamaDoneLine(String model, ToolCallDeltaAccumulator accumulator, @Nullable JsonNode usage) { + return toOllamaDoneLine(model, accumulator, usage, null); + } + + /** + * Build the terminating Ollama NDJSON line ({@code "done":true}) with the token counts and the + * {@code done_reason} of the stream: {@code "length"} when the last chunk's {@code finish_reason} + * says the answer was cut off, else {@code "stop"} (see {@link #toOllamaDoneReason}). + * + * @param model the model id to echo + * @param accumulator the tool-call accumulator fed with the stream's chunks + * @param usage the OpenAI {@code usage} object of the stream's trailing chunk, or {@code null} + * @param finishReason the OpenAI {@code finish_reason} of the stream, or {@code null} if none came + * @return the final Ollama NDJSON line (with trailing newline) + */ + static String toOllamaDoneLine( + String model, + ToolCallDeltaAccumulator accumulator, + @Nullable JsonNode usage, + @Nullable String finishReason) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("model", model); root.put("created_at", nowIso()); @@ -404,10 +421,42 @@ static String toOllamaDoneLine(String model, ToolCallDeltaAccumulator accumulato root.put("eval_count", usage.path("completion_tokens").asLong(0)); } root.put("done", true); - root.put("done_reason", "stop"); + root.put("done_reason", toOllamaDoneReason(finishReason)); return root.toString() + "\n"; } + /** + * Ollama's {@code done_reason} for an OpenAI {@code finish_reason}: {@code "length"} for an answer + * cut off by the token limit, {@code "stop"} for everything else -- Ollama reports a turn that ends + * in tool calls as {@code "stop"}, it has no {@code "tool_calls"}. + * + * @param openAiFinishReason the OpenAI finish reason, or {@code null} if unknown + * @return {@code "length"} or {@code "stop"} + */ + static String toOllamaDoneReason(@Nullable String openAiFinishReason) { + return "length".equals(openAiFinishReason) ? "length" : "stop"; + } + + /** + * Read the {@code finish_reason} of a streamed OpenAI chunk (the chunk that ends the answer carries + * it), or {@code null} when the chunk has none. + * + * @param openAiChunkJson one streamed OpenAI chunk + * @return the finish reason, or {@code null} + */ + static @Nullable String finishReasonOf(String openAiChunkJson) { + try { + JsonNode reason = OBJECT_MAPPER + .readTree(openAiChunkJson) + .path("choices") + .path(0) + .path("finish_reason"); + return reason.isTextual() ? reason.asText() : null; + } catch (IOException e) { + return null; + } + } + /** * Read the {@code usage} object of a streamed OpenAI chunk (the trailing chunk the stream emits for * {@code stream_options.include_usage}). @@ -519,76 +568,115 @@ static ObjectNode toInfillRequest(JsonNode request) { } /** - * Extract the generated text from an OpenAI completion body ({@code choices[0].text}). - * - * @param openAiCompletionJson the OpenAI {@code /v1/completions} response - * @return the completion text, or empty on an unexpected body + * What an {@code /api/generate} answer reports: the text, why it stopped and the token counts. + * Built from the backend's answer, which is an OpenAI completion or a native infill answer. */ - static String extractCompletionText(String openAiCompletionJson) { - try { - return OBJECT_MAPPER - .readTree(openAiCompletionJson) - .path("choices") - .path(0) - .path("text") - .asText(""); - } catch (IOException e) { - return ""; + static final class Generated { + final String text; + final String doneReason; + /** {@code prompt_eval_count}, or {@code -1} when the answer has none. */ + final long promptEvalCount; + /** {@code eval_count}, or {@code -1} when the answer has none. */ + final long evalCount; + + Generated(String text, String doneReason, long promptEvalCount, long evalCount) { + this.text = text; + this.doneReason = doneReason; + this.promptEvalCount = promptEvalCount; + this.evalCount = evalCount; } - } - /** - * Extract the generated text from a native infill body ({@code content}). - * - * @param infillJson the native {@code /infill} response - * @return the infill content, or empty on an unexpected body - */ - static String extractInfillContent(String infillJson) { - try { - return OBJECT_MAPPER.readTree(infillJson).path("content").asText(""); - } catch (IOException e) { - return ""; + /** + * From an OpenAI {@code /v1/completions} answer: {@code choices[0].text}, its + * {@code finish_reason} and {@code usage}. + * + * @param openAiCompletionJson the OpenAI completion answer + * @return what the generate answer reports; an empty {@code "stop"} on an unexpected body + */ + static Generated fromCompletion(String openAiCompletionJson) { + try { + JsonNode completion = OBJECT_MAPPER.readTree(openAiCompletionJson); + JsonNode choice = completion.path("choices").path(0); + JsonNode usage = completion.path("usage"); + return new Generated( + choice.path("text").asText(""), + toOllamaDoneReason(choice.path("finish_reason").asText("")), + usage.path("prompt_tokens").asLong(-1L), + usage.path("completion_tokens").asLong(-1L)); + } catch (IOException e) { + return new Generated("", "stop", -1L, -1L); + } + } + + /** + * From a native {@code /infill} answer: {@code content}, its {@code stop_type} ({@code "limit"} + * is a cut-off answer) and {@code tokens_evaluated} / {@code tokens_predicted}. + * + * @param infillJson the native infill answer + * @return what the generate answer reports; an empty {@code "stop"} on an unexpected body + */ + static Generated fromInfill(String infillJson) { + try { + JsonNode infill = OBJECT_MAPPER.readTree(infillJson); + return new Generated( + infill.path("content").asText(""), + "limit".equals(infill.path("stop_type").asText("")) ? "length" : "stop", + infill.path("tokens_evaluated").asLong(-1L), + infill.path("tokens_predicted").asLong(-1L)); + } catch (IOException e) { + return new Generated("", "stop", -1L, -1L); + } + } + + private void putDone(ObjectNode done) { + if (promptEvalCount >= 0) { + done.put("prompt_eval_count", promptEvalCount); + } + if (evalCount >= 0) { + done.put("eval_count", evalCount); + } + done.put("done", true); + done.put("done_reason", doneReason); } } /** - * Build a non-streaming Ollama {@code /api/generate} response wrapping {@code text}. + * Build a non-streaming Ollama {@code /api/generate} response. * - * @param text the generated text + * @param generated the text, stop reason and token counts of the answer * @param model the model id to echo * @return the Ollama generate response serialized as JSON */ - static String toOllamaGenerateResponse(String text, String model) { + static String toOllamaGenerateResponse(Generated generated, String model) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("model", model); root.put("created_at", nowIso()); - root.put("response", text); - root.put("done", true); - root.put("done_reason", "stop"); + root.put("response", generated.text); + generated.putDone(root); return root.toString(); } /** - * Build the streamed Ollama {@code /api/generate} NDJSON: a single response line carrying {@code text} - * followed by a terminating {@code "done":true} line. (Generation completes before emission, so this - * is one content chunk rather than token-by-token streaming.) + * Build the streamed Ollama {@code /api/generate} NDJSON: a single response line carrying the text + * followed by a terminating {@code "done":true} line with the stop reason and token counts. + * (Generation completes before emission, so this is one content chunk rather than token-by-token + * streaming.) * - * @param text the generated text + * @param generated the text, stop reason and token counts of the answer * @param model the model id to echo * @return the two NDJSON lines, concatenated (each with a trailing newline) */ - static String toOllamaGenerateStream(String text, String model) { + static String toOllamaGenerateStream(Generated generated, String model) { ObjectNode line = OBJECT_MAPPER.createObjectNode(); line.put("model", model); line.put("created_at", nowIso()); - line.put("response", text); + line.put("response", generated.text); line.put("done", false); ObjectNode done = OBJECT_MAPPER.createObjectNode(); done.put("model", model); done.put("created_at", nowIso()); done.put("response", ""); - done.put("done", true); - done.put("done_reason", "stop"); + generated.putDone(done); return line + "\n" + done + "\n"; } } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 4cbc3d9ef..5cc00e33a 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -566,6 +566,8 @@ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, Str // The done line reports the token counts, as the blocking answer does; they arrive in the // stream's usage chunk. final AtomicReference usage = new AtomicReference<>(); + // and the done_reason, which the chunk ending the answer carries + final AtomicReference finishReason = new AtomicReference<>(); try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { try { backend.stream(withUsageChunk(openAiRequest), chunkJson -> { @@ -574,12 +576,16 @@ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, Str if (chunkUsage != null) { usage.set(chunkUsage); } + String chunkFinishReason = OllamaApiSupport.finishReasonOf(chunkJson); + if (chunkFinishReason != null) { + finishReason.set(chunkFinishReason); + } String line = OllamaApiSupport.toOllamaContentLine(chunkJson, model); if (line != null) { out.writeStrict(line); } }); - out.writeStrict(OllamaApiSupport.toOllamaDoneLine(model, accumulator, usage.get())); + out.writeStrict(OllamaApiSupport.toOllamaDoneLine(model, accumulator, usage.get(), finishReason.get())); } catch (IllegalArgumentException | InvalidRequestException e) { out.writeQuietly(ollamaError(message(e)) + "\n"); } catch (IOException e) { @@ -600,13 +606,13 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { String model = request.path("model").asText(config.getModelId()); // Generation runs to completion first (there is no streaming raw-completion path), then the // text is wrapped — as a single NDJSON content line + done line when stream is requested. - final String text; + final OllamaApiSupport.Generated generated; try { if (OllamaApiSupport.hasSuffix(request)) { - text = OllamaApiSupport.extractInfillContent( + generated = OllamaApiSupport.Generated.fromInfill( backend.infill(OllamaApiSupport.toInfillRequest(request))); } else { - text = OllamaApiSupport.extractCompletionText( + generated = OllamaApiSupport.Generated.fromCompletion( backend.completions(OllamaApiSupport.toOpenAiCompletionRequest(request))); } } catch (IllegalArgumentException | InvalidRequestException e) { @@ -618,15 +624,15 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { return; } if (OllamaApiSupport.isStreaming(request)) { - byte[] bytes = - OllamaApiSupport.toOllamaGenerateStream(text, model).getBytes(StandardCharsets.UTF_8); + byte[] bytes = OllamaApiSupport.toOllamaGenerateStream(generated, model) + .getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_NDJSON); exchange.sendResponseHeaders(HTTP_OK, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } else { - sendJson(exchange, HTTP_OK, OllamaApiSupport.toOllamaGenerateResponse(text, model)); + sendJson(exchange, HTTP_OK, OllamaApiSupport.toOllamaGenerateResponse(generated, model)); } } finally { exchange.close(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 4caa7fa09..3ced022e6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -392,19 +392,36 @@ public void generateWithSuffixKeepsTheSamplingOptionsOfAPlainGenerate() throws I @Test public void generateTextExtractors() { - assertThat(OllamaApiSupport.extractCompletionText("{\"choices\":[{\"text\":\"hi\"}]}"), is("hi")); - assertThat(OllamaApiSupport.extractInfillContent("{\"content\":\"world\"}"), is("world")); - // Unexpected bodies yield empty text rather than throwing. - assertThat(OllamaApiSupport.extractCompletionText("not json"), is("")); + assertThat(OllamaApiSupport.Generated.fromCompletion("{\"choices\":[{\"text\":\"hi\"}]}").text, is("hi")); + assertThat(OllamaApiSupport.Generated.fromInfill("{\"content\":\"world\"}").text, is("world")); + // Unexpected bodies yield empty text and "stop" rather than throwing. + OllamaApiSupport.Generated broken = OllamaApiSupport.Generated.fromCompletion("not json"); + assertThat(broken.text, is("")); + assertThat(broken.doneReason, is("stop")); + assertThat(OllamaApiSupport.Generated.fromInfill("not json").doneReason, is("stop")); + } + + @Test + public void generateWithoutCountsOmitsThemAndToolCallsAreStop() throws IOException { + JsonNode out = read(OllamaApiSupport.toOllamaGenerateResponse( + OllamaApiSupport.Generated.fromCompletion("{\"choices\":[{\"text\":\"hi\"}]}"), "m")); + assertThat(out.has("prompt_eval_count"), is(false)); + assertThat(out.has("eval_count"), is(false)); + assertThat(OllamaApiSupport.toOllamaDoneReason("tool_calls"), is("stop")); + assertThat(OllamaApiSupport.toOllamaDoneReason(null), is("stop")); + assertThat(OllamaApiSupport.finishReasonOf("{\"choices\":[{\"finish_reason\":null}]}"), is((String) null)); + assertThat(OllamaApiSupport.finishReasonOf("not json"), is((String) null)); } @Test public void generateResponseAndStreamShapes() throws IOException { - JsonNode nonStream = read(OllamaApiSupport.toOllamaGenerateResponse("hello", "m")); + JsonNode nonStream = read(OllamaApiSupport.toOllamaGenerateResponse( + new OllamaApiSupport.Generated("hello", "stop", -1L, -1L), "m")); assertThat(nonStream.path("response").asText(), is("hello")); assertThat(nonStream.path("done").asBoolean(), is(true)); - String stream = OllamaApiSupport.toOllamaGenerateStream("hello", "m"); + String stream = + OllamaApiSupport.toOllamaGenerateStream(new OllamaApiSupport.Generated("hello", "stop", -1L, -1L), "m"); String[] lines = stream.trim().split("\n"); assertThat(lines.length, is(2)); JsonNode first = read(lines[0]); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 201ef6f2b..241e2a11c 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -28,6 +28,9 @@ */ public class OpenAiCompatServerHttpTest extends OpenAiServerTestSupport { + private static final com.fasterxml.jackson.databind.ObjectMapper READER = + new com.fasterxml.jackson.databind.ObjectMapper(); + private static final String CHAT_BODY = "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; private static OpenAiServerConfig config() { @@ -246,6 +249,58 @@ public void ollamaChatStreamingDoneLineCarriesTheTokenCountsLikeTheBlockingAnswe } } + @Test + public void ollamaChatDoneReasonIsLengthWhenCutOffAndStopForToolCalls() throws IOException { + // Ollama reports "length" for an answer cut off by num_predict and "stop" otherwise, tool calls + // included; the blocking path passed OpenAI's "tool_calls" through, the stream said "stop" always. + String body = "{\"model\":\"test-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]"; + for (String[] c : new String[][] {{"length", "length"}, {"tool_calls", "stop"}, {"stop", "stop"}}) { + try (OpenAiCompatServer server = new OpenAiCompatServer(new FinishReasonBackend(c[0]), config()).start()) { + JsonNode blocking = + READER.readTree(post(server.getPort(), "/api/chat", body + ",\"stream\":false}", "").body); + assertThat(c[0], blocking.path("done_reason").asText(), is(c[1])); + String[] lines = post(server.getPort(), "/api/chat", body + "}", "") + .body + .trim() + .split("\n"); + JsonNode done = READER.readTree(lines[lines.length - 1]); + assertThat(c[0], done.path("done").asBoolean(false), is(true)); + assertThat(c[0], done.path("done_reason").asText(), is(c[1])); + } + } + } + + @Test + public void ollamaGenerateDoneReasonAndCountsFollowTheCompletion() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new FinishReasonBackend("length"), config()).start()) { + String body = "{\"model\":\"test-model\",\"prompt\":\"once upon\""; + JsonNode blocking = + READER.readTree(post(server.getPort(), "/api/generate", body + ",\"stream\":false}", "").body); + assertThat(blocking.path("done_reason").asText(), is("length")); + assertThat(blocking.path("prompt_eval_count").asInt(-1), is(5)); + assertThat(blocking.path("eval_count").asInt(-1), is(2)); + String[] lines = post(server.getPort(), "/api/generate", body + ",\"stream\":true}", "") + .body + .trim() + .split("\n"); + JsonNode done = READER.readTree(lines[lines.length - 1]); + assertThat(done.path("done_reason").asText(), is("length")); + assertThat(done.path("eval_count").asInt(-1), is(2)); + } + } + + @Test + public void ollamaGenerateWithSuffixReadsTheInfillStopType() throws IOException { + // The native infill answer says why it stopped in stop_type: "limit" is Ollama's "length". + try (OpenAiCompatServer server = new OpenAiCompatServer(new FinishReasonBackend("length"), config()).start()) { + String body = "{\"model\":\"test-model\",\"prompt\":\"def f():\",\"suffix\":\"\",\"stream\":false}"; + JsonNode blocking = READER.readTree(post(server.getPort(), "/api/generate", body, "").body); + assertThat(blocking.path("done_reason").asText(), is("length")); + assertThat(blocking.path("prompt_eval_count").asInt(-1), is(4)); + assertThat(blocking.path("eval_count").asInt(-1), is(1)); + } + } + @Test public void aSubPathOfARouteIsNotHandledByTheRoute() throws IOException { // The JDK server matches contexts by prefix, so /v1/messages/count_tokens (Claude Code counts @@ -788,6 +843,44 @@ public String rerank(JsonNode request) { } } + /** Backend whose answers end with the given OpenAI finish reason (and a matching infill stop type). */ + static final class FinishReasonBackend extends FakeBackend { + private final String finishReason; + + FinishReasonBackend(String finishReason) { + this.finishReason = finishReason; + } + + @Override + public String complete(JsonNode request) { + return "{\"object\":\"chat.completion\",\"choices\":[{\"index\":0," + + "\"message\":{\"role\":\"assistant\",\"content\":\"hel\"},\"finish_reason\":\"" + + finishReason + "\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":3}}"; + } + + @Override + public void stream(JsonNode request, ChunkSink sink) throws IOException { + sink.accept("{\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}"); + sink.accept("{\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{},\"finish_reason\":\"" + + finishReason + "\"}]}"); + sink.accept("{\"object\":\"chat.completion.chunk\",\"choices\":[]," + + "\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":3}}"); + } + + @Override + public String completions(JsonNode request) { + return "{\"object\":\"text_completion\",\"choices\":[{\"text\":\"hel\",\"finish_reason\":\"" + finishReason + + "\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}"; + } + + @Override + public String infill(JsonNode request) { + return "{\"content\":\" w\",\"stop\":true,\"stop_type\":\"" + + ("length".equals(finishReason) ? "limit" : "eos") + + "\",\"tokens_evaluated\":4,\"tokens_predicted\":1}"; + } + } + /** * Backend whose every operation is rejected the way the JNI layer rejects a request llama.cpp's * validation refuses: an {@link InvalidRequestException} with the bare upstream reason. From e1d403bdb421b6f5230e11d94267d8cc9afa0157 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:39:36 +0000 Subject: [PATCH 316/589] Record the commit of F178 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a043284c5..38559102c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1312,7 +1312,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#ollamaChatDoneReasonIsLengthWhenCutOffAndStopForToolCalls` (blockierend und gestreamt, `length`/`tool_calls`/`stop`), `#ollamaGenerateDoneReasonAndCountsFollowTheCompletion` (blockierend und gestreamt), `#ollamaGenerateWithSuffixReadsTheInfillStopType`; `OllamaApiSupportTest#generateTextExtractors` (umgestellt), `#generateWithoutCountsOmitsThemAndToolCallsAreStop`. - **Rot-Grün-Nachweis:** ohne den Fix rot (3 HTTP-Tests, `was "stop"`), mit ihm grün (94 Tests der beiden Klassen; Pakete `server`, `json`, `value` 417 grün); SpotBugs grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `6eb8081` ## Offene Verdachte ohne Fund-ID From de6a59396ef56f536ba1d60b66edbd62daddcc7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:39:58 +0000 Subject: [PATCH 317/589] Close the Continue FIM-template suspicion as a deliberate non-change Continue's Ollama provider decides FIM support from {{ .Suffix }} in the template /api/show returns, which this server leaves empty. A placeholder would be an invented template, wrong for most models; Continue falls back to its own prompt templates, and POST /infill serves the model's own FIM tokens. The README says so; the suspicion is closed in the ledger and removed from TODO.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- README.md | 4 ++++ TODO.md | 2 -- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 38559102c..9a35bfc74 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1320,6 +1320,6 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). -- *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells daran, ob das `template` aus `/api/show` die Variable `.Suffix` enthält (`Ollama.ts`, „There is no API to get the model's FIM capabilities“). `showJson` liefert `template: ""`, meldet aber `insert` unter `capabilities` und bedient `suffix` in `/api/generate`; Continue nutzt daher für Autovervollständigung seine eigenen Prompt-Vorlagen statt des FIM-Wegs. Ein Platzhalter-Template mit `{{ .Suffix }}` würde das ändern, wäre aber eine erfundene Vorlage, und der Server weiß nicht, ob das Modell FIM-Token hat. Kein Fix ohne Entscheidung. +- *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). - *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* java-llama.cpp `server/OllamaApiSupport.java:481` (`toOpenAiCompletionRequest`): Ollamas `/api/generate` wendet ohne `"raw": true` die Chat-Vorlage des Modells an (und eine `system`-Angabe); erst `raw: true` schickt den Prompt unverändert. Unsere Route liest weder `raw` noch `system` und macht aus jedem `/api/generate` ohne `suffix` eine rohe Vervollständigung. Continue (`Ollama.ts` `_getGenerateOptions`) setzt `raw: options.raw`, also nur, wenn der Aufrufer es verlangt – für Nicht-Autocomplete-Aufrufe bekommt ein Instruktionsmodell dann einen Text ohne Rollenmarker und antwortet als Fortsetzung statt als Assistent; `system` geht ganz verloren. Ein Fix hieße, nicht-rohe Anfragen über den Chat-Pfad zu schicken (`messages` aus `system` + `prompt`) – eine Verhaltensänderung der Route, die README und Javadoc als „prompt completion / FIM“ beschreiben, daher Entscheidung des Nutzers. diff --git a/README.md b/README.md index a2dc564bc..397eb95a0 100644 --- a/README.md +++ b/README.md @@ -987,6 +987,10 @@ Every route is also reachable **without the `/v1` prefix**, the server answers * (llama.vscode, Twinny, Tabby, Continue's `llama.cpp` provider). Note: GitHub Copilot's **inline** completions cannot be served by any local endpoint — only its chat/agent surfaces — so use one of those autocomplete plugins for ghost text. +Continue's **Ollama** provider decides FIM support from a `{{ .Suffix }}` in the model's `template` +(`/api/show`), which this server leaves empty — it does not know the model's prompt format, and an +invented template would be wrong for most models. Continue then uses its own prompt templates for +autocomplete; for the model's own FIM tokens point it at `POST /infill` (its `llama.cpp` provider). **Alternative protocol surfaces.** For clients that don't speak OpenAI Chat Completions, the same model is exposed through additional protocols (pure translation over the OpenAI core — no extra inference diff --git a/TODO.md b/TODO.md index 1f0973059..25a7e2c2a 100644 --- a/TODO.md +++ b/TODO.md @@ -528,8 +528,6 @@ numbered for reference, the numbers stay when an item is done): gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep the remaining fields. - **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. -- **(13)** Continue decides FIM support from `.Suffix` in `/api/show`'s `template`; ours is `""`. A - placeholder template would be invented. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. - **(16)** Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat From f12d970899a732af13bb1ab06121381290f93f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:46:31 +0000 Subject: [PATCH 318/589] Apply the chat template in Ollama /api/generate unless raw is true (F179) Ollama's /api/generate runs the prompt through the model's chat template, with "system" as the system message, unless "raw" is true. Ours always completed the bare prompt, so an instruct model continued the text instead of answering, and system, images, format and think were dropped. A request without raw and without suffix now goes through the chat translation /api/chat uses and reports the answer's content, reasoning, done_reason and token counts; raw keeps the bare completion and a suffix keeps fill-in-the-middle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++- README.md | 2 +- TODO.md | 2 - .../llama/server/OllamaApiSupport.java | 90 ++++++++++++++++++- .../llama/server/OpenAiCompatServer.java | 11 ++- .../ladenthin/llama/server/package-info.java | 3 +- .../server/OpenAiCompatServerHttpTest.java | 65 +++++++++++++- ...OpenAiServerCompletionIntegrationTest.java | 18 +++- 8 files changed, 190 insertions(+), 14 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9a35bfc74..111a87896 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1314,6 +1314,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6eb8081` +### F179 – java-llama.cpp: Ollama `/api/generate` liest weder `raw` noch `system` und vervollständigt immer roh +- **Zeit:** 2026-10-19 23:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`isRaw`, `toOpenAiChatRequestFromGenerate`, `Generated.fromChat`), `OpenAiCompatServer.java` (`handleOllamaGenerate`) +- **Schwere:** niedrig (still: ein Instruct-Modell setzt den Text fort statt zu antworten, `system` und Bilder gingen verloren) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (16)). Die Route war seit ihrer Einführung „prompt completion / FIM“. Ollama wendet ohne `"raw": true` die Chat-Vorlage des Modells an, mit `system` als Systemnachricht; Continue (`Ollama.ts`) setzt `raw` nur, wenn der Aufrufer es verlangt. +- **Beschreibung:** Jede Anfrage ohne `suffix` ging als roher Prompt an `/v1/completions`; `system`, `images`, `format` und `think` wurden ignoriert. +- **Fix:** Entscheidung des Nutzers: Ollamas Semantik. Mit `suffix` wie bisher Infill, mit `raw: true` wie bisher die rohe Completion (`system` ignoriert, wie bei Ollama), sonst der Chat-Weg: aus der Anfrage wird eine `/api/chat`-Anfrage (`system` + Nutzer-`prompt` mit `images`, dazu `options`, `format`, `think`) und über die vorhandene Übersetzung geschickt; die Antwort trägt `response`, `thinking` (aus `reasoning_content`), `done_reason` und die Zählungen. README und `package-info` beschreiben die drei Wege; der modellgestützte `OpenAiServerCompletionIntegrationTest` bekam eine `raw`-Variante. +- **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama` (Aufzeichnungs-Backend: Chat-Weg, `system`, Bild, `thinking`, Zählung), `#ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawRoundTrip` (Modell, CI). +- **Rot-Grün-Nachweis:** ohne den Fix rot (`was "completions"`), mit ihm grün (96 Tests); der F178-Test gab nun explizit `raw: true` an, weil er die Completion-Abbildung prüft; SpotBugs grün. Die modellgestützte Variante läuft nur in CI bzw. beim lokalen Agenten. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. @@ -1322,4 +1334,3 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). -- *(niedrig, nur beschrieben, Entscheidung des Nutzers, 2026-10-18)* java-llama.cpp `server/OllamaApiSupport.java:481` (`toOpenAiCompletionRequest`): Ollamas `/api/generate` wendet ohne `"raw": true` die Chat-Vorlage des Modells an (und eine `system`-Angabe); erst `raw: true` schickt den Prompt unverändert. Unsere Route liest weder `raw` noch `system` und macht aus jedem `/api/generate` ohne `suffix` eine rohe Vervollständigung. Continue (`Ollama.ts` `_getGenerateOptions`) setzt `raw: options.raw`, also nur, wenn der Aufrufer es verlangt – für Nicht-Autocomplete-Aufrufe bekommt ein Instruktionsmodell dann einen Text ohne Rollenmarker und antwortet als Fortsetzung statt als Assistent; `system` geht ganz verloren. Ein Fix hieße, nicht-rohe Anfragen über den Chat-Pfad zu schicken (`messages` aus `system` + `prompt`) – eine Verhaltensänderung der Route, die README und Javadoc als „prompt completion / FIM“ beschreiben, daher Entscheidung des Nutzers. diff --git a/README.md b/README.md index 397eb95a0..301c042dc 100644 --- a/README.md +++ b/README.md @@ -998,7 +998,7 @@ path), all supporting tools and streaming: | Surface | Routes | For | |---|---|---| -| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (prompt completion / FIM) | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | +| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does; the bare prompt with `raw: true`; FIM with `suffix`) | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | | **Anthropic Messages** | `POST /v1/messages` (SSE event stream) | Claude-shaped clients (Claude Code); Copilot `messages` apiType | | **OpenAI Responses** | `POST /v1/responses` (SSE event stream) | Copilot `responses` apiType; Responses-API clients | diff --git a/TODO.md b/TODO.md index 25a7e2c2a..e6805f2f9 100644 --- a/TODO.md +++ b/TODO.md @@ -530,8 +530,6 @@ numbered for reference, the numbers stay when an item is done): - **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. -- **(16)** Ollama `/api/generate` ignores `raw` and `system`: without `raw: true` Ollama applies the chat - template, ours always completes raw. ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index f4df2cb3b..8d2d86476 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -26,6 +26,9 @@ */ final class OllamaApiSupport { + /** The fields of an {@code /api/generate} request that {@code /api/chat} carries the same way. */ + private static final String[] GENERATE_CHAT_FIELDS = {"model", "options", "format", "think"}; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** @@ -520,9 +523,50 @@ static boolean hasSuffix(JsonNode request) { return request.path("suffix").isTextual(); } + /** + * Whether the {@code /api/generate} request asks for the bare prompt ({@code "raw": true}). Without + * it Ollama applies the model's chat template, with {@code system} as the system message. + * + * @param request the parsed Ollama generate request + * @return {@code true} if {@code raw} is {@code true} + */ + static boolean isRaw(JsonNode request) { + return request.path("raw").asBoolean(false); + } + + /** + * Translate an Ollama {@code /api/generate} request without {@code raw} into the internal OpenAI chat + * request, as Ollama applies the chat template: {@code system} (when given) as the system message, + * {@code prompt} with its {@code images} as the user message, and {@code options}, {@code format} + * and {@code think} as {@code /api/chat} carries them. + * + * @param request the parsed Ollama generate request + * @return an OpenAI {@code /v1/chat/completions} request object + */ + static ObjectNode toOpenAiChatRequestFromGenerate(JsonNode request) { + ObjectNode chat = OBJECT_MAPPER.createObjectNode(); + for (String field : GENERATE_CHAT_FIELDS) { + if (request.has(field)) { + chat.set(field, request.get(field).deepCopy()); + } + } + ArrayNode messages = chat.putArray("messages"); + String system = request.path("system").asText(""); + if (!system.isEmpty()) { + messages.addObject().put("role", "system").put("content", system); + } + ObjectNode user = messages.addObject() + .put("role", "user") + .put("content", request.path("prompt").asText("")); + if (request.path("images").isArray()) { + user.set("images", request.path("images").deepCopy()); + } + return toOpenAiChatRequest(chat); + } + /** * Translate an Ollama {@code /api/generate} request into the internal OpenAI {@code /v1/completions} - * request shape ({@code prompt} + sampling). Used when there is no {@code suffix}. + * request shape ({@code prompt} + sampling). Used for {@code "raw": true} without {@code suffix}. * * @param request the parsed Ollama generate request * @return an OpenAI completion request object @@ -573,6 +617,9 @@ static ObjectNode toInfillRequest(JsonNode request) { */ static final class Generated { final String text; + /** The model's reasoning, or {@code ""} when it gave none. */ + final String thinking; + final String doneReason; /** {@code prompt_eval_count}, or {@code -1} when the answer has none. */ final long promptEvalCount; @@ -580,7 +627,12 @@ static final class Generated { final long evalCount; Generated(String text, String doneReason, long promptEvalCount, long evalCount) { + this(text, "", doneReason, promptEvalCount, evalCount); + } + + Generated(String text, String thinking, String doneReason, long promptEvalCount, long evalCount) { this.text = text; + this.thinking = thinking; this.doneReason = doneReason; this.promptEvalCount = promptEvalCount; this.evalCount = evalCount; @@ -608,6 +660,38 @@ static Generated fromCompletion(String openAiCompletionJson) { } } + /** + * From an OpenAI chat answer (the {@code /api/generate} request without {@code raw}): the + * message's {@code content} and {@code reasoning_content}, its {@code finish_reason} and + * {@code usage}. + * + * @param openAiChatJson the OpenAI chat completion answer + * @return what the generate answer reports; an empty {@code "stop"} on an unexpected body + */ + static Generated fromChat(String openAiChatJson) { + try { + JsonNode completion = OBJECT_MAPPER.readTree(openAiChatJson); + JsonNode choice = completion.path("choices").path(0); + JsonNode usage = completion.path("usage"); + return new Generated( + choice.path("message").path("content").asText(""), + choice.path("message").path("reasoning_content").asText(""), + toOllamaDoneReason(choice.path("finish_reason").asText("")), + usage.path("prompt_tokens").asLong(-1L), + usage.path("completion_tokens").asLong(-1L)); + } catch (IOException e) { + return new Generated("", "stop", -1L, -1L); + } + } + + /** Put the text and, when there is one, the reasoning into an Ollama generate line. */ + private void putText(ObjectNode line) { + line.put("response", text); + if (!thinking.isEmpty()) { + line.put("thinking", thinking); + } + } + /** * From a native {@code /infill} answer: {@code content}, its {@code stop_type} ({@code "limit"} * is a cut-off answer) and {@code tokens_evaluated} / {@code tokens_predicted}. @@ -651,7 +735,7 @@ static String toOllamaGenerateResponse(Generated generated, String model) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("model", model); root.put("created_at", nowIso()); - root.put("response", generated.text); + generated.putText(root); generated.putDone(root); return root.toString(); } @@ -670,7 +754,7 @@ static String toOllamaGenerateStream(Generated generated, String model) { ObjectNode line = OBJECT_MAPPER.createObjectNode(); line.put("model", model); line.put("created_at", nowIso()); - line.put("response", generated.text); + generated.putText(line); line.put("done", false); ObjectNode done = OBJECT_MAPPER.createObjectNode(); done.put("model", model); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 5cc00e33a..63cec5162 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -604,16 +604,21 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { return; } String model = request.path("model").asText(config.getModelId()); - // Generation runs to completion first (there is no streaming raw-completion path), then the - // text is wrapped — as a single NDJSON content line + done line when stream is requested. + // Generation runs to completion first, then the text is wrapped -- as a single NDJSON content + // line + done line when stream is requested. A suffix is fill-in-the-middle, raw the bare + // prompt, anything else goes through the chat template as Ollama does. final OllamaApiSupport.Generated generated; try { if (OllamaApiSupport.hasSuffix(request)) { generated = OllamaApiSupport.Generated.fromInfill( backend.infill(OllamaApiSupport.toInfillRequest(request))); - } else { + } else if (OllamaApiSupport.isRaw(request)) { generated = OllamaApiSupport.Generated.fromCompletion( backend.completions(OllamaApiSupport.toOpenAiCompletionRequest(request))); + } else { + // Ollama applies the chat template unless raw is true + generated = OllamaApiSupport.Generated.fromChat( + backend.complete(OllamaApiSupport.toOpenAiChatRequestFromGenerate(request))); } } catch (IllegalArgumentException | InvalidRequestException e) { sendJson(exchange, HTTP_BAD_REQUEST, ollamaError(message(e))); diff --git a/llama/src/main/java/net/ladenthin/llama/server/package-info.java b/llama/src/main/java/net/ladenthin/llama/server/package-info.java index 34eccd95b..148ba1102 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/package-info.java +++ b/llama/src/main/java/net/ladenthin/llama/server/package-info.java @@ -42,7 +42,8 @@ *

    *
  • Ollama-native ({@code GET /api/version}, {@code GET /api/tags}, * {@code POST /api/show}, {@code POST /api/chat} with NDJSON streaming, {@code POST /api/generate} - * for prompt completion / fill-in-the-middle) — for Copilot's built-in Ollama provider; see + * through the chat template, as the bare prompt with {@code raw}, or fill-in-the-middle with a + * {@code suffix}) — for Copilot's built-in Ollama provider; see * {@link net.ladenthin.llama.server.OllamaApiSupport}.
  • *
  • Anthropic Messages ({@code POST /v1/messages}, SSE event stream) — see * {@link net.ladenthin.llama.server.AnthropicApiSupport} / diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 241e2a11c..f59959a18 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -273,7 +273,8 @@ public void ollamaChatDoneReasonIsLengthWhenCutOffAndStopForToolCalls() throws I @Test public void ollamaGenerateDoneReasonAndCountsFollowTheCompletion() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FinishReasonBackend("length"), config()).start()) { - String body = "{\"model\":\"test-model\",\"prompt\":\"once upon\""; + // raw: the bare prompt goes to the completion endpoint + String body = "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"raw\":true"; JsonNode blocking = READER.readTree(post(server.getPort(), "/api/generate", body + ",\"stream\":false}", "").body); assertThat(blocking.path("done_reason").asText(), is("length")); @@ -425,6 +426,46 @@ public void ollamaGenerateNonStreamingReturnsResponse() throws IOException { } } + @Test + public void ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama() throws IOException { + // Ollama's /api/generate applies the model's chat template (with "system" as the system + // message) unless "raw" is true; ours always completed the bare prompt, so an instruct model + // continued the text instead of answering, and "system" was lost. + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String body = "{\"model\":\"test-model\",\"prompt\":\"why?\",\"system\":\"be brief\"," + + "\"images\":[\"AAAA\"],\"stream\":false}"; + JsonNode out = READER.readTree(post(server.getPort(), "/api/generate", body, "").body); + + assertThat(backend.called, is("complete")); + JsonNode messages = backend.request.path("messages"); + assertThat(messages.size(), is(2)); + assertThat(messages.path(0).path("role").asText(), is("system")); + assertThat(messages.path(0).path("content").asText(), is("be brief")); + assertThat(messages.path(1).path("role").asText(), is("user")); + assertThat(messages.path(1).path("content").path(0).path("text").asText(), is("why?")); + assertThat(messages.path(1).path("content").path(1).path("type").asText(), is("image_url")); + assertThat(out.path("response").asText(), is("because")); + assertThat(out.path("thinking").asText(), is("hmm")); + assertThat(out.path("eval_count").asInt(-1), is(3)); + } + } + + @Test + public void ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String body = "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"system\":\"x\"," + + "\"raw\":true,\"stream\":false}"; + JsonNode out = READER.readTree(post(server.getPort(), "/api/generate", body, "").body); + + assertThat(backend.called, is("completions")); + assertThat(backend.request.path("prompt").asText(), is("once upon")); + assertThat(backend.request.has("messages"), is(false)); + assertThat(out.path("response").asText(), is("hello")); + } + } + @Test public void ollamaGenerateWithSuffixUsesInfill() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { @@ -843,6 +884,28 @@ public String rerank(JsonNode request) { } } + /** Backend that records which operation a request reached and with which body. */ + static final class RecordingBackend extends FakeBackend { + volatile String called = ""; + volatile JsonNode request = READER.createObjectNode(); + + @Override + public String complete(JsonNode request) { + this.called = "complete"; + this.request = request; + return "{\"object\":\"chat.completion\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\"," + + "\"message\":{\"role\":\"assistant\",\"content\":\"because\",\"reasoning_content\":\"hmm\"}}]," + + "\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":3}}"; + } + + @Override + public String completions(JsonNode request) { + this.called = "completions"; + this.request = request; + return super.completions(request); + } + } + /** Backend whose answers end with the given OpenAI finish reason (and a matching infill stop type). */ static final class FinishReasonBackend extends FakeBackend { private final String finishReason; diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java index de607bd1a..63eb58c26 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java @@ -21,8 +21,8 @@ /** * End-to-end integration test for the completion-family routes — {@code POST /v1/completions}, - * {@code POST /infill} (fill-in-the-middle) and the Ollama {@code POST /api/generate} (plain + FIM via a - * {@code suffix}) — against a real model over a real socket. Reuses the CI text model (CodeLlama-7B, + * {@code POST /infill} (fill-in-the-middle) and the Ollama {@code POST /api/generate} (chat template, + * {@code raw} and FIM via a {@code suffix}) — against a real model over a real socket. Reuses the CI text model (CodeLlama-7B, * {@link TestConstants#MODEL_PATH}), which is FIM-capable (see {@code LlamaModelTest#testGenerateInfill}). * Self-skips when the model file is absent. Assertions are structural (valid response envelopes) rather * than value-specific. HTTP plumbing is inherited from {@link OpenAiServerTestSupport}. @@ -102,6 +102,20 @@ public void ollamaGenerateNonStreamingRoundTrip() throws IOException { assertThat(json.path("done").asBoolean(), is(true)); } + @Test + public void ollamaGenerateRawRoundTrip() throws IOException { + // "raw": true sends the bare prompt to the completion endpoint; without it the request goes + // through the chat template, as Ollama does (the test above). + String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":false,\"raw\":true," + + "\"prompt\":\"def add(a, b):\\n return\",\"options\":{\"num_predict\":16}}"; + Response response = post(port, "/api/generate", body, ""); + assertThat(response.code, is(200)); + JsonNode json = MAPPER.readTree(response.body); + assertThat(json.path("response").isTextual(), is(true)); + assertThat(json.path("done").asBoolean(), is(true)); + assertThat(json.path("eval_count").asInt(-1) > 0, is(true)); + } + @Test public void ollamaGenerateWithSuffixUsesInfill() throws IOException { String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":false," From 4545a1bec58c171550a653e6ddf5bbcd16a0cabd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:46:31 +0000 Subject: [PATCH 319/589] Record the commit of F179 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 111a87896..f1c5f1d6f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1324,7 +1324,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama` (Aufzeichnungs-Backend: Chat-Weg, `system`, Bild, `thinking`, Zählung), `#ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawRoundTrip` (Modell, CI). - **Rot-Grün-Nachweis:** ohne den Fix rot (`was "completions"`), mit ihm grün (96 Tests); der F178-Test gab nun explizit `raw: true` an, weil er die Completion-Abbildung prüft; SpotBugs grün. Die modellgestützte Variante läuft nur in CI bzw. beim lokalen Agenten. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `75b3767` ## Offene Verdachte ohne Fund-ID From cceaaa01cfc4ee6c20957f6b0ff9665ae5c0293a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:53:24 +0000 Subject: [PATCH 320/589] Forward logit_bias and n_probs, refuse n > 1 in the OpenAI mappers (F180) Upstream's request schema reads logit_bias (pairs or OpenAI's object form), n_probs and n (as n_cmpl); the chat mapper and the streamed completion mapper dropped all three, so a banned token was generated anyway and n: 3 got one answer. logit_bias goes through verbatim via the new InferenceParameters.withLogitBiasJson, n_probs via withNProbs, and n greater than 1 is an invalid request because these paths return one answer. The streamed /v1/completions builds its chunks from the generated text and carries no probabilities, so logprobs/n_probs are refused there with a pointer to stream: false. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +++++- TODO.md | 7 ++- .../llama/parameters/InferenceParameters.java | 14 ++++++ .../llama/server/OpenAiRequestMapper.java | 29 +++++++++++++ .../parameters/InferenceParametersTest.java | 18 ++++++++ .../llama/server/OpenAiRequestMapperTest.java | 43 ++++++++++++++++++- 6 files changed, 119 insertions(+), 6 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f1c5f1d6f..42b86bdbf 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1326,11 +1326,23 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `75b3767` +### F180 – java-llama.cpp: OpenAI-Mapper verwerfen `logit_bias`, `n_probs` und `n` still +- **Zeit:** 2026-10-20 00:10 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyCommonFields`, `toCompletionParameters`), `parameters/InferenceParameters.java` (`withLogitBiasJson`) +- **Schwere:** mittel (still: ein gesperrtes Token wurde trotzdem erzeugt, `n: 3` bekam eine Antwort, Wahrscheinlichkeiten fehlten) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (10)), teilweise durch F95/F123 behoben (Sampling-Felder, `logprobs`/`top_logprobs` des Chat-Wegs). Upstreams Schema liest `logit_bias` als Paar-Array oder als OpenAI-Objekt (`server-schema.cpp:382`, b11639), `n_probs` direkt und `n` als Alias von `n_cmpl` (`:62`). +- **Beschreibung:** Chat (blockierend und gestreamt, und damit die Anthropic-, Responses- und Ollama-Übersetzungen) und das gestreamte `/v1/completions` ignorierten `logit_bias`, `n_probs` und `n`. Ein früherer Test (`unknownFieldsIgnored`) schrieb das Ignorieren von `"n":3` sogar fest. Das gestreamte `/v1/completions` baut seine Chunks aus dem erzeugten Text und kann keine Wahrscheinlichkeiten tragen. +- **Fix:** Entscheidung des Nutzers: `logit_bias` (Objekt oder Array) wird unverändert über die neue öffentliche `InferenceParameters.withLogitBiasJson` weitergereicht (ein JSON-Wert, wie die anderen Fragment-Setter geprüft), `n_probs` über `withNProbs`; `n > 1` ist eine ungültige Anfrage (400), weil diese Wege eine Antwort liefern; auf dem gestreamten `/v1/completions` sind `logprobs`/`n_probs` eine ungültige Anfrage mit dem Hinweis auf `stream: false`, bis (14) den Stream nativ macht. +- **Tests:** `OpenAiRequestMapperTest#logitBiasAndNProbsAreForwardedOnBothPaths`, `#moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack`, `#probabilitiesOnTheStreamedCompletionAreRefused`, `#unknownFieldsIgnored` (ohne `n`); `InferenceParametersTest#withLogitBiasJson_storesTheValueAndRefusesASecondOne`. +- **Rot-Grün-Nachweis:** mit Platzhalter-Setter und unverändertem Mapper rot (4 Tests), mit dem Fix grün (327 Tests der Mapper-, Parameter-, Registry- und Server-Klassen); SpotBugs grün. +- **Status:** gefixt (Wahrscheinlichkeiten im gestreamten `/v1/completions` offen unter (14)) +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. -- *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. +- *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). diff --git a/TODO.md b/TODO.md index e6805f2f9..4362366c0 100644 --- a/TODO.md +++ b/TODO.md @@ -523,13 +523,12 @@ numbered for reference, the numbers stay when an item is done): - **(6)** JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. -- **(10)** Request fields still dropped (partly fixed by F95/F123): `logit_bias`, `n_probs`, and `n` - (alias of `n_cmpl` upstream; Java has no `RequestField` for it, a request with `n: 3` silently - gets one answer). The streamed `/v1/completions` needs a raw-JSON streaming entry point to keep - the remaining fields. - **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. + Since F180 `logprobs`/`n_probs` are refused on it too (400, pointing to `stream: false`), because + its chunks are built from the generated text; streaming through the native completion stream with + the raw body would carry them, the remaining fields and `usage`. ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java index 8b5d0478e..4c590d531 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java @@ -459,6 +459,20 @@ public InferenceParameters withIgnoreEos(boolean ignoreEos) { return withScalar(RequestField.IGNORE_EOS, ignoreEos); } + /** + * Returns a new request with the logit bias replaced by a pre-built JSON value, as the request + * schema reads it: an array of {@code [token, bias]} pairs or an object mapping a token to its bias + * (OpenAI's form, token ids as keys); a token may be an id or a string, a bias of {@code false} bans + * it. Overrides prior logit-bias setters. + * + * @param logitBiasJson exactly one JSON array or object + * @return a new instance; this instance is unchanged + * @throws IllegalArgumentException if {@code logitBiasJson} is not exactly one JSON value + */ + public InferenceParameters withLogitBiasJson(String logitBiasJson) { + return withRaw(RequestField.LOGIT_BIAS, logitBiasJson); + } + /** * Returns a new request with the logit bias (token-id form) replaced. Empty input is a * no-op (returns {@code this}). This entry overrides any prior logit-bias setter. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 20548dd10..86e8e91ca 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -38,6 +38,10 @@ final class OpenAiRequestMapper { /** Upstream's {@code n_probs} for a chat request with {@code logprobs} but no {@code top_logprobs}. */ private static final int DEFAULT_TOP_LOGPROBS = 20; + /** Completion parameters asking for token probabilities, which the streamed completion cannot carry. */ + private static final List COMPLETION_PROBABILITY_PARAMS = + Collections.unmodifiableList(Arrays.asList("logprobs", "n_probs")); + /** Completion parameters OpenAI defines that upstream rejects ({@code oaicompat_completion_params_parse}). */ private static final List UNSUPPORTED_COMPLETION_PARAMS = Collections.unmodifiableList(Arrays.asList("best_of", "suffix")); @@ -117,6 +121,15 @@ InferenceParameters toCompletionParameters(JsonNode request) { throw new IllegalArgumentException("Unsupported param: " + unsupported); } } + // The streamed completion is built from the generated text and carries no probabilities, so a + // request for them would get none; the non-streamed path forwards to upstream, which has them. + for (String probabilities : COMPLETION_PROBABILITY_PARAMS) { + JsonNode value = request.path(probabilities); + if (!value.isMissingNode() && !value.isNull() && !(value.isBoolean() && !value.asBoolean())) { + throw new IllegalArgumentException( + "'" + probabilities + "' is not supported with stream: true; use stream: false"); + } + } InferenceParameters params = InferenceParameters.empty().withPrompt(prompt.asText()).withCachePrompt(true); return applyCommonFields(params, request); @@ -132,6 +145,22 @@ InferenceParameters toCompletionParameters(JsonNode request) { * @return a new instance with the recognised fields applied */ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNode request) { + // Upstream reads "n" as n_cmpl (several answers); these paths return one, so more is refused + // rather than silently answered once. + JsonNode choices = request.path("n"); + if (choices.isNumber() && choices.asInt() > 1) { + throw new IllegalArgumentException( + "'n' greater than 1 is not supported (one answer is returned) but was " + choices.asInt()); + } + // logit_bias as upstream's schema reads it: [token, bias] pairs or OpenAI's object form. + JsonNode logitBias = request.path("logit_bias"); + if (logitBias.isObject() || logitBias.isArray()) { + params = params.withLogitBiasJson(logitBias.toString()); + } + JsonNode nProbs = request.path("n_probs"); + if (nProbs.isInt()) { + params = params.withNProbs(nProbs.asInt()); + } // Preserve llama.cpp extensions when advanced clients opt into them. JsonNode cachePrompt = request.path("cache_prompt"); if (cachePrompt.isBoolean()) { diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java index f478b2a71..e75b1fc9c 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java @@ -939,4 +939,22 @@ public void testDryWithersReturnNewInstance() { assertThat(params.withDryPenaltyLastN(512), is(not(sameInstance(params)))); assertThat(params.withDrySequenceBreakers("\n"), is(not(sameInstance(params)))); } + + @Test + public void withLogitBiasJson_storesTheValueAndRefusesASecondOne() throws Exception { + String json = InferenceParameters.empty() + .withLogitBiasJson("{\"15043\":-100}") + .toJson(); + assertThat( + new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(json) + .path("logit_bias") + .path("15043") + .asInt(), + is(-100)); + // one JSON value only: a sibling field cannot be injected through the fragment + assertThrows( + IllegalArgumentException.class, + () -> InferenceParameters.empty().withLogitBiasJson("{}, \"n_predict\": 999999")); + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index d534a71f8..6e492021b 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -55,6 +55,46 @@ public void completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo() thr is("p")); } + @Test + public void logitBiasAndNProbsAreForwardedOnBothPaths() throws IOException { + // Upstream reads logit_bias (OpenAI's object form included) and n_probs from the request; both + // mappers dropped them, so a client's banned token was generated anyway. + JsonNode chat = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]," + + "\"logit_bias\":{\"15043\":-100,\"2\":false},\"n_probs\":3}"); + assertThat(chat.path("logit_bias").path("15043").asInt(), is(-100)); + assertThat(chat.path("logit_bias").path("2").asBoolean(true), is(false)); + assertThat(chat.path("n_probs").asInt(), is(3)); + + JsonNode completion = MAPPER.readTree( + mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"logit_bias\":[[7,1.5]]}")) + .toJson()); + assertThat(completion.path("logit_bias").path(0).path(1).asDouble(), closeTo(1.5, 1e-9)); + } + + @Test + public void moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack() throws IOException { + // Upstream reads "n" as n_cmpl; these paths return one answer, so n > 1 silently gave one. + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"n\":2}")); + assertThrows( + IllegalArgumentException.class, + () -> mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\",\"n\":3}"))); + assertDoesNotThrow(() -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"n\":1}")); + } + + @Test + public void probabilitiesOnTheStreamedCompletionAreRefused() throws IOException { + // The streamed /v1/completions builds its chunks from the generated text and carries no + // probabilities; asking for them got none, silently. stream:false forwards to upstream, which has them. + for (String field : new String[] {"\"logprobs\":2", "\"n_probs\":2"}) { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> mapper.toCompletionParameters(MAPPER.readTree("{\"prompt\":\"p\"," + field + "}"))); + assertThat(field, e.getMessage().contains("stream"), is(true)); + } + } + @Test public void chatTemplateKwargsAreForwarded() throws IOException { // Qwen3 clients switch thinking off with chat_template_kwargs; upstream's chat parser merges the @@ -350,7 +390,8 @@ public void jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven() throws @Test public void unknownFieldsIgnored() throws IOException { JsonNode out = mapAndSerialize( - "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + "\"some_future_field\":true,\"n\":3}"); + // "n" is not unknown: more than one answer is refused (moreThanOneChoiceIsRefused...) + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + "\"some_future_field\":true}"); assertThat(out.path("messages").isArray(), is(true)); assertThat(out.has("some_future_field"), is(false)); } From ab7c41a785426b98c818ffda37c3d13ab7768720 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:53:24 +0000 Subject: [PATCH 321/589] Record the commit of F180 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 42b86bdbf..a0b46726c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1336,7 +1336,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#logitBiasAndNProbsAreForwardedOnBothPaths`, `#moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack`, `#probabilitiesOnTheStreamedCompletionAreRefused`, `#unknownFieldsIgnored` (ohne `n`); `InferenceParametersTest#withLogitBiasJson_storesTheValueAndRefusesASecondOne`. - **Rot-Grün-Nachweis:** mit Platzhalter-Setter und unverändertem Mapper rot (4 Tests), mit dem Fix grün (327 Tests der Mapper-, Parameter-, Registry- und Server-Klassen); SpotBugs grün. - **Status:** gefixt (Wahrscheinlichkeiten im gestreamten `/v1/completions` offen unter (14)) -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `3d001d2` ## Offene Verdachte ohne Fund-ID From 50b45f8f5640bb372caaf59ca481b93a95794762 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:06:54 +0000 Subject: [PATCH 322/589] Hand error messages to Java as standard UTF-8, not Modified UTF-8 (F181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThrowNew and NewStringUTF read Modified UTF-8, but llama.cpp's messages quote paths and request text in standard UTF-8. On HotSpot 21 a trainer message for "missing-😀.gguf" came back as "missing-" plus four wrong characters and ".g"; Android's CheckJNI aborts on the same input. jni_utf8.hpp (jni.h only, no cached ids) keeps ASCII on the one-call path and sends anything else as bytes through String(byte[], "UTF-8"). The C API status, the model load, the JNI exception boundary, NativeServer, RpcServer and LlamaTrainer's return text use it. The trainer's input stays on GetStringUTFChars: Jackson escapes supplementary characters, so the config is ASCII where the encodings differ, and LlamaTrainerTest pins that. Tests: six JniUtf8 cases in test_jni_helpers.cpp (red 4/6 with pass-through placeholders, 640/640 with the fix) and LlamaTrainerTest (red against the old library, green against the rebuilt one). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +- CHANGELOG.md | 7 + CLAUDE.md | 9 +- TODO.md | 3 - llama/src/main/cpp/jllama.cpp | 6 +- llama/src/main/cpp/jni_helpers.hpp | 3 +- llama/src/main/cpp/jni_utf8.hpp | 85 +++++++++ llama/src/main/cpp/native_server.cpp | 2 +- llama/src/main/cpp/rpc_bridge.cpp | 2 +- llama/src/main/cpp/train_engine.cpp | 7 +- llama/src/test/cpp/test_jni_helpers.cpp | 169 ++++++++++++++++++ .../net/ladenthin/llama/LlamaTrainerTest.java | 78 ++++++++ 12 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 llama/src/main/cpp/jni_utf8.hpp create mode 100644 llama/src/test/java/net/ladenthin/llama/LlamaTrainerTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a0b46726c..f00bfaf93 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1338,10 +1338,21 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt (Wahrscheinlichkeiten im gestreamten `/v1/completions` offen unter (14)) - **Commit:** java-llama.cpp `3d001d2` +### F181 – java-llama.cpp: JNI-Schalen geben Fehlermeldungen als Modified UTF-8 zurück +- **Zeit:** 2026-10-20 01:10 UTC +- **Datei:** `llama/src/main/cpp/jni_utf8.hpp` (neu), `jllama.cpp` (`throw_api_status`, Modell-Laden), `jni_helpers.hpp` (`report_cxx_exception_impl`), `native_server.cpp`, `rpc_bridge.cpp`, `train_engine.cpp` +- **Schwere:** niedrig bis mittel (still verfälschte Meldung auf HotSpot, Prozessabbruch unter Android-CheckJNI) +- **Historie:** Offener Verdacht vom 2026-10-18 (Musterdurchsicht nach F71, TODO (6)). Gemessen auf HotSpot 21 mit einer eigenen JNI-Sonde: `GetStringUTFChars` liefert für U+1F600 `ED A0 BD ED B8 80` (CESU-8), `NewStringUTF` macht aus `F0 9F 98 80` ein falsches Zeichen und verschluckt Folgebytes. +- **Beschreibung:** `ThrowNew` und `NewStringUTF` lesen Modified UTF-8. Die Meldungen aus llama.cpp zitieren Pfade und Anfragetext in Standard-UTF-8. Die Trainer-Meldung für `missing-😀.gguf` kam als `missing-`, U+00F0 U+009F U+0098 U+0080 und `.g` an (der Rest des Pfads fehlte). Die Eingabeseite des Trainers (`GetStringUTFChars`) ist dagegen nicht erreichbar: Jackson schreibt ein Zeichen der Zusatzebenen als maskiertes Surrogatpaar, die Konfiguration ist also dort ASCII, wo sich die Kodierungen unterscheiden (gemessen: `"Hallo \\ud83d\\ude00 Welt"`). Die Fehlermeldungen von Anfragen an das Modell zitieren in den gemessenen Fällen keinen Anfragetext (Probe mit einem Zufallsmodell). +- **Fix:** Neuer Header `jni_utf8.hpp` (nur `jni.h`, keine gecachten IDs, deshalb auch in `train_engine.cpp` nutzbar): `throw_utf8_impl` und `new_string_utf8_impl`. ASCII bleibt bei `ThrowNew`/`NewStringUTF`, alles andere geht als Bytes durch `String(byte[], "UTF-8")`, und Javas Dekoder ersetzt ungültige Folgen durch U+FFFD. Umgestellt sind alle Stellen mit fremdem Text: `throw_api_status`, das Modell-Laden, `report_cxx_exception_impl` (die JNI-Ausnahmegrenze), `NativeServer`, `RpcServer` und der Rückgabetext von `LlamaTrainer`. Stellen mit literalem ASCII-Text bleiben, wie sie sind. Die Eingabeseite des Trainers bekommt einen Kommentar, warum sie sicher ist. +- **Tests:** C++ `JniUtf8.*` (6 in `test_jni_helpers.cpp`: ASCII bleibt bei `ThrowNew`, 4-Byte-Zeichen und ungültiges Byte erreichen den Konstruktor als Originalbytes, dasselbe für `new_string_utf8_impl` und für `jni_guard_impl`); Java `LlamaTrainerTest#aFourByteCharacterInAPathComesBackIntactInTheErrorMessage` (modellfrei, braucht nur `libjllama`; überspringt, wo die Dateinamen-Kodierung den Pfad nicht darstellen kann) und `#aFourByteCharacterInTheTrainingTextReachesTheTrainer` (hält die Jackson-Maskierung fest, von der die Eingabeseite abhängt). +- **Rot-Grün-Nachweis:** C++: mit durchreichenden Platzhaltern 4 von 6 rot, mit dem Fix 6 von 6 grün, die ganze Suite 640/640. Java gegen die alte Bibliothek rot (`but: was "…/missing-ð.g"`), gegen die neu gebaute grün; `LlamaLoggerTest`, `NativeLibraryLoadSmokeTest`, `RpcServerTest` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. -- *(außerhalb des Ziels – C++, niedrig, nur beschrieben, 2026-10-18, Musterdurchsicht nach F71)* Die JNI-Schalen geben Fehlermeldungen per `env->ThrowNew(cls, jllama_last_error())` (`jllama.cpp:199`/`866`, `native_server.cpp:74`, `rpc_bridge.cpp:42`, `jni_helpers.hpp:120/167`) und `train_engine.cpp:150` per `NewStringUTF(jllama_last_error())` zurück. Beide erwarten Modified UTF-8. Die Meldung kann aber Nutzertext in Standard-UTF-8 enthalten (ein Modellpfad mit Emoji → 4-Byte-Folge) oder sogar rohe ungültige Bytes (nlohmanns `parse_error` zitiert `last read: '…'` byteweise). HotSpot dekodiert das nachsichtig zu Ersatzzeichen, `-Xcheck:jni` warnt, Android-CheckJNI bricht ab. Abhilfe wäre der vorhandene Weg über `utf8_to_jstring_impl` plus `Throw` eines selbst gebauten Exception-Objekts; Java kann es nicht reparieren, die Meldung kommt schon verfälscht an. - *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a90080b..ed8e71c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,13 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **Error messages with a 4-byte character reach Java intact.** The JNI shells handed messages to + `ThrowNew` and `NewStringUTF`, which read Modified UTF-8; a message quoting a path or request text + with a supplementary-plane character (an emoji) was mangled on HotSpot -- a trainer message for + `missing-😀.gguf` came back as `missing-`, four wrong characters and `.g` -- and aborts the process under + Android's CheckJNI. A non-ASCII message now crosses as bytes through `String(byte[], "UTF-8")` + (`jni_utf8.hpp`), on every path that carries llama.cpp's text: the C API status, the model load, + the JNI exception boundary, `NativeServer`, `RpcServer` and `LlamaTrainer`. - **A LoRA adapter change no longer answers from the old cache** (`patches/0021`). After `setLoraAdapter(s)` -- and after a request with its own adapters -- a slot reused the prompt cache it had computed under the previous scales and answered like neither adapter set (measured with diff --git a/CLAUDE.md b/CLAUDE.md index fea3e0173..52e0e2141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2316,6 +2316,11 @@ Includes `json_helpers.hpp` so all bridge helpers can call transforms directly. specifies *Modified* UTF-8 input there, so standard UTF-8 containing supplementary-plane characters (every 4-byte emoji) is spec-invalid — Android CheckJNI aborts on it. The mirror of `parse_jstring`'s `String.getBytes("UTF-8")` input path. +- **Messages go through `jni_utf8.hpp`** (`throw_utf8_impl`, `new_string_utf8_impl`): the same rule for + error text, which quotes paths and request content. ASCII stays on `ThrowNew`/`NewStringUTF` + (identical in both encodings), anything else crosses as bytes. Self-contained (jni.h only, no cached + ids), so `train_engine.cpp`, which never includes this header, uses it too. A new `ThrowNew` or + `NewStringUTF` with text that is not a literal goes through it. - `json_to_jstring_impl` — serialises any `json` value to a JNI string via upstream `safe_json_to_str` (dump with `error_handler_t::replace`, so content ending in an incomplete UTF-8 sequence yields U+FFFD instead of throwing `json::type_error 316`) + `utf8_to_jstring_impl`. @@ -2605,7 +2610,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_json_helpers.cpp` | 72 | All functions in `json_helpers.hpp`: `get_result_error_message`, `get_result_error_code` (the HTTP code of a failed task result, from which `jllama.cpp` picks `InvalidRequestException` or `LlamaException`), `results_to_json`, `rerank_results_to_json` (incl. missing/out-of-range `index` rejection and the `tokens_evaluated` pass-through), `parse_encoding_format`, `extract_embedding_prompt`, `is_infill_request`, `parse_slot_prompt_similarity`, `parse_positive_int_config`, `wrap_stream_chunk`, `server_metrics_to_json`, `route_error_message` | | `src/test/cpp/test_log_helpers.cpp` | 13 | All functions in `log_helpers.hpp`: `log_level_name`, `format_log_as_json` | | `src/test/cpp/test_common_log_callback.cpp` | 7 | **The runnable guard for `patches/0014`**: `common_log_set_callback()` on a private `common_log_init()` instance (never `common_log_main()`, so the process-wide logger the other tests print through is untouched) — delivery of level + bare text, no prefix/timestamp even when both are on (what `common_init()` does), clearing stops delivery, a swap drains queued entries to the *previous* sink (the property behind `LlamaModel.setLogger(format, null)` being a synchronous flush), a `--log-file` keeps being written alongside the sink, every `ggml_log_level` passes through unchanged, and the sink is not gated on upstream's `common_log_set_stdo()` console switch (b11612: entries reach the callback with stdout/stderr output off). The Java half (`LlamaLoggerTest`, model-free) proves the JNI trampoline on top of it. | -| `src/test/cpp/test_jni_helpers.cpp` | 70 | All functions in `jni_helpers.hpp` using a zero-filled `JNINativeInterface_` mock (incl. the `utf8_to_jstring_impl` byte-array string path: emoji byte-preservation, truncated-UTF-8 replace-not-throw). Seven of them pin `jni_guard_impl` — the JNI exception boundary every `Java_*` entry point runs inside — including the `catch (...)` arm that is the only backstop for a non-`std::exception` type, and its two refusals (never `ThrowNew` over a pending Java exception, never with a null class). | +| `src/test/cpp/test_jni_helpers.cpp` | 76 | All functions in `jni_helpers.hpp` using a zero-filled `JNINativeInterface_` mock (incl. the `utf8_to_jstring_impl` byte-array string path: emoji byte-preservation, truncated-UTF-8 replace-not-throw), and `jni_utf8.hpp` (six: an ASCII message stays on `ThrowNew`, a 4-byte character or an invalid byte reaches the exception constructor as the original bytes through `String(byte[], "UTF-8")`, the same for `new_string_utf8_impl` and for the JNI exception boundary). Seven of them pin `jni_guard_impl` — the JNI exception boundary every `Java_*` entry point runs inside — including the `catch (...)` arm that is the only backstop for a non-`std::exception` type, and its two refusals (never `ThrowNew` over a pending Java exception, never with a null class). | | `src/test/cpp/test_tts_wav.cpp` | 2 | The in-memory WAV writer `pcm_to_wav16_bytes` in `tts_wav.hpp` (WAV header/payload + little-endian clamping) — our own code, not upstream. The Qwen3-TTS pipeline it pairs with (`mtmd_helper::gen_audio`) is entirely upstream-owned (no project-side DSP to unit-test here). The load path is additionally covered by `test_tts_params.cpp` (3 tests over `tts_params.hpp`'s `build_tts_params`, plus 2 pinning the upstream `-1` default it depends on), which pins the CPU-thread resolution whose absence used to crash the JVM on every platform — see the `TODO.md` entry for the mechanism. End-to-end coverage is `TtsIntegrationTest`, which is model-gated. | | `src/test/cpp/test_tts_params.cpp` | 13 | The **three** builders every hand-assembled `common_params` goes through: `build_tts_params` (`tts_params.hpp`), `build_train_params` (`train_params.hpp`) and the shared `jllama::resolve_cpu_params` (`cpu_params.hpp`). Each builder is guarded separately on purpose — testing the resolver alone does **not** cover its call sites, because `train_engine.cpp` is compiled into `jllama` only, never into `jllama_test`, and `LlamaTrainerIntegrationTest` is gated on `net.ladenthin.llama.train.model`, which no CI job sets. Without these the JVM-abort bug could regress in the trainer on every platform, unseen. | | `src/test/cpp/test_prefetch.cpp` | 5 | **The runnable guard for `patches/0017`** (the x86 prefetch helper). The patch fixes two defects in the four `_mm_prefetch` calls of `ggml_vec_dot_q4_0_q8_0`'s SSSE3-without-AVX branch, and only one of them is a build error. The **type** half is caught by the compiler, but only by a recent one: plain clang on a `*-windows-msvc` target gets MSVC's `char const *` declaration (its own casting macro sits behind `#ifndef _MSC_VER`), and the resulting `-Wincompatible-pointer-types` is an **error by default from clang 22 on** -- measured on the real file at `-msse4.2`: 4 warnings on 20.1.8, 4 errors on 22.1.3 and 23.1.3, and 0/0 on all three once patched (`-msse4.2 -mavx` compiles either way, taking the AVX branch). So `GGML_CPU_ALL_VARIANTS=ON` stops building with plain clang on Windows once the compiler is new enough -- via the **`sse42`** variant, the only one that reaches `#elif defined(__SSSE3__)` (the `x64` baseline has no SSSE3 and takes the scalar branch; everything richer has AVX). Nothing in a test can cover that half. The **arithmetic** half is what this file guards, and it is silent: `&x[ib] + sizeof(block_q4_0)` adds `sizeof()` *elements*, so with the sizes `ggml-common.h` pins by `static_assert` (18 and 34) the prefetch targets +324 and +1156 bytes instead of +18 and +34, and the `+ 2 *` pair +648 instead of +36 -- wrong on **every** platform, including the ones where the call compiles. `ggml_prefetch_at()` casts to `const char *` first, which is what makes the offset byte-wise. **The case that makes the file worth having:** writing the cast around the whole expression, `(const char *)(&x[ib] + sizeof(block_q4_0))`, silences the compiler and keeps the wrong address -- that form builds and fails here. Verified both ways: 5/5 green against the patched header, 5/5 red against a deliberately element-wise one. Includes two internal headers (`ggml-common.h` for the block types, `prefetch.h` for the helper) via the `${llama.cpp_SOURCE_DIR}/ggml/src` and `.../ggml/src/ggml-cpu/arch/x86` include dirs added for it. **Measured, so the upstream report does not overclaim:** the wrong prefetch distance costs no measurable throughput (Ryzen 7 5800H forced onto the `sse42` kernel, 0.6B Q4_0: pp512 101.3 vs 100.9, tg128 37.3 vs 37.0, inside each other's error bars) -- it is a correctness and portability fix, not a performance one. | @@ -2619,7 +2624,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 634 tests (all passing).** +**Current total: 640 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/TODO.md b/TODO.md index 4362366c0..413e5b7cc 100644 --- a/TODO.md +++ b/TODO.md @@ -520,9 +520,6 @@ numbered for reference, the numbers stay when an item is done): request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; changing it means sending the headers with the first chunk on all five streaming routes. -- **(6)** JNI shells throw with `ThrowNew(cls, jllama_last_error())` / `NewStringUTF`, which expect Modified - UTF-8; a message with a 4-byte character or raw invalid bytes is mangled (Android CheckJNI - aborts). Fix in C++: build the message with `utf8_to_jstring_impl` and `Throw` the object. - **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. diff --git a/llama/src/main/cpp/jllama.cpp b/llama/src/main/cpp/jllama.cpp index c299a07f8..4b94c6399 100644 --- a/llama/src/main/cpp/jllama.cpp +++ b/llama/src/main/cpp/jllama.cpp @@ -196,8 +196,8 @@ static const static_object_binding g_static_object_bindings[] = { * Throws the Java exception a failed C API call maps to (jllama_last_error() carries the message). */ static void throw_api_status(JNIEnv *env, jllama_status status) { - env->ThrowNew(status == JLLAMA_STATUS_INVALID_REQUEST ? c_llama_invalid_request : c_llama_error, - jllama_last_error()); + throw_utf8_impl(env, status == JLLAMA_STATUS_INVALID_REQUEST ? c_llama_invalid_request : c_llama_error, + jllama_last_error()); } /** The handle a Java field holds, as the C API's opaque model. */ @@ -863,7 +863,7 @@ static void load_model_impl(JNIEnv *env, jobject obj, jobjectArray jparams, jobj free_string_array(argv, argc); if (status != JLLAMA_STATUS_OK) { // An unreachable --rpc server and a refused argv are both LlamaException here, as before. - env->ThrowNew(c_llama_error, jllama_last_error()); + throw_utf8_impl(env, c_llama_error, jllama_last_error()); return; } env->SetLongField(obj, f_model_pointer, reinterpret_cast(model)); diff --git a/llama/src/main/cpp/jni_helpers.hpp b/llama/src/main/cpp/jni_helpers.hpp index 8c6fa2cd9..8829a7304 100644 --- a/llama/src/main/cpp/jni_helpers.hpp +++ b/llama/src/main/cpp/jni_helpers.hpp @@ -31,6 +31,7 @@ // the C API (jllama_api.cpp) and the engine (jllama_engine.cpp); only what reads a Java object // stays here. #include "jllama_engine.hpp" +#include "jni_utf8.hpp" #include #include @@ -164,7 +165,7 @@ inline void report_cxx_exception_impl(JNIEnv *env, jclass exception_class, const if (exception_class == nullptr) { return; } - env->ThrowNew(exception_class, what); + throw_utf8_impl(env, exception_class, what); } template auto jni_guard_impl(JNIEnv *env, jclass exception_class, Fn &&fn) -> decltype(fn()) { diff --git a/llama/src/main/cpp/jni_utf8.hpp b/llama/src/main/cpp/jni_utf8.hpp new file mode 100644 index 000000000..204b42069 --- /dev/null +++ b/llama/src/main/cpp/jni_utf8.hpp @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +#pragma once + +// jni_utf8.hpp — text from native code to Java without Modified UTF-8. +// +// JNI's NewStringUTF and ThrowNew take *Modified* UTF-8: a supplementary-plane character (every +// 4-byte emoji) is spelled as two 3-byte surrogates there, and a 4-byte standard UTF-8 sequence is +// invalid input -- HotSpot turns it into garbage, Android's CheckJNI aborts the process. The text +// these helpers carry is not ours: an error message from llama.cpp quotes a file path or a piece +// of the request, and both can hold any character. So a message with a byte above 0x7F crosses as +// bytes through String(byte[], "UTF-8"), where Java's decoder is the one that decides (and replaces +// an invalid sequence with U+FFFD instead of failing). Pure ASCII is identical in both encodings +// and keeps the one-call path. +// +// Self-contained on purpose (jni.h only, no cached ids): every JNI translation unit uses it, +// including train_engine.cpp, which deliberately does not include jni_helpers.hpp. + +#include "jni.h" + +#include + +[[nodiscard]] inline bool jni_utf8_is_ascii(const char *s) { + for (const char *p = s; *p != '\0'; ++p) { + if (static_cast(*p) >= 0x80) { + return false; + } + } + return true; +} + +// A java.lang.String from standard UTF-8 bytes; nullptr with a pending Java exception on failure. +[[nodiscard]] inline jstring new_string_utf8_impl(JNIEnv *env, const char *utf8) { + if (utf8 == nullptr) { + utf8 = ""; + } + if (jni_utf8_is_ascii(utf8)) { + return env->NewStringUTF(utf8); + } + jclass string_class = env->FindClass("java/lang/String"); + if (string_class == nullptr) { + return nullptr; + } + jstring result = nullptr; + jmethodID ctor = env->GetMethodID(string_class, "", "([BLjava/lang/String;)V"); + jstring charset = ctor != nullptr ? env->NewStringUTF("UTF-8") : nullptr; + if (charset != nullptr) { + const jsize length = static_cast(std::strlen(utf8)); + jbyteArray bytes = env->NewByteArray(length); + if (bytes != nullptr) { + env->SetByteArrayRegion(bytes, 0, length, reinterpret_cast(utf8)); + result = static_cast(env->NewObject(string_class, ctor, bytes, charset)); + env->DeleteLocalRef(bytes); + } + env->DeleteLocalRef(charset); + } + env->DeleteLocalRef(string_class); + return result; +} + +// Throws a new exception_class(message), message read as standard UTF-8. exception_class must +// have a (String) constructor; every exception class of this library has one. When the message +// cannot be built, the exception that stopped it (OutOfMemoryError) is the one left pending. +inline void throw_utf8_impl(JNIEnv *env, jclass exception_class, const char *message) { + if (message == nullptr) { + message = ""; + } + if (jni_utf8_is_ascii(message)) { + env->ThrowNew(exception_class, message); + return; + } + jstring text = new_string_utf8_impl(env, message); + if (text == nullptr) { + return; + } + jmethodID ctor = env->GetMethodID(exception_class, "", "(Ljava/lang/String;)V"); + jobject exception = ctor != nullptr ? env->NewObject(exception_class, ctor, text) : nullptr; + env->DeleteLocalRef(text); + if (exception != nullptr) { + env->Throw(static_cast(exception)); + env->DeleteLocalRef(exception); + } +} diff --git a/llama/src/main/cpp/native_server.cpp b/llama/src/main/cpp/native_server.cpp index 551e829f2..19afa8baf 100644 --- a/llama/src/main/cpp/native_server.cpp +++ b/llama/src/main/cpp/native_server.cpp @@ -71,7 +71,7 @@ jclass llama_exception_class(JNIEnv *env) { return env->FindClass("net/ladenthin void throw_llama_exception(JNIEnv *env, const char *message) { jclass exception_class = llama_exception_class(env); if (exception_class != nullptr) { - env->ThrowNew(exception_class, message); + throw_utf8_impl(env, exception_class, message); } } diff --git a/llama/src/main/cpp/rpc_bridge.cpp b/llama/src/main/cpp/rpc_bridge.cpp index ce4eaffef..a6acfbfe9 100644 --- a/llama/src/main/cpp/rpc_bridge.cpp +++ b/llama/src/main/cpp/rpc_bridge.cpp @@ -39,7 +39,7 @@ jclass rpc_exception_class(JNIEnv *env) { return env->FindClass("net/ladenthin/l void throw_rpc_exception(JNIEnv *env, const char *message) { jclass exception_class = rpc_exception_class(env); if (exception_class != nullptr) { - env->ThrowNew(exception_class, message); + throw_utf8_impl(env, exception_class, message); } } diff --git a/llama/src/main/cpp/train_engine.cpp b/llama/src/main/cpp/train_engine.cpp index 9a0e64613..ed5ee8a5f 100644 --- a/llama/src/main/cpp/train_engine.cpp +++ b/llama/src/main/cpp/train_engine.cpp @@ -10,6 +10,7 @@ #include "rpc_support.hpp" #include "train_params.hpp" #include "jllama_api.h" +#include "jni_utf8.hpp" #include @@ -136,6 +137,9 @@ bool finetune_json(const std::string &config_json, std::string &err) { // this TU deliberately keeps its own nlohmann alias and never includes jni_helpers.hpp. extern "C" JNIEXPORT jstring JNICALL Java_net_ladenthin_llama_LlamaTrainer_finetuneNative(JNIEnv *env, jclass, jstring jconfig) try { + // GetStringUTFChars hands out Modified UTF-8, which differs from UTF-8 only for U+0000 and the + // supplementary planes; TrainingParameters.toJson() (Jackson) escapes both, so the JSON read + // here is the same in either encoding. LlamaTrainerTest pins that. std::string config_json; if (jconfig != nullptr) { const char *c = env->GetStringUTFChars(jconfig, nullptr); @@ -147,7 +151,8 @@ extern "C" JNIEXPORT jstring JNICALL Java_net_ladenthin_llama_LlamaTrainer_finet if (jllama_finetune(config_json.c_str()) == JLLAMA_STATUS_OK) { return env->NewStringUTF(""); // empty == success } - return env->NewStringUTF(jllama_last_error()); + // The message quotes paths, so it is standard UTF-8 of any character (see jni_utf8.hpp). + return new_string_utf8_impl(env, jllama_last_error()); } catch (...) { return env->NewStringUTF("unknown C++ exception crossed the JNI boundary"); } diff --git a/llama/src/test/cpp/test_jni_helpers.cpp b/llama/src/test/cpp/test_jni_helpers.cpp index 9f4c44291..90f979720 100644 --- a/llama/src/test/cpp/test_jni_helpers.cpp +++ b/llama/src/test/cpp/test_jni_helpers.cpp @@ -895,6 +895,175 @@ TEST(JniGuard, DoesNotThrowWithoutAnExceptionClass) { EXPECT_FALSE(g_guard_probe.threw); } +// ============================================================ +// jni_utf8.hpp — message text to Java as standard UTF-8 (TODO (6), F181) +// +// ThrowNew and NewStringUTF read Modified UTF-8, where a 4-byte sequence is invalid input: +// HotSpot mangles it, Android's CheckJNI aborts. An error message quotes paths and request text, +// so it can hold any character. The mock records which path a message took and the bytes the +// String(byte[], "UTF-8") constructor received. +// ============================================================ +namespace { + +struct utf8_probe { + bool exception_pending = false; + int throw_new_calls = 0; + std::string throw_new_message; + int new_string_utf_calls = 0; + std::string new_string_utf_value; + std::string bytes; + std::string string_ctor_charset; + jobject thrown = nullptr; + jobject exception_ctor_message = nullptr; + jclass exception_ctor_class = nullptr; +}; + +utf8_probe g_utf8; + +jclass const kStringClass = reinterpret_cast(0x5100); +jmethodID const kStringCtor = reinterpret_cast(0x5101); +jmethodID const kExceptionCtor = reinterpret_cast(0x5102); +jbyteArray const kBytes = reinterpret_cast(0x5103); +jstring const kCharsetName = reinterpret_cast(0x5104); +jstring const kDecoded = reinterpret_cast(0x5105); +jobject const kException = reinterpret_cast(0x5106); +jclass const kExceptionClass = reinterpret_cast(0x5107); + +JNIEnv *make_utf8_env(JNIEnv_ &fake_env, JNINativeInterface_ &iface) { + g_utf8 = utf8_probe{}; + iface = {}; + iface.ExceptionCheck = [](JNIEnv *) -> jboolean { return g_utf8.exception_pending ? JNI_TRUE : JNI_FALSE; }; + iface.ThrowNew = [](JNIEnv *, jclass, const char *msg) -> jint { + g_utf8.throw_new_calls++; + g_utf8.throw_new_message = msg != nullptr ? msg : ""; + return 0; + }; + iface.Throw = [](JNIEnv *, jthrowable t) -> jint { + g_utf8.thrown = t; + return 0; + }; + iface.NewStringUTF = [](JNIEnv *, const char *utf) -> jstring { + g_utf8.new_string_utf_calls++; + g_utf8.new_string_utf_value = utf != nullptr ? utf : ""; + return g_utf8.new_string_utf_value == "UTF-8" ? kCharsetName : kDecoded; + }; + iface.FindClass = [](JNIEnv *, const char *name) -> jclass { + return std::string(name) == "java/lang/String" ? kStringClass : nullptr; + }; + iface.GetMethodID = [](JNIEnv *, jclass cls, const char *name, const char *sig) -> jmethodID { + if (std::string(name) != "") { + return nullptr; + } + if (cls == kStringClass && std::string(sig) == "([BLjava/lang/String;)V") { + return kStringCtor; + } + if (cls != kStringClass && std::string(sig) == "(Ljava/lang/String;)V") { + return kExceptionCtor; + } + return nullptr; + }; + iface.NewByteArray = [](JNIEnv *, jsize) -> jbyteArray { return kBytes; }; + iface.SetByteArrayRegion = [](JNIEnv *, jbyteArray, jsize, jsize len, const jbyte *buf) { + g_utf8.bytes.assign(reinterpret_cast(buf), static_cast(len)); + }; + iface.NewObjectV = [](JNIEnv *, jclass cls, jmethodID mid, va_list args) -> jobject { + if (mid == kStringCtor) { + EXPECT_EQ(va_arg(args, jobject), (jobject)kBytes); + EXPECT_EQ(va_arg(args, jobject), (jobject)kCharsetName); + g_utf8.string_ctor_charset = "UTF-8"; + return (jobject)kDecoded; + } + if (mid == kExceptionCtor) { + g_utf8.exception_ctor_class = cls; + g_utf8.exception_ctor_message = va_arg(args, jobject); + return kException; + } + return nullptr; + }; + iface.DeleteLocalRef = [](JNIEnv *, jobject) {}; + fake_env = {}; + fake_env.functions = &iface; + return &fake_env; +} + +const char *const kEmojiMessage = "model not found: /models/\xF0\x9F\x98\x80.gguf"; // U+1F600 + +} // namespace + +TEST(JniUtf8, AnAsciiMessageIsThrownWithThrowNew) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + + throw_utf8_impl(env, kExceptionClass, "plain text"); + + EXPECT_EQ(g_utf8.throw_new_calls, 1); + EXPECT_EQ(g_utf8.throw_new_message, "plain text"); + EXPECT_EQ(g_utf8.thrown, nullptr); +} + +// The defect: the 4-byte sequence reached ThrowNew, which reads it as Modified UTF-8. +TEST(JniUtf8, AFourByteCharacterReachesTheExceptionAsStandardUtf8Bytes) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + + throw_utf8_impl(env, kExceptionClass, kEmojiMessage); + + EXPECT_EQ(g_utf8.throw_new_calls, 0) << "ThrowNew got: " << g_utf8.throw_new_message; + EXPECT_EQ(g_utf8.bytes, kEmojiMessage); + EXPECT_EQ(g_utf8.string_ctor_charset, "UTF-8"); + EXPECT_EQ(g_utf8.exception_ctor_class, kExceptionClass); + EXPECT_EQ(g_utf8.exception_ctor_message, (jobject)kDecoded); + EXPECT_EQ(g_utf8.thrown, kException); +} + +// An invalid sequence is not ours to repair either: Java's decoder replaces it with U+FFFD. +TEST(JniUtf8, AnInvalidByteSequenceIsLeftToJavasDecoder) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + + throw_utf8_impl(env, kExceptionClass, "bad \xC3 byte"); + + EXPECT_EQ(g_utf8.throw_new_calls, 0); + EXPECT_EQ(g_utf8.bytes, "bad \xC3 byte"); + EXPECT_EQ(g_utf8.thrown, kException); +} + +TEST(JniUtf8, NewStringKeepsAsciiOnNewStringUtf) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + + EXPECT_EQ(new_string_utf8_impl(env, "done"), kDecoded); + EXPECT_EQ(g_utf8.new_string_utf_value, "done"); + EXPECT_TRUE(g_utf8.bytes.empty()); +} + +TEST(JniUtf8, NewStringBuildsAFourByteCharacterFromBytes) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + + EXPECT_EQ(new_string_utf8_impl(env, kEmojiMessage), kDecoded); + EXPECT_EQ(g_utf8.bytes, kEmojiMessage); + EXPECT_EQ(g_utf8.new_string_utf_value, "UTF-8") << "only the charset name may go through NewStringUTF"; +} + +// The JNI exception boundary carries the same messages. +TEST(JniUtf8, TheGuardReportsAFourByteCharacterAsStandardUtf8) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + + jni_guard_impl(env, kExceptionClass, [&]() -> void { throw std::runtime_error(kEmojiMessage); }); + + EXPECT_EQ(g_utf8.throw_new_calls, 0); + EXPECT_EQ(g_utf8.bytes, kEmojiMessage); + EXPECT_EQ(g_utf8.thrown, kException); +} + // ============================================================ // acquire_jllama_context_impl / release_jllama_context_impl / jllama_context_guard // diff --git a/llama/src/test/java/net/ladenthin/llama/LlamaTrainerTest.java b/llama/src/test/java/net/ladenthin/llama/LlamaTrainerTest.java new file mode 100644 index 000000000..4eeb4b87d --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/LlamaTrainerTest.java @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import net.ladenthin.llama.exception.LlamaException; +import net.ladenthin.llama.loader.NativeLibraryPresence; +import net.ladenthin.llama.parameters.TrainingParameters; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Model-free checks of the trainer's JNI shell. Needs {@code libjllama} and skips without it. + */ +class LlamaTrainerTest { + + /** + * The trainer's error message comes back to Java as standard UTF-8. The shell returned it with + * {@code NewStringUTF}, which reads Modified UTF-8: on HotSpot 21 the 4-byte sequence of U+1F600 + * in the quoted path came out as the four characters U+00F0 U+009F U+0098 U+0080, and the text + * after it was cut short ({@code .gguf} became {@code .g}); Android's CheckJNI aborts the process + * on the same input (F181). Skips where the file-name encoding cannot represent the path. + */ + @Test + void aFourByteCharacterInAPathComesBackIntactInTheErrorMessage(@TempDir Path tmp) { + assumeTrue(NativeLibraryPresence.onClasspath(), "libjllama not on the classpath"); + Path model; + try { + model = tmp.resolve("missing-\uD83D\uDE00.gguf"); // U+1F600 + } catch (InvalidPathException e) { + assumeTrue(false, "the file-name encoding cannot represent U+1F600: " + e.getMessage()); + return; + } + Path missing = model; + + LlamaException e = assertThrows( + LlamaException.class, + () -> LlamaTrainer.finetune(TrainingParameters.builder() + .modelPath(missing) + .trainingText("x") + .outputPath(tmp.resolve("out.gguf")) + .build())); + + assertThat(e.getMessage(), is("failed to load model for training: " + missing)); + } + + /** + * The configuration crosses with {@code GetStringUTFChars}, which hands out Modified UTF-8, and + * that is safe only because Jackson writes a supplementary-plane character as an escaped + * surrogate pair (two {@code \\u} escapes), so the JSON is ASCII where the two encodings differ. + * Should Jackson ever write the character raw, the native parser would receive two 3-byte + * surrogates, reject them as ill-formed UTF-8 and fail with {@code invalid training config} + * before looking at the model -- this test is what would say so. + */ + @Test + void aFourByteCharacterInTheTrainingTextReachesTheTrainer(@TempDir Path tmp) { + assumeTrue(NativeLibraryPresence.onClasspath(), "libjllama not on the classpath"); + Path model = tmp.resolve("missing.gguf"); + + LlamaException e = assertThrows( + LlamaException.class, + () -> LlamaTrainer.finetune(TrainingParameters.builder() + .modelPath(model) + .trainingText("Hallo \uD83D\uDE00 Welt") // U+1F600 + .outputPath(tmp.resolve("out.gguf")) + .build())); + + assertThat(e.getMessage(), is("failed to load model for training: " + model)); + } +} From d8e39e414aac8f70019e394f24be23bab4c476d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:06:55 +0000 Subject: [PATCH 323/589] Record the commit of F181 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f00bfaf93..4e8713a49 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1348,7 +1348,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `JniUtf8.*` (6 in `test_jni_helpers.cpp`: ASCII bleibt bei `ThrowNew`, 4-Byte-Zeichen und ungültiges Byte erreichen den Konstruktor als Originalbytes, dasselbe für `new_string_utf8_impl` und für `jni_guard_impl`); Java `LlamaTrainerTest#aFourByteCharacterInAPathComesBackIntactInTheErrorMessage` (modellfrei, braucht nur `libjllama`; überspringt, wo die Dateinamen-Kodierung den Pfad nicht darstellen kann) und `#aFourByteCharacterInTheTrainingTextReachesTheTrainer` (hält die Jackson-Maskierung fest, von der die Eingabeseite abhängt). - **Rot-Grün-Nachweis:** C++: mit durchreichenden Platzhaltern 4 von 6 rot, mit dem Fix 6 von 6 grün, die ganze Suite 640/640. Java gegen die alte Bibliothek rot (`but: was "…/missing-ð.g"`), gegen die neu gebaute grün; `LlamaLoggerTest`, `NativeLibraryLoadSmokeTest`, `RpcServerTest` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `6f530e9` ## Offene Verdachte ohne Fund-ID From 9587087ecba807678db86b4104d968ccce9fddd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:16:18 +0000 Subject: [PATCH 324/589] Answer a streamed request that fails before its first chunk with the error status (F182) OpenAiCompatServer sent 200 and the stream headers before the backend ran, so a request llama.cpp refused at once arrived as 200 with an error event, on all five streaming routes. Upstream reads the first result before it starts a stream. ResponseStream now starts the response with the first write: a chunk, the protocol's opening events (startWith) or a heartbeat, which still keeps a client alive through a long prompt. Before that, answerIfNotStarted answers 400/500 with the route's own error body, as without "stream"; after it, a failure stays an event. The Atmosphere agent's wire contract test pinned the old behaviour under a "mid-stream" name while failing before any chunk; it is split into a failure before the first chunk (now the turn's failure) and one after a chunk (still the known gap in Atmosphere's client). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +- CHANGELOG.md | 9 + TODO.md | 4 - llama-atmosphere-agent/README.md | 2 +- .../AtmosphereWireContractTest.java | 36 +++- .../llama/atmosphere/ScriptedBackend.java | 11 + .../llama/server/OpenAiCompatServer.java | 191 +++++++++++++----- .../server/OpenAiCompatServerHttpTest.java | 160 ++++++++++++++- .../llama/server/OpenAiServerTestSupport.java | 11 +- 9 files changed, 362 insertions(+), 75 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 4e8713a49..421170c2b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1350,9 +1350,20 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6f530e9` +### F182 – java-llama.cpp: Gestreamte Anfragen antworten mit 200, bevor das Backend läuft +- **Zeit:** 2026-10-20 01:50 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`, die fünf Streaming-Methoden); `llama-atmosphere-agent` (Tests, README) +- **Schwere:** mittel (eine abgelehnte Anfrage kam als `200` an; ein Client, der nur den Status prüft, sah eine leere Antwort, der Atmosphere-Agent beendete den Zug still) +- **Historie:** Offener Verdacht vom 2026-10-17 (TODO (3)), damals als Designabweichung zur Entscheidung gestellt und durch `nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` festgeschrieben. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status. +- **Beschreibung:** Jede der fünf Streaming-Routen (`/v1/chat/completions`, `/v1/completions`, `/api/chat`, `/v1/messages`, `/v1/responses`) schickte `200` und die Stream-Header vor dem Aufruf des Backends; Anthropic und Responses schrieben zusätzlich sofort ihre Eröffnungsereignisse. Eine sofort abgelehnte Anfrage (`InvalidRequestException`, `IllegalArgumentException`) oder ein sofortiger Fehler wurde dadurch zum Fehlerereignis unter `200`. +- **Fix:** `ResponseStream` beginnt die Antwort erst mit dem ersten Schreiben: Status `200`, Header, dann der mit `startWith` gesetzte Vorspann (die Eröffnungsereignisse von Anthropic und Responses), dann der Text. Ein Heartbeat ist ebenfalls ein Schreiben, denn er hält einen Client über einen langen Prompt hinweg; danach bleibt ein Fehler ein Ereignis. Vorher beantwortet `answerIfNotStarted` die Anfrage mit `400`/`500` und dem Fehlerformat der Route (OpenAI, Ollama `{"error"}`, Anthropic `invalid_request_error`/`api_error`), wie ohne `stream`. Nach einer solchen Antwort sind weitere Schreibversuche wirkungslos (ein Heartbeat zwischen Antwort und Abbruch schreibt nichts). +- **Tests:** `OpenAiCompatServerHttpTest#aRejectionBeforeTheFirstChunkIsA400OnEveryStreamingRoute`, `#aFailureBeforeTheFirstChunkIsA500OnEveryStreamingRoute`, `#aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent`, `#aRejectionAfterAHeartbeatIsAnEvent`; die vier bestehenden Ereignis-Tests (Responses `response.failed`, Anthropic-Fehlertypen) laufen jetzt mit einem Backend, das nach einem Chunk scheitert (`ScriptedStreamBackend`). Im Agenten `AtmosphereWireContractTest#engineFailureBeforeTheFirstChunkFailsTheTurn` (neu) und `#midStreamEngineFailureCompletesSilentlyRatherThanErroring` (jetzt wirklich nach einem Chunk). +- **Rot-Grün-Nachweis:** vor dem Umbau rot (die beiden Vor-dem-ersten-Chunk-Tests), danach 63/63 in `OpenAiCompatServerHttpTest` und alle Server-Testklassen grün; der Agent gegen den installierten neuen Kern: vorher rot (`API returned 500: model exploded` statt `null`), nach der Aufteilung `verify` grün (278 Tests, 63 übersprungen, modell- oder bildschirmgebunden); SpotBugs grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID -- *(Designabweichung, Entscheidung des Nutzers, 2026-10-17)* `OpenAiCompatServer`: Jede Streaming-Route sendet `200` + SSE-Header, **bevor** das Backend läuft. Eine abgelehnte Streaming-Anfrage (leere `messages`, Prompt über der Kontextgröße, `InvalidRequestException`) kommt deshalb als `200` mit einem `error`-Datenereignis an, während dieselbe Anfrage ohne `stream` `400` liefert. Upstream (`server-context.cpp:4906`, b11639) holt vor dem Streamen das erste Ergebnis und antwortet bei einem Fehler mit dessen Status (`res->error`). Bewusst festgeschrieben in `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent` („the headers are out before the backend runs“); Clients, die nur den Status prüfen, sehen eine leere Antwort. Eine Angleichung hieße, die Header erst mit dem ersten Chunk zu senden (Umbau aller fünf Streaming-Routen), daher kein stiller Fix. - *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8e71c39..51a29985d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,15 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **A streamed request that fails before its first chunk is answered with the error status.** + `OpenAiCompatServer` sent `200` and the stream's headers before the engine ran, so a request + llama.cpp refused at once arrived as `200` with an error event -- on all five streaming routes + (`/v1/chat/completions`, `/v1/completions`, `/api/chat`, `/v1/messages`, `/v1/responses`). Upstream's + server reads the first result before it starts a stream; this one now does the same: the response + starts with the first write (a chunk, the protocol's opening events or a heartbeat), and a failure + before it is `400` or `500` with the route's own error body, as without `stream`. A failure after the + stream started is still an event. One visible effect: the Atmosphere agent now reports such a failure + instead of ending the turn with an empty answer. - **Error messages with a 4-byte character reach Java intact.** The JNI shells handed messages to `ThrowNew` and `NewStringUTF`, which read Modified UTF-8; a message quoting a path or request text with a supplementary-plane character (an emoji) was mangled on HotSpot -- a trainer message for diff --git a/TODO.md b/TODO.md index 413e5b7cc..273415b98 100644 --- a/TODO.md +++ b/TODO.md @@ -516,10 +516,6 @@ delete an item here when it is done. **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; numbered for reference, the numbers stay when an item is done): -- **(3)** `OpenAiCompatServer` sends `200` + SSE headers before the backend runs, so a rejected streaming - request arrives as `200` with an `error` event; upstream answers with the error status. Pinned by - `OpenAiCompatServerHttpTest#nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent`; - changing it means sending the headers with the first chunk on all five streaming routes. - **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. diff --git a/llama-atmosphere-agent/README.md b/llama-atmosphere-agent/README.md index c93595c10..f7140daad 100644 --- a/llama-atmosphere-agent/README.md +++ b/llama-atmosphere-agent/README.md @@ -838,7 +838,7 @@ into its answer instead of calling the tool. | `GET /v1/models` | yes | optional (best-effort enumeration) | wire | none | | API key | `--api-key` → `401` on mismatch | sends `Authorization: Bearer`; a dummy key is fine | wire | none | | custom base URL | — | `LLM_BASE_URL` / `AiConfig.configure(...)` | wire + model | none | -| error before the stream starts (401, 413, 500) | HTTP status + JSON error | surfaced as `session.error` (5xx retried) | wire | none | +| error before the stream starts (401, 413, 500, and an engine error before the first chunk) | HTTP status + JSON error | surfaced as `session.error` (5xx retried) | wire | none | | **engine error after the stream started** | HTTP 200 already sent → `data: {"error":…}`, no `[DONE]` | **ignored**: the SSE parser reads only `choices[0]`, the turn completes with the text so far | wire (pinned as a known gap) | SHOULD, in Atmosphere's `OpenAiCompatibleClient` | *wire* = `AtmosphereWireContractTest` / `LocalAgentTest`: the **real** `OpenAiCompatServer` (routing, diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java index cfb14b82e..e09b24d24 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AtmosphereWireContractTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; @@ -259,19 +260,42 @@ void wrongApiKeyIsRejectedBeforeReachingTheBackend() throws Exception { } } + /** + * An engine failure before the first chunk is an error status, as upstream llama-server answers it + * (it reads the first result before it starts a stream), and Atmosphere reports it as the turn's + * failure. Until F182 this server sent HTTP 200 first, and the turn ended as a silent, empty answer. + */ + @Test + void engineFailureBeforeTheFirstChunkFailsTheTurn() throws Exception { + ScriptedBackend backend = new ScriptedBackend((call, request) -> { + throw new IllegalStateException("model exploded"); + }); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { + AgentRunner runner = runner(server, API_KEY, List.of()); + ConsoleSession session = session(); + + runner.run("hello", List.of(), session); + + assertThat(session.await(TIMEOUT), is(true)); + assertThat(session.text(), is("")); + assertThat(session.failure(), is(notNullValue())); + assertThat(session.failure().getMessage(), containsString("model exploded")); + } + } + /** * Known gap, pinned so a change on either side is noticed. When the engine fails after the stream * started, java-llama.cpp (like upstream llama-server) has already sent HTTP 200 and reports the * failure as an SSE {@code data: {"error":{...}}} object without a terminating {@code [DONE]}. * Atmosphere's SSE parser only reads {@code choices[0]}, so it ignores that object and completes - * the session normally with whatever text arrived before — an empty answer here, not an error. - * An in-stream error event would be a "SHOULD" for Atmosphere's {@code OpenAiCompatibleClient}. + * the session normally with the text that arrived before. An in-stream error event would be a + * "SHOULD" for Atmosphere's {@code OpenAiCompatibleClient}. */ @Test void midStreamEngineFailureCompletesSilentlyRatherThanErroring() throws Exception { - ScriptedBackend backend = new ScriptedBackend((call, request) -> { - throw new IllegalStateException("model exploded"); - }); + ScriptedBackend backend = new ScriptedBackend( + (call, request) -> List.of(ScriptedBackend.roleChunk(), ScriptedBackend.textChunk("partial")), + new IllegalStateException("model exploded")); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config(API_KEY)).start()) { AgentRunner runner = runner(server, API_KEY, List.of()); ConsoleSession session = session(); @@ -280,7 +304,7 @@ void midStreamEngineFailureCompletesSilentlyRatherThanErroring() throws Exceptio assertThat(session.await(TIMEOUT), is(true)); assertThat(backend.requests(), hasSize(1)); - assertThat(session.text(), is("")); + assertThat(session.text(), is("partial")); assertThat("Atmosphere does not surface an in-stream error object", session.failure(), is(nullValue())); } } diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java index f638dd11e..b5fc39f08 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java @@ -11,6 +11,7 @@ import java.util.List; import net.ladenthin.llama.server.ChunkSink; import net.ladenthin.llama.server.OpenAiBackend; +import org.jspecify.annotations.Nullable; /** * A model-free {@link OpenAiBackend} that answers each streaming chat request with a scripted sequence @@ -28,10 +29,17 @@ interface Script { } private final Script script; + private final @Nullable RuntimeException afterTheChunks; private final List requests = Collections.synchronizedList(new ArrayList<>()); ScriptedBackend(Script script) { + this(script, null); + } + + /** A backend that sends the scripted chunks and then fails with {@code afterTheChunks}. */ + ScriptedBackend(Script script, @Nullable RuntimeException afterTheChunks) { this.script = script; + this.afterTheChunks = afterTheChunks; } /** Every {@code /v1/chat/completions} body received, in order. */ @@ -45,6 +53,9 @@ public void stream(JsonNode request, ChunkSink sink) throws IOException { for (String chunk : script.chunksFor(requests.size(), request)) { sink.accept(chunk); } + if (afterTheChunks != null) { + throw afterTheChunks; + } } @Override diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 63cec5162..c4f4d4f96 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -363,10 +363,7 @@ private void handleCompletions(HttpExchange exchange) throws IOException { } private void streamCompletions(HttpExchange exchange, JsonNode request) throws IOException { - exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_SSE); - exchange.getResponseHeaders().set("Cache-Control", "no-cache"); - exchange.sendResponseHeaders(HTTP_OK, 0); - try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { + try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_SSE)) { ScheduledFuture heartbeat = null; try { heartbeat = heartbeatExecutor.scheduleAtFixedRate( @@ -377,14 +374,20 @@ private void streamCompletions(HttpExchange exchange, JsonNode request) throws I backend.streamCompletions(request, chunkJson -> out.writeStrict(OpenAiSseFormatter.sseData(chunkJson))); out.writeStrict(OpenAiSseFormatter.sseDone()); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly( - OpenAiSseFormatter.sseData(OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))); + if (!out.answerIfNotStarted( + HTTP_BAD_REQUEST, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))) { + out.writeQuietly(OpenAiSseFormatter.sseData( + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))); + } } catch (IOException e) { LOG.debug("client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("streaming completion failed", e); - out.writeQuietly( - OpenAiSseFormatter.sseData(OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))); + if (!out.answerIfNotStarted( + HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) { + out.writeQuietly(OpenAiSseFormatter.sseData( + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))); + } } finally { if (heartbeat != null) { heartbeat.cancel(false); @@ -430,8 +433,9 @@ private void handleRerank(HttpExchange exchange) throws IOException { * Run a non-streaming request through {@code producer} and write its JSON body, translating an * {@link IllegalArgumentException} (a request this layer rejected) or an * {@link InvalidRequestException} (one the native layer rejected, where upstream's server answers - * {@code 400}) to {@code 400} and any other failure to {@code 500}. The streaming routes make the - * same distinction for the error event they write once the headers are out. + * {@code 400}) to {@code 400} and any other failure to {@code 500}. The streaming routes answer the + * same way while their stream has not started, and make the same distinction for the error event + * they write once it has. */ private void completeNonStreaming(HttpExchange exchange, JsonNode request, BodyProducer producer) throws IOException { @@ -450,10 +454,7 @@ private void completeNonStreaming(HttpExchange exchange, JsonNode request, BodyP } private void streamChat(HttpExchange exchange, JsonNode request) throws IOException { - exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_SSE); - exchange.getResponseHeaders().set("Cache-Control", "no-cache"); - exchange.sendResponseHeaders(HTTP_OK, 0); - try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { + try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_SSE)) { ScheduledFuture heartbeat = null; try { heartbeat = heartbeatExecutor.scheduleAtFixedRate( @@ -467,14 +468,20 @@ private void streamChat(HttpExchange exchange, JsonNode request) throws IOExcept OpenAiSseFormatter.sseData(OpenAiSseFormatter.ensureUsageCachedTokens(chunkJson)))); out.writeStrict(OpenAiSseFormatter.sseDone()); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly( - OpenAiSseFormatter.sseData(OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))); + if (!out.answerIfNotStarted( + HTTP_BAD_REQUEST, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))) { + out.writeQuietly(OpenAiSseFormatter.sseData( + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))); + } } catch (IOException e) { LOG.debug("client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("streaming chat completion failed", e); - out.writeQuietly( - OpenAiSseFormatter.sseData(OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))); + if (!out.answerIfNotStarted( + HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) { + out.writeQuietly(OpenAiSseFormatter.sseData( + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))); + } } finally { // try-with-resources closes the stream (under its lock) after the heartbeat is cancelled, // so the close never races a still-in-flight heartbeat write. @@ -559,16 +566,13 @@ private void handleOllamaChat(HttpExchange exchange) throws IOException { /** Stream an Ollama {@code /api/chat} response as newline-delimited JSON, ending with a done line. */ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, String model) throws IOException { - exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_NDJSON); - exchange.getResponseHeaders().set("Cache-Control", "no-cache"); - exchange.sendResponseHeaders(HTTP_OK, 0); final ToolCallDeltaAccumulator accumulator = new ToolCallDeltaAccumulator(); // The done line reports the token counts, as the blocking answer does; they arrive in the // stream's usage chunk. final AtomicReference usage = new AtomicReference<>(); // and the done_reason, which the chunk ending the answer carries final AtomicReference finishReason = new AtomicReference<>(); - try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { + try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_NDJSON)) { try { backend.stream(withUsageChunk(openAiRequest), chunkJson -> { accumulator.accept(chunkJson); @@ -587,12 +591,16 @@ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, Str }); out.writeStrict(OllamaApiSupport.toOllamaDoneLine(model, accumulator, usage.get(), finishReason.get())); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly(ollamaError(message(e)) + "\n"); + if (!out.answerIfNotStarted(HTTP_BAD_REQUEST, ollamaError(message(e)))) { + out.writeQuietly(ollamaError(message(e)) + "\n"); + } } catch (IOException e) { LOG.debug("ollama client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("ollama streaming chat failed", e); - out.writeQuietly(ollamaError(message(e)) + "\n"); + if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, ollamaError(message(e)))) { + out.writeQuietly(ollamaError(message(e)) + "\n"); + } } } } @@ -681,12 +689,9 @@ private void handleAnthropicMessages(HttpExchange exchange) throws IOException { /** Stream an Anthropic {@code /v1/messages} response as the Anthropic SSE event sequence. */ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, String model) throws IOException { - exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_SSE); - exchange.getResponseHeaders().set("Cache-Control", "no-cache"); - exchange.sendResponseHeaders(HTTP_OK, 0); final AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_" + Long.toHexString(System.nanoTime()), model); - try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { + try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_SSE)) { ScheduledFuture heartbeat = null; try { heartbeat = heartbeatExecutor.scheduleAtFixedRate( @@ -694,7 +699,7 @@ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, Stri config.getHeartbeatMillis(), config.getHeartbeatMillis(), TimeUnit.MILLISECONDS); - out.writeStrict(translator.begin()); + out.startWith(translator.begin()); backend.stream(withUsageChunk(openAiRequest), chunkJson -> { String events = translator.onChunk(chunkJson); if (!events.isEmpty()) { @@ -703,14 +708,18 @@ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, Stri }); out.writeStrict(translator.end()); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly( - AnthropicApiSupport.sseEvent("error", anthropicError(message(e), ANTHROPIC_ERROR_REQUEST))); + String error = anthropicError(message(e), ANTHROPIC_ERROR_REQUEST); + if (!out.answerIfNotStarted(HTTP_BAD_REQUEST, error)) { + out.writeQuietly(AnthropicApiSupport.sseEvent("error", error)); + } } catch (IOException e) { LOG.debug("anthropic client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("anthropic streaming failed", e); - out.writeQuietly( - AnthropicApiSupport.sseEvent("error", anthropicError(message(e), ANTHROPIC_ERROR_SERVER))); + String error = anthropicError(message(e), ANTHROPIC_ERROR_SERVER); + if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, error)) { + out.writeQuietly(AnthropicApiSupport.sseEvent("error", error)); + } } finally { if (heartbeat != null) { heartbeat.cancel(false); @@ -779,11 +788,8 @@ private void handleResponses(HttpExchange exchange) throws IOException { /** Stream a Responses {@code /v1/responses} reply as the Responses SSE event sequence. */ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, String model, String responseId) throws IOException { - exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_SSE); - exchange.getResponseHeaders().set("Cache-Control", "no-cache"); - exchange.sendResponseHeaders(HTTP_OK, 0); final ResponsesStreamTranslator translator = new ResponsesStreamTranslator(model, responseId); - try (ResponseStream out = new ResponseStream(exchange.getResponseBody())) { + try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_SSE)) { ScheduledFuture heartbeat = null; try { heartbeat = heartbeatExecutor.scheduleAtFixedRate( @@ -791,7 +797,7 @@ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, Stri config.getHeartbeatMillis(), config.getHeartbeatMillis(), TimeUnit.MILLISECONDS); - out.writeStrict(translator.begin()); + out.startWith(translator.begin()); backend.stream(withUsageChunk(openAiRequest), chunkJson -> { String events = translator.onChunk(chunkJson); if (!events.isEmpty()) { @@ -800,16 +806,20 @@ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, Stri }); out.writeStrict(translator.end()); } catch (IllegalArgumentException | InvalidRequestException e) { - out.writeQuietly(translator.failed(message(e), ERROR_TYPE_REQUEST) - + "event: error\ndata: " - + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null) + "\n\n"); + String error = OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null); + if (!out.answerIfNotStarted(HTTP_BAD_REQUEST, error)) { + out.writeQuietly(translator.failed(message(e), ERROR_TYPE_REQUEST) + "event: error\ndata: " + error + + "\n\n"); + } } catch (IOException e) { LOG.debug("responses client disconnected during stream", e); } catch (RuntimeException e) { LOG.warn("responses streaming failed", e); - out.writeQuietly(translator.failed(message(e), ERROR_TYPE_SERVER) - + "event: error\ndata: " - + OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null) + "\n\n"); + String error = OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null); + if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, error)) { + out.writeQuietly( + translator.failed(message(e), ERROR_TYPE_SERVER) + "event: error\ndata: " + error + "\n\n"); + } } finally { if (heartbeat != null) { heartbeat.cancel(false); @@ -1041,27 +1051,68 @@ private void sendError(HttpExchange exchange, int status, String type, String me } /** - * Per-request, thread-safe wrapper over a streaming HTTP response body. Every write and the close are + * Per-request, thread-safe wrapper over a streaming HTTP response. Every write and the close are * serialized on a {@code private final} lock, so the generation thread and the heartbeat-timer task * never write to (or close) the same stream concurrently. The lock is owned by this per-request * instance rather than shared, so independent concurrent streams never serialize against each other. * It is {@link AutoCloseable} so callers drive it with try-with-resources, which closes the stream * (under the lock) on every exit path. + * + *

    The response starts with the first write: only then are {@code 200} and the stream's headers + * sent, followed by the text set with {@link #startWith(String)}. Until then + * {@link #answerIfNotStarted(int, String)} can still answer the request with an error status and a + * JSON body, which is what upstream's server does with a request it refuses before the first result. */ private static final class ResponseStream implements AutoCloseable { - private final OutputStream os; + private final HttpExchange exchange; + private final String contentType; private final Object lock = new Object(); + private String prelude = ""; + private @Nullable OutputStream os; + private boolean answered; + + ResponseStream(HttpExchange exchange, String contentType) { + this.exchange = exchange; + this.contentType = contentType; + } - ResponseStream(OutputStream os) { - this.os = os; + /** Text the stream opens with once it starts (a protocol's opening events). */ + void startWith(String text) { + synchronized (lock) { + prelude = text; + } + } + + /** The started stream, starting it if needed; null once the request was answered without one. */ + private @Nullable OutputStream body() throws IOException { + if (answered) { + return null; + } + OutputStream out = os; + if (out == null) { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.sendResponseHeaders(HTTP_OK, 0); + out = exchange.getResponseBody(); + os = out; + if (!prelude.isEmpty()) { + out.write(prelude.getBytes(StandardCharsets.UTF_8)); + } + } + return out; } /** Write under the lock, propagating failures so a streaming generation can be cancelled. */ void writeStrict(String text) throws IOException { synchronized (lock) { - os.write(text.getBytes(StandardCharsets.UTF_8)); - os.flush(); + OutputStream out = body(); + if (out == null) { + throw new IOException("the request was already answered without a stream; " + text.length() + + " characters dropped"); + } + out.write(text.getBytes(StandardCharsets.UTF_8)); + out.flush(); } } @@ -1069,19 +1120,53 @@ void writeStrict(String text) throws IOException { void writeQuietly(String text) { synchronized (lock) { try { - os.write(text.getBytes(StandardCharsets.UTF_8)); - os.flush(); + OutputStream out = body(); + if (out != null) { + out.write(text.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } } catch (IOException e) { LOG.trace("stream write failed (client likely disconnected)", e); } } } + /** + * Answer the request with {@code status} and a JSON body instead of a stream, if the stream has + * not started yet. + * + * @return {@code false} when the stream had already started; the caller then reports the error + * as an event of the stream + */ + boolean answerIfNotStarted(int status, String json) { + synchronized (lock) { + if (os != null) { + return false; + } + answered = true; + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + try { + exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE_JSON); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } catch (IOException e) { + LOG.trace("error response failed (client likely disconnected)", e); + } + return true; + } + } + @Override public void close() { synchronized (lock) { + OutputStream out = os; + if (out == null) { + return; + } try { - os.close(); + out.close(); } catch (IOException e) { LOG.trace("stream close failed", e); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index f59959a18..d891779b1 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -7,6 +7,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; @@ -563,26 +564,114 @@ public void nativeRejectionOfACompletionReturns400() throws IOException { } } + // Upstream reads the first result before it starts a stream, so a request refused at once is answered + // with the error status, as without "stream" (TODO (3), F182). The response starts with the first + // write -- a chunk, the protocol's opening events or a heartbeat -- and only a failure after that + // travels as an event. + + /** The five streaming routes: path, body, and the error body's distinguishing parts at 400 and 500. */ + private static final String[][] STREAMING_ROUTES = { + { + "/v1/chat/completions", + "{\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}", + "\"type\":\"invalid_request_error\"", + "\"type\":\"server_error\"" + }, + { + "/v1/completions", + "{\"stream\":true,\"prompt\":\"hi\"}", + "\"type\":\"invalid_request_error\"", + "\"type\":\"server_error\"" + }, + { + "/api/chat", + "{\"model\":\"test-model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}", + "{\"error\":", + "{\"error\":" + }, + { + "/v1/messages", + "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}", + "\"error\":{\"type\":\"invalid_request_error\"", + "\"error\":{\"type\":\"api_error\"" + }, + { + "/v1/responses", + "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}", + "\"type\":\"invalid_request_error\"", + "\"type\":\"server_error\"" + }, + }; + @Test - public void nativeRejectionOfAStreamedChatCompletionIsAnInvalidRequestEvent() throws IOException { + public void aRejectionBeforeTheFirstChunkIsA400OnEveryStreamingRoute() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new RejectingBackend(), config()).start()) { + for (String[] route : STREAMING_ROUTES) { + Response response = post(server.getPort(), route[0], route[1], ""); + assertThat(route[0], response.code, is(400)); + assertThat(route[0], response.contentType, startsWith("application/json")); + assertThat(route[0], response.body, containsString(route[2])); + assertThat(route[0], response.body, containsString(RejectingBackend.REASON_FRAGMENT)); + } + } + } + + @Test + public void aFailureBeforeTheFirstChunkIsA500OnEveryStreamingRoute() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new StreamFailingBackend(), config()).start()) { + for (String[] route : STREAMING_ROUTES) { + Response response = post(server.getPort(), route[0], route[1], ""); + assertThat(route[0], response.code, is(500)); + assertThat(route[0], response.body, containsString(route[3])); + assertThat(route[0], response.body, containsString(StreamFailingBackend.REASON)); + } + } + } + + @Test + public void aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent() throws IOException { + ScriptedStreamBackend backend = + new ScriptedStreamBackend(1, 0L, new InvalidRequestException(RejectingBackend.REASON)); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; Response response = post(server.getPort(), "/v1/chat/completions", body, ""); - // the headers are out before the backend runs, so the verdict travels as an SSE data event + // the stream had started, so the verdict travels as an SSE data event after the chunk assertThat(response.code, is(200)); - assertThat(response.body, containsString("data: ")); + assertThat(response.body, containsString("\"content\":\"he\"")); assertThat(response.body, containsString("\"type\":\"invalid_request_error\"")); assertThat(response.body, containsString(RejectingBackend.REASON_FRAGMENT)); } } + // A heartbeat starts the response too: it is what keeps a client waiting through a long prompt. + @Test + public void aRejectionAfterAHeartbeatIsAnEvent() throws IOException { + OpenAiServerConfig cfg = OpenAiServerConfig.builder() + .host("127.0.0.1") + .port(0) + .heartbeatMillis(50L) + .build(); + ScriptedStreamBackend backend = + new ScriptedStreamBackend(0, 300L, new InvalidRequestException(RejectingBackend.REASON)); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, cfg).start()) { + String body = "{\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + Response response = post(server.getPort(), "/v1/chat/completions", body, ""); + assertThat(response.code, is(200)); + assertThat(response.body, containsString(": ping")); + assertThat(response.body, containsString("\"type\":\"invalid_request_error\"")); + } + } + // A Responses client dispatches on the data's "type" and reads a stream failure from a // response.failed event (Codex: process_responses_event -> parse_failed_response, which reads // response.error.message); without one it reports only "stream closed before response.completed". @Test public void rejectedStreamedResponseEndsWithAResponseFailedEvent() throws IOException { - try (OpenAiCompatServer server = new OpenAiCompatServer(new RejectingBackend(), config()).start()) { + ScriptedStreamBackend backend = + new ScriptedStreamBackend(1, 0L, new InvalidRequestException(RejectingBackend.REASON)); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}"; String events = post(server.getPort(), "/v1/responses", body, "").body; assertThat(events, containsString("event: response.failed")); @@ -596,7 +685,9 @@ public void rejectedStreamedResponseEndsWithAResponseFailedEvent() throws IOExce @Test public void failedStreamedResponseEndsWithAResponseFailedEvent() throws IOException { - try (OpenAiCompatServer server = new OpenAiCompatServer(new StreamFailingBackend(), config()).start()) { + ScriptedStreamBackend backend = + new ScriptedStreamBackend(1, 0L, new LlamaException(StreamFailingBackend.REASON)); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}"; String events = post(server.getPort(), "/v1/responses", body, "").body; assertThat(events, containsString("event: response.failed")); @@ -644,7 +735,9 @@ public void aFailedAnthropicMessageIsAnApiError() throws IOException { @Test public void aFailedStreamedAnthropicMessageIsAnApiError() throws IOException { - try (OpenAiCompatServer server = new OpenAiCompatServer(new StreamFailingBackend(), config()).start()) { + ScriptedStreamBackend backend = + new ScriptedStreamBackend(1, 0L, new LlamaException(StreamFailingBackend.REASON)); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; String events = post(server.getPort(), "/v1/messages", body, "").body; @@ -657,7 +750,9 @@ public void aFailedStreamedAnthropicMessageIsAnApiError() throws IOException { @Test public void aRejectedStreamedAnthropicMessageStaysAnInvalidRequestError() throws IOException { - try (OpenAiCompatServer server = new OpenAiCompatServer(new RejectingBackend(), config()).start()) { + ScriptedStreamBackend backend = + new ScriptedStreamBackend(1, 0L, new InvalidRequestException(RejectingBackend.REASON)); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; String events = post(server.getPort(), "/v1/messages", body, "").body; @@ -963,6 +1058,11 @@ public void stream(JsonNode request, ChunkSink sink) { throw new InvalidRequestException(REASON); } + @Override + public void streamCompletions(JsonNode request, ChunkSink sink) { + throw new InvalidRequestException(REASON); + } + @Override public String completions(JsonNode request) { throw new InvalidRequestException(REASON); @@ -990,6 +1090,52 @@ static final class StreamFailingBackend extends FakeBackend { public void stream(JsonNode request, ChunkSink sink) { throw new LlamaException(REASON); } + + @Override + public void streamCompletions(JsonNode request, ChunkSink sink) { + throw new LlamaException(REASON); + } + } + + /** Backend whose streams emit {@code chunks} chunks, wait {@code delayMillis}, then fail with {@code failure}. */ + static final class ScriptedStreamBackend extends FakeBackend { + private final int chunks; + private final long delayMillis; + private final RuntimeException failure; + + ScriptedStreamBackend(int chunks, long delayMillis, RuntimeException failure) { + this.chunks = chunks; + this.delayMillis = delayMillis; + this.failure = failure; + } + + @Override + public void stream(JsonNode request, ChunkSink sink) throws IOException { + for (int i = 0; i < chunks; i++) { + sink.accept("{\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"he\"}}]}"); + } + pause(); + throw failure; + } + + @Override + public void streamCompletions(JsonNode request, ChunkSink sink) throws IOException { + for (int i = 0; i < chunks; i++) { + sink.accept("{\"object\":\"text_completion\",\"choices\":[{\"text\":\"he\",\"finish_reason\":null}]}"); + } + pause(); + throw failure; + } + + private void pause() { + if (delayMillis > 0) { + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } } /** Backend that stalls before emitting, so the server's heartbeat fires during the gap. */ diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerTestSupport.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerTestSupport.java index 7493e4d92..676b301b6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerTestSupport.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerTestSupport.java @@ -87,7 +87,7 @@ private static Response read(HttpURLConnection conn) throws IOException { String corsAllowOrigin = conn.getHeaderField("Access-Control-Allow-Origin"); InputStream is = code < 400 ? conn.getInputStream() : conn.getErrorStream(); String body = is == null ? "" : readAll(is); - return new Response(code, body, corsAllowOrigin); + return new Response(code, body, corsAllowOrigin, conn.getContentType()); } private static String readAll(InputStream is) throws IOException { @@ -100,16 +100,21 @@ private static String readAll(InputStream is) throws IOException { return new String(buffer.toByteArray(), UTF_8); } - /** Captured HTTP response: status code, body text, and the {@code Access-Control-Allow-Origin} header. */ + /** + * Captured HTTP response: status code, body text, the {@code Access-Control-Allow-Origin} and the + * {@code Content-Type} header. + */ static final class Response { final int code; final String body; final @Nullable String corsAllowOrigin; + final @Nullable String contentType; - Response(int code, String body, @Nullable String corsAllowOrigin) { + Response(int code, String body, @Nullable String corsAllowOrigin, @Nullable String contentType) { this.code = code; this.body = body; this.corsAllowOrigin = corsAllowOrigin; + this.contentType = contentType; } } } From 9dff21337ce7d604675cba0ba32e1d060f0eb285 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:16:18 +0000 Subject: [PATCH 325/589] Record the commit of F182 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 421170c2b..ed652ba25 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1360,7 +1360,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#aRejectionBeforeTheFirstChunkIsA400OnEveryStreamingRoute`, `#aFailureBeforeTheFirstChunkIsA500OnEveryStreamingRoute`, `#aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent`, `#aRejectionAfterAHeartbeatIsAnEvent`; die vier bestehenden Ereignis-Tests (Responses `response.failed`, Anthropic-Fehlertypen) laufen jetzt mit einem Backend, das nach einem Chunk scheitert (`ScriptedStreamBackend`). Im Agenten `AtmosphereWireContractTest#engineFailureBeforeTheFirstChunkFailsTheTurn` (neu) und `#midStreamEngineFailureCompletesSilentlyRatherThanErroring` (jetzt wirklich nach einem Chunk). - **Rot-Grün-Nachweis:** vor dem Umbau rot (die beiden Vor-dem-ersten-Chunk-Tests), danach 63/63 in `OpenAiCompatServerHttpTest` und alle Server-Testklassen grün; der Agent gegen den installierten neuen Kern: vorher rot (`API returned 500: model exploded` statt `null`), nach der Aufteilung `verify` grün (278 Tests, 63 übersprungen, modell- oder bildschirmgebunden); SpotBugs grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `395d9d1` ## Offene Verdachte ohne Fund-ID From 2b62481d3e9f066b4ac5bf242ea2880c613d1e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:23:35 +0000 Subject: [PATCH 326/589] Report the matched stop word as Anthropic stop_sequence (F183) /v1/messages translates from the OpenAI answer, which does not carry the stop word, so stop_sequence was always null; upstream sets it from stopping_word and keeps stop_reason at end_turn. The translated request now asks for upstream's "verbose" answer, whose __verbose.stopping_word holds the word; the blocking translation and the stream translator read it, and neither passes __verbose on. New RequestField.VERBOSE (SCHEMA, checked against upstream's schema by test_wire_contracts.cpp) and InferenceParameters.withVerbose; the OpenAI mapper forwards a client's "verbose". Red with a no-op setter (5 tests), green with the fix; verified end to end through the real server with a random model and a stop word taken from its own greedy output, blocking and streamed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++- CHANGELOG.md | 6 ++++ TODO.md | 1 - .../llama/parameters/InferenceParameters.java | 13 +++++++ .../llama/parameters/RequestField.java | 3 ++ .../llama/server/AnthropicApiSupport.java | 36 ++++++++++++++++--- .../server/AnthropicStreamTranslator.java | 12 ++++++- .../llama/server/OpenAiRequestMapper.java | 4 +++ .../parameters/InferenceParametersTest.java | 1 + .../llama/server/AnthropicApiSupportTest.java | 34 ++++++++++++++++++ .../server/AnthropicStreamTranslatorTest.java | 21 +++++++++++ .../llama/server/OpenAiRequestMapperTest.java | 9 +++++ 12 files changed, 146 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ed652ba25..dee226da0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1362,9 +1362,20 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `395d9d1` +### F183 – java-llama.cpp: Anthropic `stop_sequence` ist immer `null` +- **Zeit:** 2026-10-20 02:25 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest`, `toAnthropicResponse`, `stopSequenceOf`, `messageDeltaEvent`), `AnthropicStreamTranslator.java`, `OpenAiRequestMapper.java`, `parameters/InferenceParameters.java` (`withVerbose`), `parameters/RequestField.java` (`VERBOSE`) +- **Schwere:** niedrig (ein Client mit mehreren `stop_sequences` erfuhr nicht, welche gegriffen hat, und konnte einen Stopp durch das Wort nicht von einem Ende des Modells unterscheiden) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (12)). Upstream setzt `stop_sequence` in `to_json_anthropic` und `to_json_anthropic_stream` aus `stopping_word` (`server-task.cpp:778`/`:959`, b11639); `stop_reason` bleibt `end_turn`. Das Feld `verbose` liest upstreams Schema (`server-schema.cpp:17`), `__verbose` enthält dann `to_json_non_oaicompat()` mit `stopping_word` (blockierend am Objekt, gestreamt am ersten Chunk des Endergebnisses). +- **Beschreibung:** `/v1/messages` übersetzt aus der OpenAI-Antwort, und die trägt das Stoppwort nicht; `stop_sequence` war deshalb fest `null`, blockierend wie gestreamt. +- **Fix:** Die übersetzte Anfrage verlangt `verbose: true`; `stopSequenceOf` liest `__verbose.stopping_word` (leer = `null`), `toAnthropicResponse` und der Stream-Übersetzer setzen es als `stop_sequence`, der `message_delta` trägt es. `__verbose` erreicht den Client nicht, weil beide Antworten neu aufgebaut werden. Dafür gibt es `RequestField.VERBOSE` (Vertrag `SCHEMA`, vom C++-Vertragstest gegen upstreams Schema geprüft) und `InferenceParameters.withVerbose`; der OpenAI-Mapper reicht ein `verbose` des Clients weiter. +- **Tests:** `AnthropicApiSupportTest#theTranslatedRequestAsksForTheStopWord`, `#theMatchedStopWordIsTheStopSequence`, `#noMatchedStopWordIsANullStopSequence`; `AnthropicStreamTranslatorTest#theMatchedStopWordIsTheStopSequenceOfTheMessageDelta`, `#withoutAStopWordTheStopSequenceIsNull`; `OpenAiRequestMapperTest#verboseIsForwarded`; `InferenceParametersTest` (`withVerbose`); C++ `test_wire_contracts.cpp` (`verbose` im Schema, 5/5). +- **Rot-Grün-Nachweis:** mit wirkungslosem Platzhalter-Setter rot (5 Tests, die beiden Null-Fälle waren schon grün), mit dem Fix grün (251 Tests der berührten Klassen, `OpenAiCompatServerHttpTest` und `WireNameRegistryTest` eingeschlossen); SpotBugs grün. Zusätzlich Ende-zu-Ende mit einem Zufallsmodell über den echten Server: ohne Stoppwort `max_tokens`/`null`, mit einem Stück der eigenen Greedy-Ausgabe als Stoppwort blockierend und gestreamt `end_turn` mit `stop_sequence: "j%j"`, kein `__verbose` in der Antwort. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. -- *(niedrig, nur beschrieben, 2026-10-18)* Anthropic `stop_sequence` ist bei uns immer `null`; upstreams `to_json_anthropic` (`server-task.cpp:778`) setzt das getroffene Stoppwort (bei `stop_reason` `end_turn`). Die OpenAI-Antwort, aus der `/v1/messages` übersetzt, trägt das Stoppwort nicht; ein Fix bräuchte es aus der nativen Antwort (`stopping_word` der Nicht-OAI-Form). Sonst stimmen blockierender und gestreamter Pfad überein (Usage mit `cache_read_input_tokens`, `stop_reason`-Abbildung). - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. - *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a29985d..5a052e1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,12 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **`/v1/messages` reports the matched stop word as `stop_sequence`**, blocking and streamed, as + upstream does (`stop_reason` stays `end_turn`); it was always `null`. The OpenAI answer the route + translates from does not carry the word, so the translated request asks for llama.cpp's `verbose` + answer and reads `__verbose.stopping_word`, which never reaches the client. New + `InferenceParameters.withVerbose(boolean)` (`RequestField.VERBOSE`), and the OpenAI mapper forwards + a client's `verbose`. - **A streamed request that fails before its first chunk is answered with the error status.** `OpenAiCompatServer` sent `200` and the stream's headers before the engine ran, so a request llama.cpp refused at once arrived as `200` with an error event -- on all five streaming routes diff --git a/TODO.md b/TODO.md index 273415b98..4a70b4a30 100644 --- a/TODO.md +++ b/TODO.md @@ -516,7 +516,6 @@ delete an item here when it is done. **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; numbered for reference, the numbers stay when an item is done): -- **(12)** Anthropic `stop_sequence` is always `null`; upstream sets the matched stop word. - **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a `prompt` given as an array is rejected with 400 when streamed, accepted when not. Since F180 `logprobs`/`n_probs` are refused on it too (400, pointing to `stream: false`), because diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java index 4c590d531..afd75ada4 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java @@ -889,6 +889,19 @@ public InferenceParameters withTimingsPerToken(boolean timingsPerToken) { return withScalar(RequestField.TIMINGS_PER_TOKEN, timingsPerToken); } + /** + * Returns a new request asking for llama.cpp's debug object ({@code verbose}, default false). When + * true, the answer -- the last chunk of a stream -- carries a {@code __verbose} object with the + * native result: the formatted prompt, the generation settings, the stop type and the matched stop + * word ({@code stopping_word}), among others. + * + * @param verbose whether to include the {@code __verbose} object + * @return a new instance; this instance is unchanged + */ + public InferenceParameters withVerbose(boolean verbose) { + return withScalar(RequestField.VERBOSE, verbose); + } + /** * Returns a new request toggling raw token-id output ({@code return_tokens}, default false). * When true, the response includes the generated token ids alongside the decoded text. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java b/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java index dbb8b3b0d..8d5d6e996 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java @@ -184,6 +184,9 @@ enum RequestField { /** Request key {@code typical_p}. */ TYPICAL_P("typical_p"), + /** Request key {@code verbose}. */ + VERBOSE("verbose"), + /** Request key {@code xtc_probability}. */ XTC_PROBABILITY("xtc_probability"), diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 4c7ab1c8e..3eb333aeb 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import java.io.IOException; +import org.jspecify.annotations.Nullable; /** * Pure translators between the Anthropic Messages API ({@code POST /v1/messages}) and the internal @@ -119,6 +120,9 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { if (request.path("stop_sequences").isArray()) { openAi.set("stop", request.path("stop_sequences").deepCopy()); } + // Upstream answers with the matched stop word as stop_sequence; the OpenAI answer this route + // translates from does not carry it, upstream's debug object does (__verbose.stopping_word). + openAi.put("verbose", true); // Passed through like upstream; the chat mapper hands it to the template. JsonNode templateKwargs = request.path("chat_template_kwargs"); if (templateKwargs.isObject()) { @@ -381,6 +385,7 @@ static String toAnthropicResponse(String openAiCompletionJson, String model) { root.put("model", model); ArrayNode content = root.putArray("content"); String stopReason = "end_turn"; + String stopSequence = null; ObjectNode usage = root.putObject("usage"); usage.put("input_tokens", 0); usage.put("output_tokens", 0); @@ -421,11 +426,16 @@ static String toAnthropicResponse(String openAiCompletionJson, String model) { usage.put("cache_read_input_tokens", cachedTokens); usage.put("output_tokens", openAiUsage.path("completion_tokens").asLong(0)); } + stopSequence = stopSequenceOf(completion); } catch (IOException e) { stopReason = "end_turn"; } root.put("stop_reason", stopReason); - root.putNull("stop_sequence"); + if (stopSequence != null) { + root.put("stop_sequence", stopSequence); + } else { + root.putNull("stop_sequence"); + } return root.toString(); } @@ -459,6 +469,19 @@ private static JsonNode parseToObject(JsonNode arguments) { return OBJECT_MAPPER.createObjectNode(); } + /** + * The stop word that ended the answer, as upstream reports it in {@code stop_sequence}: read from the + * {@code __verbose} object the translated request asks for, {@code null} when generation ended + * otherwise or the object is absent. + * + * @param openAiAnswer an OpenAI chat answer or stream chunk + * @return the matched stop word, or {@code null} + */ + static @Nullable String stopSequenceOf(JsonNode openAiAnswer) { + String word = openAiAnswer.path("__verbose").path("stopping_word").asText(""); + return word.isEmpty() ? null : word; + } + /** Map an OpenAI finish_reason to an Anthropic stop_reason. */ static String anthropicStopReason(String openAiFinishReason) { switch (openAiFinishReason) { @@ -596,16 +619,21 @@ static String blockStopEvent(int index) { /** {@code message_delta} event carrying the final stop reason. */ static String messageDeltaEvent(String stopReason) { - return messageDeltaEvent(stopReason, 0, 0, 0); + return messageDeltaEvent(stopReason, null, 0, 0, 0); } /** Final message delta carrying token usage collected from the trailing OpenAI usage chunk. */ - static String messageDeltaEvent(String stopReason, long inputTokens, long outputTokens, long cachedTokens) { + static String messageDeltaEvent( + String stopReason, @Nullable String stopSequence, long inputTokens, long outputTokens, long cachedTokens) { ObjectNode data = OBJECT_MAPPER.createObjectNode(); data.put("type", "message_delta"); ObjectNode delta = data.putObject("delta"); delta.put("stop_reason", stopReason); - delta.putNull("stop_sequence"); + if (stopSequence != null) { + delta.put("stop_sequence", stopSequence); + } else { + delta.putNull("stop_sequence"); + } ObjectNode usage = data.putObject("usage"); usage.put("input_tokens", inputTokens); usage.put("output_tokens", outputTokens); diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java index bd6ac4e70..8fab8965b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import lombok.ToString; +import org.jspecify.annotations.Nullable; /** * Stateful translator that turns the OpenAI streaming chat chunks into the Anthropic Messages SSE event @@ -35,6 +36,7 @@ final class AnthropicStreamTranslator { private int textBlockIndex = -1; private int nextIndex; private String finishReason = "stop"; + private @Nullable String stopSequence; private long inputTokens; private long outputTokens; private long cachedTokens; @@ -75,6 +77,10 @@ String onChunk(String openAiChunkJson) { inputTokens = Math.max(0L, promptTokens - cachedTokens); outputTokens = usage.path("completion_tokens").asLong(0); } + String word = AnthropicApiSupport.stopSequenceOf(chunk); + if (word != null) { + stopSequence = word; + } JsonNode choice = chunk.path("choices").path(0); // Reasoning streams as a thinking block, as in upstream's to_json_anthropic_stream. JsonNode reasoning = choice.path("delta").path("reasoning_content"); @@ -130,7 +136,11 @@ String end() { } } out.append(AnthropicApiSupport.messageDeltaEvent( - AnthropicApiSupport.anthropicStopReason(finishReason), inputTokens, outputTokens, cachedTokens)); + AnthropicApiSupport.anthropicStopReason(finishReason), + stopSequence, + inputTokens, + outputTokens, + cachedTokens)); out.append(AnthropicApiSupport.messageStopEvent()); return out.toString(); } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 86e8e91ca..6ec7d8142 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -161,6 +161,10 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo if (nProbs.isInt()) { params = params.withNProbs(nProbs.asInt()); } + JsonNode verbose = request.path("verbose"); + if (verbose.isBoolean()) { + params = params.withVerbose(verbose.asBoolean()); + } // Preserve llama.cpp extensions when advanced clients opt into them. JsonNode cachePrompt = request.path("cache_prompt"); if (cachePrompt.isBoolean()) { diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java index e75b1fc9c..cfca50bac 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java @@ -100,6 +100,7 @@ public void testAdditionalCompletionScalarsFromB9864Audit() { assertThat( InferenceParameters.empty().withTimingsPerToken(true).parameters.get("timings_per_token"), is("true")); assertThat(InferenceParameters.empty().withReturnTokens(true).parameters.get("return_tokens"), is("true")); + assertThat(InferenceParameters.empty().withVerbose(true).parameters.get("verbose"), is("true")); } @Test diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 94958f9e9..d128c7f39 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -259,6 +259,40 @@ public void requestConcatenatesSystemBlocksAndMapsStopSequences() throws IOExcep assertThat(openAi.path("stop").get(0).asText(), is("X")); } + // Upstream reports the matched stop word as stop_sequence (stop_reason stays end_turn). The OpenAI + // answer the route translates from does not carry it, so the translation asks for upstream's + // "verbose" answer, whose __verbose.stopping_word does (F183). + + @Test + public void theTranslatedRequestAsksForTheStopWord() throws IOException { + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stop_sequences\":[\"X\"]}")); + assertThat(openAi.path("verbose").asBoolean(false), is(true)); + } + + @Test + public void theMatchedStopWordIsTheStopSequence() throws IOException { + JsonNode response = read(AnthropicApiSupport.toAnthropicResponse( + "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"a\"}," + + "\"finish_reason\":\"stop\"}],\"__verbose\":{\"stopping_word\":\"X\",\"prompt\":\"p\"}}", + "m")); + assertThat(response.path("stop_reason").asText(), is("end_turn")); + assertThat(response.path("stop_sequence").asText(), is("X")); + assertThat("the debug object stays inside", response.has("__verbose"), is(false)); + } + + @Test + public void noMatchedStopWordIsANullStopSequence() throws IOException { + JsonNode eos = read(AnthropicApiSupport.toAnthropicResponse( + "{\"choices\":[{\"message\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]," + + "\"__verbose\":{\"stopping_word\":\"\"}}", + "m")); + assertThat(eos.path("stop_sequence").isNull(), is(true)); + JsonNode plain = read(AnthropicApiSupport.toAnthropicResponse( + "{\"choices\":[{\"message\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]}", "m")); + assertThat(plain.path("stop_sequence").isNull(), is(true)); + } + @Test public void toolChoiceNoneForbidsToolCalls() throws IOException { // Anthropic's {"type":"none"} forbids tool use; left out, the request ran as "auto" and the model diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java index 5514f23ca..f35fbceee 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java @@ -35,6 +35,27 @@ public void firstTextDeltaOpensBlockThenSubsequentDeltasAppend() { assertThat(second, containsString("\"text\":\"llo\"")); } + @Test + public void theMatchedStopWordIsTheStopSequenceOfTheMessageDelta() { + // upstream puts __verbose on the first chunk of the final result (F183) + AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}"); + String last = translator.onChunk("{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]," + + "\"__verbose\":{\"stopping_word\":\"END\"}}"); + assertThat(last.contains("__verbose"), is(false)); + String end = translator.end(); + assertThat(end, containsString("\"stop_reason\":\"end_turn\"")); + assertThat(end, containsString("\"stop_sequence\":\"END\"")); + } + + @Test + public void withoutAStopWordTheStopSequenceIsNull() { + AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]," + + "\"__verbose\":{\"stopping_word\":\"\"}}"); + assertThat(translator.end(), containsString("\"stop_sequence\":null")); + } + @Test public void endClosesTextBlockAndEmitsStopReasonAndMessageStop() { AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 6e492021b..baa7b7398 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -71,6 +71,15 @@ public void logitBiasAndNProbsAreForwardedOnBothPaths() throws IOException { assertThat(completion.path("logit_bias").path(0).path(1).asDouble(), closeTo(1.5, 1e-9)); } + @Test + public void verboseIsForwarded() throws IOException { + // upstream's "verbose" adds __verbose to the answer, which carries the matched stop word (F183) + JsonNode chat = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"verbose\":true}"); + assertThat(chat.path("verbose").asBoolean(false), is(true)); + JsonNode without = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"); + assertThat(without.has("verbose"), is(false)); + } + @Test public void moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack() throws IOException { // Upstream reads "n" as n_cmpl; these paths return one answer, so n > 1 silently gave one. From bf91066b00d70f76e68556b4c97db9e8a98886b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:23:35 +0000 Subject: [PATCH 327/589] Record the commit of F183 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index dee226da0..844f0fa9e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1372,7 +1372,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#theTranslatedRequestAsksForTheStopWord`, `#theMatchedStopWordIsTheStopSequence`, `#noMatchedStopWordIsANullStopSequence`; `AnthropicStreamTranslatorTest#theMatchedStopWordIsTheStopSequenceOfTheMessageDelta`, `#withoutAStopWordTheStopSequenceIsNull`; `OpenAiRequestMapperTest#verboseIsForwarded`; `InferenceParametersTest` (`withVerbose`); C++ `test_wire_contracts.cpp` (`verbose` im Schema, 5/5). - **Rot-Grün-Nachweis:** mit wirkungslosem Platzhalter-Setter rot (5 Tests, die beiden Null-Fälle waren schon grün), mit dem Fix grün (251 Tests der berührten Klassen, `OpenAiCompatServerHttpTest` und `WireNameRegistryTest` eingeschlossen); SpotBugs grün. Zusätzlich Ende-zu-Ende mit einem Zufallsmodell über den echten Server: ohne Stoppwort `max_tokens`/`null`, mit einem Stück der eigenen Greedy-Ausgabe als Stoppwort blockierend und gestreamt `end_turn` mit `stop_sequence: "j%j"`, kein `__verbose` in der Antwort. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `a575714` ## Offene Verdachte ohne Fund-ID From ffb3a8c7c8e695684b9b9f9f8dd0d89bfec4b6d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:26:20 +0000 Subject: [PATCH 328/589] Refuse several prompts in one request instead of answering the first (F184) populate_completion_task kept tokenized_prompts[0], and every path that uses it posts one task: /v1/completions, handleCompletions and the raw stream answered {"prompt": ["Hi", "Ho"]} with one choice and dropped "Ho" without a word. Upstream answers each prompt. More than one prompt is now an invalid request naming the count; a token array is one prompt and stays accepted. Red/green in test_c_api.cpp on a random model (641/641). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++++++++++ CHANGELOG.md | 5 +++++ CLAUDE.md | 4 ++-- llama/src/main/cpp/jllama_engine.cpp | 7 +++++++ llama/src/test/cpp/test_c_api.cpp | 23 +++++++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 844f0fa9e..b20a0b381 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1374,6 +1374,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `a575714` +### F184 – java-llama.cpp: Mehrere Prompts in einer Anfrage: alle außer dem ersten still verworfen +- **Zeit:** 2026-10-20 02:50 UTC +- **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`populate_completion_task`) +- **Schwere:** mittel (still: zwei Prompts, eine Antwort; ein Client, der nach `index` zuordnet, wartet auf eine Antwort, die nie kommt) +- **Historie:** Gefunden bei der Arbeit an TODO (14): `populate_completion_task` übernimmt nur `tokenized_prompts[0]`, und alle drei Wege, die es nutzen (blockierendes `/v1/completions` über `completions_oai`, `handleCompletions`, der Roh-Stream), posten genau eine Aufgabe. Upstream (`handle_completions_impl`, b11639) legt pro Prompt eine Aufgabe an und antwortet mit einer Wahl je Prompt. +- **Beschreibung:** `{"prompt": ["Hi", "Ho"]}` lieferte `200` mit einer einzigen Wahl für `Hi`; `Ho` verschwand ohne Hinweis. Eine Token-Liste (`[72, 105]`) ist dagegen ein einziger Prompt. +- **Fix:** Mehr als ein Prompt ist eine ungültige Anfrage (`400`, `a request takes one prompt, this one has N prompts; send them as separate requests`). Mehrere Prompts wirklich zu bedienen hieße mehrere Aufgaben je Anfrage und, gestreamt, eine Zusammenführung nach `index` – für keinen Weg hier vorhanden, und keiner der Clients, für die der Server gebaut ist, schickt das. +- **Tests:** C++ `CApi.SeveralPromptsInOneRequestAreRefusedNotDropped` (`test_c_api.cpp`, Zufallsmodell): zwei Prompts auf allen drei Wegen abgelehnt, mit der Anzahl in der Meldung; eine Token-Liste und ein Array mit einem Prompt angenommen. +- **Rot-Grün-Nachweis:** vor dem Fix rot (alle drei Wege `JLLAMA_STATUS_OK`), danach grün; ganze C++-Suite 641/641. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a052e1c6..be3b752e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,11 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **Several prompts in one request are refused instead of answered with the first.** A task carries + one prompt, and `/v1/completions` (and `handleCompletions`, the raw stream) took the first of + `"prompt": ["a", "b"]` and dropped the rest without a word -- two prompts, one choice. Upstream + answers each; here the request is now an invalid request (`400`) naming the count. A token array is + one prompt and stays accepted. - **`/v1/messages` reports the matched stop word as `stop_sequence`**, blocking and streamed, as upstream does (`stop_reason` stays `end_turn`); it was always `null`. The OpenAI answer the route translates from does not carry the word, so the translated request asks for llama.cpp's `verbose` diff --git a/CLAUDE.md b/CLAUDE.md index 52e0e2141..08eba6ce5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2619,12 +2619,12 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). | | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. | -| `src/test/cpp/test_c_api.cpp` | 27 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | +| `src/test/cpp/test_c_api.cpp` | 28 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request refused on the blocking, raw and streamed paths while a token array stays one prompt), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 640 tests (all passing).** +**Current total: 641 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/llama/src/main/cpp/jllama_engine.cpp b/llama/src/main/cpp/jllama_engine.cpp index 8f2854662..8ad88915c 100644 --- a/llama/src/main/cpp/jllama_engine.cpp +++ b/llama/src/main/cpp/jllama_engine.cpp @@ -355,6 +355,13 @@ void populate_completion_task(server_task &task, jllama_context *jctx, if (!configure_multimodal_task_impl(task, has_mtmd, data, std::move(files))) { auto tokenized_prompts = tokenize_input_prompts(jctx->vocab, nullptr, data.at("prompt"), true, true, mtmd_helper_init_opt_default()); + // A task carries one prompt. Upstream answers several prompts with one choice each; taking the + // first here dropped the others without a word, so they are refused instead. + if (tokenized_prompts.size() > 1) { + throw jllama_invalid_request("a request takes one prompt, this one has " + + std::to_string(tokenized_prompts.size()) + + " prompts; send them as separate requests"); + } if (!tokenized_prompts.empty()) { task.tokens = std::move(tokenized_prompts[0]); } diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index 60a127b99..d0902bf5b 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -259,6 +259,29 @@ TEST_F(CApi, OaiCompletionsReturnATextCompletionObject) { EXPECT_EQ(jllama_completions_oai(model_, R"({"max_tokens":2})", &response.s), JLLAMA_STATUS_INVALID_REQUEST); } +// Upstream answers several prompts in one request with one choice each; a task here carries one +// prompt, and every prompt after the first was dropped without a word -- two prompts, one answer +// (F184). They are refused instead; a token array is one prompt and stays accepted. +TEST_F(CApi, SeveralPromptsInOneRequestAreRefusedNotDropped) { + Owned response; + EXPECT_EQ(jllama_completions_oai(model_, R"({"prompt":["Hi","Ho"],"max_tokens":2})", &response.s), + JLLAMA_STATUS_INVALID_REQUEST); + EXPECT_NE(std::string(jllama_last_error()).find("2 prompts"), std::string::npos) << jllama_last_error(); + EXPECT_EQ(jllama_completions(model_, R"({"prompt":["Hi","Ho"],"n_predict":2})", &response.s), + JLLAMA_STATUS_INVALID_REQUEST); + int task = -1; + EXPECT_EQ(jllama_completions_stream_request(model_, R"({"prompt":["Hi","Ho"],"n_predict":2,"stream":true})", &task), + JLLAMA_STATUS_INVALID_REQUEST); + + ASSERT_EQ(jllama_completions_oai(model_, R"({"prompt":[72,105],"max_tokens":2,"temperature":0})", &response.s), + JLLAMA_STATUS_OK) + << jllama_last_error(); + EXPECT_EQ(json::parse(response.str()).at("choices").size(), 1u); + ASSERT_EQ(jllama_completions_oai(model_, R"({"prompt":["Hi"],"max_tokens":2,"temperature":0})", &response.s), + JLLAMA_STATUS_OK) + << jllama_last_error(); +} + TEST_F(CApi, RawStreamingDeliversResultsWithAStopKeyAndReleasesTheTask) { int task = -1; ASSERT_EQ(jllama_completions_stream_request( From 611d466ab8ddae4a1ad455c27ab8bb78a62f8bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:26:21 +0000 Subject: [PATCH 329/589] Record the commit of F184 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b20a0b381..e4bc0b72e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1384,7 +1384,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `CApi.SeveralPromptsInOneRequestAreRefusedNotDropped` (`test_c_api.cpp`, Zufallsmodell): zwei Prompts auf allen drei Wegen abgelehnt, mit der Anzahl in der Meldung; eine Token-Liste und ein Array mit einem Prompt angenommen. - **Rot-Grün-Nachweis:** vor dem Fix rot (alle drei Wege `JLLAMA_STATUS_OK`), danach grün; ganze C++-Suite 641/641. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `c4062bd` ## Offene Verdachte ohne Fund-ID From a8faaefe48c54abcc7817e6d9ac462aa86afaf13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:34:43 +0000 Subject: [PATCH 330/589] Give every OpenAI answer an id and the model name (F185) Upstream sets oaicompat_cmpl_id = gen_chatcmplid() and oaicompat_model = the server's model name on every completion task; the engine set neither, so every native OpenAI answer and stream chunk carried "id":"" and "model":"". name_completion_task now does it for blocking and streamed tasks (one id per stream). With that, OpenAiCompatServer --model-id x would list "x" under /v1/models but answer with the file name, so the CLI passes the id on as llama.cpp's alias: new ModelOption.ALIAS (--alias, held to upstream's server parser by test_model_flags.cpp) and ModelParameters.setAlias. Red/green in test_c_api.cpp (642/642) and in ModelParametersTest / OpenAiServerCliTest; verified through the real server with a random model. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 +++++ CHANGELOG.md | 5 ++ CLAUDE.md | 4 +- llama/src/main/cpp/jllama_engine.cpp | 10 ++++ .../net/ladenthin/llama/args/ModelOption.java | 3 ++ .../llama/parameters/ModelParameters.java | 14 ++++++ .../llama/server/OpenAiServerCli.java | 4 ++ llama/src/test/cpp/test_c_api.cpp | 49 +++++++++++++++++++ .../llama/parameters/ModelParametersTest.java | 7 +++ .../llama/server/OpenAiServerCliTest.java | 18 +++++++ 10 files changed, 124 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e4bc0b72e..3fa024c61 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1386,6 +1386,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `c4062bd` +### F185 – java-llama.cpp: OpenAI-Antworten ohne `id` und ohne `model` +- **Zeit:** 2026-10-20 03:20 UTC +- **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`name_completion_task`, aufgerufen von `blocking_completion` und `streaming_request`); `args/ModelOption.java` (`ALIAS`), `parameters/ModelParameters.java` (`setAlias`), `server/OpenAiServerCli.java` (`toModelParameters`) +- **Schwere:** niedrig bis mittel (still: ein Client, der Antworten oder Stream-Chunks nach `id` zuordnet oder das Modell aus `model` liest, bekam leere Zeichenketten) +- **Historie:** Gefunden bei der Arbeit an TODO (14), als die OAI-Antworten mit einem Zufallsmodell angesehen wurden. Upstream setzt in `handle_completions_impl` (`server-context.cpp:4884`, b11639) `oaicompat_cmpl_id = gen_chatcmplid()` und `oaicompat_model = meta->model_name` für jede Aufgabe; die Engine setzte keins von beiden, seit sie die Aufgaben selbst baut. +- **Beschreibung:** `/v1/chat/completions`, `/v1/completions` und jeder Stream-Chunk (also auch die Übersetzungen) trugen `"id":""` und `"model":""`. Nach dem Engine-Fix hätte `OpenAiCompatServer --model-id x` außerdem in `/v1/models` `x` gemeldet, in den Antworten aber den Dateinamen, weil die CLI die Id nicht an llama.cpp weitergab. +- **Fix:** `name_completion_task` setzt beide Felder wie upstream (eine `chatcmpl-`-Id je Aufgabe, also je Stream eine). Neu `ModelOption.ALIAS` (`--alias`, Vertrag `SERVER_PARSER`, vom C++-Vertragstest gegen upstreams Parser geprüft) und `ModelParameters.setAlias`; die CLI reicht `--model-id` als Alias weiter. +- **Tests:** C++ `CApi.OpenAiAnswersCarryAnIdAndTheModelName` (`test_c_api.cpp`, Zufallsmodell: blockierende Completion und Chat mit `chatcmpl-`-Id und dem Modellnamen aus den Meta-Daten, verschiedene Ids, ein Stream mit genau einer Id über alle Chunks); Java `ModelParametersTest#testSetAlias`, `OpenAiServerCliTest#anExplicitModelIdBecomesTheModelsAlias`; `test_model_flags.cpp` (`--alias` im Server-Parser). +- **Rot-Grün-Nachweis:** C++ vor dem Fix rot (`id` und `model` leer), danach grün, Suite 642/642; Java mit wirkungslosem Platzhalter rot (2), danach grün (304 Tests der Parameter-, CLI- und Registry-Klassen). Ende-zu-Ende über den echten Server mit `--model-id my-model`: `/v1/models` und die Chat-Antwort nennen beide `my-model`. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. diff --git a/CHANGELOG.md b/CHANGELOG.md index be3b752e1..a2356c056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,11 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **OpenAI answers carry an id and the model name.** Every native OpenAI answer and stream chunk had + `"id": ""` and `"model": ""`; upstream gives each completion a `chatcmpl-` id (one per stream) and + names the model it runs (the alias, else the model's name). They do now. New + `ModelParameters.setAlias(String)` (`--alias`), and `OpenAiCompatServer --model-id` passes the id on + as the alias, so the answers name the model `/v1/models` lists. - **Several prompts in one request are refused instead of answered with the first.** A task carries one prompt, and `/v1/completions` (and `handleCompletions`, the raw stream) took the first of `"prompt": ["a", "b"]` and dropped the rest without a word -- two prompts, one choice. Upstream diff --git a/CLAUDE.md b/CLAUDE.md index 08eba6ce5..8bc7ac7ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2619,12 +2619,12 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). | | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. | -| `src/test/cpp/test_c_api.cpp` | 28 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request refused on the blocking, raw and streamed paths while a token array stays one prompt), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | +| `src/test/cpp/test_c_api.cpp` | 29 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 641 tests (all passing).** +**Current total: 642 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/llama/src/main/cpp/jllama_engine.cpp b/llama/src/main/cpp/jllama_engine.cpp index 8ad88915c..836f99557 100644 --- a/llama/src/main/cpp/jllama_engine.cpp +++ b/llama/src/main/cpp/jllama_engine.cpp @@ -370,6 +370,14 @@ void populate_completion_task(server_task &task, jllama_context *jctx, configure_task_slot_impl(task, data); } +// The id and the model name every OpenAI answer and chunk carries, set as upstream's +// handle_completions_impl sets them: a fresh completion id, and the name the server answers to (the +// alias, else the model's name). Left unset, they were "" in every answer. +static void name_completion_task(server_task &task, const server_context_meta &meta) { + task.params.oaicompat_cmpl_id = gen_chatcmplid(); + task.params.oaicompat_model = meta.model_name; +} + // Wake the queue before posting, then post. // // With ModelParameters.setSleepIdleSeconds(> 0) the queue enters a sleeping state after the @@ -412,6 +420,7 @@ std::vector blocking_completion(jllama_context *jctx, co } task.apply_chat_session(session); task.params.res_type = res_type; + name_completion_task(task, meta); std::vector tasks; tasks.push_back(std::move(task)); wake_and_post(rd, std::move(tasks), session); @@ -431,6 +440,7 @@ int streaming_request(jllama_context *jctx, const json &data, server_task_type t populate_completion_task(task, jctx, meta.logit_bias_eog, data, meta.has_mtmd, std::move(files)); task.apply_chat_session(session); task.params.res_type = res_type; + name_completion_task(task, meta); std::vector tasks; tasks.push_back(std::move(task)); wake_and_post(*rd, std::move(tasks), session); diff --git a/llama/src/main/java/net/ladenthin/llama/args/ModelOption.java b/llama/src/main/java/net/ladenthin/llama/args/ModelOption.java index 729cbc696..d088bb31d 100644 --- a/llama/src/main/java/net/ladenthin/llama/args/ModelOption.java +++ b/llama/src/main/java/net/ladenthin/llama/args/ModelOption.java @@ -20,6 +20,9 @@ */ public enum ModelOption { + /** CLI option {@code --alias}. */ + ALIAS("--alias"), + /** CLI option {@code --batch-size}. */ BATCH_SIZE("--batch-size"), diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index 48592499f..9c19b7ce2 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -1517,6 +1517,20 @@ public ModelParameters setMmprojAuto(boolean enabled) { return this; } + /** + * Set the name the model answers to ({@code --alias}): the {@code model} field of every OpenAI + * answer and chunk, and the id the embedded server lists under {@code /v1/models}. Unset, llama.cpp + * uses the model's own name or file name. Upstream accepts several aliases separated by commas and + * answers with the first. + * + * @param alias the model name, or several separated by commas + * @return this builder + */ + public ModelParameters setAlias(String alias) { + putString(ModelOption.ALIAS, alias); + return this; + } + /** * Select the device the multimodal projector runs on ({@code --mmproj-device}, upstream * llama.cpp b10541). Independent of {@link #setDevices(String)}, which covers the main model: diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index 015adc1ae..16c31eb7e 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -641,6 +641,10 @@ public boolean isReranking() { public ModelParameters toModelParameters() { final ModelParameters params = new ModelParameters().setModel(modelPath).setGpuLayers(gpuLayers); + if (modelId != null) { + // the answers name the model as llama.cpp knows it; this keeps them equal to /v1/models + params.setAlias(modelId); + } if (mmprojDevice != null) { params.setMmprojDevice(mmprojDevice); } diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index d0902bf5b..ba9a11678 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -259,6 +260,54 @@ TEST_F(CApi, OaiCompletionsReturnATextCompletionObject) { EXPECT_EQ(jllama_completions_oai(model_, R"({"max_tokens":2})", &response.s), JLLAMA_STATUS_INVALID_REQUEST); } +// Upstream gives every completion an id (gen_chatcmplid) and names the model it ran on +// (model_name: the alias, else the file name); the engine set neither, so every OpenAI answer and +// chunk carried "id":"" and "model":"" (F185). +TEST_F(CApi, OpenAiAnswersCarryAnIdAndTheModelName) { + Owned meta; + ASSERT_EQ(jllama_model_meta(model_, &meta.s), JLLAMA_STATUS_OK) << jllama_last_error(); + const std::string name = json::parse(meta.str()).at("name").get(); + ASSERT_FALSE(name.empty()); + + Owned completion; + ASSERT_EQ(jllama_completions_oai(model_, R"({"prompt":"Hi","max_tokens":2})", &completion.s), JLLAMA_STATUS_OK) + << jllama_last_error(); + const json c = json::parse(completion.str()); + EXPECT_EQ(c.at("id").get().rfind("chatcmpl-", 0), 0u) << c.dump(); + EXPECT_EQ(c.at("model").get(), name); + + Owned chat; + ASSERT_EQ( + jllama_chat_completions(model_, R"({"messages":[{"role":"user","content":"Hi"}],"max_tokens":2})", &chat.s), + JLLAMA_STATUS_OK) + << jllama_last_error(); + const json m = json::parse(chat.str()); + EXPECT_EQ(m.at("id").get().rfind("chatcmpl-", 0), 0u) << m.dump(); + EXPECT_EQ(m.at("model").get(), name); + EXPECT_NE(m.at("id"), c.at("id")); + + // every chunk of one stream carries the same id + int task = 0; + ASSERT_EQ(jllama_chat_completions_stream_request( + model_, R"({"messages":[{"role":"user","content":"Hi"}],"max_tokens":3,"stream":true})", &task), + JLLAMA_STATUS_OK) + << jllama_last_error(); + std::set ids; + bool stop = false; + for (int i = 0; i < 20 && !stop; ++i) { + Owned envelope; + ASSERT_EQ(jllama_chat_completions_stream_receive(model_, task, &envelope.s, &stop), JLLAMA_STATUS_OK) + << jllama_last_error(); + const json data = json::parse(envelope.str()).at("data"); + for (const json &chunk : data.is_array() ? data : json::array({data})) { + ids.insert(chunk.at("id").get()); + EXPECT_EQ(chunk.at("model").get(), name); + } + } + ASSERT_EQ(ids.size(), 1u); + EXPECT_EQ(ids.begin()->rfind("chatcmpl-", 0), 0u); +} + // Upstream answers several prompts in one request with one choice each; a task here carries one // prompt, and every prompt after the first was dropped without a word -- two prompts, one answer // (F184). They are refused instead; a token array is one prompt and stays accepted. diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java index 0a8619c37..cacc2f4e9 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java @@ -500,6 +500,13 @@ public void testDisableMmprojAuto() { assertThat(p.parameters, not(hasKey("--mmproj-auto"))); } + @Test + public void testSetAlias() { + // the name every OpenAI answer and chunk carries in "model" (F185) + ModelParameters p = new ModelParameters().setAlias("local-model"); + assertThat(p.parameters.get("--alias"), is("local-model")); + } + @Test public void testSetMmprojDevice() { ModelParameters p = new ModelParameters().setMmprojDevice("CUDA1"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index fdeb575b8..07383aaa9 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -9,6 +9,8 @@ import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.Arrays; +import java.util.List; import net.ladenthin.llama.args.CacheType; import net.ladenthin.llama.value.RpcEndpoint; import org.junit.jupiter.api.Test; @@ -204,6 +206,22 @@ public void explicitModelIdOverridesDerivation() { assertThat(options.getModelId(), is("x")); } + // The answers name the model llama.cpp knows (its alias, else the file); without passing the + // advertised id on, /v1/models said "x" while every answer said "Llama-3.gguf" (F185). + @Test + public void anExplicitModelIdBecomesTheModelsAlias() { + List argv = Arrays.asList(OpenAiServerCli.parse("-m", "/opt/models/Llama-3.gguf", "--model-id", "x") + .toModelParameters() + .toArray()); + assertThat(argv.get(argv.indexOf("--alias") + 1), is("x")); + assertThat( + Arrays.asList(OpenAiServerCli.parse("-m", "/opt/models/Llama-3.gguf") + .toModelParameters() + .toArray()) + .contains("--alias"), + is(false)); + } + @Test public void missingModelThrows() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, OpenAiServerCli::parse); From 5ffc7a1dd82e26d6d04b244847cc70544296cea1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:34:43 +0000 Subject: [PATCH 331/589] Record the commit of F185 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3fa024c61..0b2f13624 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1396,7 +1396,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `CApi.OpenAiAnswersCarryAnIdAndTheModelName` (`test_c_api.cpp`, Zufallsmodell: blockierende Completion und Chat mit `chatcmpl-`-Id und dem Modellnamen aus den Meta-Daten, verschiedene Ids, ein Stream mit genau einer Id über alle Chunks); Java `ModelParametersTest#testSetAlias`, `OpenAiServerCliTest#anExplicitModelIdBecomesTheModelsAlias`; `test_model_flags.cpp` (`--alias` im Server-Parser). - **Rot-Grün-Nachweis:** C++ vor dem Fix rot (`id` und `model` leer), danach grün, Suite 642/642; Java mit wirkungslosem Platzhalter rot (2), danach grün (304 Tests der Parameter-, CLI- und Registry-Klassen). Ende-zu-Ende über den echten Server mit `--model-id my-model`: `/v1/models` und die Chat-Antwort nennen beide `my-model`. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `0be9b33` ## Offene Verdachte ohne Fund-ID From 4fb82c1d5b5fde31bc64cfcbae088d0f70bfdb73 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:45:01 +0000 Subject: [PATCH 332/589] Stream /v1/completions through llama.cpp's own completion stream (F186) The streamed /v1/completions went through the Java generator and built its chunks from the generated text: no usage on the last chunk, no probabilities (refused since F180), and a token-array prompt refused while the same request without "stream" was accepted. The body now goes to llama.cpp verbatim, as the blocking request does: new engine function completions_oai_stream_request (parsed with oaicompat_completion_params_parse, streamed as TASK_RESPONSE_TYPE_OAI_CMPL), C function jllama_completions_oai_stream_request (received with jllama_chat_completions_stream_receive), JNI requestCompletionOaiStream, LlamaModel.streamCompletionsOai and FfmLlamaModel.completionsOaiStream. The mapper's completion path and OpenAiSseFormatter.completionChunk had no caller left and are removed; the mapper tests of shared fields run over the chat path. Red/green with a model-backed OpenAiServerCompletionIntegrationTest case run locally on a random model (400 before, upstream's chunks with usage after); C++ 643/643; llama-ffm verify green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 ++- CHANGELOG.md | 8 ++ CLAUDE.md | 10 +- README.md | 2 +- TODO.md | 10 +- docs/c-api.md | 1 + .../ladenthin/llama/ffm/FfmLlamaModel.java | 13 +++ .../net/ladenthin/llama/ffm/JllamaNative.java | 2 + .../ffm/FfmLlamaModelIntegrationTest.java | 16 ++++ llama/spotbugs-exclude.xml | 7 +- llama/src/main/cpp/jllama.cpp | 14 +++ llama/src/main/cpp/jllama.h | 2 + llama/src/main/cpp/jllama_api.cpp | 10 ++ llama/src/main/cpp/jllama_api.h | 12 ++- llama/src/main/cpp/jllama_engine.cpp | 12 +++ llama/src/main/cpp/jllama_engine.hpp | 6 ++ .../java/net/ladenthin/llama/LlamaModel.java | 36 +++++++ .../llama/server/LlamaModelBackend.java | 43 ++++----- .../llama/server/OpenAiRequestMapper.java | 52 +--------- .../llama/server/OpenAiSseFormatter.java | 31 ------ llama/src/test/cpp/test_c_api.cpp | 40 ++++++++ .../llama/server/OpenAiRequestMapperTest.java | 94 +++++-------------- ...OpenAiServerCompletionIntegrationTest.java | 37 ++++++++ 23 files changed, 279 insertions(+), 193 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0b2f13624..e10779bf6 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1398,8 +1398,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0be9b33` +### F186 – java-llama.cpp: Das gestreamte `/v1/completions` ist ein selbstgebauter Stream ohne `usage`, Wahrscheinlichkeiten und Token-Prompt +- **Zeit:** 2026-10-20 04:00 UTC +- **Datei:** `llama/src/main/cpp/jllama_engine.{hpp,cpp}` (`completions_oai_stream_request`), `jllama_api.{h,cpp}` (`jllama_completions_oai_stream_request`), `jllama.cpp` + `jllama.h` (`requestCompletionOaiStream`), `LlamaModel.java` (`streamCompletionsOai`), `server/LlamaModelBackend.java` (`streamCompletions`), `server/OpenAiRequestMapper.java` und `OpenAiSseFormatter.java` (toter Completion-Weg entfernt); `llama-ffm` (`JllamaNative`, `FfmLlamaModel.completionsOaiStream`); `docs/c-api.md`, README +- **Schwere:** mittel (dieselbe Anfrage bedeutete gestreamt etwas anderes als blockierend: ohne `usage`, ohne `logprobs`, Token-Liste abgelehnt) +- **Historie:** Offener Verdacht vom 2026-10-18 (TODO (14)); F180 hatte `logprobs`/`n_probs` auf diesem Weg ausdrücklich abgelehnt, bis der Stream nativ ist. Upstream legt `usage` auf den letzten Chunk (`to_json_oaicompat`, `server-task.cpp:391`, b11639) und streamt `logprobs` je Chunk (`:1068`). +- **Beschreibung:** `LlamaModelBackend.streamCompletions` lief über `LlamaModel.generate` und baute `text_completion`-Chunks aus dem erzeugten Text: der letzte trug kein `usage`, Wahrscheinlichkeiten fehlten, und `OpenAiRequestMapper.toCompletionParameters` lehnte einen `prompt` als Array ab, den das blockierende `/v1/completions` (wörtlich an upstream) annimmt. +- **Fix:** Neuer nativer Einstieg, der den Körper wie das blockierende `completions_oai` mit `oaicompat_completion_params_parse` liest und als `TASK_RESPONSE_TYPE_OAI_CMPL` streamt; der Empfang ist der vorhandene Hüllen-Empfang des Chat-Streams. `LlamaModelBackend.streamCompletions` reicht den Körper (mit `stream: true`) wörtlich weiter. `toCompletionParameters` samt seiner Ablehnungslisten und `OpenAiSseFormatter.completionChunk` hatten danach keinen Aufrufer mehr und sind entfernt; die Mapper-Tests, die gemeinsame Felder prüften, laufen jetzt über den Chat-Weg. Mehrere Prompts lehnt der native Weg seit F184 ab, gestreamt wie blockierend. +- **Tests:** Java `OpenAiServerCompletionIntegrationTest#aStreamedCompletionIsUpstreamsStream` (modellgestützt: Token-Prompt, `logprobs`, eine Id, `usage` mit `prompt_tokens` 2, `finish_reason`, `[DONE]`); C++ `CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` (Zufallsmodell, dazu die Ablehnungen ohne Prompt, mit zwei Prompts und mit Null-Argument); FFM `FfmLlamaModelIntegrationTest#oaiCompletionStreamEndsWithUsage`; `OpenAiRequestMapperTest` umgestellt (33). +- **Rot-Grün-Nachweis:** Der Java-Integrationstest mit einem Zufallsmodell vor dem Umbau rot (`400` für den Token-Prompt), danach grün; C++ 643/643; `llama-ffm verify` grün (17 + 4 + 9) gegen die neu gebaute Bibliothek; `OpenAiCompatServerHttpTest` 63, `NativeLibraryLoadSmokeTest` 6 grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID -- *(erledigt 2026-10-19 als F180; offen nur noch, was unter (14) steht)* *(mittel, teilweise gefixt durch F95, 2026-10-18)* **Nachtrag:** Die Sampling-Felder mit vorhandenem Setter übernimmt seit F95 der Mapper (beide Chat-Pfade und das gestreamte Completion); offen bleiben `logit_bias`, `n_probs`/`logprobs` und `n` (Nachtrag: `logprobs`/`top_logprobs` des Chat-Pfads seit F123 übernommen). Ursprünglicher Text: Gestreamtes `/v1/completions` verliert weiter alle Felder außerhalb von `applyCommonFields`, die der nicht gestreamte Weg durchreicht und upstream liest: `n_predict`, `min_p`, `repeat_penalty`, `grammar`, `json_schema`, `logit_bias`, `n_probs`/`logprobs` und weitere. Dieselbe Anfrage erzeugt gestreamt also andere Ausgaben. Ein Fix bräuchte einen Streaming-Einstieg mit rohem JSON (die Natives `requestCompletion`/`receiveCompletionJson` sind paketintern in `net.ladenthin.llama`, `LlamaModelBackend` liegt in `server`) – ein Umbau der API, daher zur Entscheidung des Nutzers. Dasselbe gilt in kleinerem Maß für den Chat-Pfad (blockierend und gestreamt), der seit F77 die Reasoning-Steuerungen übernimmt, aber z. B. `min_p`, `repeat_penalty`, `logprobs` und `n_predict` weiter verwirft. Ebenso `n` (mehrere Antworten): upstream liest es als Alias von `n_cmpl` (`server-schema.cpp:62`, Grenze `n_parallel`), Java kennt das Feld gar nicht (kein `RequestField`, kein Setter), eine Anfrage mit `n: 3` bekommt still eine Antwort. Ein Fix bräuchte einen neuen `RequestField`-Eintrag (C++-Vertragstest `test_wire_contracts.cpp`, CI-Bedarf) und einen Setter – ein API-Zusatz. - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. -- *(niedrig, nur beschrieben, 2026-10-18)* java-llama.cpp `server/LlamaModelBackend.java:94` (`streamCompletions`) gegen upstream (`server_task_result_cmpl_final::to_json_oaicompat`, `server-task.cpp:366`, b11639): Das gestreamte `/v1/completions` endet mit einem Chunk ohne `usage`, upstream legt `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`, `prompt_tokens_details.cached_tokens`) auf den letzten Chunk, unabhängig von `stream_options`; das nicht gestreamte `/v1/completions` (an upstream durchgereicht) hat sie. Der Pfad läuft über `LlamaModel.generate` → `LlamaOutput`, das keine Tokenzahlen trägt, und es gibt keinen nativen OAI-Completion-Stream (nur `requestChatCompletionStream`). Ein Fix bräuchte ein neues Feld an `LlamaOutput` (öffentliche API) oder einen nativen Einstieg, daher hier nur notiert. Dazu: `OpenAiRequestMapper.toCompletionParameters` lehnt einen `prompt` als Array (Token-Liste oder mehrere Prompts) mit 400 ab, während dieselbe Anfrage ohne `stream` an upstream geht, das beides annimmt (`oaicompat_completion_params_parse` kopiert `prompt` unverändert, `server-common.cpp:1110`). diff --git a/CHANGELOG.md b/CHANGELOG.md index a2356c056..64c077db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,14 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **A streamed `/v1/completions` is upstream's stream.** `OpenAiCompatServer` built the chunks of a + streamed completion itself from the Java generator: no `usage` on the last chunk, no probabilities + (since F180 refused with a pointer to `stream: false`), and a prompt given as a token array refused + while the same request without `stream` was accepted. The body now goes to llama.cpp verbatim, as + the blocking request does, and the chunks are upstream's own `text_completion` events. New + `LlamaModel.streamCompletionsOai(String, Consumer)`, the C function + `jllama_completions_oai_stream_request` (received with `jllama_chat_completions_stream_receive`) + and `FfmLlamaModel.completionsOaiStream`. - **OpenAI answers carry an id and the model name.** Every native OpenAI answer and stream chunk had `"id": ""` and `"model": ""`; upstream gives each completion a `chatcmpl-` id (one per stream) and names the model it runs (the alias, else the model's name). They do now. New diff --git a/CLAUDE.md b/CLAUDE.md index 8bc7ac7ed..5bd57f36f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2229,7 +2229,7 @@ If the local check passes (`BUILD SUCCESS`), the `mvn package` job in - The `server` package is a dedicated top layer in the ArchUnit `layeredArchitecture` rule (the only layer allowed to access the root `Api`); `noInternalJdkImports` carries an explicit exception for the supported `com.sun.net.httpserver` (the exported `jdk.httpserver` module, which `module-info.java` `requires`). See README "OpenAI-compatible HTTP server". **Native layer** (`src/main/cpp/`): -- `jllama.cpp` — JNI implementation bridging Java calls to llama.cpp. 35 native methods (31 `LlamaModel` + 3 `TextToSpeech` + 1 `LlamaQuantizer`) plus `JNI_OnLoad`/`JNI_OnUnload`; since the C API (see "The C API" below) every one of them is a shell over `jllama_api.cpp` / `jllama_engine.cpp`, where the model logic lives. +- `jllama.cpp` — JNI implementation bridging Java calls to llama.cpp. 36 native methods (32 `LlamaModel` + 3 `TextToSpeech` + 1 `LlamaQuantizer`) plus `JNI_OnLoad`/`JNI_OnUnload`; since the C API (see "The C API" below) every one of them is a shell over `jllama_api.cpp` / `jllama_engine.cpp`, where the model logic lives. - `utils.hpp` — Helper utilities (format helpers, argv stripping, token-piece serialisation). - `json_helpers.hpp` — Pure JSON transformation helpers (no JNI, no llama state). Independently unit-testable. - `jni_helpers.hpp` — JNI bridge helpers (handle management + server orchestration). Includes `json_helpers.hpp`. @@ -2355,8 +2355,8 @@ Functions with `_impl` suffix are called directly from `jllama.cpp`. An exception that escapes a native method and unwinds across the JNI boundary is **undefined behaviour and aborts the JVM** on most implementations. **Every `Java_*` entry point must therefore -convert anything that escapes into a Java exception**, and there are 45 of them across four TUs — -`jllama.cpp` (35), `native_server.cpp` (5), `rpc_bridge.cpp` (4), `train_engine.cpp` (1). +convert anything that escapes into a Java exception**, and there are 46 of them across four TUs — +`jllama.cpp` (36), `native_server.cpp` (5), `rpc_bridge.cpp` (4), `train_engine.cpp` (1). The mechanism is `jni_guard_impl(env, exception_class, [&]() -> Ret { … })` (`jni_helpers.hpp`, Layer A). It is **additive**: an entry point that already converts `std::exception` itself keeps @@ -2619,12 +2619,12 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). | | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. | -| `src/test/cpp/test_c_api.cpp` | 29 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | +| `src/test/cpp/test_c_api.cpp` | 30 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 642 tests (all passing).** +**Current total: 643 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/README.md b/README.md index 301c042dc..46ac5f4bd 100644 --- a/README.md +++ b/README.md @@ -965,7 +965,7 @@ built-in WebUI" below). It serves: | Method & path | Backed by | |---|---| | `POST /v1/chat/completions` | `LlamaModel.streamChatCompletion` (streaming SSE) / `chatComplete` (blocking) | -| `POST /v1/completions` | `LlamaModel.handleCompletionsOai` | +| `POST /v1/completions` | `LlamaModel.streamCompletionsOai` (streaming SSE) / `handleCompletionsOai` (blocking) | | `POST /v1/embeddings` (requires `--embedding`) | `LlamaModel.handleEmbeddings` | | `POST /v1/rerank` (requires `--reranking`) | `LlamaModel.handleRerank` (reshaped to `results`/`data`) | | `POST /infill` | `LlamaModel.handleInfill` (fill-in-the-middle autocomplete) | diff --git a/TODO.md b/TODO.md index 4a70b4a30..60c2544e2 100644 --- a/TODO.md +++ b/TODO.md @@ -514,13 +514,9 @@ named in each item holds the analysis, the upstream references (b11639) and the delete an item here when it is done. **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; -numbered for reference, the numbers stay when an item is done): - -- **(14)** Streamed `/v1/completions` ends without `usage` (upstream puts it on the last chunk); and a - `prompt` given as an array is rejected with 400 when streamed, accepted when not. - Since F180 `logprobs`/`n_probs` are refused on it too (400, pointing to `stream: false`), because - its chunks are built from the generated text; streaming through the native completion stream with - the raw body would carry them, the remaining fields and `usage`. +numbered for reference, the numbers stay when an item is done): none left -- the last ones were +fixed as F181 to F186 on 2026-10-20. What still needs a model to confirm is listed in +`BUGHUNT-MEASUREMENTS.md` (the brief for a local run). ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) diff --git a/docs/c-api.md b/docs/c-api.md index ae8165400..c8a111aa4 100644 --- a/docs/c-api.md +++ b/docs/c-api.md @@ -40,6 +40,7 @@ Every operation of `LlamaModel` and `LlamaQuantizer` is in it (the roadmap's "B. | `jllama_chat_completions_stream_request()` / `_receive()` / `jllama_task_release()` | a streamed one: a task id, then one `{"data": , "stop": }` envelope per call until `stop` | | `jllama_chat_completions_request()` | a streamed chat completion whose results are llama.cpp's raw result objects (received with `jllama_completions_stream_receive`) | | `jllama_completions()`, `jllama_completions_oai()`, `jllama_infill()` | a raw `/completion`, an OpenAI `/v1/completions`, a fill-in-the-middle request, blocking | +| `jllama_completions_oai_stream_request()` | a streamed OpenAI `/v1/completions` (the body as for `jllama_completions_oai()`), received with `jllama_chat_completions_stream_receive()`: `text_completion` chunks, the last with `usage` | | `jllama_completions_stream_request()` / `jllama_completions_stream_receive()` | a raw streamed completion (an infill one when the body has the infill fields): each result object with a `"stop"` key | | `jllama_apply_template()` | the chat template applied to a messages body: the prompt text | | `jllama_embed()`, `jllama_embeddings()` | one embedding vector; an `/embeddings` or `/v1/embeddings` request (`--embedding`) | diff --git a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java index 41c9e8b15..4b9904f81 100644 --- a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java +++ b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java @@ -195,6 +195,19 @@ public String completionsOai(String requestJson) { return stringOut(JllamaNative.COMPLETIONS_OAI, requireNonNull(requestJson, "requestJson")); } + /** + * A streamed OpenAI {@code /v1/completions} request: each {@link FfmTaskStream#next()} is one + * {@code {"data": , "stop": }} envelope, the last chunk with + * {@code usage} (as {@code LlamaModel.streamCompletionsOai}). + * + * @param requestJson the request object, parsed as {@link #completionsOai(String)} parses it + * @return the stream; close it to release the task early + */ + public FfmTaskStream completionsOaiStream(String requestJson) { + int task = taskOut(JllamaNative.COMPLETIONS_OAI_STREAM_REQUEST, requireNonNull(requestJson, "requestJson")); + return new FfmTaskStream(this, task, JllamaNative.CHAT_COMPLETIONS_STREAM_RECEIVE); + } + /** * A fill-in-the-middle request, blocking (the model needs FIM tokens). * diff --git a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java index 60e56efe2..a7490f17c 100644 --- a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java +++ b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java @@ -98,6 +98,8 @@ final class JllamaNative { downcall("jllama_chat_completions_request", json(ADDRESS, ADDRESS, ADDRESS)); static final MethodHandle COMPLETIONS = downcall("jllama_completions", json(ADDRESS, ADDRESS, ADDRESS)); static final MethodHandle COMPLETIONS_OAI = downcall("jllama_completions_oai", json(ADDRESS, ADDRESS, ADDRESS)); + static final MethodHandle COMPLETIONS_OAI_STREAM_REQUEST = + downcall("jllama_completions_oai_stream_request", json(ADDRESS, ADDRESS, ADDRESS)); static final MethodHandle INFILL = downcall("jllama_infill", json(ADDRESS, ADDRESS, ADDRESS)); static final MethodHandle COMPLETIONS_STREAM_REQUEST = downcall("jllama_completions_stream_request", json(ADDRESS, ADDRESS, ADDRESS)); diff --git a/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmLlamaModelIntegrationTest.java b/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmLlamaModelIntegrationTest.java index dacb66b50..92743ce13 100644 --- a/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmLlamaModelIntegrationTest.java +++ b/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmLlamaModelIntegrationTest.java @@ -122,6 +122,22 @@ void rawCompletionAndItsStream() throws IOException { assertThat(results, greaterThan(0)); } + @Test + void oaiCompletionStreamEndsWithUsage() throws IOException { + JsonNode last = null; + try (FfmTaskStream stream = + model.completionsOaiStream("{\"prompt\": \"Hello\", \"max_tokens\": 3, \"temperature\": 0}")) { + while (stream.hasNext()) { + JsonNode data = JSON.readTree(stream.next()).path("data"); + for (JsonNode chunk : data.isArray() ? data : JSON.createArrayNode().add(data)) { + assertEquals("text_completion", chunk.path("object").asText()); + last = chunk; + } + } + } + assertTrue(last != null && last.path("usage").path("completion_tokens").asInt() > 0, String.valueOf(last)); + } + @Test void tokenizeAndDetokenizeRoundTrip() throws IOException { int[] tokens = model.tokenize("Hello", false); diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml index 2e177e03b..65b0b78bc 100644 --- a/llama/spotbugs-exclude.xml +++ b/llama/spotbugs-exclude.xml @@ -529,7 +529,7 @@ SPDX-License-Identifier: MIT + +# Messauftrag zu F67 und F179 … F186 — java-llama.cpp + +Die Fixes vom 2026-10-19/20 sind alle modellfrei rot/grün belegt: C++ über ein Zufallsmodell, Java über +Fake-Backends oder ein lokal erzeugtes Zufalls-GGUF. Was ein Zufallsmodell nicht zeigen kann — echte +Texte, echte Stoppwörter, echte Wahrscheinlichkeiten, echte Adapter, Windows-Dateinamen — sammelt dieser +Auftrag. Hintergrund in `BUGHUNT-FOUND.md` (die Fund-IDs) und `CHANGELOG.md` („Fixed“). + +**Regeln für den ausführenden Agenten** + +- **Nur messen und berichten.** Keine Produktionsänderung, keine Fixes. Wegwerf-Proben (Testklassen, + kleine `main`-Programme) nach dem Lauf entfernen; committet wird nur diese Datei mit den Ergebnissen. +- Arbeitsstand: Branch `ccr-90fc5f6a-vs4s0f` (mindestens `13ddf17`). Pin: llama.cpp b11639. +- Dateien aus `.github/shared-files.sha256` nicht verändern. +- Jeder Punkt wird beantwortet, auch wenn er nicht ausführbar war (mit Grund). Zu jedem Ergebnis die + Umgebung (CPU, Kerne, RAM, GPU, OS, JDK) und die wörtlichen Ausgaben, die das Ergebnis tragen. +- Ergebnis unter **Ergebnis:**, darunter **Folgerung:** (bestätigt der Lauf den Fix, oder was weicht ab). +- Ein Test, der hier rot wird, ist ein Befund: Ausgabe vollständig eintragen, nicht „reparieren“. + +**Gemeinsame Vorbereitung** + +```bash +# JDK: Temurin 21 (nicht Oracle 21, siehe CLAUDE.md); für M10 zusätzlich ein JDK 22+ in ~/.m2/toolchains.xml +mvn -B -q -f llama/pom.xml compile +cd llama && cmake -B build -DBUILD_TESTING=ON && cmake --build build --config Release -j"$(nproc)" && cd .. +ctest --test-dir llama/build --output-on-failure # erwartet: 643/643 +# Modelle aus .github/models.csv nach models/ (die Tests finden sie dort): +mkdir -p models +while IFS=, read -r f url; do case "$f" in \#*|"") continue;; esac + [ -f "models/$f" ] || curl -L --fail -o "models/$f" "$url"; done < .github/models.csv +# Auf einem Rechner ohne GPU alle Läufe mit -Dnet.ladenthin.llama.test.ngl=0 +``` + +Für die kleinen Messprogramme unten genügt eine Wegwerf-Testklasse unter +`llama/src/test/java/net/ladenthin/llama/` (dann gilt der Test-Klassenpfad mit `src/main/natives/`), +gestartet mit `mvn -B -q -f llama/pom.xml test -Dtest=`. Pfade über `TestConstants` +(`MODEL_PATH`, `DRAFT_MODEL_PATH`, `REASONING_MODEL_PATH`, `DEFAULT_TOOL_MODEL_PATH`). + +--- + +## M1 Die neuen und geänderten modellgestützten Tests + +**Ziel.** Die Tests, die nur in CI mit Modellen laufen, einmal lokal mit dem echten Modellsatz grün sehen. + +```bash +mvn -B -f llama/pom.xml test -Dnet.ladenthin.llama.test.ngl=0 -Dtest='ErrorHandlingTest,OpenAiServerCompletionIntegrationTest,OpenAiServerRerankIntegrationTest,OpenAiCompatServerIntegrationTest,LlamaTrainerTest,LlamaTrainerIntegrationTest' +``` + +Erwartet: alle grün, keiner übersprungen. Im Einzelnen: +`ErrorHandlingTest#testNPredictMinusTwoPerRequestIsAnInvalidRequest` (F69: `n_predict: -2` pro Anfrage +ist `InvalidRequestException`); `OpenAiServerCompletionIntegrationTest#aStreamedCompletionIsUpstreamsStream` +(F186), `#ollamaGenerateRawRoundTrip` und `#ollamaGenerateNonStreamingRoundTrip` (F179); +`OpenAiServerRerankIntegrationTest` (F177). + +**Ergebnis:** + +**Folgerung:** + +--- + +## M2 F186: das gestreamte `/v1/completions` mit dem echten Modell + +**Ziel.** Der Integrationstest prüft die Form; hier zusätzlich der Inhalt. Mit `OpenAiCompatServer` über +`MODEL_PATH` (CodeLlama) dieselbe Anfrage einmal blockierend und einmal gestreamt, Greedy: + +```json +{"prompt": "def fibonacci(n):", "max_tokens": 24, "temperature": 0, "top_k": 1, "logprobs": 3} +``` + +Aufzeichnen: (a) der zusammengesetzte Text aller gestreamten `choices[0].text` gegen `choices[0].text` +der blockierenden Antwort; (b) `usage` des letzten Chunks gegen `usage` der blockierenden Antwort; +(c) die ersten drei `logprobs.content`-Einträge beider Wege; (d) dasselbe mit `"prompt": []` (Ids über `LlamaModel.encode`); (e) `"prompt": ["a", "b"]` gestreamt und +blockierend (erwartet: beide `400`, Meldung nennt „2 prompts“, F184). + +**Hypothese.** (a) gleich, (b) gleiche Zahlen, (c) gleiche Tokens und Werte, (d) wie (a), (e) zweimal 400. + +**Ergebnis:** + +**Folgerung:** + +--- + +## M3 F183: Anthropic `stop_sequence` mit einem echten Stoppwort + +**Ziel.** Mit dem Tool-Modell (`DEFAULT_TOOL_MODEL_PATH`, Qwen2.5-1.5B) über `OpenAiCompatServer`: + +```json +{"model": "m", "max_tokens": 64, "temperature": 0, "stop_sequences": ["5"], + "messages": [{"role": "user", "content": "Count from 1 to 10, separated by commas."}]} +``` + +an `/v1/messages`, einmal blockierend, einmal mit `"stream": true`. Aufzeichnen: `stop_reason`, +`stop_sequence`, der Text; im Stream das `message_delta`-Ereignis. Dazu dieselbe Anfrage ohne +`stop_sequences` (erwartet `stop_sequence: null`) und eine, die an `max_tokens` endet (`max_tokens: 3`, +erwartet `stop_reason: max_tokens`, `stop_sequence: null`). Prüfen, dass `__verbose` in keiner Antwort und +keinem Ereignis auftaucht. + +**Hypothese.** `end_turn` mit `stop_sequence: "5"`, Text endet vor der 5; die beiden Gegenproben wie erwartet. + +**Ergebnis:** + +**Folgerung:** + +--- + +## M4 F182: ein gestreamter Fehler vor dem ersten Chunk mit dem echten Modell + +**Ziel.** Ein Prompt über der Kontextgröße ist bei upstream `400` (`exceed_context_size_error`), gestreamt +wie nicht gestreamt. Mit `OpenAiCompatServer` über `DRAFT_MODEL_PATH` und `setCtxSize(256)` eine +Chat-Anfrage mit ~2000 Wörtern Inhalt an alle fünf Streaming-Routen (`/v1/chat/completions`, +`/v1/completions`, `/api/chat`, `/v1/messages`, `/v1/responses`, jeweils mit `"stream": true`). +Aufzeichnen: HTTP-Status, `Content-Type`, Körper. + +**Hypothese.** Überall `400` mit JSON-Körper im Fehlerformat der Route, kein SSE. + +**Ergebnis:** + +**Folgerung:** + +--- + +## M5 F185: `id` und `model` über beide Server + +**Ziel.** (a) `OpenAiCompatServer` mit `--model-id my-model` (Kommandozeile über `ServerLauncher +--jllama-openai-compat`): `/v1/models`, eine blockierende Chat-Antwort und ein Stream; jedes `id` beginnt +mit `chatcmpl-`, alle Chunks eines Streams tragen dieselbe, `model` ist überall `my-model`. (b) Dasselbe +ohne `--model-id` (erwartet: der Dateiname). (c) `NativeServer` mit `--alias my-model` als Gegenprobe gegen +upstreams eigenen Server im selben Prozess. + +**Ergebnis:** + +**Folgerung:** + +--- + +## M6 F67 / Patch 0021: LoRA-Wechsel mit dem echten Adapterpaar + +**Ziel.** J9 des vorigen Auftrags mit dem Patch wiederholen. Paar aus llama.cpps Server-Tests: +`ggml-org/stories15M_MOE` → `stories15M_MOE-F16.gguf` und `moe_shakespeare15M.gguf`. Greedy +(`temperature 0, top_k 1, seed 42`, `cache_prompt true`, Slot 0, `setParallel(1)`, Kontext 512), Prompt +„Look in thy glass“, 32 Tokens. Zwei Durchgänge: mit `setCacheRamMib(0)` und mit der Voreinstellung. + +Ablauf je Durchgang: frisches Modell ohne Adapter (Referenz A); mit Adapter Skala 1, kalt und warm +(Referenz B); `setLoraAdapter(0, 0f)` und sofort dieselbe Anfrage auf dem warmen Slot; dann +`setLoraAdapter(0, 1f)` und wieder dieselbe Anfrage; dann eine Anfrage mit eigenem `"lora": [{"id": 0, +"scale": 0}]`, gefolgt von einer ohne. + +**Hypothese.** Mit `setCacheRamMib(0)`: nach `0f` gleich A, nach `1f` gleich B, nach der Anfrage mit eigenen +Adaptern die nächste gleich B. Mit der Voreinstellung kann der Host-Speicher-Cache (Javadoc von +`setLoraAdapters`) abweichen — das ist der offene Rest, genau aufzeichnen. + +**Ergebnis:** + +**Folgerung:** + +--- + +## M7 F181: Fehlermeldungen mit einem 4-Byte-Zeichen auf Windows + +**Ziel.** `LlamaTrainerTest` läuft in CI auf Linux; der Windows-Weg (UTF-16-Dateinamen, `cmd`-Codepage) +ist ungeprüft. Auf Windows: `mvn -B -f llama/pom.xml test -Dtest=LlamaTrainerTest`, dann eine Wegwerf-Probe, +die `new LlamaModel(new ModelParameters().setModel("C:/tmp/fehlt-\uD83D\uDE00.gguf"))` und +`NativeServer` mit demselben Pfad startet und die Meldung codepunktweise ausgibt. + +**Hypothese.** Beide Tests grün (nicht übersprungen); die Probe zeigt U+1F600 als einen Codepunkt, nichts +abgeschnitten. + +**Ergebnis:** + +**Folgerung:** + +--- + +## M8 F182 im Agenten: Atmosphere über das echte Modell + +**Ziel.** Seit F182 meldet der Agent einen Fehler vor dem ersten Chunk als Fehler des Zuges statt still zu +enden. Lauf: `mvn -B -f llama-atmosphere-agent/pom.xml -Dllama.version=6.0.0-SNAPSHOT -Dllama.natives=none +verify` gegen den installierten Kern (`mvn -pl llama,llama-platform -am install -DskipTests`), dann +`AtmosphereToolLoopIntegrationTest` mit dem Tool-Modell (`-Dnet.ladenthin.llama.lib.path=`). +Dazu ein Zug im Agenten mit einem Prompt über der Kontextgröße (`--ctx-size 256`): was zeigt die Konsole? + +**Ergebnis:** + +**Folgerung:** + +--- + +## M9 `llama-ffm` mit dem echten Modell + +**Ziel.** `mvn -B -f llama-ffm/pom.xml verify -Dnet.ladenthin.llama.ffm.model=models/AMD-Llama-135m-code.Q2_K.gguf +-Dnet.ladenthin.llama.lib.path=<…/llama/src/main/natives/net/ladenthin/llama///cpu>` — neu ist +`FfmLlamaModelIntegrationTest#oaiCompletionStreamEndsWithUsage` (F186). + +**Ergebnis:** + +**Folgerung:** + +--- + +## M10 Gesamtlauf + +**Ziel.** Nach M1 … M9 einmal alles: `ctest` (erwartet 643/643) und +`mvn -B -f llama/pom.xml test -Dnet.ladenthin.llama.test.ngl=0` mit dem ganzen Modellsatz, dazu +`.github/verify-test-counts.sh` wie in CI. Zahlen (run / failures / errors / skipped) eintragen. + +**Ergebnis:** + +**Folgerung:** From d68aa3d64646d5ed07f1ec69d6d0cdde87aa91af Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Tue, 20 Oct 2026 00:36:04 +0200 Subject: [PATCH 335/589] Answer the F67/F179..F186 measurement brief, and fix the harness defect it found Every point of BUGHUNT-MEASUREMENTS.md answered on Windows 11 / Ryzen 7 5800H with a freshly built static CPU library (b11639, patches 0001..0021, MSVC 19.44), the full models.csv set and ngl=0. M1..M5, M7, M9 and M10 confirm their hypotheses; M6 confirms patch 0021 in BOTH cache passes, which closes the open remainder the brief flagged. Three findings the brief did not expect. B1 (M6): addLoraScaledAdapter and addControlVectorScaled emit "FNAME,SCALE" while upstream has read --lora-scaled as "FNAME:SCALE,..." since b7448 (#18056), so a scaled adapter fails the model load with "Failed to parse model parameters"; the only test over it pins the wrong format. Four argv forms measured against the real parser -- and the colon form cannot carry an absolute Windows path either, because string_split splits on every colon. B2 (M5): without --model-id, /v1/models reports the file name while the answers report the full model path, because toModelParameters sets --alias only when the flag was given. Both are described, not touched. B3 (M8) is fixed on instruction: ScriptedBackend.quote escaped only the quote, the backslash and the newline, leaving 31 of 32 control characters producing invalid JSON, so every test feeding it System.lineSeparator() failed on Windows and nowhere else -- AgentSessionTest.aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker deterministically, 3 of 3 runs, with a raw CR in the chunk and a loop that stalled instead of reaching its sentinel. CI runs the agent jobs on Linux only. The fix adds the four named escapes and a default branch escaping everything below 0x20, so an unlisted control character cannot reopen the gap; ScriptedBackendTest guards it (every chunk parses as JSON for CRLF/LF/CR, each of the 32 control characters on its own, and the text round-trips). Verified red against the old quote and green with the fix, three runs each; the agent module is now 281/0/0/60 on Windows. The eight throwaway probes are removed. Note for anyone taking a total from a full run: mvn test does not clean surefire-reports, so the first count read 2158 over 146 classes because the deleted probes' XML was still there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E2h8gXyyE5UeimkL9vQv9G --- BUGHUNT-MEASUREMENTS.md | 801 +++++++++++++++++- .../llama/atmosphere/ScriptedBackend.java | 28 +- .../llama/atmosphere/ScriptedBackendTest.java | 80 ++ 3 files changed, 888 insertions(+), 21 deletions(-) create mode 100644 llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackendTest.java diff --git a/BUGHUNT-MEASUREMENTS.md b/BUGHUNT-MEASUREMENTS.md index 6f02a2c38..79a35de05 100644 --- a/BUGHUNT-MEASUREMENTS.md +++ b/BUGHUNT-MEASUREMENTS.md @@ -22,6 +22,11 @@ Auftrag. Hintergrund in `BUGHUNT-FOUND.md` (die Fund-IDs) und `CHANGELOG.md` ( - Ergebnis unter **Ergebnis:**, darunter **Folgerung:** (bestätigt der Lauf den Fix, oder was weicht ab). - Ein Test, der hier rot wird, ist ein Befund: Ausgabe vollständig eintragen, nicht „reparieren“. +**Eine Ausnahme von der ersten Regel, ausdrücklich beauftragt:** Befund **B3** aus M8 (das +Agentenmodul baute auf Windows nicht grün) wurde im selben Lauf behoben und mit einem Wächter +versehen. Alles andere blieb Messung — die drei Befunde **B1** (M6), **B2** (M5) und die beiden +Nebenbeobachtungen aus M7 sind nur beschrieben, nicht angefasst. + **Gemeinsame Vorbereitung** ```bash @@ -43,6 +48,65 @@ gestartet mit `mvn -B -q -f llama/pom.xml test -Dtest=`. Pfade über `Te --- +## Umgebung aller Läufe (einmal, gilt für M1 … M10) + +| | | +|---|---| +| CPU | AMD Ryzen 7 5800H with Radeon Graphics, 8 Kerne / 16 Threads | +| RAM | 63,3 GiB | +| GPU | NVIDIA GeForce RTX 3070 Laptop (Treiber 32.0.15.8183) + AMD Radeon (iGPU) — **ungenutzt**, siehe unten | +| OS | Microsoft Windows 11 Pro 10.0.26300 (Build 26300.9711) | +| JDK (Maven, Kern) | Temurin 21.0.12.1+1 — **nicht** das daneben installierte Oracle 21.0.9, das `mvn compile` laut CLAUDE.md in Error Prone abbricht | +| JDK (`llama-ffm`) | Temurin 25.0.4.1+1, über einen neu angelegten `~/.m2/toolchains.xml` | +| Maven | 3.9.11 | +| C++-Toolchain | MSVC 19.44.35229.0 (`cl.exe`), Ninja Multi-Config, CMake 3.31.6-msvc6 aus VS 2022 Community | +| Build | statisch mit `-DBUILD_TESTING=ON` (`.github\build.bat`), **ohne** sccache (`USE_CACHE=false`) | + +**Der Build ist CPU-only.** Es wurde kein GPU-Modul gebaut, das Natives-Verzeichnis trägt genau +`Windows/x86_64/cpu/`, und `JNI_OnLoad` meldet in jedem Lauf +`[jllama] native backend 'cpu' loaded from … with no GPU module`. Alle Läufe daher mit +`-Dnet.ladenthin.llama.test.ngl=0`; die RTX 3070 ist an keiner Messung beteiligt. + +`llama/src/main/natives/net/ladenthin/llama/Windows/x86_64/cpu/jllama-build.txt` des gemessenen Builds: + +``` +llama.cpp=b11639 +llama.cpp.commit=a0e6dc7384edb8f5d927155e1d26c10b4a310e6d +backend=cpu +kind=library +classifier=cpu-windows-x86-64 +jllama.version=6.0.0-SNAPSHOT +jllama.commit=c56982b2823210075dd0bcecf0c042051b91d002 +patches=0001,0002,0003,0006,0007,0008,0014,0015,0016,0017,0018,0019,0020,0021 +patches.sha256=0176a9b12e147b97027b393ca107f559bc9c997f6076b5a94390957bf10106b8 +compiler=MSVC 19.44.35229.0 +toolkit=none +signed=no +``` + +Arbeitsstand also `c56982b2` auf `ccr-90fc5f6a-vs4s0f` (die verlangte Mindesthöhe `13ddf17` ist +enthalten), Pin b11639, alle 14 Patches angewandt. + +**Modelle.** Die elf Zeilen aus `.github/models.csv` lagen bereits vollständig in `models/`; nichts +wurde nachgeladen. Für M6 kamen `stories15M_MOE-F16.gguf` (73 466 432 B) und +`moe_shakespeare15M.gguf` (16 364 896 B) von `ggml-org/stories15M_MOE` hinzu, beide mit GGUF-Magic +`47475546`. + +**Vorbereitung.** `mvn -f llama/pom.xml compile` grün; der Build lief 549/549 Schritte durch; danach + +``` +100% tests passed, 0 tests failed out of 643 +Total Test time (real) = 150.90 sec +``` + +also die vom Auftrag erwarteten **643/643**. + +Ein Hinweis zum Ausgangszustand: die vorhandenen Natives waren `llama.cpp=b11538` vom 11.10. und +damit älter als Pin und Patches `0020`/`0021`. `llama/build` und `llama/src/main/natives` wurden +deshalb gelöscht und alles frisch gebaut. + +--- + ## M1 Die neuen und geänderten modellgestützten Tests **Ziel.** Die Tests, die nur in CI mit Modellen laufen, einmal lokal mit dem echten Modellsatz grün sehen. @@ -57,9 +121,43 @@ ist `InvalidRequestException`); `OpenAiServerCompletionIntegrationTest#aStreamed (F186), `#ollamaGenerateRawRoundTrip` und `#ollamaGenerateNonStreamingRoundTrip` (F179); `OpenAiServerRerankIntegrationTest` (F177). -**Ergebnis:** +**Ergebnis:** Alle sechs Klassen grün, **keine übersprungen**: + +``` +[INFO] Running net.ladenthin.llama.ErrorHandlingTest +[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.544 s +[INFO] Running net.ladenthin.llama.LlamaTrainerIntegrationTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.793 s +[INFO] Running net.ladenthin.llama.LlamaTrainerTest +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.021 s +[INFO] Running net.ladenthin.llama.server.OpenAiCompatServerIntegrationTest +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.583 s +[INFO] Running net.ladenthin.llama.server.OpenAiServerCompletionIntegrationTest +[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 12.78 s +[INFO] Running net.ladenthin.llama.server.OpenAiServerRerankIntegrationTest +[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.303 s +[INFO] Tests run: 40, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` -**Folgerung:** +Weil eine Klasse mit einer `@BeforeAll`-Annahme als `tests="0"` durchläuft und damit unsichtbar +bliebe (CLAUDE.md, „CI model policy"), sind die namentlich genannten Methoden zusätzlich einzeln aus +den surefire-XML belegt — jede mit einer eigenen ``-Zeile und einer Laufzeit > 0: + +``` +name="testNPredictMinusTwoPerRequestIsAnInvalidRequest" classname="net.ladenthin.llama.ErrorHandlingTest" time="0.06" +name="aStreamedCompletionIsUpstreamsStream" classname="…OpenAiServerCompletionIntegrationTest" time="0.438" +name="ollamaGenerateRawRoundTrip" classname="…OpenAiServerCompletionIntegrationTest" time="2.106" +name="ollamaGenerateNonStreamingRoundTrip" classname="…OpenAiServerCompletionIntegrationTest" time="2.881" +testcase name="rerankReturnsScoredResultsCappedByTopN" (OpenAiServerRerankIntegrationTest) +testcase name="aFourByteCharacterInAPathComesBackIntactInTheErrorMessage" (LlamaTrainerTest) +testcase name="aFourByteCharacterInTheTrainingTextReachesTheTrainer" (LlamaTrainerTest) +testcase name="finetuneWritesAnOutputModel" (LlamaTrainerIntegrationTest) +``` + +**Folgerung:** Der Lauf bestätigt die Erwartung. F69, F186, F179 und F177 sind mit dem echten +Modellsatz auf Windows belegt, und `LlamaTrainerTest` — in CI nur auf Linux — läuft auch hier grün, +was die erste Hälfte von M7 bereits mit abdeckt. --- @@ -80,9 +178,88 @@ blockierend (erwartet: beide `400`, Meldung nennt „2 prompts“, F184). **Hypothese.** (a) gleich, (b) gleiche Zahlen, (c) gleiche Tokens und Werte, (d) wie (a), (e) zweimal 400. -**Ergebnis:** +**Ergebnis:** Der erste Durchgang bestätigte (a), (d) und (e) sofort, zeigte aber in (b) und (c) +Abweichungen — die sich als **Prompt-Cache** erklärten: das Paar läuft blockierend zuerst, die +gestreamte Anfrage trifft denselben Slot warm. Deshalb lief dasselbe Paar ein zweites Mal mit +`"cache_prompt": false`. Beide Durchgänge unten. -**Folgerung:** +**(a) Text, mit Prompt-Cache** — identisch, blockierend wie gestreamt (25 Chunks): + +``` + (a) blocking text = [ + if n == 0: + return 0 + elif n == 1: + return] + (a) streamed text = [… byte-identisch …] + (a) EQUAL=true +``` + +**(b) mit Prompt-Cache** — die Zahlen gleich, nur `cached_tokens` unterschiedlich: + +``` + (b) blocking usage = {"completion_tokens":24,"prompt_tokens":8,"total_tokens":32,"prompt_tokens_details":{"cached_tokens":0}} + (b) last-chunk usage = {"completion_tokens":24,"prompt_tokens":8,"total_tokens":32,"prompt_tokens_details":{"cached_tokens":7}} + (b) EQUAL=false +``` + +**(b) Gegenprobe mit `cache_prompt:false`** — vollständig gleich: + +``` + (b) blocking usage = {"completion_tokens":24,"prompt_tokens":8,"total_tokens":32,"prompt_tokens_details":{"cached_tokens":0}} + (b) last-chunk usage = {"completion_tokens":24,"prompt_tokens":8,"total_tokens":32,"prompt_tokens_details":{"cached_tokens":0}} + (b) EQUAL=true +``` + +**(c) mit Prompt-Cache** — gleiche Tokens und Ids, Werte minimal verschieden (warme KV-Wiederverwendung +ändert die Summationsreihenfolge), und im dritten Eintrag kippt der dritte `top_logprobs`-Platz +(`" #"` gegen `" return"`): + +``` +blocking [0] {"id":13,"token":"\n","bytes":[10],"logprob":-0.1300244927406311, …} +streamed [0] {"id":13,"token":"\n","bytes":[10],"logprob":-0.13420109450817108, …} +blocking [2] {"id":565,"token":" if", …,"logprob":-0.36814239621162415,"top_logprobs":[… ," #" …]} +streamed [2] {"id":565,"token":" if", …,"logprob":-0.3986988961696625, "top_logprobs":[… ," return" …]} +``` + +**(c) Gegenprobe mit `cache_prompt:false`** — die ersten drei Einträge **zeichengleich**, in beiden +Prompt-Formen: + +``` +=== control text prompt + [0] identisch=True [1] identisch=True [2] identisch=True + alle drei identisch = True +=== control token-id prompt + [0] identisch=True [1] identisch=True [2] identisch=True + alle drei identisch = True +``` + +**(d) Prompt als Token-Ids.** `LlamaModel.encode("def fibonacci(n):")` ergibt +`[822,18755,265,21566,29898,29876,1125]` (n=7). Derselbe Text wie in (a), `EQUAL=true`, `usage` +`prompt_tokens: 7`; mit `cache_prompt:false` auch `usage` und Logprobs gleich. Wichtig: die +gestreamte Anfrage mit Token-Id-Prompt wird **angenommen** — genau das, was F186 reparierte. + +**(e) zwei Prompts** — beide Wege `400`, die Meldung nennt die Zahl: + +``` + (e) blocking status=400 body={"error":{"message":"a request takes one prompt, this one has 2 prompts; send them as separate requests","type":"invalid_request_error","code":null}} + (e) streamed status=400 body={"error":{"message":"a request takes one prompt, this one has 2 prompts; send them as separate requests","type":"invalid_request_error","code":null}} + (e) blocking mentions 2 prompts=true + (e) streamed mentions 2 prompts=true +``` + +Nebenbeobachtungen: `contentType` ist blockierend `application/json; charset=utf-8` und gestreamt +`text/event-stream; charset=utf-8`; alle 25 Chunks eines Streams tragen **eine** Id +(`chatcmpl-…`, 32 Zeichen), 24 davon einen `logprobs.content`-Eintrag, der letzte das `usage` und +ein textuelles `finish_reason`; `model` ist in beiden Wegen der Modellpfad, weil die Probe kein +`modelId` setzt, das vom Standard abweicht (dazu M5). + +**Folgerung:** Die Hypothese ist bestätigt. (a), (d) und (e) ohne Vorbehalt; (b) und (c) ebenfalls, +sobald der Prompt-Cache ausgeschaltet ist — die beobachtete Abweichung ist allein Cache-Wirkung und +kein Unterschied zwischen den beiden Wegen. Der gestreamte Weg liefert damit denselben Text, +dieselben Zahlen und dieselben Wahrscheinlichkeiten wie der blockierende und nimmt den +Token-Id-Prompt an. Wer das Paar künftig vergleicht, muss `cache_prompt:false` setzen oder beide +Seiten gleich warm fahren, sonst vergleicht er den Cache mit. --- @@ -103,9 +280,52 @@ keinem Ereignis auftaucht. **Hypothese.** `end_turn` mit `stop_sequence: "5"`, Text endet vor der 5; die beiden Gegenproben wie erwartet. -**Ergebnis:** +**Ergebnis:** Alle drei Anfragen `200`, blockierend `application/json; charset=utf-8`, gestreamt +`text/event-stream; charset=utf-8`. Wörtlich: + +**Mit `stop_sequences: ["5"]`** — der Text endet vor der 5: + +``` +BLOCKING body={"id":"msg_6dbc1d7e","type":"message","role":"assistant","model":"m", + "content":[{"type":"text","text":"1, 2, 3, 4, "}], + "usage":{"input_tokens":42,"output_tokens":13,"cache_read_input_tokens":0}, + "stop_reason":"end_turn","stop_sequence":"5"} +STREAMED message_delta={"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":"5"}, + "usage":{"input_tokens":1,"output_tokens":13,"cache_read_input_tokens":41}} +STREAMED text=[1, 2, 3, 4, ] +``` + +**Ohne `stop_sequences`** — die Zählung läuft durch, `stop_sequence` ist `null`: + +``` +BLOCKING text=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +BLOCKING stop_reason="end_turn" stop_sequence=null +STREAMED message_delta={"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null}, + "usage":{"input_tokens":1,"output_tokens":30,"cache_read_input_tokens":41}} +``` + +**Mit `max_tokens: 3`**: -**Folgerung:** +``` +BLOCKING text=[1, ] +BLOCKING stop_reason="max_tokens" stop_sequence=null +STREAMED message_delta={"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null}, + "usage":{"input_tokens":1,"output_tokens":3,"cache_read_input_tokens":41}} +``` + +`__verbose` kommt in **keiner** der sechs Antworten und in keinem Ereignis vor (je `has +__verbose=false` über den vollständigen Körper bzw. den vollständigen SSE-Strom geprüft, nicht nur +über einzelne Felder). + +**Folgerung:** Die Hypothese ist in allen drei Fällen exakt bestätigt — `end_turn` mit +`stop_sequence: "5"` und ein Text, der vor dem Stoppwort endet; `null` ohne Stoppwort; `max_tokens` +mit `null` am Budgetende; blockierend und gestreamt tragen dasselbe Paar. F183 ist mit einem echten +Stoppwort belegt. + +Eine Beobachtung ohne Befundcharakter: das `usage` der gestreamten Anfrage zählt `input_tokens: 1` +bei `cache_read_input_tokens: 41`, weil die identische Anfrage unmittelbar nach der blockierenden +läuft und den warmen Prompt-Cache trifft — dieselbe Cache-Wirkung wie in M2(b), nicht ein +Unterschied der beiden Wege. --- @@ -119,9 +339,36 @@ Aufzeichnen: HTTP-Status, `Content-Type`, Körper. **Hypothese.** Überall `400` mit JSON-Körper im Fehlerformat der Route, kein SSE. -**Ergebnis:** +**Ergebnis:** Prompt 2000 Wörter / 13 789 Zeichen, Modell `AMD-Llama-135m-code.Q2_K.gguf` mit +`setCtxSize(256)`. **Alle fünf Routen antworten `400` mit JSON, kein SSE** — geprüft wurde nicht nur +der Status, sondern auch, dass der Körper kein `data: ` und kein `event: ` enthält: + +| Route | Status | `Content-Type` | SSE? | +|---|---|---|---| +| `/v1/chat/completions` | 400 | `application/json; charset=utf-8` | nein | +| `/v1/completions` | 400 | `application/json; charset=utf-8` | nein | +| `/api/chat` | 400 | `application/json; charset=utf-8` | nein | +| `/v1/messages` | 400 | `application/json; charset=utf-8` | nein | +| `/v1/responses` | 400 | `application/json; charset=utf-8` | nein | -**Folgerung:** +Die Körper, je im Fehlerformat der Route: + +``` +/v1/chat/completions {"error":{"message":"request (5818 tokens) exceeds the available context size (256 tokens), try increasing it","type":"invalid_request_error","code":null}} +/v1/completions {"error":{"message":"request (5791 tokens) exceeds the available context size (256 tokens), try increasing it","type":"invalid_request_error","code":null}} +/api/chat {"error":"request (5818 tokens) exceeds the available context size (256 tokens), try increasing it"} +/v1/messages {"type":"error","error":{"type":"invalid_request_error","message":"request (5818 tokens) exceeds the available context size (256 tokens), try increasing it"}} +/v1/responses {"error":{"message":"request (5818 tokens) exceeds the available context size (256 tokens), try increasing it","type":"invalid_request_error","code":null}} +``` + +Gegenprobe, dieselbe Anfrage **ohne** `"stream"` an `/v1/chat/completions`: `400`, +`application/json; charset=utf-8`, Körper zeichengleich mit der gestreamten Zeile oben. + +**Folgerung:** Die Hypothese ist vollständig bestätigt. Der gestreamte Weg verhält sich wie der nicht +gestreamte — Status, `Content-Type` und Körper sind gleich —, und keine Route schickt ein +SSE-Vorspiel, bevor sie den Fehler meldet. Dass `/v1/completions` 5791 statt 5818 Tokens nennt, ist +der fehlende Chat-Template-Rahmen, nicht eine Abweichung. F182 ist mit dem echten Modell auf allen +fünf Routen belegt. --- @@ -133,9 +380,76 @@ mit `chatcmpl-`, alle Chunks eines Streams tragen dieselbe, `model` ist überall ohne `--model-id` (erwartet: der Dateiname). (c) `NativeServer` mit `--alias my-model` als Gegenprobe gegen upstreams eigenen Server im selben Prozess. -**Ergebnis:** +**Ergebnis:** Je ein echtes Kind-JVM über `ServerLauncher`, Modell `AMD-Llama-135m-code.Q2_K.gguf`, +`-c 512 -ngl 0`; der Port wird aus der Startmeldung des Kindes gelesen und mit `/health` bestätigt. + +**(a) `--jllama-openai-compat … --model-id my-model`** — wie erwartet: + +``` +GET /v1/models {"object":"list","data":[{"id":"my-model","object":"model","created":1792447453,"owned_by":"llama.cpp","ftype":"Q2_K - Medium"}]} +blocking id=chatcmpl-14XgXnJnNaDyD6DdDBXV6g5uuEY4N9Q6 startsWith chatcmpl-=true +blocking model=my-model == requested=true +streamed chunks=8 ids=[chatcmpl-1CHN9eEjZyRnHKJdRENc6eBH7pRXQRn7] models=[my-model] +streamed one id=true id startsWith chatcmpl-=true +``` + +Also: jede `id` beginnt mit `chatcmpl-`, alle acht Chunks eines Streams tragen **eine** Id, und +`model` ist überall `my-model`. + +**(c) `NativeServer --alias my-model`** als Gegenprobe gegen upstreams eigenen Server — deckungsgleich: + +``` +GET /v1/models … "data":[{"id":"my-model","aliases":["my-model"], … }] +blocking id=chatcmpl-fqJZm8uzg7akXK4lPIOnH9br0hNIYpqY startsWith chatcmpl-=true +blocking model=my-model == requested=true +streamed chunks=8 ids=[chatcmpl-EWLKYE2i6pFSMWGhlXs5JukSRzbxDHjk] models=[my-model] +``` -**Folgerung:** +**(b) ohne `--model-id` — hier weicht der Lauf ab, und das ist ein Befund.** + +**Befund B2: ohne `--model-id` benennen `/v1/models` und die Antworten dasselbe Modell verschieden.** + +``` +GET /v1/models {"object":"list","data":[{"id":"AMD-Llama-135m-code.Q2_K.gguf", … }]} +blocking model=X:\Privat\OpenSource\github\java-llama.cpp\models\AMD-Llama-135m-code.Q2_K.gguf +streamed models=[X:\Privat\OpenSource\github\java-llama.cpp\models\AMD-Llama-135m-code.Q2_K.gguf] +``` + +`/v1/models` meldet den **Dateinamen** — genau was die Usage-Zeile verspricht („`--model-id ` +Model id reported by /v1/models (default: file name)") und was +`OpenAiServerCli.ParsedArgs.getModelId()` aus `Paths.get(modelPath).getFileName()` ableitet. Die +Antworten melden dagegen den **vollen absoluten Pfad**, weil das `model`-Feld der Antwort nicht aus +der Konfiguration kommt, sondern aus llama.cpps `--alias`, und das wird nur unter einer Bedingung +gesetzt (`OpenAiServerCli.toModelParameters()`): + +```java +if (modelId != null) { + // the answers name the model as llama.cpp knows it; this keeps them equal to /v1/models + params.setAlias(modelId); +} +``` + +Der Kommentar nennt die Absicht — Antworten und `/v1/models` gleich halten —, aber der Zweig läuft +nur, wenn `--model-id` wirklich übergeben wurde. Ohne das Flag bleibt der Alias leer und upstream +setzt den Modellpfad ein. (c) zeigt, dass `--alias` die eine wirksame Stelle ist: damit stimmen beide +Flächen. Die `id`-Hälfte von F185 ist davon **nicht** betroffen — `chatcmpl-` und eine Id pro Stream +gelten in allen drei Fällen. + +Wirkung: ein Client, der `/v1/models` liest und das `model`-Feld der Antwort dagegen hält — oder der +den aus `/v1/models` gelesenen Namen in die nächste Anfrage zurückschreibt —, sieht ohne +`--model-id` zwei verschiedene Namen. Zusätzlich steht dann ein absoluter lokaler Pfad in jeder +Antwort, auch in einer über das Netz. + +Dieselbe Trennung erklärt eine Beobachtung aus M2: die dortige Probe setzt `modelId("m")` direkt über +`OpenAiServerConfig` (nicht über die CLI), und die Antworten tragen trotzdem den Modellpfad — weil +nur die CLI `setAlias` aufruft. Über die eingebettete API ist das Feld also gar nicht einstellbar. + +**Folgerung:** (a) und (c) bestätigen F185 vollständig. (b) weicht ab: der Vorgabewert greift nur für +`/v1/models`, nicht für das `model`-Feld der Antworten, sodass die im Code ausgesprochene Zusage +„keeps them equal to /v1/models" ohne das Flag nicht gilt. Nicht reparieren war Auftragslage; B2 +gehört in `BUGHUNT-FOUND.md`. Die naheliegende Richtung wäre, den abgeleiteten Dateinamen genauso in +den Alias zu setzen wie einen übergebenen (und `OpenAiServerConfig.modelId` denselben Weg zu geben), +damit der Vorgabewert dieselbe Zusage trägt wie das Flag. --- @@ -155,9 +469,117 @@ Ablauf je Durchgang: frisches Modell ohne Adapter (Referenz A); mit Adapter Skal Adaptern die nächste gleich B. Mit der Voreinstellung kann der Host-Speicher-Cache (Javadoc von `setLoraAdapters`) abweichen — das ist der offene Rest, genau aufzeichnen. -**Ergebnis:** +**Ergebnis:** Zuerst ein Befund, der den Ablauf änderte, dann die Messung selbst. + +**Befund B1: `ModelParameters.addLoraScaledAdapter` ist gegen den Pin unbrauchbar.** Der erste +Versuch, Referenz B über `addLoraScaledAdapter(ADAPTER, 1.0f)` zu laden, scheiterte: + +``` +net.ladenthin.llama.exception.LlamaException: Failed to parse model parameters + at net.ladenthin.llama.LlamaModel.loadModel(Native Method) +``` + +Die Methode rendert `putString(ModelOption.LORA_SCALED, fname + "," + scale)`, also +`--lora-scaled ,`. Upstream liest den Wert aber als `FNAME:SCALE,...` — +`common/arg.cpp` am Pin: + +``` +{"--lora-scaled"}, "FNAME:SCALE,...", +"path to LoRA adapter with user defined scaling (format: FNAME:SCALE,...)\nnote: use comma-separated values", +[](common_params & params, const std::string & value) { + for (const auto & item : parse_csv_row(value)) { + auto parts = string_split(item, ':'); + if (parts.size() != 2) { throw std::invalid_argument("lora-scaled format: FNAME:SCALE"); } +``` + +Das Komma ist seit **b7448** der Listentrenner, nicht der Feldtrenner: `487674fbb` („common: fix +--override-kv to support comma-separated values", upstream #18056, 2025-12-17), später erweitert um +`07fbe19f1` (b7648, „arg: use CSV escape style for multiple-value args", #18643). Der Pin ist +b11639, die Methode ist also seit rund 4200 Builds tot. **`addControlVectorScaled` hat dieselbe +Form** (`fname + "," + scale`) und `--control-vector-scaled` dasselbe upstream-Format, ist also +gleichermaßen betroffen. `setOverrideKv(String)` übergibt den Wert unverändert, liegt damit beim +Aufrufer. + +Abgedeckt ist das nur von `ModelParametersTest#testAddLoraScaledAdapter`, das genau das falsche +Format festschreibt: + +```java +ModelParameters p = new ModelParameters().addLoraScaledAdapter("adapter.bin", 0.5f); +assertThat(p.parameters.get("--lora-scaled"), is("adapter.bin,0.5")); +``` + +Das ist die Klasse, die CLAUDE.md für die Wire-Name-Registries beschreibt — ein Test, der die +String-Abbildung prüft und darum grün bleibt, während das Flag tot ist. Die Registry-Prüfung +`test_model_flags.cpp` sieht es nicht: sie hält den **Namen** gegen die Optionstabelle des Servers, +nicht das **Werteformat**. + +Alle vier Formen gegen den echten Parser (je ein Kind-JVM über `ServerLauncher` → `NativeServer`, +der llama-server-argv unverändert weitergibt): + +``` +1. --lora-scaled ,1.0 (was addLoraScaledAdapter heute sendet) + -> error while handling argument "--lora-scaled": invalid stof argument Server startet nicht +2. --lora-scaled :1.0 (upstream-Format, absoluter Windows-Pfad) + -> error while handling argument "--lora-scaled": lora-scaled format: FNAME:SCALE Server startet nicht +3. --lora-scaled :1.0 (upstream-Format, relativer Pfad) + -> 0.00.202.912 I srv llama_server: listening on http://127.0.0.1:64459 angenommen +4. --lora (Gegenprobe, Skala 1.0 per Vorgabe) + -> 0.00.202.540 I srv llama_server: listening on http://127.0.0.1:61766 angenommen +``` + +Form 2 ist bemerkenswert: **auch das korrekte Doppelpunkt-Format trägt auf Windows keinen absoluten +Pfad.** `string_split` trennt an *jedem* Doppelpunkt (`common/common.h`), also ergibt +`X:\…\a.gguf:1.0` drei Teile und nicht zwei. Eine Korrektur am Projekt von `,` auf `:` würde auf +Linux greifen, auf Windows mit Laufwerksbuchstaben weiter scheitern — das ist eine +upstream-Einschränkung, keine Projektentscheidung. (Nebenbei: der Parse-Fehler endet den Prozess mit +**Exit 0** nach der Usage-Ausgabe.) + +Gemessen wurde darum mit `addLoraAdapter(ADAPTER)` (`--lora`, Skala 1.0 per Vorgabe) — genau die +Skala, die M6 verlangt. + +**Die Messung.** Modell `stories15M_MOE-F16.gguf`, Adapter `moe_shakespeare15M.gguf`, Prompt „Look in +thy glass", 32 Tokens, `temperature 0, top_k 1, seed 42, cache_prompt true, id_slot 0`, +`setParallel(1)`, Kontext 512. Der Server meldet den Adapter als +`[{"id":0,"path":"…moe_shakespeare15M.gguf","scale":1.0,…}]`. + +| Schritt | `cacheRamMib(0)` | Voreinstellung | +|---|---|---| +| A ohne Adapter | `was a little girl. She was only three years old …` | gleich | +| B Adapter 1.0, kalt | `in love of the eye:\nThat's when when the eye see thy on the sun's shining bright. …` | gleich | +| B′ Adapter 1.0, warm | = B | = B | +| C nach `setLoraAdapter(0, 0f)` | **= A** | **= A** | +| D nach `setLoraAdapter(0, 1f)` | **= B** | **= B** | +| E Anfrage mit eigenem `lora` Skala 0 | **= A** | **= A** | +| F nächste Anfrage, Server-Adapter 1.0 | **= B** | **= B** | + +Wörtlich aus dem Durchgang mit der Voreinstellung — dem, den der Auftrag als offenen Rest markiert: -**Folgerung:** +``` + C (setLoraAdapter(0,0f), warm slot) = [ was a little girl. She was only three years old …] + C == A (expected) : true + C == B (defect) : false + D (setLoraAdapter(0,1f), warm slot) = [ in love of the eye: …] + D == B (expected) : true + D == A (defect) : false + E (request lora scale 0) = [ was a little girl. …] + E == A (expected) : true + F (next request, server adapters 1.0) = [ in love of the eye: …] + F == B (expected) : true + F == A (defect) : false +``` + +**Folgerung:** Die Messung bestätigt Patch 0021 mit dem echten Adapterpaar, und sie schließt den +offenen Rest: **der Host-Speicher-Cache weicht nicht ab.** Beide Durchgänge sind Schritt für Schritt +gleich — ein Adapterwechsel am warmen Slot wird nie aus dem alten Cache beantwortet, in keiner der +beiden Cache-Einstellungen, auch nicht nach einer Anfrage mit eigenen Adaptern. Der Vorbehalt im +Javadoc von `setLoraAdapters` lässt sich mit dieser Messung nicht nachstellen. + +Was offen bleibt, ist nicht Patch 0021, sondern Befund **B1**: der skalierte Adapterpfad über +`ModelParameters` lädt am Pin überhaupt kein Modell, und der einzige Test darüber schreibt das +falsche Format fest. Die Messung musste deshalb auf `--lora` ausweichen — Skala 1.0 ist damit +belegt, eine **andere** Skala als 1.0 lässt sich über `ModelParameters` derzeit nicht setzen (zur +Laufzeit geht es weiter, wie C/D/E zeigen). Nicht reparieren war Auftragslage; der Befund gehört in +`BUGHUNT-FOUND.md`. --- @@ -171,9 +593,64 @@ die `new LlamaModel(new ModelParameters().setModel("C:/tmp/fehlt-\uD83D\uDE00.gg **Hypothese.** Beide Tests grün (nicht übersprungen); die Probe zeigt U+1F600 als einen Codepunkt, nichts abgeschnitten. -**Ergebnis:** +**Ergebnis:** Beide Teile grün. + +**`LlamaTrainerTest` auf Windows** — die beiden Methoden liefen, nicht übersprungen (aus M1): + +``` +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in net.ladenthin.llama.LlamaTrainerTest +testcase name="aFourByteCharacterInAPathComesBackIntactInTheErrorMessage" +testcase name="aFourByteCharacterInTheTrainingTextReachesTheTrainer" +``` + +**Die Probe.** Umgebung des Test-JVM: + +``` +os=Windows 11 10.0 | file.encoding=UTF-8 | sun.jnu.encoding=Cp1252 | native.encoding=Cp1252 | java=21.0.12.1+1-LTS +path asked for=[C:\Users\…\junit-4414633441307162606\fehlt-😀.gguf] codePointCount=72 +Paths.get round trip equal=true +``` -**Folgerung:** +`sun.jnu.encoding` ist also **Cp1252** und kann U+1F600 gar nicht darstellen — der interessante Fall. +Die Meldung des Trainers, codepunktweise: + +``` +### M7 LlamaTrainer.finetune -> net.ladenthin.llama.exception.LlamaException + message=[failed to load model for training: C:\…\junit-4414633441307162606\fehlt-😀.gguf] + length(chars)=108 codePointCount=107 + codepoints=… U+0066 U+0065 U+0068 U+006C U+0074 U+002D U+1F600 U+002E U+0067 U+0067 U+0075 U+0066 + contains U+1F600 as ONE codepoint=true + mangled as U+00F0 U+009F U+0098 U+0080=false + truncated (ends in ".g" instead of ".gguf")=false + replacement char U+FFFD present=false + '?' mangling present=false + EQUALS expected=true +``` + +Die Codepunktfolge endet also auf `f e h l t - U+1F600 . g g u f`: ein Codepunkt für das Emoji, +`.gguf` vollständig, und die Meldung ist zeichengleich mit dem erwarteten String. Die +Modified-UTF-8-Verstümmelung, die F181 behob (U+00F0 U+009F U+0098 U+0080 und ein nach `.g` +abgeschnittener Rest), tritt nicht auf. Dasselbe mit dem 4-Byte-Zeichen im **Trainingstext** statt im +Pfad: Meldung unverstümmelt, Pfad korrekt. + +Zwei Nebenbeobachtungen, beide **kein** Befund: + +- `new LlamaModel(…)` mit demselben Pfad wirft `LlamaException: could not load model from given file + path` — die Meldung trägt den Pfad **gar nicht**, dieser Eintrittspunkt kann F181 also nicht + zeigen. Der Trainer ist die Stelle, an der der Pfad in der Meldung steht. +- Die **nativen Logzeilen** auf der Konsole zeigen `fehlt-?.gguf` + (`gguf_init_from_file: failed to open GGUF file 'C:\…\fehlt-?.gguf'`). Das ist die Codepage der + `cmd`-Konsole beim Schreiben von stderr, nicht Datenverlust: die Java-sichtbare Meldung derselben + Anfrage trägt U+1F600 unverändert (oben). Wer die Konsole als Beleg nimmt, kommt zum falschen + Schluss. +- `new NativeServer("--model", , "--port", "0").start()` kehrt ohne Ausnahme zurück. Das + ist der dokumentierte Vertrag — „Starts the native HTTP server … and returns immediately. The + server binds and begins serving `GET /health` before the model finishes loading; poll `/health` for + readiness" —, also erwartet und kein Befund. + +**Folgerung:** Die Hypothese ist bestätigt. Beide Trainer-Tests laufen auf Windows grün und sind +nicht übersprungen, und die Probe zeigt U+1F600 als einen Codepunkt bei intaktem Rest, obwohl +`sun.jnu.encoding=Cp1252` das Zeichen nicht darstellen kann. F181 trägt auf dem Windows-Weg. --- @@ -185,9 +662,205 @@ verify` gegen den installierten Kern (`mvn -pl llama,llama-platform -am install `AtmosphereToolLoopIntegrationTest` mit dem Tool-Modell (`-Dnet.ladenthin.llama.lib.path=`). Dazu ein Zug im Agenten mit einem Prompt über der Kontextgröße (`--ctx-size 256`): was zeigt die Konsole? -**Ergebnis:** +**Ergebnis:** In drei Teilen — der modellfreie Lauf (mit einem Befund), der Tool-Loop mit dem echten +Modell, und der Zug über der Kontextgröße. + +**(1) Modellfreier Lauf.** `mvn -f llama-atmosphere-agent/pom.xml -Dllama.version=6.0.0-SNAPSHOT +-Dllama.natives=none verify` gegen den installierten Kern (`mvn -pl llama,llama-platform -am install +-DskipTests`, `BUILD SUCCESS`): + +``` +[ERROR] Tests run: 276, Failures: 1, Errors: 1, Skipped: 60 +[INFO] BUILD FAILURE +``` + +Die 60 übersprungenen sind erwartet und dokumentiert (`ScreenUseCasesTest` 57 + `ReflowResizeTest` 3, +die sich gegen das veröffentlichte JLine selbst abschalten, siehe `llama-atmosphere-agent/CLAUDE.md`). +Der `Errors: 1` ist mein Aufruf: `AtmosphereToolLoopIntegrationTest` findet lokal sein GGUF (anders als +in CIs modellfreiem Job) und läuft dann in `UnsatisfiedLinkError: No native library found`, weil +`-Dllama.natives=none` keine Natives auf den Klassenpfad legt und ich `lib.path` erst in Teil (2) +gesetzt habe. Kein Projektfehler, aber eine Folge, die lokal jeden trifft, der den modellfreien Lauf +mit vorhandenen Modellen fährt. + +**Der `Failures: 1` ist ein Befund.** + +**Befund B3: `AgentSessionTest.aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker` ist auf Windows +deterministisch rot.** Drei Läufe, drei Mal dieselbe Zeile: + +``` +Lauf 1 : [ERROR] Tests run: 13, Failures: 1, Errors: 0, Skipped: 0 +Lauf 2 : [ERROR] Tests run: 13, Failures: 1, Errors: 0, Skipped: 0 +Lauf 3 : [ERROR] Tests run: 13, Failures: 1, Errors: 0, Skipped: 0 +``` + +``` +java.lang.AssertionError: +Expected: a string containing "loop: done after 1 steps" + but: was "loop: write a poem +loop: state in C:\Users\…\AGENT-LOOP.md, max 20 steps, budget 120 min +── loop step 1/20 ── +── loop step 2/20 ── +── loop step 3/20 ── +loop: no progress for 3 steps (nothing written, no tools used)" + at …AgentSessionTest.aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker(AgentSessionTest.java:341) +``` + +und unmittelbar davor im Log: + +``` +WARN org.atmosphere.ai.llm.OpenAiCompatibleClient - LLM connection error, retrying in 500ms (attempt 1/3): HTTP/1.1 header parser received no bytes +``` + +Ursache, mit einer Probe belegt statt erschlossen. Der Test lässt das Skript-Backend +`"working" + System.lineSeparator() + TaskLoop.SENTINEL` senden, und `ScriptedBackend.quote` +(`llama-atmosphere-agent/src/test/.../ScriptedBackend.java`) escapt Anführungszeichen, Backslash und +`\n` — **nicht `\r`**: der `switch` hat Zweige für `'"'`, `'\\'` und `'\n'` und schreibt alles andere +unverändert durch. + +Auf Windows ist `System.lineSeparator()` `\r\n`, der Chunk trägt also ein rohes CR mitten im +JSON-String. Die Probe zeigt beides nebeneinander: + +``` +os=Windows 11 System.lineSeparator() = CRLF +### as the test builds it: "working" + System.lineSeparator() + SENTINEL + text = working<0D><0A><> + chunk = {… "delta":{"content":"working<0D>\n<>"} …} + raw CR in chunk = 113 + parses as JSON = NO: JsonParseException: Illegal unquoted character ((CTRL-CHAR, code 13)): has to be escaped using backslash to be included in string value + TaskLoop.isComplete(text) = true +### with LF only (what Linux gets) + text = working<0A><> + chunk = {… "delta":{"content":"working\n<>"} …} + raw CR in chunk = -1 + parses as JSON = yes + TaskLoop.isComplete(text) = true +``` + +Der Chunk ist also auf Windows **kein gültiges JSON** (RFC 8259 verbietet unescapte Steuerzeichen +U+0000–U+001F), die SSE-Antwort bricht ab, jeder Loop-Schritt bekommt nichts, und der +Stillstandswächter beendet den Loop nach drei Schritten. **Die Sentinel-Erkennung ist nicht +schuld** — `TaskLoop.isComplete` ist für beide Zeilenenden `true`. + +Es ist Testcode, kann also nichts an Nutzer ausliefern; die Wirkung ist, dass das Agentenmodul auf +Windows nicht grün baut. Gesehen wurde es nie, weil die drei Agenten-Jobs nur auf Linux laufen. + +**B3 ist auf Weisung in diesem Lauf behoben** — eine Ausnahme von der Regel „nur messen", ausdrücklich +beauftragt. Zwei Dinge dabei, die beim Messen auffielen: + +**Die Lücke war viel größer als das fehlende `\r`.** Beide `quote`-Fassungen in einem +Einzeldatei-Programm gegen Jackson, auf dieser Maschine: + +``` +System.lineSeparator() = CRLF +VORHER control chars that are not JSON: 31/32 value altered: 0 + namely: U+0000 U+0001 … U+000D … U+001F + System.lineSeparator() round trip: FAILS: Illegal unquoted character ((CTRL-CHAR, code 13)) +NACHHER control chars that are not JSON: 0/32 value altered: 0 + System.lineSeparator() round trip: ok +``` + +Von 32 Steuerzeichen erzeugten **31** ungültiges JSON; behandelt war allein `\n`. Der Wert wird in +keiner Fassung verändert, es ging rein um Gültigkeit. + +**Der Fix** setzt die vier benannten Escapes `\r \t \b \f` dazu und — das Tragende — einen +`default`-Zweig, der alles unter `0x20` als `\uXXXX` schreibt, damit ein Steuerzeichen, das niemand +aufgelistet hat, die Lücke nicht wieder aufreißen kann. Der Test selbst bleibt unangetastet: +`System.lineSeparator()` dort ist richtig, weil genau das CRLF überhaupt erst ausübt. + +**Wächter: `ScriptedBackendTest`** (neu, 3 Fälle) — jeder Chunk eines Text-Zuges muss als JSON +parsen (mit `System.lineSeparator()`, CRLF, LF, einzelnem CR); **jedes** der 32 Steuerzeichen +einzeln, also der `default`-Zweig und nicht die vier benannten Fälle; und der Text muss unverändert +aus dem Chunk zurückkommen. Ohne so einen Wächter ist der Fehler unsichtbar, weil ein kaputter Chunk +als abgebrochener Stream ankommt und die rote Zusicherung mehrere Schichten entfernt über die +*Antwort* spricht. + +**Rot/grün im echten Build belegt, nicht nur in der Isolation.** Mit der alten `quote`-Fassung kurz +zurückgesetzt: + +``` +[ERROR] Tests run: 13, Failures: 1, Errors: 0, Skipped: 0 -- in …AgentSessionTest +[ERROR] Tests run: 3, Failures: 2, Errors: 1, Skipped: 0 -- in …ScriptedBackendTest +[ERROR] ScriptedBackendTest.aTextTurnCarryingTheLineSeparatorIsJsonOnEveryPlatform:44 System.lineSeparator(): chunk is not JSON: … +[ERROR] ScriptedBackendTest.everyControlCharacterSurvivesAsJson:60 U+0000: chunk is not JSON: … +[ERROR] ScriptedBackendTest.theTextComesBackOutOfTheChunkUnchanged:73 » JsonParse Illegal unquoted character ((CTRL-CHAR, code 13)) +``` + +und mit dem Fix (die Datei danach byte-identisch wiederhergestellt), drei Läufe hintereinander: + +``` +Lauf 1/2/3: Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 -- in …AgentSessionTest +Lauf 1/2/3: Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in …ScriptedBackendTest +``` + +Und das ganze Modul, diesmal mit `lib.path`, also ohne den Aufruffehler aus Teil (1): + +``` +[INFO] Tests run: 281, Failures: 0, Errors: 0, Skipped: 60 +[INFO] BUILD SUCCESS +``` + +Die 60 Auslassungen sind unverändert die dokumentierten JLine-Fälle. `spotless:apply` hatte nichts zu +ändern. Geprüft wurde außerdem, ob dieselbe Lücke woanders steckt: ein `grep` über alle handgerollten +JSON-Quote-Helfer im Repository findet nur diese eine Stelle. + +**(2) Der Tool-Loop mit dem echten Modell.** Dieselbe Klasse mit +`-Dnet.ladenthin.llama.lib.path=` und dem Tool-Modell: + +``` +[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.76 s -- in net.ladenthin.llama.atmosphere.AtmosphereToolLoopIntegrationTest +[INFO] BUILD SUCCESS +``` -**Folgerung:** +mit den Methoden `plainChatStreamsAnAnswer`, `toolCallResultIsFedBackAndAnswered` und +`multiRoundReadWriteReadLoopChangesTheFile` — nicht übersprungen. + +**(3) Der Zug über der Kontextgröße.** Vier Züge über `exec:java` (einmal `--ctx-size 256` wie im +Auftrag, dazu eine brauchbare Gegenprobe, siehe unten), je `--plain --temperature 0 --ngl 0`: + +Zug 1, `--ctx-size 256` mit dem 2000-Wörter-Prompt — die Konsole zeigt: + +``` +(context nearly full — compacting first) +(nothing to compact) +srv send_error: task id = 0, error: request (1897 tokens) exceeds the available context size (256 tokens), try increasing it +srv stop: cancel task, id_task = 0 +ERROR org.atmosphere.ai.llm.OpenAiCompatibleClient - LLM API error (400): {"error":{"message":"request (1897 tokens) exceeds the available context size (256 tokens), try increasing it","type":"invalid_request_error","code":null}} +[error] org.atmosphere.ai.AiProviderException$TerminalProviderError: API returned 400: request (1897 tokens) exceeds the available context size (256 tokens), try increasing it +AGENT_EXIT_OVER=1 +``` + +Zug 2, `--ctx-size 256` mit dem **kurzen** Prompt, scheitert ebenso (`request (1483 tokens) exceeds +the available context size (256 tokens)`) — **bei 256 sprengt schon der Systemprompt den Kontext**, +der im Auftrag vorgeschlagene Wert ist für den Agenten also an sich zu klein und taugt nicht als +Gegenprobe. Darum zwei weitere: + +Zug 3, `--ctx-size 4096`, kurzer Prompt — der Agent antwortet normal: + +``` +2 + 2 equals 4. +AGENT_EXIT_CTRL=0 +``` + +Zug 4, `--ctx-size 1024`, der 2000-Wörter-Prompt — wieder der Fehler des Zuges: + +``` +[error] org.atmosphere.ai.AiProviderException$TerminalProviderError: API returned 400: request (1897 tokens) exceeds the available context size (1024 tokens), try increasing it +AGENT_EXIT_OVER2=1 +``` + +**Folgerung:** Teil (3) bestätigt F182 im Agenten: ein Fehler vor dem ersten Chunk erscheint als +`[error] … API returned 400: …` mit der vollständigen Meldung des Servers und beendet den Prozess mit +Code 1, statt still zu enden — und Zug 3 zeigt im Gegenzug, dass derselbe Agent mit arbeitsfähigem +Kontext normal antwortet, der Fehler also am Kontext hängt und nicht am Aufbau. Teil (2) bestätigt +den Tool-Loop mit dem echten Modell. Teil (1) brachte Befund **B3** — das Agentenmodul baute auf +Windows nicht grün, weil der Testharnisch 31 von 32 Steuerzeichen unescapt in seine JSON-Chunks +schrieb —, und B3 ist auf Weisung in diesem Lauf behoben und mit einem Wächter versehen; das Modul +ist damit auf Windows grün (281 Tests, 0 Fehler, 60 dokumentiert übersprungen). + +Zwei Dinge, die **kein** Befund sind: das `â€"` in einer früheren Lesung von +`(context nearly full — compacting first)` war die Codepage meines Log-Lesens, nicht die Ausgabe des +Agenten (als UTF-8 gelesen steht dort ein korrektes `—`); und `--ctx-size 256` als Vorschlag im +Auftrag ist, wie oben, für den Agenten zu klein. --- @@ -197,9 +870,42 @@ Dazu ein Zug im Agenten mit einem Prompt über der Kontextgröße (`--ctx-size 2 -Dnet.ladenthin.llama.lib.path=<…/llama/src/main/natives/net/ladenthin/llama///cpu>` — neu ist `FfmLlamaModelIntegrationTest#oaiCompletionStreamEndsWithUsage` (F186). -**Ergebnis:** +**Ergebnis:** Grün, nichts übersprungen. Die Toolchain greift wie vorgesehen — Maven läuft auf +Temurin 21, das Modul auf Temurin 25: + +``` +[INFO] --- toolchains:3.3.0:toolchain (default) @ llama-ffm --- +[INFO] Required toolchain: jdk [ version='[22,)' ] +[INFO] Found matching toolchain for type jdk: JDK[C:\Users\berna\AppData\Local\Programs\jdk-25.0.4.1+1] +[INFO] Toolchain in maven-compiler-plugin: JDK[…jdk-25.0.4.1+1] +[INFO] Toolchain in maven-surefire-plugin: JDK[…jdk-25.0.4.1+1] +``` + +``` +[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.355 s -- in net.ladenthin.llama.ffm.FfmLlamaModelIntegrationTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.512 s -- in net.ladenthin.llama.ffm.FfmNativeServerTest +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.036 s -- in net.ladenthin.llama.ffm.JllamaNativeTest +[INFO] Tests run: 30, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +Der neue Test lief, aus dem surefire-XML belegt (eine Selbstabschaltung wäre hier der stille Fall, +den der CI-Job eigens prüft): + +``` +name="oaiCompletionStreamEndsWithUsage" classname="net.ladenthin.llama.ffm.FfmLlamaModelIntegrationTest" time="0.036" +``` + +Aufruf: `mvn -f llama-ffm/pom.xml verify +-Dnet.ladenthin.llama.ffm.model=models/AMD-Llama-135m-code.Q2_K.gguf +-Dnet.ladenthin.llama.lib.path=llama/src/main/natives/net/ladenthin/llama/Windows/x86_64/cpu`. Der +relative Modellpfad greift, weil `FfmTestSupport.modelPath()` auf `../models/…` zurückfällt. -**Folgerung:** +**Folgerung:** Der FFM-Weg trägt mit dem echten Modell auf Windows, und F186 ist auch dort belegt: +`oaiCompletionStreamEndsWithUsage` verlangt `object: "text_completion"` auf jedem Chunk und ein +`usage.completion_tokens > 0` auf dem letzten. Für diesen Punkt musste ein JDK 22+ her — auf der +Maschine war keines, also wurde Temurin 25.0.4.1+1 (141 MB) installiert und ein +`~/.m2/toolchains.xml` mit 21 und 25 angelegt, den es vorher nicht gab. --- @@ -209,6 +915,61 @@ Dazu ein Zug im Agenten mit einem Prompt über der Kontextgröße (`--ctx-size 2 `mvn -B -f llama/pom.xml test -Dnet.ladenthin.llama.test.ngl=0` mit dem ganzen Modellsatz, dazu `.github/verify-test-counts.sh` wie in CI. Zahlen (run / failures / errors / skipped) eintragen. -**Ergebnis:** +**Ergebnis:** Beide Hälften grün. + +**`ctest`** gegen den statischen Build mit `-DBUILD_TESTING=ON` — die vom Auftrag erwartete Zahl: + +``` +100% tests passed, 0 tests failed out of 643 +Total Test time (real) = 147.01 sec +``` + +**Die Java-Suite**, `mvn -f llama/pom.xml test -Dnet.ladenthin.llama.test.ngl=0` mit dem ganzen +Modellsatz: + +``` +[INFO] Tests run: 2151, Failures: 0, Errors: 0, Skipped: 18 +[INFO] BUILD SUCCESS +[INFO] Total time: 08:48 min +``` + +**Die Zählprüfung wie in CI** (`.github/verify-test-counts.sh llama/target/surefire-reports +--min-executed 1800`): + +``` +test counts verified: 2151 test(s) run, 18 skipped, 2133 executed across 139 class(es), none empty (floors: total 0, executed 1800) +``` -**Folgerung:** +| | | +|---|---:| +| run | 2151 | +| failures | 0 | +| errors | 0 | +| skipped | 18 | +| executed (run − skipped) | 2133 | +| Klassen, keine leer | 139 | + +**Eine Falle beim Zählen, die hier zuschlug und protokolliert gehört:** der erste Aufruf meldete +2158 über 146 Klassen. `mvn test` räumt `target/surefire-reports` nicht auf, also lagen dort noch die +XML der sieben Wegwerf-Proben aus M2 … M7 — genau 7 Klassen und 7 Tests mehr. Die Zahlen oben sind +nach dem Entfernen dieser Berichte, bestehen also nur aus dem Gesamtlauf. Wer die Zahl eines +Gesamtlaufs übernimmt, muss das Verzeichnis vorher leeren oder `clean` fahren. + +**Die 18 Auslassungen, einzeln geprüft** — jede ist erklärt und für einen statischen Windows-Build +erwartet, keine ist eine stumme Klasse (`none empty` oben): + +| Anzahl | Tests | Grund | +|---:|---|---| +| 8 | `BackendLoadTest` (alle) | `backend fixtures are committed for Linux only` | +| 2 | `RouterModeIntegrationTest#chatCompletion_isProxiedToWorker`, `#models_listContainsLoadedModel` | `Router worker-relaunch test runs on Linux only` | +| 2 | `ProcessRunnerTest#returnsStandardOutput`, `#commandThatDoesNotEndInTimeIsKilledAndReported` | `uname` bzw. `sleep` sind POSIX-Kommandos (`assumeFalse(WINDOWS, …)`) | +| 2 | `CpuModuleIsolationTest#everyCpuModuleLoadsOnItsOwn`, `LlamaLoaderTest#extractionDirectoriesOfThisClasspathNameTheLibraryWithItsModules` | brauchen die **modulare** Auslage aus den Jars; dieser Build ist der statische, den der Loader direkt aus `src/main/natives` lädt | +| 2 | `SystemOneIntegrationTest#aDecisionModelAnswersEveryQuestionType`, `#aDecisionModelRejectsAnUnknownQuestionType` | das Entscheidungsmodell ist nicht im CI-Satz (CLAUDE.md) | +| 1 | `AudioInputIntegrationTest#audioInputProducesNonEmptyReply` | Audio-Modell + mmproj sind nicht im CI-Satz (CLAUDE.md) | +| 1 | `LlamaModelTest#testLogStdout` | `@Disabled` | + +**Folgerung:** Der Gesamtlauf bestätigt, dass die Messungen M1 … M9 den Baum nicht hinterlassen +haben, wie sie ihn gefunden haben wollten: 643/643 in C++ und 2151/0/0/18 in Java, 2133 ausgeführt +gegen eine Untergrenze von 1800, keine Klasse mit null Einträgen. Die acht Wegwerf-Proben sind +entfernt; der Arbeitsbaum trägt außer dieser Datei nur den Fix zu Befund **B3** samt seinem Wächter +(siehe M8). diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java index b5fc39f08..2b145ad7f 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java @@ -132,6 +132,22 @@ static List textTurn(String... pieces) { return chunks; } + /** + * A JSON string literal for {@code s}. + * + *

    Every control character has to be escaped, not just the newline. RFC 8259 forbids + * U+0000..U+001F unescaped inside a string, and a chunk that carries one is not JSON: the client + * drops the stream and the turn produces nothing, so what fails is an assertion about the answer, + * several layers from the cause. It escaped only the quote, the backslash and {@code \n}, which + * left 31 of the 32 control characters producing invalid JSON (measured) -- and that made + * every test feeding it {@link System#lineSeparator()} fail on Windows and nowhere else: + * {@code AgentSessionTest.aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker} deterministically, + * with a raw CR inside the chunk, {@code Illegal unquoted character ((CTRL-CHAR, code 13))} behind + * the client's {@code HTTP/1.1 header parser received no bytes}, and a loop that stalled instead of + * reaching its sentinel. The three agent jobs run on Linux, where the separator is a bare LF, so CI + * never saw it. The default branch below is the load-bearing part: a control character nobody + * listed cannot reintroduce the gap. {@link ScriptedBackendTest} is the guard. + */ private static String quote(String s) { StringBuilder sb = new StringBuilder("\""); for (char c : s.toCharArray()) { @@ -139,7 +155,17 @@ private static String quote(String s) { case '"' -> sb.append("\\\""); case '\\' -> sb.append("\\\\"); case '\n' -> sb.append("\\n"); - default -> sb.append(c); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } } } return sb.append('"').toString(); diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackendTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackendTest.java new file mode 100644 index 000000000..66e28504a --- /dev/null +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackendTest.java @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.atmosphere; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * The scripted backend's own chunks must be JSON, which is a property the tests driving it cannot see: + * an invalid chunk reaches the client as a dropped stream, so the turn simply produces nothing and the + * assertion that fails is about the answer, several layers away from the cause. + * + *

    That is what happened. {@code quote} escaped only {@code "}, {@code \} and {@code \n}, so a text + * carrying {@code System.lineSeparator()} put a raw CR inside the JSON string on Windows -- + * {@code Illegal unquoted character ((CTRL-CHAR, code 13))}, behind the client's + * {@code HTTP/1.1 header parser received no bytes} -- + * and {@link AgentSessionTest#aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker()} failed + * deterministically with a stalled loop. The three agent jobs run on Linux, where the separator is a + * bare LF, so CI never saw it. + */ +class ScriptedBackendTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static void assertEveryChunkIsJson(String what, List chunks) { + assertThat("a turn is at least role + text + finish", chunks.size() >= 3, is(true)); + for (String chunk : chunks) { + try { + MAPPER.readTree(chunk); + } catch (Exception e) { + throw new AssertionError(what + ": chunk is not JSON: " + chunk, e); + } + } + } + + @Test + void aTextTurnCarryingTheLineSeparatorIsJsonOnEveryPlatform() { + assertEveryChunkIsJson( + "System.lineSeparator()", + ScriptedBackend.textTurn("working" + System.lineSeparator() + TaskLoop.SENTINEL)); + assertEveryChunkIsJson("CRLF", ScriptedBackend.textTurn("working\r\n" + TaskLoop.SENTINEL)); + assertEveryChunkIsJson("LF", ScriptedBackend.textTurn("working\n" + TaskLoop.SENTINEL)); + assertEveryChunkIsJson("bare CR", ScriptedBackend.textTurn("working\r" + TaskLoop.SENTINEL)); + } + + /** + * The default branch of {@code quote}, not the named cases: a control character nobody listed is + * what reopened the gap, so every one of them is checked rather than the four with a short escape. + */ + @Test + void everyControlCharacterSurvivesAsJson() { + for (int c = 0; c < 0x20; c++) { + String text = "a" + (char) c + "b"; + assertEveryChunkIsJson("U+" + String.format("%04X", c), ScriptedBackend.textTurn(text)); + } + assertEveryChunkIsJson("quote and backslash", ScriptedBackend.textTurn("say \"hi\"\\ now")); + assertEveryChunkIsJson("DEL and beyond", ScriptedBackend.textTurn("a\u007fb\u0085c
d")); + } + + /** A chunk must also carry the text back unchanged -- escaping may not lose or alter it. */ + @Test + void theTextComesBackOutOfTheChunkUnchanged() throws Exception { + String text = "working" + System.lineSeparator() + "\tdone \"quoted\" \\ U+0001:\u0001"; + List chunks = ScriptedBackend.textTurn(text); + String content = null; + for (String chunk : chunks) { + var delta = MAPPER.readTree(chunk).path("choices").path(0).path("delta"); + if (delta.path("content").isTextual()) { + content = delta.path("content").asText(); + } + } + assertThat(content, is(text)); + } +} From 5fff6b883b18eccd3d7862aa7aaae32a93dccd52 Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Tue, 20 Oct 2026 01:15:49 +0200 Subject: [PATCH 336/589] Fix the wire format of the repeatable adapter options and the advertised model name Two findings from the F67/F179..F186 measurement run, plus a third that came out of fixing the first. All three are Java only; ctest is unaffected. B1 -- addLoraScaledAdapter and addControlVectorScaled rendered "FNAME,SCALE" while llama.cpp has read these options as "FNAME:SCALE,..." since b7448 (#18056, later #18643 which gave them CSV escaping). Every value they emitted was rejected: --lora-scaled ,1.0 fails the argv parse with "invalid stof argument", so the model did not load at all and the exception the caller saw was the generic "Failed to parse model parameters". Only ModelParametersTest covered it, pinning the broken format -- a string-mapping test that stays green while the flag is dead. B4, found while fixing B1 -- all four addX methods say "can be repeated to use multiple adapters" and all four replaced instead, because putString is map.put on a map keyed by the wire string. Upstream takes these options as one comma-separated value, so the new CliParameters.appendCsvValue appends, writing each value as a CSV field (quoted, inner quotes doubled, when it carries a comma or a quote -- parse_csv_row recognises an opening quote only at the start of a field, so a path with a comma cannot be passed otherwise). The colon half cannot be fixed downstream: string_split splits on EVERY colon and demands exactly two parts, so X:\models\a.gguf:1.0 yields three and no absolute Windows path can be expressed. That is measured against the real parser, not read: of four argv forms only a relative path with a colon, and plain --lora, were accepted. The scaled methods therefore refuse a path containing a colon with an IllegalArgumentException naming the cause and the two ways out, instead of letting the load fail with the generic message. B2 -- without --model-id, GET /v1/models reported the file name while every answer reported the full model path. upstream's chain for the model name is alias -> common_params_model::get_name() -> file name, and get_name() returns the PATH for a local file, so the file-name branch its own comment describes is unreachable. toModelParameters set the alias only when the flag was given, so the promise its comment makes -- "this keeps them equal to /v1/models" -- held only with the flag. The alias is now always set, from getModelId(). The embedded path (OpenAiCompatServer over an already-loaded model) cannot set an alias any more; that is stated in OpenAiServerConfig.getModelId() rather than papered over by rewriting the model field of an answer. Verified end to end, not only as string mappings. --lora-scaled :1.0 now loads and matches the --lora reference, :0.0 matches the no-adapter reference and the server reports scale 0.0, and two scaled adapters accumulate into one value. Without --model-id, /v1/models, the blocking answer and every streamed chunk all name the file name and no absolute path reaches an answer. Guards: three new ModelParametersTest cases (accumulation of all four options, CSV quoting, the colon refusal with a relative path as the control) and OpenAiServerCliTest.theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag, which replaces a test that pinned the opposite. After the fixes, every module green against the reinstalled core: ctest 643/643, llama 2154/0/0/18 (2136 executed, floor 1800), spotbugs 0, agent 281/0/0/60, llama-ffm 30/0/0/0, llama-langchain4j 55/0/0/7, llama-kotlin 6/0/0/0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E2h8gXyyE5UeimkL9vQv9G --- BUGHUNT-MEASUREMENTS.md | 160 +++++++++++++++--- .../llama/parameters/CliParameters.java | 29 ++++ .../llama/parameters/ModelParameters.java | 54 ++++-- .../llama/server/OpenAiServerCli.java | 13 +- .../llama/server/OpenAiServerConfig.java | 8 + .../llama/parameters/ModelParametersTest.java | 83 ++++++++- .../llama/server/OpenAiServerCliTest.java | 33 ++-- 7 files changed, 331 insertions(+), 49 deletions(-) diff --git a/BUGHUNT-MEASUREMENTS.md b/BUGHUNT-MEASUREMENTS.md index 79a35de05..25eac857d 100644 --- a/BUGHUNT-MEASUREMENTS.md +++ b/BUGHUNT-MEASUREMENTS.md @@ -22,10 +22,14 @@ Auftrag. Hintergrund in `BUGHUNT-FOUND.md` (die Fund-IDs) und `CHANGELOG.md` ( - Ergebnis unter **Ergebnis:**, darunter **Folgerung:** (bestätigt der Lauf den Fix, oder was weicht ab). - Ein Test, der hier rot wird, ist ein Befund: Ausgabe vollständig eintragen, nicht „reparieren“. -**Eine Ausnahme von der ersten Regel, ausdrücklich beauftragt:** Befund **B3** aus M8 (das -Agentenmodul baute auf Windows nicht grün) wurde im selben Lauf behoben und mit einem Wächter -versehen. Alles andere blieb Messung — die drei Befunde **B1** (M6), **B2** (M5) und die beiden -Nebenbeobachtungen aus M7 sind nur beschrieben, nicht angefasst. +**Eine Ausnahme von der ersten Regel, ausdrücklich beauftragt:** die Befunde dieses Auftrags wurden +nach Abschluss der Messungen behoben, jeder mit einem Wächter und jeder end-to-end belegt — **B1** +(M6, `--lora-scaled` rendert das falsche Werteformat) samt dem dabei gefundenen **B4** (die vier +`addX`-Methoden ersetzten statt anzuhängen), **B2** (M5, `/v1/models` und die Antworten benennen das +Modell verschieden) und **B3** (M8, der Testharnisch schreibt unescapte Steuerzeichen in sein JSON). +Die Messabschnitte selbst stehen unverändert so da, wie sie gemessen wurden; was danach geändert +wurde, steht jeweils in einem eigenen Unterabschnitt „ist behoben". Nicht angefasst sind die +Nebenbeobachtungen aus M7 und der eingebettete Weg aus B2 — bei beiden ist begründet, warum. **Gemeinsame Vorbereitung** @@ -444,12 +448,61 @@ Dieselbe Trennung erklärt eine Beobachtung aus M2: die dortige Probe setzt `mod `OpenAiServerConfig` (nicht über die CLI), und die Antworten tragen trotzdem den Modellpfad — weil nur die CLI `setAlias` aufruft. Über die eingebettete API ist das Feld also gar nicht einstellbar. -**Folgerung:** (a) und (c) bestätigen F185 vollständig. (b) weicht ab: der Vorgabewert greift nur für +**Folgerung:** (a) und (c) bestätigen F185 vollständig. (b) wich ab: der Vorgabewert griff nur für `/v1/models`, nicht für das `model`-Feld der Antworten, sodass die im Code ausgesprochene Zusage -„keeps them equal to /v1/models" ohne das Flag nicht gilt. Nicht reparieren war Auftragslage; B2 -gehört in `BUGHUNT-FOUND.md`. Die naheliegende Richtung wäre, den abgeleiteten Dateinamen genauso in -den Alias zu setzen wie einen übergebenen (und `OpenAiServerConfig.modelId` denselben Weg zu geben), -damit der Vorgabewert dieselbe Zusage trägt wie das Flag. +„keeps them equal to /v1/models" ohne das Flag nicht galt. + +### B2 ist behoben (auf Weisung, nach der Messung) + +**Warum der Pfad überhaupt erschien, nachgelesen statt vermutet.** Upstreams Kette für den Modellnamen +(`tools/server/server-context.cpp`) ist Alias → `common_params_model::get_name()` → Dateiname, und +`get_name()` lautet: + +```cpp +std::string get_name() const { + if (!hf_repo.empty()) { return hf_repo; } + if (!docker_repo.empty()) { return docker_repo; } + return path; +} +``` + +Für eine lokale Datei gibt Schritt 2 also den **Pfad** zurück, womit Schritt 3 — upstreams eigener +Kommentar „fallback: derive model name from file name" — unerreichbar ist. Ohne Alias nennt der Server +darum den Pfad, auch der von upstream selbst. + +**Der Fix** ist eine Zeile in `OpenAiServerCli.toModelParameters()`: der Alias wird **immer** gesetzt, +aus `getModelId()` statt nur aus einem übergebenen `modelId`. Damit trägt der Vorgabewert dieselbe +Zusage wie das Flag, und der eine Name, den der Server bewirbt, ist der, den llama.cpp benutzt. + +**End-to-end belegt** (Wegwerf-Probe, danach entfernt), derselbe Fall wie (b), ohne `--model-id`: + +``` + expected id (file name)=AMD-Llama-135m-code.Q2_K.gguf + GET /v1/models id = AMD-Llama-135m-code.Q2_K.gguf + blocking model = AMD-Llama-135m-code.Q2_K.gguf + blocking id = chatcmpl-rkhNFoYimoN16qjlHGQNTFGFnWhvXRxw + streamed models = [AMD-Llama-135m-code.Q2_K.gguf] + AGREE blocking == /v1/models : true + AGREE streamed == /v1/models : true + id is chatcmpl- and one per stream : true + no absolute path in the answer : true +``` + +Vorher nannten dieselben drei Stellen den vollen absoluten Pfad; der lokale Pfad steht jetzt auch +nicht mehr in jeder Antwort über das Netz. + +**Wächter:** `OpenAiServerCliTest.theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag` — +vorher hieß der Test `anExplicitModelIdBecomesTheModelsAlias` und schrieb das Gegenteil fest (ohne +Flag *kein* `--alias`); er prüft nun beide Fälle und dass Alias und `getModelId()` derselbe String +sind. + +**Was bewusst offen bleibt: der eingebettete Weg.** `OpenAiCompatServer(model, config)` bekommt ein +schon geladenes Modell, kann also kein `--alias` mehr setzen — die Beobachtung aus M2 (dort steht der +Pfad im `model`-Feld, obwohl die Probe `modelId("m")` setzt) gilt weiter. Das ist jetzt im Javadoc von +`OpenAiServerConfig.getModelId()` gesagt, mit dem Hinweis, dass ein einbettender Aufrufer sein Modell +mit `ModelParameters.setAlias(…)` laden muss. Das `model`-Feld der Antwort nachträglich umzuschreiben +wäre die Alternative; sie würde über die Engine etwas anderes behaupten als diese getan hat und würde +von `NativeServer` im Attach-Modus ohnehin nicht nachvollzogen — daher nicht getan, sondern benannt. --- @@ -574,12 +627,59 @@ gleich — ein Adapterwechsel am warmen Slot wird nie aus dem alten Cache beantw beiden Cache-Einstellungen, auch nicht nach einer Anfrage mit eigenen Adaptern. Der Vorbehalt im Javadoc von `setLoraAdapters` lässt sich mit dieser Messung nicht nachstellen. -Was offen bleibt, ist nicht Patch 0021, sondern Befund **B1**: der skalierte Adapterpfad über -`ModelParameters` lädt am Pin überhaupt kein Modell, und der einzige Test darüber schreibt das -falsche Format fest. Die Messung musste deshalb auf `--lora` ausweichen — Skala 1.0 ist damit -belegt, eine **andere** Skala als 1.0 lässt sich über `ModelParameters` derzeit nicht setzen (zur -Laufzeit geht es weiter, wie C/D/E zeigen). Nicht reparieren war Auftragslage; der Befund gehört in -`BUGHUNT-FOUND.md`. +Offen blieb damit nicht Patch 0021, sondern Befund **B1**: der skalierte Adapterpfad über +`ModelParameters` lud am Pin überhaupt kein Modell, und der einzige Test darüber schrieb das falsche +Format fest. Die Messung musste deshalb auf `--lora` ausweichen. + +### B1 ist behoben (auf Weisung, nach der Messung) + +Beim Fixen kam ein weiterer Fehler derselben Familie mit heraus, **B4**: `putString` ist +`map.put(option, value)` auf einer `LinkedHashMap`, also **überschreibt** ein zweiter +`addLoraAdapter` / `addLoraScaledAdapter` / `addControlVector` / `addControlVectorScaled` den ersten — +während alle vier im Javadoc „can be repeated to use multiple adapters" versprechen. Upstream liest +genau diese Optionen seit #18056 / #18643 als **eine** komma-separierte Liste, Akkumulation ist also +das, was sie zusammensetzt. + +Der Fix, in `llama/src/main/java/net/ladenthin/llama/parameters/`: + +- **`CliParameters.appendCsvValue(option, value)`** neu: hängt einen Wert an, statt zu ersetzen, und + schreibt ihn als CSV-Feld — in Anführungszeichen mit verdoppelten inneren Anführungszeichen, sobald + er ein Komma oder ein Anführungszeichen trägt, weil upstreams `parse_csv_row` ein öffnendes + Anführungszeichen nur am Feldanfang erkennt und ein Pfad mit Komma sonst gar nicht übergeben werden + kann. +- **Die vier Methoden** nutzen ihn; die beiden skalierten rendern `FNAME:SCALE` statt `FNAME,SCALE`. +- **Ein Pfad mit Doppelpunkt wird laut abgelehnt** (`IllegalArgumentException`), statt den Ladevorgang + später mit „Failed to parse model parameters" scheitern zu lassen. Die Meldung nennt die Ursache und + die zwei Auswege. Das ist die Grenze aus Form 2 oben: sie ist upstreams, nicht unsere, und lässt + sich hier nicht wegkorrigieren — ein `,`→`:`-Tausch allein hätte auf Windows weiter nicht geladen. + +**End-to-end belegt** mit demselben Adapterpaar (Wegwerf-Probe, danach entfernt): + +``` +### B1 absolute path refused: --lora-scaled reads its value as FNAME:SCALE and splits on every colon, + so a path containing one cannot be expressed: X:\…\moe_shakespeare15M.gguf + -- pass a relative path, or load it unscaled and set the scale after the load + rendered argv for the relative path: [, --fit, on, --lora-scaled, ..\models\moe_shakespeare15M.gguf:0.5,second.gguf:0.25] + A (no adapter) = [ was a little girl. She was only three years old …] + B (--lora, scale 1.0) = [ in love of the eye: …] + C (--lora-scaled :1.0) = [ in love of the eye: …] + adapters: [{"id":0,"path":"..\\models\\moe_shakespeare15M.gguf","scale":1.0,…}] + C == B (expected) : true + D (--lora-scaled :0.0) = [ was a little girl. …] + adapters: [{"id":0,"path":"..\\models\\moe_shakespeare15M.gguf","scale":0.0,…}] + D == A (expected, scale carried) : true + D == B (defect, scale ignored) : false +``` + +Also: die Option **lädt überhaupt erst** (vorher `LlamaException: Failed to parse model parameters`), +zwei skalierte Adapter sammeln sich zu einem Wert, und die Skala wirkt — `:1.0` ergibt Referenz B, +`:0.0` ergibt Referenz A, und der Server meldet die Skala zurück. Damit ist auch das nachgeholt, was +die Messung nicht konnte: eine **andere** Skala als 1.0 über `ModelParameters`. + +**Wächter**, in `ModelParametersTest`: das neue `FNAME:SCALE`-Format (die beiden Tests, die das alte +festschrieben, sind umgestellt), die Akkumulation aller vier Optionen, das CSV-Quoting eines Pfads mit +Komma und Anführungszeichen, und die Ablehnung eines Doppelpunkt-Pfads samt der Meldung — mit einem +relativen Pfad als Gegenprobe im selben Test. 97 Tests der Klasse grün. --- @@ -968,8 +1068,28 @@ erwartet, keine ist eine stumme Klasse (`none empty` oben): | 1 | `AudioInputIntegrationTest#audioInputProducesNonEmptyReply` | Audio-Modell + mmproj sind nicht im CI-Satz (CLAUDE.md) | | 1 | `LlamaModelTest#testLogStdout` | `@Disabled` | -**Folgerung:** Der Gesamtlauf bestätigt, dass die Messungen M1 … M9 den Baum nicht hinterlassen -haben, wie sie ihn gefunden haben wollten: 643/643 in C++ und 2151/0/0/18 in Java, 2133 ausgeführt -gegen eine Untergrenze von 1800, keine Klasse mit null Einträgen. Die acht Wegwerf-Proben sind -entfernt; der Arbeitsbaum trägt außer dieser Datei nur den Fix zu Befund **B3** samt seinem Wächter -(siehe M8). +**Folgerung:** Der Gesamtlauf bestätigt, dass die Messungen M1 … M9 den Baum so hinterlassen haben, +wie sie ihn gefunden haben: 643/643 in C++ und 2151/0/0/18 in Java, 2133 ausgeführt gegen eine +Untergrenze von 1800, keine Klasse mit null Einträgen. + +### Nachlauf nach den Fixes + +Die Zahlen oben sind der Gesamtlauf **vor** den Behebungen von B1/B2/B4 (B3 war zu diesem Zeitpunkt +schon drin). Danach wurde alles noch einmal gefahren, jedes Modul gegen den neu installierten Kern: + +| Lauf | Ergebnis | +|---|---| +| `ctest` (C++, unverändert — die Fixes sind reines Java) | 643/643 | +| `mvn -f llama/pom.xml clean test` (ganzer Modellsatz) | **2154** / 0 / 0 / 18, 8:43 min | +| `verify-test-counts.sh … --min-executed 1800` | 2154 run, 18 skipped, **2136 executed**, 139 Klassen, keine leer | +| `spotbugs:check` wie im `code-style`-Job | `BugInstance size is 0`, `Error size is 0` | +| `spotless:check` (Kern und Agentenmodul) | grün | +| `llama-atmosphere-agent` (`verify` mit `lib.path`) | 281 / 0 / 0 / 60 | +| `llama-ffm` (`verify` mit echtem Modell) | 30 / 0 / 0 / 0 | +| `llama-langchain4j` (`verify`) | 55 / 0 / 0 / 7 | +| `llama-kotlin` (`verify`) | 6 / 0 / 0 / 0 | + +2154 statt 2151 sind die drei neuen Tests in `ModelParametersTest` (Akkumulation, CSV-Quoting, +Doppelpunkt-Ablehnung); `ScriptedBackendTest` steckt in den 281 des Agentenmoduls. Die acht +Wegwerf-Proben der Messung und die zwei des Fix-Nachweises sind entfernt; was im Arbeitsbaum bleibt, +ist diese Datei plus die Fixes zu B1, B2, B3 und B4 mit ihren Wächtern. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java index b0428f54e..3230634f3 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java @@ -90,6 +90,35 @@ protected final T putString(ModelOption option, String return (T) this; } + /** + * Append one value to an option llama.cpp reads as a comma-separated list, so that a builder + * method documented as repeatable really is. + * + *

    The parameter set is a map keyed by the wire string, so {@link #putString} replaces + * — which is right for a setter and wrong for an {@code addX}: a second call silently dropped the + * first. Upstream takes these options as one comma-separated value (since llama.cpp #18056 / + * #18643, which also gave them CSV escaping), so appending is what accumulates. + * + *

    The value is written as a CSV field: quoted, with doubled quotes inside, when it contains a + * comma or a quote — upstream's {@code parse_csv_row} recognises an opening quote only at the + * start of a field, so a path with a comma in it cannot be passed any other way. + * + * @param option the option to append to + * @param value the single value to append, unescaped + * @param the concrete subtype of this builder + * @return this builder + */ + // Self-typing builder idiom — see putScalar above. + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + protected final T appendCsvValue(ModelOption option, String value) { + final String field = + value.indexOf(',') < 0 && value.indexOf('"') < 0 ? value : '"' + value.replace("\"", "\"\"") + '"'; + final String key = option.getCliOption(); + final String present = parameters.get(key); + parameters.put(key, present == null || present.isEmpty() ? field : present + "," + field); + return (T) this; + } + /** * Enable a valueless flag: the key is stored with a {@code null} value, which * {@code toArray()} renders as the flag alone. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index 9c19b7ce2..e22dceef6 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -1047,20 +1047,27 @@ public ModelParameters setOverrideKv(String keyValue) { * @return this builder */ public ModelParameters addLoraAdapter(String fname) { - putString(ModelOption.LORA, fname); - return this; + return appendCsvValue(ModelOption.LORA, fname); } /** * Add a LoRA adapter with user-defined scaling (can be repeated to use multiple adapters). * - * @param fname the file path of the LoRA adapter + *

    The file path may not contain a colon, which on Windows means it may not be absolute. + * llama.cpp reads this option as {@code FNAME:SCALE,...} and splits each item on every + * colon, demanding exactly two parts, so {@code X:\models\a.gguf:1.0} yields three and is + * refused. That is upstream's limitation, not a choice here; pass a path relative to the working + * directory, or load the adapter with {@link #addLoraAdapter(String)} (scale 1.0) and set the + * scale after the load with {@link net.ladenthin.llama.LlamaModel#setLoraAdapter(int, float)}, + * which takes any path. + * + * @param fname the file path of the LoRA adapter, without a colon * @param scale the user-defined scaling factor * @return this builder + * @throws IllegalArgumentException if {@code fname} contains a colon */ public ModelParameters addLoraScaledAdapter(String fname, float scale) { - putString(ModelOption.LORA_SCALED, fname + "," + scale); - return this; + return appendCsvValue(ModelOption.LORA_SCALED, scaledField(ModelOption.LORA_SCALED, fname, scale)); } /** @@ -1070,20 +1077,47 @@ public ModelParameters addLoraScaledAdapter(String fname, float scale) { * @return this builder */ public ModelParameters addControlVector(String fname) { - putString(ModelOption.CONTROL_VECTOR, fname); - return this; + return appendCsvValue(ModelOption.CONTROL_VECTOR, fname); } /** * Add a control vector with user-defined scaling (can be repeated to add multiple scaled control vectors). * - * @param fname the file path of the control vector + *

    The file path may not contain a colon — same upstream constraint as + * {@link #addLoraScaledAdapter(String, float)}, which explains it. + * + * @param fname the file path of the control vector, without a colon * @param scale the user-defined scaling factor * @return this builder + * @throws IllegalArgumentException if {@code fname} contains a colon */ public ModelParameters addControlVectorScaled(String fname, float scale) { - putString(ModelOption.CONTROL_VECTOR_SCALED, fname + "," + scale); - return this; + return appendCsvValue( + ModelOption.CONTROL_VECTOR_SCALED, scaledField(ModelOption.CONTROL_VECTOR_SCALED, fname, scale)); + } + + /** + * Render one {@code FNAME:SCALE} item, refusing a path the upstream parser cannot split. + * + *

    It used to be rendered {@code FNAME,SCALE}. The comma has been the list separator + * since llama.cpp #18056 (b7448) while the field separator is a colon, so every value this emitted + * was rejected: {@code --lora-scaled ,1.0} fails the argv parse with {@code invalid stof + * argument} and the model does not load at all. Measured against the real parser at b11639. + * + * @param option the option the item is for, named in the error message + * @param fname the file path + * @param scale the scaling factor + * @return the rendered {@code FNAME:SCALE} item + * @throws IllegalArgumentException if {@code fname} contains a colon + */ + private static String scaledField(ModelOption option, String fname, float scale) { + if (fname.indexOf(':') >= 0) { + throw new IllegalArgumentException(option.getCliOption() + + " reads its value as FNAME:SCALE and splits on every colon, so a path containing one" + + " cannot be expressed: " + fname + + " -- pass a relative path, or load it unscaled and set the scale after the load"); + } + return fname + ":" + scale; } /** diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index 16c31eb7e..69e3bed4e 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -641,10 +641,15 @@ public boolean isReranking() { public ModelParameters toModelParameters() { final ModelParameters params = new ModelParameters().setModel(modelPath).setGpuLayers(gpuLayers); - if (modelId != null) { - // the answers name the model as llama.cpp knows it; this keeps them equal to /v1/models - params.setAlias(modelId); - } + // The answers name the model as llama.cpp knows it, so this keeps them equal to + // /v1/models -- and it is set from getModelId() rather than from `modelId`, so the + // DEFAULT carries the same promise as the flag. It used to run only when --model-id was + // given, and without the flag the two surfaces then disagreed: /v1/models reported the + // file name (getModelId()) while every answer reported the full model path, because + // upstream's fallback chain for the model name is alias -> common_params_model::get_name() + // -> file name, and get_name() returns the PATH for a local file, so the file-name branch + // upstream's own comment describes is unreachable. Measured at b11639. + params.setAlias(getModelId()); if (mmprojDevice != null) { params.setMmprojDevice(mmprojDevice); } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java index e95d6abae..3401489d0 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java @@ -112,6 +112,14 @@ public int getPort() { /** * The advertised model id. * + *

    This is what {@code GET /v1/models} (and the Ollama {@code /api/tags} / {@code /api/show}) + * reports, and the default {@code model} for a request that names none. It does not reach + * the {@code model} field of an answer: that comes from llama.cpp, which takes the first + * {@code --alias} and falls back to the model path for a local file. For the two to agree, the + * model has to be loaded with {@link net.ladenthin.llama.parameters.ModelParameters#setAlias(String)} + * set to the same name — which {@link OpenAiServerCli} does for you, and an embedding caller that + * loads its own {@code LlamaModel} has to do itself. + * * @return the model id reported by {@code GET /v1/models} */ public String getModelId() { diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java index cacc2f4e9..89c4ea12d 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java @@ -213,19 +213,96 @@ public void testSetSamplersAllLowercase() { } // ------------------------------------------------------------------------- - // addLoraScaledAdapter / addControlVectorScaled — "fname,scale" format + // addLoraScaledAdapter / addControlVectorScaled — "FNAME:SCALE,..." format + // + // These rendered "FNAME,SCALE" and every value was rejected by the argv parser: the comma has + // been the LIST separator since llama.cpp #18056 (b7448) while the field separator is a colon, so + // `--lora-scaled ,1.0` fails with `invalid stof argument` and the model does not load at + // all. Only ModelParametersTest covered it, pinning the broken format -- a string-mapping test + // that stays green while the flag is dead. Measured against the real parser at b11639. // ------------------------------------------------------------------------- @Test public void testAddLoraScaledAdapter() { ModelParameters p = new ModelParameters().addLoraScaledAdapter("adapter.bin", 0.5f); - assertThat(p.parameters.get("--lora-scaled"), is("adapter.bin,0.5")); + assertThat(p.parameters.get("--lora-scaled"), is("adapter.bin:0.5")); } @Test public void testAddControlVectorScaled() { ModelParameters p = new ModelParameters().addControlVectorScaled("vec.bin", 1.5f); - assertThat(p.parameters.get("--control-vector-scaled"), is("vec.bin,1.5")); + assertThat(p.parameters.get("--control-vector-scaled"), is("vec.bin:1.5")); + } + + /** + * All four say "can be repeated" in their javadoc, and all four replaced instead: the parameter + * set is a map keyed by the wire string. Upstream reads them as one comma-separated value, so + * appending is what accumulates. + */ + @Test + public void theRepeatableAdapterOptionsAccumulateInsteadOfReplacing() { + ModelParameters p = new ModelParameters() + .addLoraAdapter("a.gguf") + .addLoraAdapter("b.gguf") + .addLoraScaledAdapter("c.gguf", 0.5f) + .addLoraScaledAdapter("d.gguf", 1f) + .addControlVector("v1.gguf") + .addControlVector("v2.gguf") + .addControlVectorScaled("v3.gguf", 0.25f) + .addControlVectorScaled("v4.gguf", 2f); + assertThat(p.parameters.get("--lora"), is("a.gguf,b.gguf")); + assertThat(p.parameters.get("--lora-scaled"), is("c.gguf:0.5,d.gguf:1.0")); + assertThat(p.parameters.get("--control-vector"), is("v1.gguf,v2.gguf")); + assertThat(p.parameters.get("--control-vector-scaled"), is("v3.gguf:0.25,v4.gguf:2.0")); + } + + /** + * A path carrying the list separator has to be a quoted CSV field, because upstream's + * {@code parse_csv_row} recognises an opening quote only at the start of a field. + */ + @Test + public void aValueCarryingACommaOrAQuoteIsWrittenAsAQuotedCsvField() { + assertThat(new ModelParameters().addLoraAdapter("a,b.gguf").parameters.get("--lora"), is("\"a,b.gguf\"")); + assertThat( + new ModelParameters() + .addLoraAdapter("say\"hi\".gguf") + .parameters + .get("--lora"), + is("\"say\"\"hi\"\".gguf\"")); + assertThat( + new ModelParameters() + .addLoraAdapter("plain.gguf") + .addLoraAdapter("a,b.gguf") + .parameters + .get("--lora"), + is("plain.gguf,\"a,b.gguf\"")); + } + + /** + * The colon is the field separator and upstream splits on every one of them, demanding exactly + * two parts -- so an absolute Windows path cannot be expressed at all. Refusing it here names the + * cause instead of letting the load fail with "Failed to parse model parameters". + */ + @Test + public void aScaledPathContainingAColonIsRefusedWithTheReason() { + IllegalArgumentException lora = assertThrows( + IllegalArgumentException.class, + () -> new ModelParameters().addLoraScaledAdapter("X:\\models\\a.gguf", 1f)); + assertThat(lora.getMessage(), containsString("--lora-scaled")); + assertThat(lora.getMessage(), containsString("FNAME:SCALE")); + assertThat(lora.getMessage(), containsString("X:\\models\\a.gguf")); + + IllegalArgumentException vector = assertThrows( + IllegalArgumentException.class, () -> new ModelParameters().addControlVectorScaled("C:/vec.gguf", 1f)); + assertThat(vector.getMessage(), containsString("--control-vector-scaled")); + + // a relative path is what works, and is what the message points at + assertThat( + new ModelParameters() + .addLoraScaledAdapter("../models/a.gguf", 1f) + .parameters + .get("--lora-scaled"), + is("../models/a.gguf:1.0")); } // ------------------------------------------------------------------------- diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index 07383aaa9..314108b8b 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -208,18 +208,27 @@ public void explicitModelIdOverridesDerivation() { // The answers name the model llama.cpp knows (its alias, else the file); without passing the // advertised id on, /v1/models said "x" while every answer said "Llama-3.gguf" (F185). - @Test - public void anExplicitModelIdBecomesTheModelsAlias() { - List argv = Arrays.asList(OpenAiServerCli.parse("-m", "/opt/models/Llama-3.gguf", "--model-id", "x") - .toModelParameters() - .toArray()); - assertThat(argv.get(argv.indexOf("--alias") + 1), is("x")); - assertThat( - Arrays.asList(OpenAiServerCli.parse("-m", "/opt/models/Llama-3.gguf") - .toModelParameters() - .toArray()) - .contains("--alias"), - is(false)); + // + // The DEFAULT needs it just as much, which this used to assert the opposite of: with no + // --model-id no alias was set at all, and llama.cpp's fallback for the model name is + // alias -> common_params_model::get_name() -> file name, where get_name() returns the PATH for a + // local file -- so /v1/models reported the file name while every answer reported the absolute + // path. Measured against b11639 with both servers; the alias is therefore always set, from + // getModelId(), which is the one name the server advertises. + @Test + public void theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag() { + List explicitId = + Arrays.asList(OpenAiServerCli.parse("-m", "/opt/models/Llama-3.gguf", "--model-id", "x") + .toModelParameters() + .toArray()); + assertThat(explicitId.get(explicitId.indexOf("--alias") + 1), is("x")); + + OpenAiServerCli.Options defaulted = OpenAiServerCli.parse("-m", "/opt/models/Llama-3.gguf"); + List argv = Arrays.asList(defaulted.toModelParameters().toArray()); + assertThat(argv.contains("--alias"), is(true)); + assertThat(argv.get(argv.indexOf("--alias") + 1), is("Llama-3.gguf")); + // the alias and /v1/models are the same string, which is the whole point + assertThat(argv.get(argv.indexOf("--alias") + 1), is(defaulted.getModelId())); } @Test From 06d6d682b52acde795e5af04d0dc5cba7eb738b1 Mon Sep 17 00:00:00 2001 From: Bernard Ladenthin Date: Tue, 20 Oct 2026 01:22:01 +0200 Subject: [PATCH 337/589] Record the four measurement-run findings as F187..F190 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixes landed in 3b9d96b and 15f857a; this gives them their global ids in the ledger and, for the three a consumer can see, a CHANGELOG entry. - F187 -- addLoraScaledAdapter / addControlVectorScaled rendered FNAME,SCALE while llama.cpp has read FNAME:SCALE,... since b7448 (#18056), so no scaled adapter ever loaded. Includes the upstream limit measured along the way: string_split splits on every colon, so an absolute Windows path cannot be expressed at all. - F188 -- the four addX options say "can be repeated" and all four replaced, because the parameter set is a map keyed by the wire string. - F189 -- without --model-id, /v1/models reported the file name while every answer reported the full model path, because upstream's fallback for the model name resolves to the path for a local file (common_params_model::get_name()). - F190 -- the agent's scripted test backend wrote unescaped control characters into its JSON chunks, which made the module red on Windows only; test code, so no CHANGELOG entry. The next free id was 186 + 1 across all three repos (java-llama.cpp 186, BitcoinAddressFinder 171, streambuffer 149). BUGHUNT-MEASUREMENTS.md now names the id next to each B-label so the two documents link up. Note: the "Übersicht" list and the per-entry anchors in BUGHUNT-FOUND.md have not been kept up since F158, so these four follow their 28 immediate neighbours and add neither. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E2h8gXyyE5UeimkL9vQv9G --- BUGHUNT-FOUND.md | 50 +++++++++++++++++++++++++++++++++++++++++ BUGHUNT-MEASUREMENTS.md | 16 +++++++------ CHANGELOG.md | 21 +++++++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e31088d8a..bd106cb65 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1410,6 +1410,56 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d98a6ad` +### F187 – java-llama.cpp: `addLoraScaledAdapter`/`addControlVectorScaled` senden `FNAME,SCALE`, upstream liest `FNAME:SCALE` +- **Zeit:** 2026-10-19 23:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`addLoraScaledAdapter`, `addControlVectorScaled`, neu `scaledField`), `llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java` +- **Schwere:** mittel (das Modell lädt überhaupt nicht; die Ausnahme nennt die Ursache nicht) +- **Historie:** Gefunden beim Messauftrag `BUGHUNT-MEASUREMENTS.md`, M6: der erste Versuch, die Referenz mit Adapter über `addLoraScaledAdapter(ADAPTER, 1.0f)` zu laden, scheiterte mit `LlamaException: Failed to parse model parameters`, worauf die Messung auf `--lora` auswich. Upstream hat das Format mit `487674fbb` (b7448, „common: fix --override-kv to support comma-separated values“, #18056, 2025-12-17) umgestellt und mit `07fbe19f1` (b7648, #18643) um CSV-Maskierung erweitert; das Komma ist seither der **Listen**trenner, der Feldtrenner ist ein Doppelpunkt. Der Pin ist b11639, die Methode war also rund 4200 Builds tot. +- **Beschreibung:** Beide Methoden rendern `putString(…, fname + "," + scale)`. `common/arg.cpp` am Pin liest den Wert als `FNAME:SCALE,...`: `parse_csv_row(value)`, dann je Element `string_split(item, ':')` mit der Bedingung `parts.size() != 2 → throw`. `--lora-scaled ,1.0` scheitert daher im argv-Parser mit `invalid stof argument`, und der Aufrufer sieht nur „Failed to parse model parameters“. Gemessen gegen den echten Parser (je ein Kind-JVM über `ServerLauncher` → `NativeServer`, der llama-server-argv wörtlich weitergibt): Form `,1.0` → `invalid stof argument`, Server startet nicht; `:1.0` → `lora-scaled format: FNAME:SCALE`, Server startet nicht; `:1.0` → angenommen, Server lauscht; `--lora ` → angenommen. Abgedeckt war die Methode nur von `ModelParametersTest#testAddLoraScaledAdapter`, das genau das defekte Format festschrieb (`is("adapter.bin,0.5")`) — die Klasse Fehler, die CLAUDE.md für die Wire-Name-Registries beschreibt: ein Test über die String-Abbildung bleibt grün, während das Flag tot ist. `test_model_flags.cpp` sieht es nicht, es hält den **Namen** gegen die Optionstabelle des Servers, nicht das **Werteformat**. +- **Begleitende upstream-Grenze, nicht downstream behebbar:** `string_split` trennt an *jedem* Doppelpunkt (`common/common.h`), also ergibt `X:\models\a.gguf:1.0` drei Teile statt zwei. **Über `--lora-scaled` lässt sich kein absoluter Windows-Pfad ausdrücken**, auch im korrekten Format nicht (Form 2 oben). Ein reiner `,`→`:`-Tausch hätte auf Windows weiter nicht geladen. +- **Fix:** Die beiden Methoden rendern `FNAME:SCALE` über das neue private `scaledField`, das einen Pfad mit Doppelpunkt mit `IllegalArgumentException` ablehnt — die Meldung nennt das Flag, das Format, den Pfad und die zwei Auswege (relativer Pfad, oder ungeskaliert laden und die Skala nach dem Laden mit `LlamaModel.setLoraAdapter` setzen). Eine laute Ablehnung beim Bauen ist besser als ein Ladefehler ohne Ursache; die Grenze ist im Javadoc von `addLoraScaledAdapter` erklärt, auf das `addControlVectorScaled` verweist. Beide gehen jetzt durch `appendCsvValue` (siehe F188). +- **Tests:** `ModelParametersTest#testAddLoraScaledAdapter` und `#testAddControlVectorScaled` auf `FNAME:SCALE` umgestellt; neu `#aScaledPathContainingAColonIsRefusedWithTheReason` (beide Optionen, Meldung nennt Flag, Format und Pfad, relativer Pfad als Gegenprobe im selben Test). +- **Rot-Grün-Nachweis:** Vorher scheiterte `new LlamaModel(base().addLoraScaledAdapter(adapter, 1f))` mit `Failed to parse model parameters`. Nachher, end-to-end mit dem Adapterpaar aus M6 (`ggml-org/stories15M_MOE`, Wegwerf-Probe, danach entfernt): `--lora-scaled :1.0` lädt und liefert denselben Text wie die `--lora`-Referenz, `:0.0` denselben wie ohne Adapter, und der Server meldet `"scale":0.0` zurück — die Skala wirkt also, was über `ModelParameters` vorher gar nicht möglich war. Danach alle Module grün: Kern 2154/0/0/18 (2136 ausgeführt), `ctest` 643/643, SpotBugs 0, Agent 281/0/0/60, `llama-ffm` 30/0/0/0, `llama-langchain4j` 55/0/0/7, `llama-kotlin` 6/0/0/0. +- **Status:** gefixt +- **Commit:** java-llama.cpp `15f857a` + +### F188 – java-llama.cpp: Die vier „can be repeated“-Adapteroptionen ersetzen den vorigen Wert statt ihn zu ergänzen +- **Zeit:** 2026-10-19 23:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` (neu `appendCsvValue`), `ModelParameters.java` (`addLoraAdapter`, `addLoraScaledAdapter`, `addControlVector`, `addControlVectorScaled`) +- **Schwere:** niedrig bis mittel (von mehreren Adaptern wirkt nur der letzte, ohne Hinweis) +- **Historie:** Gefunden beim Beheben von F187 — dieselben vier Methoden, dieselbe Wire-Option, aber ein eigener Fehler. +- **Beschreibung:** Alle vier versprechen im Javadoc „can be repeated to use multiple adapters“ bzw. „can be repeated to add multiple control vectors“. Der Parametersatz ist aber eine `LinkedHashMap`, die nach dem Wire-String schlüsselt, und `putString` ist `parameters.put(option, value)` — ein zweiter Aufruf überschreibt den ersten still. Für einen Setter ist das richtig, für ein `addX` nicht. Upstream nimmt genau diese Optionen seit #18056/#18643 als **einen** komma-separierten Wert, Ergänzen ist also das, was sie zusammensetzt. +- **Fix:** Neues `protected final appendCsvValue(ModelOption, String)` in `CliParameters`: hängt an einen vorhandenen Wert mit Komma an und schreibt jeden Wert als CSV-Feld — in Anführungszeichen mit verdoppelten inneren Anführungszeichen, sobald er ein Komma oder ein Anführungszeichen trägt. Das ist nötig, weil upstreams `parse_csv_row` ein öffnendes Anführungszeichen nur am **Feldanfang** erkennt, ein Pfad mit Komma also anders nicht übergeben werden kann. Die vier Methoden nutzen es; `setOverrideKv` bleibt unverändert, es ist ein Setter, der den Wert des Aufrufers durchgibt. +- **Tests:** `ModelParametersTest#theRepeatableAdapterOptionsAccumulateInsteadOfReplacing` (alle vier Optionen, je zwei Aufrufe, mit den erwarteten Listenwerten einschließlich `c.gguf:0.5,d.gguf:1.0`) und `#aValueCarryingACommaOrAQuoteIsWrittenAsAQuotedCsvField` (Komma, Anführungszeichen, und ein einfacher Wert vor einem zu maskierenden). +- **Rot-Grün-Nachweis:** Die Akkumulationszusicherungen sind gegen `putString` rot (der erste Wert fehlt). Die End-to-end-Probe aus F187 zeigt zwei skalierte Adapter als einen Wert: `--lora-scaled ..\models\moe_shakespeare15M.gguf:0.5,second.gguf:0.25`. Modullaufzahlen wie in F187. +- **Status:** gefixt +- **Commit:** java-llama.cpp `15f857a` + +### F189 – java-llama.cpp: Ohne `--model-id` nennen `/v1/models` und die Antworten dasselbe Modell verschieden +- **Zeit:** 2026-10-19 23:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toModelParameters`), `OpenAiServerConfig.java` (Javadoc von `getModelId`), `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java` +- **Schwere:** niedrig bis mittel (ein Client, der den aus `/v1/models` gelesenen Namen gegen das `model`-Feld der Antwort hält, sieht zwei verschiedene; zusätzlich steht ein absoluter lokaler Pfad in jeder Antwort, auch über das Netz) +- **Historie:** Gefunden beim Messauftrag `BUGHUNT-MEASUREMENTS.md`, M5(b). F185 hatte die Hälfte erwischt: sein Test hieß `anExplicitModelIdBecomesTheModelsAlias` und sein Kommentar beschrieb genau diesen Fehler für den **expliziten** Fall, schrieb für den Vorgabefall aber fest, dass *kein* `--alias` gesetzt wird. +- **Beschreibung:** `OpenAiServerCli.ParsedArgs.getModelId()` leitet ohne `--model-id` den Dateinamen ab, und `/v1/models` meldet ihn. Das `model`-Feld einer Antwort kommt dagegen nicht aus der Konfiguration, sondern aus llama.cpp, und `toModelParameters()` setzte `--alias` nur unter `if (modelId != null)`. Ohne Alias ist upstreams Kette für den Modellnamen (`tools/server/server-context.cpp:1531`) Alias → `common_params_model::get_name()` → Dateiname, und `get_name()` (`common/common.h:312`) gibt für eine lokale Datei `path` zurück — der Dateiname-Zweig, den upstreams eigener Kommentar „fallback: derive model name from file name“ beschreibt, ist für `-m ` also unerreichbar. Gemessen mit zwei Kind-JVMs: ohne `--model-id` meldete `/v1/models` `AMD-Llama-135m-code.Q2_K.gguf`, jede Antwort und jeder Stream-Chunk den vollen Pfad; mit `--model-id my-model` stimmten beide; `NativeServer --alias my-model` als Gegenprobe ebenfalls. Die `id`-Hälfte von F185 ist nicht betroffen (`chatcmpl-`, eine Id pro Stream, in allen drei Fällen). +- **Fix:** `toModelParameters()` setzt den Alias **immer**, aus `getModelId()` statt aus `modelId`, damit der Vorgabewert dieselbe Zusage trägt wie das Flag — die Zusage, die der Kommentar an der Stelle schon aussprach („this keeps them equal to /v1/models“). +- **Bewusst nicht geändert:** der eingebettete Weg. `OpenAiCompatServer(LlamaModel, OpenAiServerConfig)` bekommt ein schon geladenes Modell und kann kein `--alias` mehr setzen, also trägt eine Antwort dort weiter den Namen, mit dem das Modell geladen wurde (beobachtet in M2, wo die Probe `modelId("m")` setzt und die Antworten den Pfad nennen). Das steht jetzt im Javadoc von `OpenAiServerConfig.getModelId()` mit dem Hinweis, dass ein einbettender Aufrufer sein Modell mit `ModelParameters.setAlias(…)` laden muss. Das `model`-Feld einer Antwort nachträglich zu überschreiben wäre die Alternative; sie würde über die Engine etwas behaupten, was diese nicht getan hat, und `NativeServer` im Attach-Modus könnte es ohnehin nicht nachvollziehen. +- **Tests:** `OpenAiServerCliTest#theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag` — ersetzt `anExplicitModelIdBecomesTheModelsAlias`, prüft beide Fälle und dass der Alias und `getModelId()` derselbe String sind. +- **Rot-Grün-Nachweis:** Der alte Test schrieb das Gegenteil fest und ist gegen den Fix rot. End-to-end (Wegwerf-Probe, danach entfernt), Server ohne `--model-id` über `ServerLauncher`: `/v1/models`, die blockierende Antwort und jeder Stream-Chunk nennen alle `AMD-Llama-135m-code.Q2_K.gguf`, kein absoluter Pfad erreicht eine Antwort, `id` ist `chatcmpl-…` und je Stream eine. Modullaufzahlen wie in F187. +- **Status:** gefixt +- **Commit:** java-llama.cpp `15f857a` + +### F190 – java-llama.cpp: Das Skript-Backend des Agenten schreibt unmaskierte Steuerzeichen in seine JSON-Chunks (Modul baut auf Windows nicht grün) +- **Zeit:** 2026-10-19 22:35 UTC +- **Datei:** `llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java` (`quote`), neu `ScriptedBackendTest.java` +- **Schwere:** niedrig (reiner Testcode, nichts davon wird ausgeliefert — aber das Agentenmodul ist auf Windows nicht baubar) +- **Historie:** Gefunden beim Messauftrag `BUGHUNT-MEASUREMENTS.md`, M8: der modellfreie Lauf des Agentenmoduls war rot, deterministisch in 3 von 3 Läufen. Die drei Agenten-Jobs laufen nur auf Linux, wo `System.lineSeparator()` ein einzelnes LF ist, darum hat CI es nie gesehen. +- **Beschreibung:** `quote` maskierte nur `"`, `\` und `\n` und schrieb alles andere unverändert durch. RFC 8259 verbietet U+0000..U+001F unmaskiert in einem String, also war der Chunk kein JSON, sobald der Text ein Steuerzeichen trug. Gemessen mit beiden `quote`-Fassungen gegen Jackson: **31 von 32** Steuerzeichen ergaben ungültiges JSON, nur `\n` war behandelt; der Wert selbst wurde in keiner Fassung verändert, es ging rein um Gültigkeit. `AgentSessionTest#aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker` ist der einzige Test, der `System.lineSeparator()` einspeist: auf Windows landete ein rohes CR im JSON-String (`Illegal unquoted character ((CTRL-CHAR, code 13))`), der Client verwarf den Strom (`HTTP/1.1 header parser received no bytes`), jeder Loop-Schritt bekam nichts, und der Stillstandswächter beendete den Loop nach drei Schritten. Was rot wurde, war also eine Zusicherung über die **Antwort**, mehrere Schichten von der Ursache entfernt. `TaskLoop.isComplete` ist für beide Zeilenenden `true`, die Sentinel-Erkennung ist nicht beteiligt. +- **Fix:** `quote` bekommt `\r`, `\t`, `\b`, `\f` und — das Tragende — einen `default`-Zweig, der alles unter `0x20` als `\uXXXX` schreibt, damit ein nicht aufgelistetes Steuerzeichen die Lücke nicht wieder aufreißen kann. Der Test selbst bleibt unangetastet: `System.lineSeparator()` dort ist richtig, weil genau das CRLF überhaupt erst ausübt. Ein `grep` über alle handgerollten JSON-Quote-Helfer im Repository zeigt, dass es diese eine Stelle war. +- **Tests:** Neu `ScriptedBackendTest` (3): jeder Chunk eines Text-Zuges parst als JSON (mit `System.lineSeparator()`, CRLF, LF und einzelnem CR); **jedes** der 32 Steuerzeichen einzeln, also der `default`-Zweig und nicht die vier benannten Fälle; und der Text kommt unverändert aus dem Chunk zurück. +- **Rot-Grün-Nachweis:** Mit der alten `quote`-Fassung kurz zurückgesetzt: `ScriptedBackendTest` 2 Failures + 1 Error (`System.lineSeparator(): chunk is not JSON`, `U+0000: chunk is not JSON`, `JsonParse Illegal unquoted character ((CTRL-CHAR, code 13))`) und `AgentSessionTest` 1 Failure. Mit dem Fix (Datei danach byte-identisch wiederhergestellt) beide dreimal hintereinander grün, und das ganze Modul 281/0/0/60 — die 60 sind die dokumentierten JLine-Fälle. `spotless:check` grün. +- **Status:** gefixt +- **Commit:** java-llama.cpp `3b9d96b` + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/BUGHUNT-MEASUREMENTS.md b/BUGHUNT-MEASUREMENTS.md index 25eac857d..fb60e8831 100644 --- a/BUGHUNT-MEASUREMENTS.md +++ b/BUGHUNT-MEASUREMENTS.md @@ -31,6 +31,8 @@ Die Messabschnitte selbst stehen unverändert so da, wie sie gemessen wurden; wa wurde, steht jeweils in einem eigenen Unterabschnitt „ist behoben". Nicht angefasst sind die Nebenbeobachtungen aus M7 und der eingebettete Weg aus B2 — bei beiden ist begründet, warum. +Die vier Befunde stehen mit ihren globalen Fund-IDs in [`BUGHUNT-FOUND.md`](BUGHUNT-FOUND.md): **F187** (B1), **F188** (B4), **F189** (B2) und **F190** (B3). + **Gemeinsame Vorbereitung** ```bash @@ -411,7 +413,7 @@ streamed chunks=8 ids=[chatcmpl-EWLKYE2i6pFSMWGhlXs5JukSRzbxDHjk] models=[my-mod **(b) ohne `--model-id` — hier weicht der Lauf ab, und das ist ein Befund.** -**Befund B2: ohne `--model-id` benennen `/v1/models` und die Antworten dasselbe Modell verschieden.** +**Befund B2 (Fundbuch: F189): ohne `--model-id` benennen `/v1/models` und die Antworten dasselbe Modell verschieden.** ``` GET /v1/models {"object":"list","data":[{"id":"AMD-Llama-135m-code.Q2_K.gguf", … }]} @@ -452,7 +454,7 @@ nur die CLI `setAlias` aufruft. Über die eingebettete API ist das Feld also gar `/v1/models`, nicht für das `model`-Feld der Antworten, sodass die im Code ausgesprochene Zusage „keeps them equal to /v1/models" ohne das Flag nicht galt. -### B2 ist behoben (auf Weisung, nach der Messung) +### B2 (F189) ist behoben (auf Weisung, nach der Messung) **Warum der Pfad überhaupt erschien, nachgelesen statt vermutet.** Upstreams Kette für den Modellnamen (`tools/server/server-context.cpp`) ist Alias → `common_params_model::get_name()` → Dateiname, und @@ -524,7 +526,7 @@ Adaptern die nächste gleich B. Mit der Voreinstellung kann der Host-Speicher-Ca **Ergebnis:** Zuerst ein Befund, der den Ablauf änderte, dann die Messung selbst. -**Befund B1: `ModelParameters.addLoraScaledAdapter` ist gegen den Pin unbrauchbar.** Der erste +**Befund B1 (Fundbuch: F187): `ModelParameters.addLoraScaledAdapter` ist gegen den Pin unbrauchbar.** Der erste Versuch, Referenz B über `addLoraScaledAdapter(ADAPTER, 1.0f)` zu laden, scheiterte: ``` @@ -631,9 +633,9 @@ Offen blieb damit nicht Patch 0021, sondern Befund **B1**: der skalierte Adapter `ModelParameters` lud am Pin überhaupt kein Modell, und der einzige Test darüber schrieb das falsche Format fest. Die Messung musste deshalb auf `--lora` ausweichen. -### B1 ist behoben (auf Weisung, nach der Messung) +### B1 (F187) und B4 (F188) sind behoben (auf Weisung, nach der Messung) -Beim Fixen kam ein weiterer Fehler derselben Familie mit heraus, **B4**: `putString` ist +Beim Fixen kam ein weiterer Fehler derselben Familie mit heraus, **B4** (Fundbuch: F188): `putString` ist `map.put(option, value)` auf einer `LinkedHashMap`, also **überschreibt** ein zweiter `addLoraAdapter` / `addLoraScaledAdapter` / `addControlVector` / `addControlVectorScaled` den ersten — während alle vier im Javadoc „can be repeated to use multiple adapters" versprechen. Upstream liest @@ -784,7 +786,7 @@ mit vorhandenen Modellen fährt. **Der `Failures: 1` ist ein Befund.** -**Befund B3: `AgentSessionTest.aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker` ist auf Windows +**Befund B3 (Fundbuch: F190): `AgentSessionTest.aLoopRunsItsStepsThroughTheFrontEndUntilTheMarker` ist auf Windows deterministisch rot.** Drei Läufe, drei Mal dieselbe Zeile: ``` @@ -844,7 +846,7 @@ schuld** — `TaskLoop.isComplete` ist für beide Zeilenenden `true`. Es ist Testcode, kann also nichts an Nutzer ausliefern; die Wirkung ist, dass das Agentenmodul auf Windows nicht grün baut. Gesehen wurde es nie, weil die drei Agenten-Jobs nur auf Linux laufen. -**B3 ist auf Weisung in diesem Lauf behoben** — eine Ausnahme von der Regel „nur messen", ausdrücklich +**B3 (F190) ist auf Weisung in diesem Lauf behoben** — eine Ausnahme von der Regel „nur messen", ausdrücklich beauftragt. Zwei Dinge dabei, die beim Messen auffielen: **Die Lücke war viel größer als das fehlende `\r`.** Beide `quote`-Fassungen in einem diff --git a/CHANGELOG.md b/CHANGELOG.md index 64c077db7..677c9465e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -633,6 +633,27 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **A scaled LoRA adapter or control vector loads at all.** `ModelParameters.addLoraScaledAdapter` and + `addControlVectorScaled` rendered their value as `FNAME,SCALE`, while llama.cpp has read these options + as `FNAME:SCALE,...` since b7448 (#18056): the comma is the list separator, the colon the field + separator. Every value they emitted was rejected by the argv parser (`invalid stof argument`), so the + model did not load and the caller saw only `Failed to parse model parameters`. They now render + `FNAME:SCALE`. One upstream limit comes with it and is refused loudly rather than silently: the parser + splits each item on *every* colon and demands exactly two parts, so an absolute Windows path cannot be + expressed — `addLoraScaledAdapter` throws `IllegalArgumentException` naming the cause and the two ways + out (a relative path, or `addLoraAdapter` plus `LlamaModel.setLoraAdapter` after the load). +- **The repeatable adapter options accumulate instead of replacing.** `addLoraAdapter`, + `addLoraScaledAdapter`, `addControlVector` and `addControlVectorScaled` all say "can be repeated" and + all four silently dropped everything but the last call, because the parameter set is keyed by the wire + string. They now append to one comma-separated value, as llama.cpp reads these options, and a value + carrying a comma or a quote is written as a quoted CSV field. +- **`/v1/models` and the answers name the model the same without `--model-id` too.** The id was passed on + as llama.cpp's `--alias` only when the flag was given; without it `/v1/models` reported the model's file + name while every answer and stream chunk reported the full model path, because upstream's fallback for + the model name resolves to the path for a local file. `OpenAiCompatServer` now always sets the alias + from the advertised id, so the two agree and no local path reaches an answer. An embedding caller that + loads its own `LlamaModel` still has to set `ModelParameters.setAlias(…)` itself — the server cannot, + the model is already loaded; `OpenAiServerConfig.getModelId()` says so. - **A streamed `/v1/completions` is upstream's stream.** `OpenAiCompatServer` built the chunks of a streamed completion itself from the Java generator: no `usage` on the last chunk, no probabilities (since F180 refused with a pointer to `stream: false`), and a prompt given as a token array refused From 1d0bc7cece7d18bda5387c27f1dda7487db5f66a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:34:57 +0000 Subject: [PATCH 338/589] Record the two limits the measurement run left in TODO.md The local run of BUGHUNT-MEASUREMENTS.md confirmed F181..F186 and was fixed up as F187..F190; two decided limits remained only in the measurement file and the ledger: --lora-scaled cannot name an absolute Windows path (upstream splits on every colon; added to the upstream inventory with the last-colon fix), and the embedded OpenAiCompatServer reports the model's loaded name rather than config.getModelId(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- TODO.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 60c2544e2..3912672f6 100644 --- a/TODO.md +++ b/TODO.md @@ -515,8 +515,21 @@ delete an item here when it is done. **Open suspicions without a finding id** (each needs the owner's decision or a model to decide; numbered for reference, the numbers stay when an item is done): none left -- the last ones were -fixed as F181 to F186 on 2026-10-20. What still needs a model to confirm is listed in -`BUGHUNT-MEASUREMENTS.md` (the brief for a local run). +fixed as F181 to F186 on 2026-10-20. The local measurement run of that brief +(`BUGHUNT-MEASUREMENTS.md`, M1 … M10, Windows) confirmed all of them with the real models and found +four more, fixed as F187 … F190. It left two limits, both decided and documented, recorded here so +they are not rediscovered: + +- **`--lora-scaled` / `--control-vector-scaled` cannot carry an absolute Windows path** (F187). + Upstream reads `FNAME:SCALE,...` and `string_split` splits on *every* colon, so `X:\a.gguf:1.0` + is three parts. `ModelParameters` refuses such a path with the reason and the two ways out (a + relative path, or load unscaled and call `LlamaModel.setLoraAdapter`). A candidate for the upstream + inventory below (split on the last colon); nothing to do downstream. +- **The embedded `OpenAiCompatServer(LlamaModel, config)` reports the model's loaded name, not + `config.getModelId()`** (F189). An already-loaded model cannot take `--alias` any more; the Javadoc + of `OpenAiServerConfig.getModelId()` tells an embedding caller to load with + `ModelParameters.setAlias(…)`. Rewriting the `model` field of every answer was rejected (it would + claim something the engine did not do, and attach-mode `NativeServer` could not follow it). ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) @@ -1299,6 +1312,13 @@ accepted upstream change deletes a patch from the bump checklist once the pin is own CPU picks. Worth an issue with the table from `docs/local-test-plan.md`; **no patch here** -- overriding upstream's scoring downstream would be a maintenance burden with no end. +- **A fourth, measured 2026-10-19 (F187): `--lora-scaled` / `--control-vector-scaled` cannot name an + absolute Windows path.** `common/arg.cpp` splits each `FNAME:SCALE` item with `string_split(item, ':')` + and demands exactly two parts, so `X:\models\a.gguf:1.0` is rejected as three. Measured against the + real parser in `BUGHUNT-MEASUREMENTS.md` M6 (of four argv forms only a relative path and plain + `--lora` were accepted). The obvious fix is to split on the **last** colon; small enough for a PR, + so draft it then. Downstream `ModelParameters` refuses such a path with the reason meanwhile. + (`0009`, `0010`, `0011` and `0013` are gone -- upstream fixed each defect itself -- and recorded in `docs/history/dropped-llama-patches.md`.) From 2345f050583da733c1aaeab45ad60c7c100fc5e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:48:05 +0000 Subject: [PATCH 339/589] Remove the stale modular-natives passages from TODO.md "Where it sticks" listed three problems and a follow-up batch that have all landed: Linux builds with GGML_OPENMP=OFF and verify-native-deps.py holds the absence of libgomp (libstdc++ kept dynamic on purpose), the explicit CUDA arch list, OpenVINO as opt-in, the Diagnose entry point, and the two green full runs behind package. The hand-over section still warned about two 0018 patches before #507 merged; #507 merged with one. The passage now says where each problem went, and the merge note and the "never ran behind package" bullet are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- TODO.md | 72 +++++++++------------------------------------------------ 1 file changed, 11 insertions(+), 61 deletions(-) diff --git a/TODO.md b/TODO.md index 3912672f6..468fd49df 100644 --- a/TODO.md +++ b/TODO.md @@ -258,32 +258,14 @@ them to ggml's `if (NOT MSVC)` block). All 14 x86 modules were measured on one G and 6 of 8 aarch64 ones on a Graviton 4 -- before that, no module had ever been *chosen* on hardware the project could inspect. -**Where it sticks, in the order that matters for a consumer:** - -1. **The library jar is not self-contained** (item 1 below). Java runs in containers, and the usual - slim base images have no `libgomp`; the jar then fails to load at all. Measured, decided, not yet - changed. -2. **"A module that cannot help is skipped" holds for Vulkan and breaks for the other two** - (items 2 and 3): CUDA on an incompatible driver `abort()`s the JVM in *every* configuration, - OpenVINO on a CPU-only host takes over and runs 172x slower. The premise the whole additive design - rests on is therefore conditional, and that was unknown until a module was actually used. -3. **Nothing behind `package` has ever run on the modular natives.** `package` was red until the - Windows OpenVINO module jar was unblocked, so the smoke-set assembly, `smoke-natives-jars.sh` with - all 23 jars on classpath *and* module path, the four `smoke-natives` rows, the macOS smoke, the - agent smoke, the bytecode gate and `verify-native-deps.py` over all 23 jars have never executed - together. **This is what the next Publish run is for.** - -**So the next CI run should be the current `main`, unchanged.** It is the first opportunity to see -everything behind `package`, and bundling the decided changes into it would confound the result: a red -run would not say whether the pipeline or the change is at fault. Expect several rounds there -- a -stage that has never run is unlikely to pass first time -- and note that the `windows-x86-64` smoke -row draws the same CPU lottery as the Windows test job, so it exercises AMX only when GitHub hands it -an Intel runner. - -**The batch for the run after that**, assembled while the first one runs: `GGML_OPENMP=OFF` plus -`-static-libstdc++ -static-libgcc` on Linux and s390x with the dependency allowlist inverted, the -explicit `CMAKE_CUDA_ARCHITECTURES`, the OpenVINO opt-in decision, and the shipped diagnostic entry -point (item 5). These are independent of each other and each is verifiable in the same run. +**What stuck after the first runs, and where it went** (kept so it is not re-derived): the library +jar was not self-contained (`libgomp` missing on slim images) -- Linux builds with `GGML_OPENMP=OFF` +now and `verify-native-deps.py` holds the absence, while `libstdc++`/`libgcc_s` stay dynamic on +purpose (CLAUDE.md "Windows natives", the Linux OpenMP paragraph); CUDA aborted the JVM on an old +driver -- the explicit `CMAKE_CUDA_ARCHITECTURES` fixed it (T4 measured); OpenVINO on a CPU-only host +took over and ran 172x slower -- it is opt-in (`LlamaLoader.OPT_IN_BACKENDS`); nothing behind +`package` had ever run -- it has, in the two green runs above; and the shipped diagnostic entry point +is `net.ladenthin.llama.Diagnose`. The rounds that got there follow. ### Round 0 found one defect, and it was mine @@ -1582,32 +1564,11 @@ SME**, which Graviton 4 lacks and no current instance provides. EC2 Mac also bil minimum** (dedicated host), so it is the expensive way to answer nothing. -## Handed over from the modular-natives session (2026-10-11) -- read the first item before merging #507 +## Handed over from the modular-natives session (2026-10-11) Appended on `local/cpu-module-isolation-test` by the session that shipped the modular natives -(#504 to #506) and watched their two full runs (38065017595, 38069595101). Nothing above was moved. -Items `main` already carries through #506 are named, not repeated. - -### Before #507 merges: two `0018` patches for the same hunk - -`main` (cfc10fd2, #506) carries `llama/patches/0018-ggml-amx-free-the-aligned-buffer.patch`; this -branch carries `llama/patches/0018-ggml-amx-aligned-free.patch`. Both replace the same line of -`ggml/src/ggml-cpu/amx/amx.cpp` (main's adds a four-line comment above the call, this one is bare). -After a merge both files exist, the applier takes them in filename order, the first applies, the -second no longer finds its `-` line and aborts the configure with "does not apply cleanly" -- every -native build job red, on every platform. `git merge-tree main HEAD` (2026-10-11) reports a textual -conflict in `TODO.md` **only**; the two patch files, CLAUDE.md (two `0018` rows in the patch table) -and CHANGELOG.md (two "Fixed" bullets for the same defect) merge *silently*. Resolve in the merge -commit: keep exactly one patch file (the comment is the only difference), one patch-table row and -one CHANGELOG bullet, and keep this branch's `llama/cmake/check-aligned-free-pairing.cmake` guard -either way (main has none). For the `TODO.md` conflict: take main's wording for the three places -#506 touched -- a fifth "Modular natives -- follow-ups" item (the Windows ROCm job is the pipeline's -critical path at 4:15 h and builds uncached), the corrected sccache sentence under "Upstream -submissions" (the `-Xclang` that sccache 0.18.0 refuses comes from CMake's -`Platform/Windows-Clang.cmake` runtime-library selection on every plain-clang Windows TU, not only -from llama.cpp's `-fno-pch-timestamp`; CLAUDE.md "sccache on every Windows Ninja job but one" on -main has the measurement) and the AMX bullet under "Needs a machine" -- and this branch's structure -for everything else. +(#504 to #506) and watched their two full runs (38065017595, 38069595101); merged as #507 with one +`0018` patch and the `check-aligned-free-pairing.cmake` guard. What is still open from it: ### Open from that session, recorded nowhere else @@ -1650,17 +1611,6 @@ for everything else. every probe "failed" until the fixtures were given a `#!/bin/sh` line. The first run of that probe reported all five layouts broken, including the one that demonstrably works in CI -- the harness, not the logic. -- **Everything behind `package` has never run on the modular natives.** Run 38069595101 skipped - `package` (it `needs` the Windows clang test job, red with the AMX crash); this branch's run - 38112603436 reaches it and fails on the Windows OpenVINO module jar (the blocker at the top of - this file). Still unexercised after that: the smoke-set assembly (`package-smoke-sets.sh`), - `smoke-natives-jars.sh` (classes + all 23 natives jars on the classpath **and** the module path), - the bytecode gate and `verify-native-deps.py` over all 23 jars, the four `smoke-natives` rows - (loader line naming every module jar of the platform, the HTTPS round, the RPC smoke, the - bytecode gate over the set), `smoke-natives-macos`, `smoke-agent-linux`, and the publish jobs - attaching the Maven artifacts. Expect further rounds there; the `windows-x86-64` smoke row runs - on the same CPU lottery as the Windows test job, so it exercises AMX only when GitHub hands it an - Intel runner. - **The two server defaults disagree, by design or not -- owner's call.** Upstream #30159 (b11529) moved llama-server's default port from 8080 to 9931, which `NativeServer.DEFAULT_PORT` follows because it inherits the forwarded argv; `OpenAiServerConfig.DEFAULT_PORT` stayed 8080 as the From 7a51bbcf235df33f2bf73ed728bfa9e055abe1d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:17:56 +0000 Subject: [PATCH 340/589] Refuse several completions of one prompt instead of answering one (F197) Upstream's schema reads "n" as n_cmpl, limited to the slot count, and the server answers the extra completions in child tasks. The engine posts one task and creates none, so "n": 2 came back as one choice without a word on every native path: C API, FFM, LlamaModel.handle*, the blocking /v1/completions that forwards its body, and since F186 the streamed one -- F186 moved that route onto the native OAI path and with it dropped the Java refusal F180 had added. Found by the branch review. populate_completion_task refuses n_cmpl > 1 after the schema, as F184 refuses several prompts, which covers the blocking, streamed and chat paths in one place. Tests: a C API case on a two-slot model (four paths refused, n:1 answers one choice; red before, green after), and the HTTP and model-level Java assertions back for both /v1/completions routes (the test model now has two slots, so "n": 2 passes the schema and reaches the engine's refusal). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++++ llama/src/main/cpp/jllama_engine.cpp | 7 ++++ llama/src/test/cpp/test_c_api.cpp | 37 ++++++++++++++++++ ...OpenAiServerCompletionIntegrationTest.java | 38 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index bd106cb65..5bb35576d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1460,6 +1460,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3b9d96b` +### F197 – java-llama.cpp: `n` > 1 wird auf jedem nativen Completion-Weg still auf eine Antwort reduziert (das gestreamte `/v1/completions` verlor dabei den Schutz aus F180) +- **Zeit:** 2026-10-20 10:30 UTC +- **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`populate_completion_task`), `llama/src/test/cpp/test_c_api.cpp`, `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java` +- **Schwere:** mittel (zwei Antworten verlangt, eine geliefert, ohne Hinweis — dieselbe Klasse wie F184) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20. F180 hatte `n > 1` im Java-Mapper für den gestreamten `/v1/completions` abgelehnt; F186 (`d98a6ad`) verlegte diesen Weg auf den nativen OAI-Pfad und entfernte `toCompletionParameters` und damit die Ablehnung samt ihrer Testzusicherung. +- **Beschreibung:** Upstreams Schema liest `n` als `n_cmpl` mit den harten Grenzen 1 … `n_parallel` (`server-schema.cpp:62`) und beantwortet die weiteren Completions in Kind-Tasks, die der Server anlegt (`server-context.cpp:4888`). Die Engine stellt genau einen Task ein und legt keine Kinder an. Jedes `n` zwischen 2 und der Slot-Zahl wurde also angenommen und mit einer Wahl beantwortet — gemessen vom Reviewer mit dem Zufallsmodell und `setParallel(2)`: `handleCompletionsOai` mit `n:2` eine Wahl, `streamCompletionsOai` ebenso; erst `n:3` scheiterte am Schema. Betroffen: C-API, FFM, `LlamaModel.handle*`, das blockierende `/v1/completions` (leitet den Körper wörtlich weiter) und seit F186 das gestreamte. +- **Fix:** `populate_completion_task` lehnt `task.params.n_cmpl > 1` nach dem Schema mit `jllama_invalid_request` ab ("a request takes one completion, this one asks for 'n' = N; send separate requests"), wie mehrere Prompts (F184). Das deckt die blockierenden, gestreamten und Chat-Wege an einer Stelle. +- **Tests:** `CApi.SeveralCompletionsInOneRequestAreRefusedNotDropped` (eigenes Modell mit `-np 2`; `jllama_completions_oai`, `jllama_completions` mit `n_cmpl`, der Stream-Request und `jllama_chat_completions` werden abgelehnt, `n:1` liefert eine Wahl), `OpenAiServerCompletionIntegrationTest#aCompletionAskingForSeveralChoicesIsAnswered400OnBothRoutes` (beide `/v1/completions`-Routen, das Testmodell jetzt mit `setParallel(2)`), `#severalChoicesAreRefusedOnTheModelsOwnCompletionAndChatPaths`. +- **Rot-Grün-Nachweis:** Der C-API-Test gegen den alten Code rot (alle vier Wege `OK` statt `INVALID_REQUEST`), mit dem Fix grün; `ctest` grün. Die beiden Java-Tests lokal mit dem Zufallsmodell grün (die zwei Infill-Tests der Klasse scheitern dort erwartungsgemäß, das Zufallsmodell hat keine FIM-Tokens); in CI mit dem echten Modell. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/cpp/jllama_engine.cpp b/llama/src/main/cpp/jllama_engine.cpp index 90f1a7d6a..5cb440cad 100644 --- a/llama/src/main/cpp/jllama_engine.cpp +++ b/llama/src/main/cpp/jllama_engine.cpp @@ -367,6 +367,13 @@ void populate_completion_task(server_task &task, jllama_context *jctx, } } task.params = server_schema::eval_llama_cmpl_schema(jctx->vocab, jctx->params, logit_bias_eog, data); + // Several completions of one prompt ("n", upstream's n_cmpl): upstream answers them in child tasks + // the server creates, the engine posts this one task alone, so the others were dropped without a + // word. Refused like several prompts. + if (task.params.n_cmpl > 1) { + throw jllama_invalid_request("a request takes one completion, this one asks for 'n' = " + + std::to_string(task.params.n_cmpl) + "; send separate requests"); + } configure_task_slot_impl(task, data); } diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index 0999e06b6..d0295fa52 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -331,6 +331,43 @@ TEST_F(CApi, SeveralPromptsInOneRequestAreRefusedNotDropped) { << jllama_last_error(); } +// The same for several completions of one prompt: upstream's schema accepts "n" (n_cmpl) up to the +// slot count and answers each in a child task the server creates; the engine posts one task and +// creates none, so "n": 2 came back as one choice without a word. Needs two slots: with one, the +// schema itself refuses "n": 2. Covers the blocking, the streamed and the chat path, which all go +// through populate_completion_task. +TEST_F(CApi, SeveralCompletionsInOneRequestAreRefusedNotDropped) { + const std::vector args = {"jllama", "-m", path_, "-c", "128", "-np", + "2", "-ngl", "0", "--no-warmup", "-t", "2"}; + std::vector argv; + for (const auto &a : args) { + argv.push_back(a.c_str()); + } + jllama_model *two_slots = nullptr; + ASSERT_EQ(jllama_model_load(static_cast(argv.size()), argv.data(), nullptr, nullptr, &two_slots), + JLLAMA_STATUS_OK) + << jllama_last_error(); + + Owned response; + EXPECT_EQ(jllama_completions_oai(two_slots, R"({"prompt":"Hi","n":2,"max_tokens":2})", &response.s), + JLLAMA_STATUS_INVALID_REQUEST); + EXPECT_NE(std::string(jllama_last_error()).find("'n'"), std::string::npos) << jllama_last_error(); + EXPECT_EQ(jllama_completions(two_slots, R"({"prompt":"Hi","n_cmpl":2,"n_predict":2})", &response.s), + JLLAMA_STATUS_INVALID_REQUEST); + int task = -1; + EXPECT_EQ(jllama_completions_oai_stream_request(two_slots, R"({"prompt":"Hi","n":2,"max_tokens":2})", &task), + JLLAMA_STATUS_INVALID_REQUEST); + EXPECT_EQ(jllama_chat_completions( + two_slots, R"({"messages":[{"role":"user","content":"Hi"}],"n":2,"max_tokens":2})", &response.s), + JLLAMA_STATUS_INVALID_REQUEST); + + ASSERT_EQ(jllama_completions_oai(two_slots, R"({"prompt":"Hi","n":1,"max_tokens":2,"temperature":0})", &response.s), + JLLAMA_STATUS_OK) + << jllama_last_error(); + EXPECT_EQ(json::parse(response.str()).at("choices").size(), 1u); + jllama_model_free(two_slots); +} + // The streamed /v1/completions: the body parsed as the blocking one, upstream's text_completion // chunks, one id, the last with usage (TODO (14), F186). TEST_F(CApi, OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java index 795eef35d..96865c3d8 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -13,6 +14,7 @@ import java.io.IOException; import net.ladenthin.llama.LlamaModel; import net.ladenthin.llama.TestConstants; +import net.ladenthin.llama.exception.InvalidRequestException; import net.ladenthin.llama.parameters.ModelParameters; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assumptions; @@ -45,6 +47,8 @@ public static void setup() throws IOException { model = new LlamaModel(new ModelParameters() .setModel(TestConstants.MODEL_PATH) .setCtxSize(512) + // two slots, so that "n": 2 passes upstream's schema and reaches the engine's own refusal + .setParallel(2) .setGpuLayers(gpuLayers)); server = new OpenAiCompatServer( model, @@ -119,6 +123,40 @@ public void aStreamedCompletionIsUpstreamsStream() throws IOException { assertThat(last.path("choices").path(0).path("finish_reason").isTextual(), is(true)); } + /** + * Several completions of one prompt ({@code "n": 2}) are refused, not answered with one: upstream + * creates the other completions as child tasks, which the engine does not. The streamed route lost + * this refusal when it moved onto the native OAI path (F186) and got it back in the engine; the + * blocking route forwards the body to the same engine. + */ + @Test + public void aCompletionAskingForSeveralChoicesIsAnswered400OnBothRoutes() throws IOException { + String streamed = + "{\"model\":\"" + MODEL_ID + "\",\"stream\":true,\"max_tokens\":2,\"n\":2," + "\"prompt\":\"Hi\"}"; + Response streamedResponse = post(port, "/v1/completions", streamed, ""); + assertThat(streamedResponse.body, streamedResponse.code, is(400)); + assertThat(streamedResponse.body, streamedResponse.body.contains("'n'"), is(true)); + + String blocking = "{\"model\":\"" + MODEL_ID + "\",\"max_tokens\":2,\"n\":2,\"prompt\":\"Hi\"}"; + Response blockingResponse = post(port, "/v1/completions", blocking, ""); + assertThat(blockingResponse.body, blockingResponse.code, is(400)); + } + + /** The same refusal for a caller of the model itself, on every native completion path. */ + @Test + public void severalChoicesAreRefusedOnTheModelsOwnCompletionAndChatPaths() { + assertThrows( + InvalidRequestException.class, + () -> model.handleCompletionsOai("{\"prompt\":\"Hi\",\"n\":2,\"max_tokens\":2}")); + assertThrows( + InvalidRequestException.class, + () -> model.streamCompletionsOai("{\"prompt\":\"Hi\",\"n\":2,\"max_tokens\":2}", chunk -> {})); + assertThrows( + InvalidRequestException.class, + () -> model.handleChatCompletions( + "{\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}],\"n\":2,\"max_tokens\":2}")); + } + @Test public void infillReturnsContent() throws IOException { String body = "{\"input_prefix\":\"def add(a, b):\\n return \",\"input_suffix\":\"\\n\",\"n_predict\":16}"; From 0bf2d41602255f5c6ea9fb4343b6d0558f27aed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:17:56 +0000 Subject: [PATCH 341/589] Record the commit of F197 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5bb35576d..ac1db5a6f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1470,7 +1470,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `CApi.SeveralCompletionsInOneRequestAreRefusedNotDropped` (eigenes Modell mit `-np 2`; `jllama_completions_oai`, `jllama_completions` mit `n_cmpl`, der Stream-Request und `jllama_chat_completions` werden abgelehnt, `n:1` liefert eine Wahl), `OpenAiServerCompletionIntegrationTest#aCompletionAskingForSeveralChoicesIsAnswered400OnBothRoutes` (beide `/v1/completions`-Routen, das Testmodell jetzt mit `setParallel(2)`), `#severalChoicesAreRefusedOnTheModelsOwnCompletionAndChatPaths`. - **Rot-Grün-Nachweis:** Der C-API-Test gegen den alten Code rot (alle vier Wege `OK` statt `INVALID_REQUEST`), mit dem Fix grün; `ctest` grün. Die beiden Java-Tests lokal mit dem Zufallsmodell grün (die zwei Infill-Tests der Klasse scheitern dort erwartungsgemäß, das Zufallsmodell hat keine FIM-Tokens); in CI mit dem echten Modell. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `447de71` ## Offene Verdachte ohne Fund-ID From c07aad0e1672dadd27d0f99ab5e74de64ddcbb50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:20:21 +0000 Subject: [PATCH 342/589] Let FfmTextToSpeech.close() wait for a synthesis in progress (F198) F16 fixed the use-after-free between close() and synthesize() for the JNI TextToSpeech and named FfmTextToSpeech as affected too, but the FFM class stayed as it was: synthesize() checked a flag and called into the engine without a lock, and close() freed the engine (and the mutex a synthesis holds) underneath it. Found by the branch review. A ReentrantReadWriteLock as in the JNI class: syntheses share the read lock (the engine serializes them itself), close takes the write lock and waits. The two native calls move behind a Backend seam, as in FfmNativeServer, so the locking is tested without the library. FfmTextToSpeechTest (5): close waits for a synthesis inside the native call (red with the seam but without the lock, green with it), synthesize after close is refused without touching the handle, close twice frees once, and the plain contract. llama-ffm verify 35/35. F16's ledger status now says the FFM half came with this commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +- .../ladenthin/llama/ffm/FfmTextToSpeech.java | 104 ++++++++++++--- .../llama/ffm/FfmTextToSpeechTest.java | 123 ++++++++++++++++++ 3 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmTextToSpeechTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ac1db5a6f..5f797e3e3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -193,7 +193,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `TextToSpeechLifecycleTest#close_whileSynthesizeIsInsideTheEngine_freesItOnlyAfterSynthesizeReturned` (Fake über die neue paketinterne Naht `TextToSpeech.Backend` hält `synthesize` im „nativen“ Aufruf fest), dazu `#synthesize_afterClose_throwsIllegalState`, `#close_calledTwice_freesTheEngineOnce`, `#synthesize_whileOpen_returnsTheBackendsWav` - **Fix:** `ReentrantReadWriteLock`: `synthesize` hält die Lesesperre über den ganzen nativen Aufruf, `close` die Schreibsperre. Gleichzeitige Synthesen bleiben möglich (die Engine ordnet sie selbst über `synthesize_mutex`), aber `close` gibt die Engine (samt diesem Mutex) erst frei, wenn keine mehr läuft. Die JNI-Aufrufe laufen über eine paketinterne Naht `Backend`; `LlamaLoader.initialize()` wanderte aus dem static-Block in deren `load`. - **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral eingezogen; dagegen rot 1/4 („the engine was freed while synthesize() still ran in it – Expected: is but: was “). Mit Fix grün 4/4 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot 1/4, wiederhergestellt grün 4/4. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. -- **Status:** gefixt +- **Status:** gefixt für JNI; die FFM-Hälfte (`FfmTextToSpeech`, in „Datei“ genannt) blieb ungeändert und ist erst mit F198 behoben (Branch-Review 2026-10-20) - **Commit:** java-llama.cpp `128be3a` @@ -1472,6 +1472,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `447de71` +### F198 – java-llama.cpp: `FfmTextToSpeech.close()` gibt die Engine frei, während `synthesize()` auf einem anderen Thread in ihr läuft (FFM-Hälfte von F16) +- **Zeit:** 2026-10-20 11:00 UTC +- **Datei:** `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java`, neu `llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmTextToSpeechTest.java` +- **Schwere:** mittel (Use-after-free → JVM-Absturz, nur bei `close()` parallel zu einer laufenden Synthese) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20: F16 nennt die Datei als betroffen, auf dem Branch war sie aber unverändert, und F16 stand auf „gefixt“. +- **Beschreibung:** `synthesize()` prüfte `closed.get()` und rief dann ohne Lock `jllama_tts_synthesize`; `close()` setzte das Flag per CAS und rief `jllama_tts_free` → `engine_free` → `delete engine`. Die native Seite zählt keine Nutzer (`jllama_api.cpp`), also löschte ein `close()` von einem anderen Thread die Engine samt ihrem `synthesize_mutex` unter einer laufenden Synthese — dieselbe Prüfen-dann-Rufen-Lücke wie F59 bei `FfmNativeServer`. +- **Fix:** Ein `ReentrantReadWriteLock` wie im JNI-`TextToSpeech`: Synthesen halten das Lese-Lock (die Engine serialisiert sie selbst), `close()` nimmt das Schreib-Lock und wartet so auf jede laufende Synthese; das `closed`-Flag steht unter dem Lock. Die zwei nativen Aufrufe liegen hinter einem `Backend`-Seam, wie bei `FfmNativeServer`, damit die Sperre ohne Bibliothek testbar ist. +- **Tests:** `FfmTextToSpeechTest` (5, ohne Bibliothek und Modell): `closeWaitsForASynthesisInsideTheNativeCall`, `synthesizeAfterCloseIsRefusedWithoutTouchingTheHandle`, `closeTwiceFreesOnce`, `synthesizeReturnsTheBackendsWav`, `synthesizeWithoutTextIsRefused`. +- **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne Lock: `closeWaitsForASynthesisInsideTheNativeCall` rot (`close()` kehrte zurück, während die Synthese noch lief); mit dem Lock grün. `llama-ffm` `verify` 35/35 mit dem Zufallsmodell. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java index 8d99f3b46..51ac5dbe2 100644 --- a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java +++ b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java @@ -13,7 +13,7 @@ import java.lang.foreign.Arena; import java.lang.foreign.MemorySegment; import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantReadWriteLock; import net.ladenthin.llama.exception.LlamaException; /** @@ -22,11 +22,81 @@ */ public final class FfmTextToSpeech implements AutoCloseable { + /** The two native calls on a loaded pair's handle; a seam so the locking is testable without it. */ + interface Backend { + + /** + * Synthesises {@code text} on {@code handle}. + * + * @param handle the loaded pair + * @param text the text to speak + * @param speakerReferencePath a WAV to clone the voice from, or {@code null} + * @param language the language, or {@code null} + * @param maxFrames the bound on the generated frames + * @param topK the sampling top-k + * @param seed the sampling seed + * @return the WAV bytes + */ + byte[] synthesize(MemorySegment handle, String text, String speakerReferencePath, String language, + int maxFrames, int topK, int seed); + + /** + * Frees {@code handle}, the engine with it. + * + * @param handle the loaded pair + */ + void free(MemorySegment handle); + } + + /** The real calls into the library. */ + private static final Backend NATIVE = new Backend() { + @Override + public byte[] synthesize(MemorySegment handle, String text, String speakerReferencePath, String language, + int maxFrames, int topK, int seed) { + try (Arena arena = Arena.ofConfined()) { + MemorySegment wav = pointerSlot(arena); + MemorySegment length = longSlot(arena); + JllamaNative.call(JllamaNative.TTS_SYNTHESIZE, handle, cString(arena, text), + cString(arena, speakerReferencePath), cString(arena, language), maxFrames, topK, seed, wav, + length); + return takeBytes(wav, length); + } + } + + @Override + public void free(MemorySegment handle) { + invoke(JllamaNative.TTS_FREE, handle); + } + }; + private final MemorySegment handle; - private final AtomicBoolean closed = new AtomicBoolean(); + private final Backend backend; + + /** + * Serializes {@link #close()} against {@link #synthesize}, as in the JNI {@code TextToSpeech} + * (F16): the native free deletes the engine, and nothing on the native side counts the threads + * still inside a call on it, so a free during a synthesis left that synthesis on freed memory. + * Syntheses share the read lock (the engine serializes them itself); close takes the write lock + * and so waits for every synthesis in progress. + */ + private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock(); + + /** Set by {@link #close()} under the write lock. */ + private boolean closed; private FfmTextToSpeech(MemorySegment handle) { + this(handle, NATIVE); + } + + /** + * A pair over {@code handle} that calls {@code backend}; for tests. + * + * @param handle the loaded pair + * @param backend the native calls + */ + FfmTextToSpeech(MemorySegment handle, Backend backend) { this.handle = handle; + this.backend = backend; } /** @@ -65,24 +135,28 @@ public static FfmTextToSpeech load(String modelPath, String mmprojPath, int gpuL public byte[] synthesize(String text, String speakerReferencePath, String language, int maxFrames, int topK, int seed) { Objects.requireNonNull(text, "text"); - if (closed.get()) { - throw new IllegalStateException("the model is closed"); - } - try (Arena arena = Arena.ofConfined()) { - MemorySegment wav = pointerSlot(arena); - MemorySegment length = longSlot(arena); - JllamaNative.call(JllamaNative.TTS_SYNTHESIZE, handle, cString(arena, text), - cString(arena, speakerReferencePath), cString(arena, language), maxFrames, topK, seed, wav, - length); - return takeBytes(wav, length); + lifecycleLock.readLock().lock(); + try { + if (closed) { + throw new IllegalStateException("the model is closed"); + } + return backend.synthesize(handle, text, speakerReferencePath, language, maxFrames, topK, seed); + } finally { + lifecycleLock.readLock().unlock(); } } - /** Frees the model pair; a second call is a no-op. */ + /** Frees the model pair once every synthesis in progress has returned; a second call is a no-op. */ @Override public void close() { - if (closed.compareAndSet(false, true)) { - invoke(JllamaNative.TTS_FREE, handle); + lifecycleLock.writeLock().lock(); + try { + if (!closed) { + closed = true; + backend.free(handle); + } + } finally { + lifecycleLock.writeLock().unlock(); } } } diff --git a/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmTextToSpeechTest.java b/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmTextToSpeechTest.java new file mode 100644 index 000000000..02fa1f95d --- /dev/null +++ b/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmTextToSpeechTest.java @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT +package net.ladenthin.llama.ffm; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.foreign.MemorySegment; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +/** + * The lifecycle locking of {@link FfmTextToSpeech}, over a fake backend: no library, no model. + * + *

    The native free deletes the engine (and the mutex a synthesis holds), and nothing counts users on + * the native side. Without a lock, {@code close()} freed it while a {@code synthesize()} on another + * thread was still inside its native call on it — the use-after-free F16 fixed for the JNI + * {@code TextToSpeech}. + */ +class FfmTextToSpeechTest { + + /** Upper bound for the waits below; reaching it is a failure, not a slow machine. */ + private static final long AWAIT_SECONDS = 10; + + /** How long {@code close()} is given to show that it does not wait. */ + private static final long CLOSE_GRACE_MILLIS = 500; + + private static final byte[] WAV = {1, 2, 3}; + + /** A backend that records the calls and holds a synthesis until it is released. */ + private static final class GatedBackend implements FfmTextToSpeech.Backend { + + final List events = new CopyOnWriteArrayList<>(); + final CountDownLatch insideSynthesize = new CountDownLatch(1); + final CountDownLatch releaseSynthesize = new CountDownLatch(1); + + @Override + public byte[] synthesize(MemorySegment handle, String text, String speakerReferencePath, String language, + int maxFrames, int topK, int seed) { + events.add("synthesize entered"); + insideSynthesize.countDown(); + try { + releaseSynthesize.await(AWAIT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + events.add("synthesize returned"); + return WAV; + } + + @Override + public void free(MemorySegment handle) { + events.add("free (deletes the engine)"); + } + } + + @Test + void closeWaitsForASynthesisInsideTheNativeCall() throws Exception { + GatedBackend backend = new GatedBackend(); + FfmTextToSpeech tts = new FfmTextToSpeech(MemorySegment.NULL, backend); + + Thread speaker = new Thread(() -> tts.synthesize("hi", null, null, 10, 1, 1), "synthesize"); + speaker.start(); + assertThat(backend.insideSynthesize.await(AWAIT_SECONDS, TimeUnit.SECONDS), is(true)); + Thread closer = new Thread(tts::close, "close"); + closer.start(); + closer.join(CLOSE_GRACE_MILLIS); + boolean closeWaited = closer.isAlive(); + backend.releaseSynthesize.countDown(); + speaker.join(TimeUnit.SECONDS.toMillis(AWAIT_SECONDS)); + closer.join(TimeUnit.SECONDS.toMillis(AWAIT_SECONDS)); + + assertThat(closeWaited, is(true)); + assertThat( + backend.events, + contains("synthesize entered", "synthesize returned", "free (deletes the engine)")); + } + + @Test + void synthesizeAfterCloseIsRefusedWithoutTouchingTheHandle() { + GatedBackend backend = new GatedBackend(); + backend.releaseSynthesize.countDown(); + FfmTextToSpeech tts = new FfmTextToSpeech(MemorySegment.NULL, backend); + + tts.close(); + + assertThrows(IllegalStateException.class, () -> tts.synthesize("hi", null, null, 10, 1, 1)); + assertThat(backend.events, contains("free (deletes the engine)")); + } + + @Test + void closeTwiceFreesOnce() { + GatedBackend backend = new GatedBackend(); + FfmTextToSpeech tts = new FfmTextToSpeech(MemorySegment.NULL, backend); + + tts.close(); + tts.close(); + + assertThat(backend.events, contains("free (deletes the engine)")); + } + + @Test + void synthesizeReturnsTheBackendsWav() { + GatedBackend backend = new GatedBackend(); + backend.releaseSynthesize.countDown(); + FfmTextToSpeech tts = new FfmTextToSpeech(MemorySegment.NULL, backend); + + assertThat(tts.synthesize("hi", null, null, 10, 1, 1), is(WAV)); + } + + @Test + void synthesizeWithoutTextIsRefused() { + FfmTextToSpeech tts = new FfmTextToSpeech(MemorySegment.NULL, new GatedBackend()); + + assertThrows(NullPointerException.class, () -> tts.synthesize(null, null, null, 10, 1, 1)); + } +} From 7215d0a066e5ee27e7e8d912e12f3745d8584d80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:20:21 +0000 Subject: [PATCH 343/589] Record the commit of F198 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5f797e3e3..9ae295762 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1482,7 +1482,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `FfmTextToSpeechTest` (5, ohne Bibliothek und Modell): `closeWaitsForASynthesisInsideTheNativeCall`, `synthesizeAfterCloseIsRefusedWithoutTouchingTheHandle`, `closeTwiceFreesOnce`, `synthesizeReturnsTheBackendsWav`, `synthesizeWithoutTextIsRefused`. - **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne Lock: `closeWaitsForASynthesisInsideTheNativeCall` rot (`close()` kehrte zurück, während die Synthese noch lief); mit dem Lock grün. `llama-ffm` `verify` 35/35 mit dem Zufallsmodell. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `d6be2c5` ## Offene Verdachte ohne Fund-ID From aa58171f25d96c13cd6c73e3833de23d62e22fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:27:58 +0000 Subject: [PATCH 344/589] Write the stream's opening event even when a heartbeat started the stream (F199) streamAnthropic and streamResponses scheduled the heartbeat before setting the opening event (message_start / response.created). A heartbeat that fired first started the stream with an empty prelude, and startWith only set the field the stream writes when it starts: the opening event was lost. startWith now writes the text at once on a started stream, and both routes set the opening before scheduling the heartbeat. ResponseStreamTest pins it over a fake HttpExchange. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++ .../llama/server/OpenAiCompatServer.java | 25 ++- .../llama/server/ResponseStreamTest.java | 179 ++++++++++++++++++ 3 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9ae295762..43085cdfc 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1484,6 +1484,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d6be2c5` +### F199 – java-llama.cpp: Ein Heartbeat vor dem Eröffnungsevent verschluckt `message_start` (Anthropic) bzw. `response.created` (Responses) +- **Zeit:** 2026-10-20 11:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`, `streamAnthropic`, `streamResponses`), neu `llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java` +- **Schwere:** mittel (Client erhält einen Stream ohne Eröffnungsevent; Anthropic-SDKs verwerfen ihn) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20. +- **Beschreibung:** Beide Routen planten den Heartbeat-Timer **vor** `out.startWith(translator.begin())`. Feuerte der Heartbeat dazwischen (oder kam die Antwort erst nach einem Heartbeat in Gang), startete er den Stream mit leerem Prelude; `startWith` setzte danach nur noch das Feld, das `body()` nur beim Start schreibt — das Eröffnungsevent ging still verloren. +- **Fix:** `startWith` schreibt den Text sofort, wenn der Stream schon läuft (ein Heartbeat ist ein SSE-Kommentar, kein Event, also bleibt das Eröffnungsevent das erste Event); beide Routen setzen die Eröffnung zudem vor dem Planen des Heartbeats. +- **Tests:** `ResponseStreamTest` (4, über einen `HttpExchange`-Fake ohne Socket): `theOpeningIsWrittenFirstWhenItIsSetBeforeTheFirstWrite`, `anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten`, `anErrorBeforeTheFirstWriteIsAnsweredWithItsStatusAndNoOpening`, `anErrorAfterTheFirstWriteIsLeftToTheStream`. +- **Rot-Grün-Nachweis:** Ohne den Fix `anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten` rot (erwartet `: ping\nOPEN\nDATA\n`, war `: ping\nDATA\n`); mit ihm grün. `OpenAiCompatServerHttpTest` 63/63. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index c4f4d4f96..36f919abf 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -694,12 +694,14 @@ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, Stri try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_SSE)) { ScheduledFuture heartbeat = null; try { + // the opening first: a heartbeat starts the stream, and the opening event must precede + // every other event + out.startWith(translator.begin()); heartbeat = heartbeatExecutor.scheduleAtFixedRate( () -> out.writeQuietly(OpenAiSseFormatter.heartbeat()), config.getHeartbeatMillis(), config.getHeartbeatMillis(), TimeUnit.MILLISECONDS); - out.startWith(translator.begin()); backend.stream(withUsageChunk(openAiRequest), chunkJson -> { String events = translator.onChunk(chunkJson); if (!events.isEmpty()) { @@ -792,12 +794,14 @@ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, Stri try (ResponseStream out = new ResponseStream(exchange, CONTENT_TYPE_SSE)) { ScheduledFuture heartbeat = null; try { + // the opening first: a heartbeat starts the stream, and the opening event must precede + // every other event + out.startWith(translator.begin()); heartbeat = heartbeatExecutor.scheduleAtFixedRate( () -> out.writeQuietly(OpenAiSseFormatter.heartbeat()), config.getHeartbeatMillis(), config.getHeartbeatMillis(), TimeUnit.MILLISECONDS); - out.startWith(translator.begin()); backend.stream(withUsageChunk(openAiRequest), chunkJson -> { String events = translator.onChunk(chunkJson); if (!events.isEmpty()) { @@ -1063,7 +1067,8 @@ private void sendError(HttpExchange exchange, int status, String type, String me * {@link #answerIfNotStarted(int, String)} can still answer the request with an error status and a * JSON body, which is what upstream's server does with a request it refuses before the first result. */ - private static final class ResponseStream implements AutoCloseable { + // package-private for ResponseStreamTest + static final class ResponseStream implements AutoCloseable { private final HttpExchange exchange; private final String contentType; @@ -1077,10 +1082,22 @@ private static final class ResponseStream implements AutoCloseable { this.contentType = contentType; } - /** Text the stream opens with once it starts (a protocol's opening events). */ + /** + * Text the stream opens with once it starts (a protocol's opening events). Set after the stream + * already started (a heartbeat came first), it is written at once, so it is never lost. + */ void startWith(String text) { synchronized (lock) { prelude = text; + OutputStream out = os; + if (out != null && !answered && !text.isEmpty()) { + try { + out.write(text.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } catch (IOException e) { + LOG.trace("stream write failed (client likely disconnected)", e); + } + } } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java new file mode 100644 index 000000000..2b9b48158 --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT +package net.ladenthin.llama.server; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +/** + * {@code ResponseStream}, the lazily started response of the streaming routes, over a recording + * {@link HttpExchange}: no server, no model. + */ +public class ResponseStreamTest { + + /** Records the status and the body; everything else is inert. */ + private static final class RecordingExchange extends HttpExchange { + + final Headers responseHeaders = new Headers(); + final ByteArrayOutputStream body = new ByteArrayOutputStream(); + int status = -1; + + @Override + public Headers getRequestHeaders() { + return new Headers(); + } + + @Override + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public URI getRequestURI() { + return URI.create("/"); + } + + @Override + public String getRequestMethod() { + return "POST"; + } + + @Override + public @Nullable HttpContext getHttpContext() { + return null; + } + + @Override + public void close() {} + + @Override + public InputStream getRequestBody() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public OutputStream getResponseBody() { + return body; + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) { + status = rCode; + } + + @Override + public InetSocketAddress getRemoteAddress() { + return InetSocketAddress.createUnresolved("localhost", 0); + } + + @Override + public int getResponseCode() { + return status; + } + + @Override + public InetSocketAddress getLocalAddress() { + return InetSocketAddress.createUnresolved("localhost", 0); + } + + @Override + public String getProtocol() { + return "HTTP/1.1"; + } + + @Override + public @Nullable Object getAttribute(String name) { + return null; + } + + @Override + public void setAttribute(String name, @Nullable Object value) {} + + @Override + public void setStreams(@Nullable InputStream i, @Nullable OutputStream o) {} + + @Override + public @Nullable HttpPrincipal getPrincipal() { + return null; + } + + String text() { + return new String(body.toByteArray(), StandardCharsets.UTF_8); + } + } + + @Test + void theOpeningIsWrittenFirstWhenItIsSetBeforeTheFirstWrite() throws Exception { + RecordingExchange exchange = new RecordingExchange(); + try (OpenAiCompatServer.ResponseStream out = + new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) { + out.startWith("OPEN\n"); + out.writeQuietly(": ping\n"); + out.writeStrict("DATA\n"); + } + + assertThat(exchange.status, is(200)); + assertThat(exchange.text(), is("OPEN\n: ping\nDATA\n")); + } + + /** + * A heartbeat that fires before the route set its opening starts the stream. The opening used to be + * lost then: it was only ever written on the first write, which had already happened, so an + * Anthropic stream lacked {@code message_start} and a Responses stream {@code response.created}. A + * heartbeat is an SSE comment, not an event, so the opening written after it is still the first + * event of the stream. + */ + @Test + void anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten() throws Exception { + RecordingExchange exchange = new RecordingExchange(); + try (OpenAiCompatServer.ResponseStream out = + new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) { + out.writeQuietly(": ping\n"); + out.startWith("OPEN\n"); + out.writeStrict("DATA\n"); + } + + assertThat(exchange.text(), is(": ping\nOPEN\nDATA\n")); + } + + @Test + void anErrorBeforeTheFirstWriteIsAnsweredWithItsStatusAndNoOpening() throws Exception { + RecordingExchange exchange = new RecordingExchange(); + try (OpenAiCompatServer.ResponseStream out = + new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) { + out.startWith("OPEN\n"); + assertThat(out.answerIfNotStarted(400, "{\"error\":\"bad\"}"), is(true)); + out.writeQuietly(": ping\n"); + } + + assertThat(exchange.status, is(400)); + assertThat(exchange.text(), is("{\"error\":\"bad\"}")); + } + + @Test + void anErrorAfterTheFirstWriteIsLeftToTheStream() throws Exception { + RecordingExchange exchange = new RecordingExchange(); + try (OpenAiCompatServer.ResponseStream out = + new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) { + out.writeQuietly(": ping\n"); + assertThat(out.answerIfNotStarted(400, "{\"error\":\"bad\"}"), is(false)); + } + + assertThat(exchange.status, is(200)); + } +} From b6c3f00497e189b895b6739deb107528b430896e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:27:58 +0000 Subject: [PATCH 345/589] Record the commit of F199 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 43085cdfc..0c392484a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1494,7 +1494,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponseStreamTest` (4, über einen `HttpExchange`-Fake ohne Socket): `theOpeningIsWrittenFirstWhenItIsSetBeforeTheFirstWrite`, `anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten`, `anErrorBeforeTheFirstWriteIsAnsweredWithItsStatusAndNoOpening`, `anErrorAfterTheFirstWriteIsLeftToTheStream`. - **Rot-Grün-Nachweis:** Ohne den Fix `anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten` rot (erwartet `: ping\nOPEN\nDATA\n`, war `: ping\nDATA\n`); mit ihm grün. `OpenAiCompatServerHttpTest` 63/63. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `bbdafda` ## Offene Verdachte ohne Fund-ID From a2bfad8877807b823e4437034676fc087f334042 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:33:27 +0000 Subject: [PATCH 346/589] Bound chatBatch's threads and wait for every request before throwing (F200) runEachOnItsOwnThread made its pool as large as the batch, so ten thousand requests were ten thousand threads, although the native scheduler only runs as many as it has slots. It now takes a bound (chatBatch passes 64) and the rest start as threads free up. An Error from a request was rethrown at the first future holding it, returning while the others still ran; every failure now propagates after all requests finished. BatchDispatchTest pins both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++ .../net/ladenthin/llama/BatchDispatch.java | 44 ++++++++---- .../java/net/ladenthin/llama/LlamaModel.java | 4 +- .../ladenthin/llama/BatchDispatchTest.java | 69 +++++++++++++++++-- 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0c392484a..a438234f8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1496,6 +1496,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `bbdafda` +### F200 – java-llama.cpp: `chatBatch` startet einen Thread pro Anfrage ohne Obergrenze, und ein `Error` beendet das Warten vor den anderen Anfragen +- **Zeit:** 2026-10-20 11:45 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java`, `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`chatBatch`), `llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java` +- **Schwere:** niedrig bis mittel (eine große Liste wird zu ebenso vielen Threads; nach einem `Error` kehrt der Aufruf zurück, während Anfragen noch laufen) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20; `runEachOnItsOwnThread` entstand mit dem Fix, der `chatBatch` vom gemeinsamen `ForkJoinPool` löste. +- **Beschreibung:** `Executors.newFixedThreadPool(requests.size(), ...)`: zehntausend Anfragen waren zehntausend Threads, obwohl der native Scheduler nur so viele gleichzeitig rechnet, wie er Slots hat. Und `asRuntimeException` warf einen `Error` aus der ersten Future, die ihn trug, sofort — die übrigen wurden nicht mehr abgewartet, entgegen der Zusage, dass ein Fehler erst nach allen Anfragen propagiert. +- **Fix:** `runEachOnItsOwnThread(requests, maxThreads, call)`: der Pool hat `min(requests.size(), maxThreads)` Threads (`chatBatch` übergibt `MAX_CHAT_THREADS` = 64), weitere Anfragen starten, sobald ein Thread frei wird; eine Grenze unter 1 wird abgelehnt. Der erste Fehler jeder Art wird gemerkt und erst nach allen Futures geworfen. +- **Tests:** `BatchDispatchTest`: neu `runEachOnItsOwnThread_runsNoMoreRequestsAtOnceThanTheBound` (12 Anfragen, Grenze 3, höchstens 3 gleichzeitig), `runEachOnItsOwnThread_anErrorIsThrownAfterTheOthersFinished`, `runEachOnItsOwnThread_aBoundBelowOneIsRefused`. +- **Rot-Grün-Nachweis:** Mit der neuen Signatur, aber ohne Grenze und ohne den Error-Pfad: die drei neuen Tests rot (12 gleichzeitig statt 3; zwei statt null fertige Anfragen fehlten beim Wurf; keine Ablehnung von 0); mit dem Fix 11/11 grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java b/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java index b32567eb5..3cd7b3da9 100644 --- a/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java +++ b/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java @@ -15,7 +15,6 @@ import java.util.function.IntConsumer; import java.util.function.IntFunction; import java.util.function.ToIntFunction; -import org.jspecify.annotations.Nullable; /** * How the batch methods of {@link LlamaModel} hand several requests to the native scheduler at once. @@ -30,6 +29,13 @@ */ final class BatchDispatch { + /** + * The most threads {@link LlamaModel#chatBatch} runs at once. More requests than the model has + * slots only wait in the native scheduler, so a larger pool buys nothing but threads; 64 covers + * every slot count this project has seen configured. Requests beyond it start as threads free up. + */ + static final int MAX_CHAT_THREADS = 64; + private BatchDispatch() {} /** @@ -88,32 +94,39 @@ static List submitAllThenReceive( * Runs every request on a thread of its own and returns the results in input order. * *

    For calls that submit and wait in one native call (the OpenAI chat endpoint), where the - * submission cannot be separated from the wait. One thread per request, so all of them reach the - * native scheduler at once; the threads are daemons and end with the call. + * submission cannot be separated from the wait. One thread per request up to {@code maxThreads}, + * so that many reach the native scheduler at once and the rest start as threads free up; the + * threads are daemons and end with the call. A failure, an {@link Error} included, propagates once + * every request has finished, the first one first. * - * @param requests the requests in order - * @param call runs one request to completion + * @param requests the requests in order + * @param maxThreads the most requests run at once, at least 1 + * @param call runs one request to completion * @param the request type * @param the result type * @return the results in input order */ - static List runEachOnItsOwnThread(Collection requests, Function call) { + static List runEachOnItsOwnThread(Collection requests, int maxThreads, Function call) { + if (maxThreads < 1) { + throw new IllegalArgumentException("maxThreads must be at least 1, was " + maxThreads); + } if (requests.isEmpty()) { return new ArrayList(0); } final AtomicInteger threadNumber = new AtomicInteger(); - final ExecutorService executor = Executors.newFixedThreadPool(requests.size(), runnable -> { - Thread thread = new Thread(runnable, "jllama-batch-" + threadNumber.incrementAndGet()); - thread.setDaemon(true); - return thread; - }); + final ExecutorService executor = + Executors.newFixedThreadPool(Math.min(requests.size(), maxThreads), runnable -> { + Thread thread = new Thread(runnable, "jllama-batch-" + threadNumber.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); try { final List> futures = new ArrayList>(requests.size()); for (final R request : requests) { futures.add(executor.submit(() -> call.apply(request))); } final List out = new ArrayList(futures.size()); - RuntimeException failure = null; + Throwable failure = null; boolean interrupted = false; for (Future future : futures) { // The native call behind a future cannot be abandoned, so an interrupt does not end @@ -126,7 +139,8 @@ static List runEachOnItsOwnThread(Collection requests, Function List runEachOnItsOwnThread(Collection requests, Function List runEachOnItsOwnThread(Collection requests, Function completeBatchWithStats(java.util.Collect /** * Dispatch a list of typed chat requests in parallel and return the parsed responses * in the same order. Each request runs on a thread of its own (the chat endpoint submits and - * waits in one native call), so all of them reach the native scheduler at once. Requires + * waits in one native call), up to 64 at once; the rest start as threads free up. Requires * {@link net.ladenthin.llama.parameters.ModelParameters#setParallel(int)} > 1 for * actual parallelism; otherwise the calls run sequentially on the single slot. * @@ -226,7 +226,7 @@ public java.util.List completeBatchWithStats(java.util.Collect * @return parsed responses in input order */ public java.util.List chatBatch(java.util.Collection requests) { - return BatchDispatch.runEachOnItsOwnThread(requests, this::chat); + return BatchDispatch.runEachOnItsOwnThread(requests, BatchDispatch.MAX_CHAT_THREADS, this::chat); } /** diff --git a/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java b/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java index 62769888e..6aacab9d9 100644 --- a/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java +++ b/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java @@ -16,6 +16,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; /** @@ -125,7 +126,7 @@ public void runEachOnItsOwnThread_runsMoreRequestsAtOnceThanTheCommonPoolHasThre input.add(i); } - List results = BatchDispatch.runEachOnItsOwnThread(input, request -> { + List results = BatchDispatch.runEachOnItsOwnThread(input, requests, request -> { allStarted.countDown(); try { return allStarted.await(AWAIT_SECONDS, TimeUnit.SECONDS); @@ -140,7 +141,7 @@ public void runEachOnItsOwnThread_runsMoreRequestsAtOnceThanTheCommonPoolHasThre @Test public void runEachOnItsOwnThread_keepsTheInputOrder() { - List results = BatchDispatch.runEachOnItsOwnThread(Arrays.asList(3, 1, 2), request -> { + List results = BatchDispatch.runEachOnItsOwnThread(Arrays.asList(3, 1, 2), 3, request -> { try { Thread.sleep(request * 20L); } catch (InterruptedException e) { @@ -159,7 +160,7 @@ public void runEachOnItsOwnThread_failure_isThrownAfterTheOthersFinished() { IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, - () -> BatchDispatch.runEachOnItsOwnThread(Arrays.asList(1, 2, 3), request -> { + () -> BatchDispatch.runEachOnItsOwnThread(Arrays.asList(1, 2, 3), 3, request -> { if (request == 1) { throw failure; } @@ -174,6 +175,66 @@ public void runEachOnItsOwnThread_failure_isThrownAfterTheOthersFinished() { @Test public void runEachOnItsOwnThread_noRequests_returnsAnEmptyList() { assertThat( - BatchDispatch.runEachOnItsOwnThread(Collections.emptyList(), request -> request), is(empty())); + BatchDispatch.runEachOnItsOwnThread(Collections.emptyList(), 1, request -> request), + is(empty())); + } + + @Test + public void runEachOnItsOwnThread_runsNoMoreRequestsAtOnceThanTheBound() { + // One thread per request made the pool as large as the batch: ten thousand requests were ten + // thousand threads, although the native scheduler runs only as many as it has slots. + AtomicInteger running = new AtomicInteger(); + AtomicInteger mostAtOnce = new AtomicInteger(); + List input = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + input.add(i); + } + + List results = BatchDispatch.runEachOnItsOwnThread(input, 3, request -> { + mostAtOnce.accumulateAndGet(running.incrementAndGet(), Math::max); + try { + Thread.sleep(20L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + running.decrementAndGet(); + return request; + }); + + assertThat(results, is(input)); + assertThat(mostAtOnce.get(), is(3)); + } + + @Test + public void runEachOnItsOwnThread_anErrorIsThrownAfterTheOthersFinished() { + // An Error used to be rethrown at the first future that held it, so the call returned while + // the requests after it were still running. + AssertionError error = new AssertionError("native failure"); + List finished = Collections.synchronizedList(new ArrayList<>()); + + AssertionError thrown = assertThrows( + AssertionError.class, + () -> BatchDispatch.runEachOnItsOwnThread(Arrays.asList(1, 2, 3), 3, request -> { + if (request == 1) { + throw error; + } + try { + Thread.sleep(100L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + finished.add(request); + return request; + })); + + assertThat(thrown, is(error)); + assertThat(finished.size(), is(2)); + } + + @Test + public void runEachOnItsOwnThread_aBoundBelowOneIsRefused() { + assertThrows( + IllegalArgumentException.class, + () -> BatchDispatch.runEachOnItsOwnThread(Arrays.asList(1), 0, request -> request)); } } From 12398c9081d2ca246d7ae23cb2ae48cc3a8de9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:33:27 +0000 Subject: [PATCH 347/589] Record the commit of F200 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a438234f8..7c546605f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1506,7 +1506,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `BatchDispatchTest`: neu `runEachOnItsOwnThread_runsNoMoreRequestsAtOnceThanTheBound` (12 Anfragen, Grenze 3, höchstens 3 gleichzeitig), `runEachOnItsOwnThread_anErrorIsThrownAfterTheOthersFinished`, `runEachOnItsOwnThread_aBoundBelowOneIsRefused`. - **Rot-Grün-Nachweis:** Mit der neuen Signatur, aber ohne Grenze und ohne den Error-Pfad: die drei neuen Tests rot (12 gleichzeitig statt 3; zwei statt null fertige Anfragen fehlten beim Wurf; keine Ablehnung von 0); mit dem Fix 11/11 grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `3e996db` ## Offene Verdachte ohne Fund-ID From 8e2642f5a1b9cc6c1b279756b7e2158e762330cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:36:55 +0000 Subject: [PATCH 348/589] Refuse a model id llama.cpp would split into several aliases (F201) The CLI passes the advertised id on as --alias, which llama.cpp splits at commas and strips into a set, naming the first entry in every answer: --model-id my,model advertised "my,model" and answered as "model". The flag now refuses a comma, an empty value and surrounding whitespace, and an id derived from a file name replaces commas with '_' and is trimmed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 +++++++ .../llama/server/OpenAiServerCli.java | 32 ++++++++++++++++--- .../llama/server/OpenAiServerCliTest.java | 29 +++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7c546605f..7623db189 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1508,6 +1508,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3e996db` +### F201 – java-llama.cpp: `--model-id` mit Komma (oder abgeleitet aus einem Dateinamen mit Komma) wird zu mehreren llama.cpp-Aliasen; `/v1/models` und die Antworten nennen das Modell verschieden +- **Zeit:** 2026-10-20 12:00 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java`, `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java` +- **Schwere:** niedrig (falscher Modellname in den Antworten; Clients, die den Namen aus `/v1/models` vergleichen, verwerfen sie) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20; Folge von F185, das die ID immer als `--alias` weitergibt. +- **Beschreibung:** Upstreams `--alias` (`common/arg.cpp`, b11639) teilt den Wert an Kommas, strippt jeden Teil und legt ihn in ein `std::set`; die Antworten nennen den ersten Eintrag. `--model-id my,model` meldete in `/v1/models` `my,model`, in jeder Antwort `model`; dasselbe für eine aus `a,b.gguf` abgeleitete ID, und eine ID aus Leerzeichen wurde ganz verworfen (die Antworten nannten wieder den Pfad). +- **Fix:** `--model-id`/`--model-alias` lehnen ein Komma, einen leeren Wert und Leerraum am Rand mit einer Nutzungsmeldung ab; die aus dem Dateinamen abgeleitete ID ersetzt Kommas durch `_` und wird getrimmt (leer → Default-ID), also genau die Form, die der Alias behält. +- **Tests:** `OpenAiServerCliTest`: `aModelIdWithACommaIsRefused`, `aModelIdLlamaCppWouldStripIsRefused`, `aFileNameWithACommaIsAdvertisedAsItsAlias`. +- **Rot-Grün-Nachweis:** Ohne den Fix alle drei rot (keine Ablehnung; abgeleitete ID `a,b.gguf`); mit ihm 54/54 grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java index 69e3bed4e..75d061491 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java @@ -151,7 +151,7 @@ public static Options parse(String... args) { break; case "--model-id": case "--model-alias": - modelId = nextValue(args, ++i, arg); + modelId = modelIdValue(nextValue(args, ++i, arg), arg); break; case "--api-key": apiKey = nextValue(args, ++i, arg); @@ -243,7 +243,7 @@ public static String usage() { " --chat-template-kwargs JSON object of chat-template variables (requires --jinja),", " e.g. {\"reasoning_effort\":\"low\"}", " --parallel Parallel inference slots (default: llama.cpp default)", - " --model-id Model id reported by /v1/models (default: file name)", + " --model-id Model id reported by /v1/models (default: file name; no comma)", " --api-key Require an 'Authorization: Bearer ' header", " --mmproj Multimodal projector for vision models (enables image input)", " -mmdev, --mmproj-device Device for the multimodal projector ('none' = keep on CPU)", @@ -269,6 +269,25 @@ private static String nextValue(String[] args, int valueIndex, String flag) { return args[valueIndex]; } + /** + * Reads a model id. It becomes llama.cpp's {@code --alias}, which splits its value at commas and + * strips each part into a set of aliases, and the answers name the first of that set; an id it + * would change would make {@code /v1/models} and the answers disagree, so it is refused here. + * + * @param value the id + * @param flag the flag the value belongs to, for the message + * @return the id + */ + private static String modelIdValue(String value, String flag) { + if (value.indexOf(',') >= 0) { + throw error(flag + " must not contain a comma (llama.cpp reads it as a list of aliases), got: " + value); + } + if (value.isEmpty() || !value.equals(value.trim())) { + throw error(flag + " must be non-empty without leading or trailing whitespace, got: '" + value + "'"); + } + return value; + } + private static List rpcValue(String value, String flag) { try { return RpcEndpoint.parseList(value); @@ -464,7 +483,8 @@ public String getModelPath() { } /** - * The advertised model id, resolved from {@code --model-id} or derived from the model file name. + * The advertised model id, resolved from {@code --model-id} or derived from the model file name + * (commas replaced by {@code _}, surrounding whitespace removed: the form llama.cpp's alias keeps). * * @return the model id reported by {@code GET /v1/models} */ @@ -473,7 +493,11 @@ public String getModelId() { return modelId; } final Path name = Paths.get(modelPath).getFileName(); - return name != null ? name.toString() : OpenAiServerConfig.DEFAULT_MODEL_ID; + // the form llama.cpp's --alias keeps (no comma, stripped), so the advertised id and the + // name in the answers agree + final String derived = + name != null ? name.toString().replace(',', '_').trim() : ""; + return derived.isEmpty() ? OpenAiServerConfig.DEFAULT_MODEL_ID : derived; } /** diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java index 314108b8b..cd9616ad2 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java @@ -231,6 +231,35 @@ public void theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag() { assertThat(argv.get(argv.indexOf("--alias") + 1), is(defaulted.getModelId())); } + // llama.cpp's --alias splits its value at commas and strips each part into a SET of aliases, and + // the answers name the first of that set: "my,model" made /v1/models say "my,model" while every + // answer said "model". So an id llama.cpp would change is refused, and a file name it would change + // is advertised in the form it keeps. + @Test + public void aModelIdWithACommaIsRefused() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, () -> OpenAiServerCli.parse("-m", "m.gguf", "--model-id", "my,model")); + assertThat(ex.getMessage(), containsString("--model-id")); + assertThat(ex.getMessage(), containsString("comma")); + assertThrows( + IllegalArgumentException.class, + () -> OpenAiServerCli.parse("-m", "m.gguf", "--model-alias", "my,model")); + } + + @Test + public void aModelIdLlamaCppWouldStripIsRefused() { + assertThrows(IllegalArgumentException.class, () -> OpenAiServerCli.parse("-m", "m.gguf", "--model-id", " x")); + assertThrows(IllegalArgumentException.class, () -> OpenAiServerCli.parse("-m", "m.gguf", "--model-id", "")); + } + + @Test + public void aFileNameWithACommaIsAdvertisedAsItsAlias() { + OpenAiServerCli.Options options = OpenAiServerCli.parse("-m", "/opt/models/a,b.gguf"); + assertThat(options.getModelId(), is("a_b.gguf")); + List argv = Arrays.asList(options.toModelParameters().toArray()); + assertThat(argv.get(argv.indexOf("--alias") + 1), is("a_b.gguf")); + } + @Test public void missingModelThrows() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, OpenAiServerCli::parse); From 28aec2a286df7237459ede261a19b59abe44034b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:36:55 +0000 Subject: [PATCH 349/589] Record the commit of F201 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7623db189..66770c11b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1518,7 +1518,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiServerCliTest`: `aModelIdWithACommaIsRefused`, `aModelIdLlamaCppWouldStripIsRefused`, `aFileNameWithACommaIsAdvertisedAsItsAlias`. - **Rot-Grün-Nachweis:** Ohne den Fix alle drei rot (keine Ablehnung; abgeleitete ID `a,b.gguf`); mit ihm 54/54 grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `707f0f2` ## Offene Verdachte ohne Fund-ID From 29ff8c16fc8e853025e23a54c7a96919155b6287 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:44:46 +0000 Subject: [PATCH 350/589] Ask upstream only for the stop word on the Anthropic route (F202) The Anthropic translation set "verbose" to read __verbose.stopping_word, and upstream's debug object is the whole native result: the rendered prompt, the tokens and the generation settings, carried through JNI with every answer and dropped. Upstream's response_fields projects exactly that object. It is now a RequestField (SCHEMA, checked by test_wire_contracts) with InferenceParameters.withResponseFields, the mapper forwards it (a non-string array is a 400), and the Anthropic route asks for ["stopping_word"]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 +++++++++ .../llama/parameters/InferenceParameters.java | 19 ++++++++++++++ .../parameters/ParameterJsonSerializer.java | 12 +++++++++ .../llama/parameters/RequestField.java | 3 +++ .../llama/server/AnthropicApiSupport.java | 3 +++ .../llama/server/OpenAiRequestMapper.java | 26 +++++++++++++++++++ .../parameters/InferenceParametersTest.java | 12 +++++++++ .../llama/server/AnthropicApiSupportTest.java | 4 +++ .../OpenAiCompatServerIntegrationTest.java | 17 ++++++++++++ .../llama/server/OpenAiRequestMapperTest.java | 23 ++++++++++++++++ 10 files changed, 131 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 66770c11b..de7d99e2d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1520,6 +1520,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `707f0f2` +### F202 – java-llama.cpp: Die Anthropic-Route holt für das Stoppwort upstreams ganzes Debug-Objekt samt gerendertem Prompt durch JNI +- **Zeit:** 2026-10-20 12:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java`, `OpenAiRequestMapper.java`, `parameters/InferenceParameters.java` (neu `withResponseFields`), `parameters/RequestField.java` (`RESPONSE_FIELDS`), `parameters/ParameterJsonSerializer.java` +- **Schwere:** niedrig (Aufwand: jede Anthropic-Antwort trug den vollständigen Prompt, die Token-Liste und die Generierungseinstellungen zusätzlich, um sie danach zu verwerfen) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20; Folge von F183, das `verbose` setzt, um `stop_sequence` zu füllen. +- **Beschreibung:** `verbose: true` hängt an die OpenAI-Antwort `__verbose` = `to_json_non_oaicompat()` des Endergebnisses: Inhalt, Tokens, `prompt` (der ganze gerenderte Prompt), `generation_settings`, `timings` … Gebraucht wird nur `stopping_word`. Upstreams `response_fields` projiziert genau dieses Objekt (`json_get_nested_values`), war aber weder ein `RequestField` noch vom Mapper durchgereicht. +- **Fix:** `RequestField.RESPONSE_FIELDS` (SCHEMA-Vertrag, gegen `make_llama_cmpl_schema` geprüft) mit `InferenceParameters.withResponseFields(String...)`; der Mapper reicht `response_fields` durch und lehnt alles außer einem String-Array mit 400 ab; die Anthropic-Übersetzung setzt `response_fields: ["stopping_word"]`. +- **Tests:** `AnthropicApiSupportTest.theTranslatedRequestAsksForTheStopWord` (erweitert), `OpenAiRequestMapperTest.responseFieldsAreForwarded`, `responseFieldsThatAreNotStringsAreRefused`, `InferenceParametersTest.withResponseFieldsWritesTheProjectionAsAStringArray`, modellgestützt `OpenAiCompatServerIntegrationTest.theDebugObjectTheAnthropicRouteAsksForCarriesOnlyTheStopWord` (`__verbose` hat genau den Schlüssel `stopping_word`); C++ `test_wire_contracts` prüft den neuen Schlüssel gegen upstreams Schema. +- **Rot-Grün-Nachweis:** Mit `RequestField`/`withResponseFields`, aber dem alten Mapper und der alten Übersetzung: drei Tests rot; mit dem Fix grün (221 Tests der fünf Klassen, Integrationstest 13/13 mit dem Zufallsmodell, ctest Wire 6/6). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java index afd75ada4..8b0b7192f 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java @@ -902,6 +902,25 @@ public InferenceParameters withVerbose(boolean verbose) { return withScalar(RequestField.VERBOSE, verbose); } + /** + * Returns a new request projecting llama.cpp's native result onto the given fields + * ({@code response_fields}; dot-separated paths such as {@code timings.predicted_n}). It applies to + * the native answer, and with {@link #withVerbose(boolean)} to the {@code __verbose} object of an + * OpenAI answer, which then carries only these fields instead of the prompt and the generation + * settings. Empty input is a no-op. + * + * @param fields the fields to keep + * @return a new instance with the projection set, or {@code this} if {@code fields} is empty + */ + public InferenceParameters withResponseFields(String... fields) { + if (fields.length == 0) { + return this; + } + return withRaw( + RequestField.RESPONSE_FIELDS, + serializer.buildStringArray(fields).toString()); + } + /** * Returns a new request toggling raw token-id output ({@code return_tokens}, default false). * When true, the response includes the generated token ids alongside the decoded text. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java index 391dad4b0..8cf7c0568 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java @@ -173,6 +173,18 @@ public ArrayNode buildMessages(List messages) { // Simple array builders // ------------------------------------------------------------------ + /** + * Build a JSON array of strings. + * + * @param values the strings + * @return a Jackson {@link ArrayNode} of the strings + */ + public ArrayNode buildStringArray(String... values) { + ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + for (String value : values) arr.add(value); + return arr; + } + /** * Build a JSON string array from the given stop strings * (e.g. {@code ["<|endoftext|>", "\n"]}). diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java b/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java index 8d5d6e996..c3785fe87 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java @@ -133,6 +133,9 @@ enum RequestField { /** Request key {@code repeat_penalty}. */ REPEAT_PENALTY("repeat_penalty"), + /** Request key {@code response_fields}. */ + RESPONSE_FIELDS("response_fields"), + /** Request key {@code response_format}, consumed by the OAI/task layer. */ RESPONSE_FORMAT("response_format", RequestContract.OAI_LAYER), diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 3eb333aeb..17a7e1b72 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -122,7 +122,10 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { } // Upstream answers with the matched stop word as stop_sequence; the OpenAI answer this route // translates from does not carry it, upstream's debug object does (__verbose.stopping_word). + // Only that word: the whole object echoes the rendered prompt and the generation settings + // through JNI with every answer, to be dropped here. openAi.put("verbose", true); + openAi.putArray("response_fields").add("stopping_word"); // Passed through like upstream; the chat mapper hands it to the template. JsonNode templateKwargs = request.path("chat_template_kwargs"); if (templateKwargs.isObject()) { diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 6154a576d..8adcdeeff 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -117,6 +117,10 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo if (verbose.isBoolean()) { params = params.withVerbose(verbose.asBoolean()); } + JsonNode responseFields = request.path("response_fields"); + if (!responseFields.isMissingNode() && !responseFields.isNull()) { + params = params.withResponseFields(stringArray(responseFields, "response_fields")); + } // Preserve llama.cpp extensions when advanced clients opt into them. JsonNode cachePrompt = request.path("cache_prompt"); if (cachePrompt.isBoolean()) { @@ -239,6 +243,28 @@ private static InferenceParameters withFloat( return value.isNumber() ? setter.apply(params, (float) value.asDouble()) : params; } + /** + * Read a field that must be an array of strings, as upstream's schema reads it; anything else is + * an invalid request rather than a field silently left out. + * + * @param value the field's value + * @param field the field name, for the message + * @return the strings + */ + private static String[] stringArray(JsonNode value, String field) { + if (!value.isArray()) { + throw new IllegalArgumentException("'" + field + "' must be an array of strings"); + } + List strings = new ArrayList<>(value.size()); + for (JsonNode entry : value) { + if (!entry.isTextual()) { + throw new IllegalArgumentException("'" + field + "' must be an array of strings"); + } + strings.add(entry.asText()); + } + return strings.toArray(new String[0]); + } + /** * Apply {@code setter} with the request's integer {@code field}, if it is one. * diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java index cfca50bac..75b6ef0b8 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java @@ -103,6 +103,18 @@ public void testAdditionalCompletionScalarsFromB9864Audit() { assertThat(InferenceParameters.empty().withVerbose(true).parameters.get("verbose"), is("true")); } + @Test + public void withResponseFieldsWritesTheProjectionAsAStringArray() { + assertThat( + InferenceParameters.empty() + .withResponseFields("stopping_word", "timings.predicted_n") + .parameters + .get("response_fields"), + is("[\"stopping_word\",\"timings.predicted_n\"]")); + InferenceParameters empty = InferenceParameters.empty(); + assertThat(empty.withResponseFields(), is(empty)); + } + @Test public void testSetCacheReuse() { InferenceParameters params = InferenceParameters.empty().withCacheReuse(256); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index d128c7f39..28d7c02ed 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -268,6 +268,10 @@ public void theTranslatedRequestAsksForTheStopWord() throws IOException { JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest( read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stop_sequences\":[\"X\"]}")); assertThat(openAi.path("verbose").asBoolean(false), is(true)); + // ... and only for it: the whole debug object echoes the rendered prompt and the generation + // settings through JNI with every answer, to be dropped here + assertThat(openAi.path("response_fields").size(), is(1)); + assertThat(openAi.path("response_fields").path(0).asText(), is("stopping_word")); } @Test diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java index 53c4360c1..15b8484f0 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java @@ -164,6 +164,23 @@ public void anthropicMessagesNonStreamingRoundTrip() throws IOException { assertThat(json.path("role").asText(), is("assistant")); } + /** + * The Anthropic route asks for upstream's debug object only for the matched stop word; with the + * projection it carries just that word, not the rendered prompt and the generation settings it + * used to bring through JNI with every answer. + */ + @Test + public void theDebugObjectTheAnthropicRouteAsksForCarriesOnlyTheStopWord() throws IOException { + JsonNode answer = MAPPER.readTree(model.handleChatCompletions("{\"max_tokens\":4,\"verbose\":true," + + "\"response_fields\":[\"stopping_word\"]," + + "\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one word.\"}]}")); + JsonNode debug = answer.path("__verbose"); + assertThat(answer.toString(), debug.isObject(), is(true)); + java.util.List keys = new java.util.ArrayList<>(); + debug.fieldNames().forEachRemaining(keys::add); + assertThat(keys, is(java.util.Collections.singletonList("stopping_word"))); + } + @Test public void anthropicMessagesStreamingRoundTrip() throws IOException { String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":true,\"max_tokens\":16," diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 598f114b3..c2470683e 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -56,6 +56,29 @@ public void verboseIsForwarded() throws IOException { assertThat(without.has("verbose"), is(false)); } + @Test + public void responseFieldsAreForwarded() throws IOException { + // upstream's projection of the native result, which also narrows __verbose + JsonNode chat = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"response_fields\":[\"stopping_word\"]}"); + assertThat(chat.path("response_fields").size(), is(1)); + assertThat(chat.path("response_fields").path(0).asText(), is("stopping_word")); + JsonNode without = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"); + assertThat(without.has("response_fields"), is(false)); + } + + @Test + public void responseFieldsThatAreNotStringsAreRefused() { + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"response_fields\":[1]}")); + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"response_fields\":\"stopping_word\"}")); + } + @Test public void moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack() throws IOException { // Upstream reads "n" as n_cmpl; the chat paths return one answer, so n > 1 silently gave one. From 4f2c4ef54101467c95957bf1fcdf55f543eec32f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:44:46 +0000 Subject: [PATCH 351/589] Record the commit of F202 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index de7d99e2d..7b0fd81e2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1530,7 +1530,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest.theTranslatedRequestAsksForTheStopWord` (erweitert), `OpenAiRequestMapperTest.responseFieldsAreForwarded`, `responseFieldsThatAreNotStringsAreRefused`, `InferenceParametersTest.withResponseFieldsWritesTheProjectionAsAStringArray`, modellgestützt `OpenAiCompatServerIntegrationTest.theDebugObjectTheAnthropicRouteAsksForCarriesOnlyTheStopWord` (`__verbose` hat genau den Schlüssel `stopping_word`); C++ `test_wire_contracts` prüft den neuen Schlüssel gegen upstreams Schema. - **Rot-Grün-Nachweis:** Mit `RequestField`/`withResponseFields`, aber dem alten Mapper und der alten Übersetzung: drei Tests rot; mit dem Fix grün (221 Tests der fünf Klassen, Integrationstest 13/13 mit dem Zufallsmodell, ctest Wire 6/6). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `05f356b` ## Offene Verdachte ohne Fund-ID From af84cb5bd65831e94814ba010287ade67e75187c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:49:32 +0000 Subject: [PATCH 352/589] Keep a chat template kwarg that is not one JSON value as text (F203) buildRawValueObject read each value with a plain readTree, which parses the first JSON value and drops the rest: "true false" reached the template as true. It now reads with FAIL_ON_TRAILING_TOKENS, so such a value falls back to a string literal as documented, the same rule JsonParameters and ChatRequest already apply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++++++++++ .../parameters/ParameterJsonSerializer.java | 14 ++++++++++++-- .../llama/json/ParameterJsonSerializerTest.java | 16 ++++++++++++++++ .../parameters/InferenceParametersTest.java | 15 +++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7b0fd81e2..bb288f835 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1532,6 +1532,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `05f356b` +### F203 – java-llama.cpp: `chat_template_kwargs`: ein Wert mit mehr als einem JSON-Wert wird still auf den ersten gekürzt +- **Zeit:** 2026-10-20 12:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildRawValueObject`; betrifft `InferenceParameters.withChatTemplateKwargs` und `ModelParameters.setChatTemplateKwargs`) +- **Schwere:** niedrig (falscher Template-Parameter ohne Meldung) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20; dieselbe Klasse wie die `FAIL_ON_TRAILING_TOKENS`-Fixes in `JsonParameters` und `ChatRequest`, hier übersehen. +- **Beschreibung:** `OBJECT_MAPPER.readTree(value)` liest den ersten JSON-Wert und ignoriert den Rest: `"true false"` wurde `true`, `{"a":1} {"b":2}` wurde `{"a":1}`. Die dokumentierte Regel (kein JSON → als String-Literal) griff dadurch nicht. +- **Fix:** Ein `ObjectReader` mit `FAIL_ON_TRAILING_TOKENS`; ein Wert, der nicht genau ein JSON-Wert ist, wird wie dokumentiert zum String-Literal. +- **Tests:** `ParameterJsonSerializerTest.testBuildRawValueObject_trailingTokensFallBackToString`, `InferenceParametersTest.aChatTemplateKwargHoldingTwoValuesIsKeptAsText` (beide öffentlichen Setter). +- **Rot-Grün-Nachweis:** Ohne den Fix rot (`{"two":true,…,"object":{"a":1}}`); mit ihm grün (40 + 114). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java index 8cf7c0568..793dc6967 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java @@ -5,8 +5,10 @@ package net.ladenthin.llama.parameters; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; @@ -42,6 +44,13 @@ public ParameterJsonSerializer() {} /** Shared Jackson mapper; thread-safe and reused across all instances. */ public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * Reads exactly one JSON value: {@code FAIL_ON_TRAILING_TOKENS} refuses text after it, which a plain + * {@code readTree} parses past and drops ({@code "true false"} would become {@code true}). + */ + private static final ObjectReader SINGLE_VALUE_READER = + OBJECT_MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + // ------------------------------------------------------------------ // String escaping // ------------------------------------------------------------------ @@ -58,6 +67,7 @@ public ParameterJsonSerializer() {} */ public String toJsonString(String value) { if (value == null) return "null"; + try { return OBJECT_MAPPER.writeValueAsString(value); } catch (JsonProcessingException e) { @@ -319,7 +329,7 @@ public ArrayNode buildDisableTokenStringArray(Collection tokens) { * produces {@code {"enable_thinking": true}}, not {@code {"enable_thinking": "true"}}. * *

    Used for {@code chat_template_kwargs} which stores raw JSON values. - * If a value cannot be parsed as JSON, it is stored as a JSON string literal. + * If a value is not exactly one JSON value, it is stored as a JSON string literal. * * @param map map of key to pre-serialized JSON value strings * @return a Jackson {@link ObjectNode} with each value embedded as a parsed JSON node @@ -328,7 +338,7 @@ public ObjectNode buildRawValueObject(Map map) { ObjectNode node = OBJECT_MAPPER.createObjectNode(); for (Map.Entry entry : map.entrySet()) { try { - JsonNode val = OBJECT_MAPPER.readTree(entry.getValue()); + JsonNode val = SINGLE_VALUE_READER.readTree(entry.getValue()); // An empty or blank value is no JSON either, but Jackson returns MissingNode (written as null). if (val.isMissingNode()) { node.put(entry.getKey(), entry.getValue()); diff --git a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java index 247bfa731..bdd42f89c 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/ParameterJsonSerializerTest.java @@ -353,6 +353,22 @@ public void testBuildRawValueObject_invalidJsonFallsBackToString() { assertThat(node.path("key").asText(), is("not-valid-json{{{")); } + /** + * A value holding more than one JSON value is no JSON value either. The plain readTree parsed the + * first and dropped the rest without a word: "true false" became true, "1, 2" became 1. + */ + @Test + public void testBuildRawValueObject_trailingTokensFallBackToString() { + Map map = new LinkedHashMap<>(); + map.put("two", "true false"); + map.put("list", "1, 2"); + map.put("object", "{\"a\":1} {\"b\":2}"); + ObjectNode node = serializer.buildRawValueObject(map); + assertThat( + node.toString(), + is("{\"two\":\"true false\",\"list\":\"1, 2\",\"object\":\"{\\\"a\\\":1} {\\\"b\\\":2}\"}")); + } + /** Jackson parses an empty or blank text to MissingNode, which would be written as null. */ @Test public void testBuildRawValueObject_emptyOrBlankValueFallsBackToString() { diff --git a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java index 75b6ef0b8..818a8c2be 100644 --- a/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java +++ b/llama/src/test/java/net/ladenthin/llama/parameters/InferenceParametersTest.java @@ -356,6 +356,21 @@ public void testSetChatTemplateKwargs() { assertThat(value, containsString("\"max_tokens\":1024")); } + @Test + public void aChatTemplateKwargHoldingTwoValuesIsKeptAsText() { + // used to become its first value: "true false" reached the template as true + java.util.Map kwargs = java.util.Collections.singletonMap("enable_thinking", "true false"); + assertThat( + new InferenceParameters("") + .withChatTemplateKwargs(kwargs) + .parameters + .get("chat_template_kwargs"), + is("{\"enable_thinking\":\"true false\"}")); + assertThat( + new ModelParameters().setChatTemplateKwargs(kwargs).parameters.get("--chat-template-kwargs"), + is("{\"enable_thinking\":\"true false\"}")); + } + @Test public void testSetChatTemplateKwargsEmpty() { java.util.Map kwargs = new java.util.LinkedHashMap<>(); From a7e761a8513208c08c3a342397ba6f9ffd5f53a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:49:32 +0000 Subject: [PATCH 353/589] Record the commit of F203 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index bb288f835..0c09ba77d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1542,7 +1542,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ParameterJsonSerializerTest.testBuildRawValueObject_trailingTokensFallBackToString`, `InferenceParametersTest.aChatTemplateKwargHoldingTwoValuesIsKeptAsText` (beide öffentlichen Setter). - **Rot-Grün-Nachweis:** Ohne den Fix rot (`{"two":true,…,"object":{"a":1}}`); mit ihm grün (40 + 114). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `266d70d` ## Offene Verdachte ohne Fund-ID From da6fa8eb981e10d29899ba8c5b0dd87de6c0197a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:51:19 +0000 Subject: [PATCH 354/589] Leave a pending Java exception in place in the UTF-8 JNI helpers (F204) throw_utf8_impl replaced a pending exception with ThrowNew on its ASCII path and called FindClass/GetMethodID/NewObject over it on the other, which the JNI spec forbids and Android's CheckJNI aborts on. Both helpers now check ExceptionCheck first, as jni_guard_impl does: the pending exception is the more precise error. Pinned by JniUtf8.APendingExceptionIsLeftInPlaceOnBothPaths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++++++++++ llama/src/main/cpp/jni_utf8.hpp | 11 ++++++++++- llama/src/test/cpp/test_jni_helpers.cpp | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0c09ba77d..a0ecdd660 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1544,6 +1544,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `266d70d` +### F204 – java-llama.cpp: `throw_utf8_impl`/`new_string_utf8_impl` rufen JNI über einer schon anstehenden Java-Exception auf und ersetzen sie +- **Zeit:** 2026-10-20 12:50 UTC +- **Datei:** `llama/src/main/cpp/jni_utf8.hpp`, `llama/src/test/cpp/test_jni_helpers.cpp` +- **Schwere:** niedrig bis mittel (die genauere Exception geht verloren; unter Android-CheckJNI bricht ein `FindClass`/`NewObject` bei anstehender Exception den Prozess ab) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20; `jni_utf8.hpp` entstand mit dem Modified-UTF-8-Fix, `jni_guard_impl` hatte die Regel schon. +- **Beschreibung:** Nach einem fehlgeschlagenen JNI-Aufruf (z. B. `GetStringUTFChars` mit `OutOfMemoryError`) rief ein Aufrufer `throw_utf8_impl`: der ASCII-Pfad `ThrowNew` ersetzte die anstehende Exception, der Nicht-ASCII-Pfad rief `FindClass`, `GetMethodID` und `NewObject` — laut JNI-Spezifikation bei anstehender Exception verboten. `jni_guard_impl` lässt eine anstehende Exception stehen, diese beiden Helfer nicht. +- **Fix:** Beide prüfen zuerst `ExceptionCheck()`: `throw_utf8_impl` kehrt zurück und lässt die anstehende Exception stehen, `new_string_utf8_impl` gibt `nullptr` zurück (sein Vertrag: nullptr mit anstehender Exception). +- **Tests:** C++ `JniUtf8.APendingExceptionIsLeftInPlaceOnBothPaths` (ASCII- und 4-Byte-Pfad, kein `ThrowNew`, kein String gebaut, nichts geworfen; `new_string_utf8_impl` ohne `NewStringUTF`). +- **Rot-Grün-Nachweis:** Ohne den Fix rot; mit ihm grün, ctest 645/645. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/cpp/jni_utf8.hpp b/llama/src/main/cpp/jni_utf8.hpp index 204b42069..5cefbabd8 100644 --- a/llama/src/main/cpp/jni_utf8.hpp +++ b/llama/src/main/cpp/jni_utf8.hpp @@ -32,7 +32,12 @@ } // A java.lang.String from standard UTF-8 bytes; nullptr with a pending Java exception on failure. +// Called with an exception already pending, it builds nothing (the JNI spec forbids the calls) and +// leaves that exception in place. [[nodiscard]] inline jstring new_string_utf8_impl(JNIEnv *env, const char *utf8) { + if (env->ExceptionCheck()) { + return nullptr; + } if (utf8 == nullptr) { utf8 = ""; } @@ -62,8 +67,12 @@ // Throws a new exception_class(message), message read as standard UTF-8. exception_class must // have a (String) constructor; every exception class of this library has one. When the message -// cannot be built, the exception that stopped it (OutOfMemoryError) is the one left pending. +// cannot be built, the exception that stopped it (OutOfMemoryError) is the one left pending. An +// exception already pending is the more precise error and is never replaced, as jni_guard_impl does. inline void throw_utf8_impl(JNIEnv *env, jclass exception_class, const char *message) { + if (env->ExceptionCheck()) { + return; + } if (message == nullptr) { message = ""; } diff --git a/llama/src/test/cpp/test_jni_helpers.cpp b/llama/src/test/cpp/test_jni_helpers.cpp index 90f979720..6e27481ea 100644 --- a/llama/src/test/cpp/test_jni_helpers.cpp +++ b/llama/src/test/cpp/test_jni_helpers.cpp @@ -1018,6 +1018,24 @@ TEST(JniUtf8, AFourByteCharacterReachesTheExceptionAsStandardUtf8Bytes) { EXPECT_EQ(g_utf8.thrown, kException); } +// A pending Java exception is the more precise error, and the JNI spec forbids most calls while one +// is pending: neither path may replace it, nor call FindClass/NewObject over it. +TEST(JniUtf8, APendingExceptionIsLeftInPlaceOnBothPaths) { + JNIEnv_ fake_env; + JNINativeInterface_ iface; + JNIEnv *env = make_utf8_env(fake_env, iface); + g_utf8.exception_pending = true; + + throw_utf8_impl(env, kExceptionClass, "plain text"); + throw_utf8_impl(env, kExceptionClass, kEmojiMessage); + + EXPECT_EQ(g_utf8.throw_new_calls, 0) << "ThrowNew got: " << g_utf8.throw_new_message; + EXPECT_EQ(g_utf8.bytes, "") << "a String was built over the pending exception"; + EXPECT_EQ(g_utf8.thrown, nullptr); + EXPECT_EQ(new_string_utf8_impl(env, kEmojiMessage), nullptr); + EXPECT_EQ(g_utf8.new_string_utf_calls, 0); +} + // An invalid sequence is not ours to repair either: Java's decoder replaces it with U+FFFD. TEST(JniUtf8, AnInvalidByteSequenceIsLeftToJavasDecoder) { JNIEnv_ fake_env; From 14d5917318e28569c4a6fcf2ad2875964a44414b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:51:20 +0000 Subject: [PATCH 355/589] Record the commit of F204 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a0ecdd660..f6ebe963e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1554,7 +1554,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `JniUtf8.APendingExceptionIsLeftInPlaceOnBothPaths` (ASCII- und 4-Byte-Pfad, kein `ThrowNew`, kein String gebaut, nichts geworfen; `new_string_utf8_impl` ohne `NewStringUTF`). - **Rot-Grün-Nachweis:** Ohne den Fix rot; mit ihm grün, ctest 645/645. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `2611a38` ## Offene Verdachte ohne Fund-ID From 200cc908b062b43beb8c0cac32ce9415171e3f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:57:13 +0000 Subject: [PATCH 356/589] Answer an Ollama /api/generate without a prompt as a model load (F205) Ollama clients preload a model by sending /api/generate without a prompt, and Ollama answers at once with done and done_reason "load". Ours sent an empty user turn through the chat template and generated an answer. The route now answers such a request (no prompt, no suffix) with Ollama's load answer without calling the backend; an empty prompt with a suffix stays FIM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 12 ++++++++ .../llama/server/OllamaApiSupport.java | 30 +++++++++++++++++++ .../llama/server/OpenAiCompatServer.java | 5 ++++ .../llama/server/OllamaApiSupportTest.java | 13 ++++++++ .../server/OpenAiCompatServerHttpTest.java | 26 ++++++++++++++++ 5 files changed, 86 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f6ebe963e..6de17d022 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1556,6 +1556,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `2611a38` +### F205 – java-llama.cpp: Ollama `/api/generate` ohne Prompt (das Vorladen eines Modells) erzeugt eine Antwort auf einen leeren Nutzer-Turn +- **Zeit:** 2026-10-20 13:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `isLoadOnly`, `toOllamaLoadResponse`), `OpenAiCompatServer.java` (`handleOllamaGenerate`) +- **Schwere:** niedrig (unnötige Generierung bis `num_predict`/Kontextende bei jedem Vorladen; Clients erwarten `done_reason "load"`) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20; vorher erzeugte der Pfad ohne `raw` (Chat-Template seit diesem Branch) bzw. der `raw`-Pfad eine Antwort auf einen leeren Prompt. +- **Beschreibung:** Ollama beantwortet ein `/api/generate` ohne Prompt mit dem Laden des Modells (`done: true`, leere `response`, `done_reason "load"`) — so laden Clients ein Modell vor. Hier lief ein leerer Nutzer-Turn durch das Chat-Template und wurde beantwortet. +- **Fix:** Ohne Prompt und ohne `suffix` (ein leerer Prompt mit `suffix` bleibt FIM am Dateianfang) antwortet die Route sofort mit Ollamas Ladeantwort, ohne das Backend zu rufen — auch bei `stream` ein einzelnes Objekt, wie Ollama. Entladen (`keep_alive: 0`) gibt es hier nicht; das Modell bleibt geladen. +- **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithoutAPromptOnlyReportsTheModelLoaded` (drei Formen, das Backend wird nicht gerufen), `OllamaApiSupportTest.aGenerateRequestWithoutAPromptOrSuffixOnlyLoadsTheModel`. +- **Rot-Grün-Nachweis:** Ohne den Fix der HTTP-Test rot (das Backend generierte); mit ihm grün (64 + 37). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 8d2d86476..d0c2203b2 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -523,6 +523,36 @@ static boolean hasSuffix(JsonNode request) { return request.path("suffix").isTextual(); } + /** + * Whether the {@code /api/generate} request only asks for the model to be loaded: Ollama answers a + * request without a prompt (and without a {@code suffix}) with {@code done} at once, which is how + * its clients preload a model. The model of this server is always loaded, so there is nothing to do. + * + * @param request the parsed Ollama generate request + * @return {@code true} when the request carries no prompt and no suffix + */ + static boolean isLoadOnly(JsonNode request) { + return request.path("prompt").asText("").isEmpty() && !hasSuffix(request); + } + + /** + * The answer to a load-only {@code /api/generate} request, as Ollama gives it: one object, empty + * {@code response}, {@code done} and {@code done_reason "load"}, whether streaming was asked for + * or not. + * + * @param model the model id to echo + * @return the answer + */ + static String toOllamaLoadResponse(String model) { + ObjectNode root = OBJECT_MAPPER.createObjectNode(); + root.put("model", model); + root.put("created_at", nowIso()); + root.put("response", ""); + root.put("done", true); + root.put("done_reason", "load"); + return root.toString(); + } + /** * Whether the {@code /api/generate} request asks for the bare prompt ({@code "raw": true}). Without * it Ollama applies the model's chat template, with {@code system} as the system message. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 36f919abf..2da179d28 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -612,6 +612,11 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { return; } String model = request.path("model").asText(config.getModelId()); + if (OllamaApiSupport.isLoadOnly(request)) { + // Ollama's preload: no prompt means "load the model", answered at once + sendJson(exchange, HTTP_OK, OllamaApiSupport.toOllamaLoadResponse(model)); + return; + } // Generation runs to completion first, then the text is wrapped -- as a single NDJSON content // line + done line when stream is requested. A suffix is fill-in-the-middle, raw the bare // prompt, anything else goes through the chat template as Ollama does. diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 3ced022e6..3727f24fb 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -442,4 +442,17 @@ public void toOllamaDoneLineCarriesAccumulatedToolCalls() throws IOException { assertThat(arguments.isObject(), is(true)); assertThat(arguments.path("a").asInt(), is(1)); } + + @Test + public void aGenerateRequestWithoutAPromptOrSuffixOnlyLoadsTheModel() throws IOException { + assertThat(OllamaApiSupport.isLoadOnly(read("{\"model\":\"m\"}")), is(true)); + assertThat(OllamaApiSupport.isLoadOnly(read("{\"prompt\":\"\"}")), is(true)); + assertThat(OllamaApiSupport.isLoadOnly(read("{\"prompt\":\"hi\"}")), is(false)); + // an empty prompt with a suffix is fill-in-the-middle at the start of a file, not a preload + assertThat(OllamaApiSupport.isLoadOnly(read("{\"prompt\":\"\",\"suffix\":\"x\"}")), is(false)); + JsonNode answer = read(OllamaApiSupport.toOllamaLoadResponse("m")); + assertThat(answer.path("done_reason").asText(), is("load")); + assertThat(answer.path("done").asBoolean(false), is(true)); + assertThat(answer.path("response").asText(null), is("")); + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index d891779b1..d88f01a1a 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -467,6 +467,32 @@ public void ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem() throws } } + /** + * Ollama answers an {@code /api/generate} without a prompt by loading the model, which is how its + * clients preload one: {@code done} at once, empty text, {@code done_reason "load"}. Ours sent an + * empty user turn through the chat template and generated an answer nobody asked for. + */ + @Test + public void ollamaGenerateWithoutAPromptOnlyReportsTheModelLoaded() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + for (String body : new String[] { + "{\"model\":\"test-model\"}", + "{\"model\":\"test-model\",\"prompt\":\"\",\"stream\":false}", + "{\"model\":\"test-model\",\"prompt\":\"\",\"raw\":true,\"stream\":true}" + }) { + Response response = post(server.getPort(), "/api/generate", body, ""); + assertThat(body, response.code, is(200)); + JsonNode out = READER.readTree(response.body); + assertThat(body, out.path("done").asBoolean(false), is(true)); + assertThat(body, out.path("done_reason").asText(), is("load")); + assertThat(body, out.path("response").asText(null), is("")); + assertThat(body, out.path("model").asText(), is("test-model")); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + @Test public void ollamaGenerateWithSuffixUsesInfill() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { From 897a6ba44d3326da6f116e48ecdf017a49fdb616 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:57:13 +0000 Subject: [PATCH 357/589] Record the commit of F205 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6de17d022..61f2caf18 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1566,7 +1566,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithoutAPromptOnlyReportsTheModelLoaded` (drei Formen, das Backend wird nicht gerufen), `OllamaApiSupportTest.aGenerateRequestWithoutAPromptOrSuffixOnlyLoadsTheModel`. - **Rot-Grün-Nachweis:** Ohne den Fix der HTTP-Test rot (das Backend generierte); mit ihm grün (64 + 37). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `6c176d5` ## Offene Verdachte ohne Fund-ID From 380e672439dbac1e3b7beb49f8424234b27cf7b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:59:57 +0000 Subject: [PATCH 358/589] Bring the bug-hunt ledger overview and statuses up to date The overview stopped at F158 and showed F158 as behaviour still open; it is now generated from the entries (121, every one with its anchor). F16, F59, F95, F158 and F180 note what later fixes closed (F198, F180, F186, F197); BUGHUNT-CHECKED records F168 for JllamaNative and adds BatchDispatch (F200). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 5 +-- BUGHUNT-FOUND.md | 85 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index ba8a0cf62..b044e44bf 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -69,7 +69,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `4c59b6c` · 2026-10-17 · Fund F39 — F39 parseToObject: nur Objekt/Array durchreichen, sonst {}; readTree-Durchsicht der übrigen Aufrufer siehe Notiz in BUGHUNT-FOUND - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-17 · Fund F30 — java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · unsicher — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · Fund F168 — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … **Nachtrag (Branch-Review 2026-10-20):** als F168 behoben (`takeStrings` gibt das Array auch bei 0 Einträgen frei). - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab - `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-17 · Fund F16 — F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen - `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36 — F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft @@ -278,7 +278,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. - `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung) · `0ebc857`/`386d73f` · 2026-10-18 · Fund F134 — die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. -- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (unsicher, 17.) bleibt unerreichbar, da `server_devices()` nie leer liefert. +- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (damals unsicher, 17.; inzwischen F168, behoben), da `server_devices()` nie leer liefert. - `llama/src/main/java/net/ladenthin/llama/Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`) · `61d2505` · 2026-10-18 · Fund F137 — negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`) · `190803b` · 2026-10-18 · Fund F138 — Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. - `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (zweite Durchsicht nach F30: `start`, `close`, `Options.parse`, `awaitListening`/`stopAndJoin`; Musterdurchsicht nach F140 – wirft ein früher Schritt nach dem Belegen von `ACTIVE`?) · `0585de5` · 2026-10-18 · kein Fund — `ACTIVE` wird bei jedem gescheiterten Start im `finally` freigegeben, auch wenn die Geräteauflösung wirft; der Server-Thread ist Daemon, ein gescheiterter Start hält die JVM also nicht. Randfall notiert: endet der Server-Thread in `close()` nicht binnen `STOP_TIMEOUT_MILLIS` (30 s), gibt `close()` `ACTIVE` trotzdem frei, und ein neuer Start träfe auf ggmls noch belegten globalen Serverzustand – nicht reproduzierbar ohne native Bibliothek. @@ -296,3 +296,4 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`, Fristgrenzen) · `f23050e` · 2026-10-19 · Fund F143 — bei Frist 0 keine einzige Abfrage. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (dritte Durchsicht) · `e5eb40e` · 2026-10-19 · kein Fund — `ensureUsageCachedTokens` ergänzt nur fehlendes `cached_tokens` und gibt jede andere Form (`usage` null, mit Leerzeichen, als Text im Inhalt, kein Objekt, unlesbar) unverändert weiter; SSE-Rahmen mit Leerzeile, Fehlerobjekt mit `code` null. - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` · `37473c2` · 2026-10-19 · kein Fund — Werte werden über `index` (Eingabeposition) zurückgeordnet, ohne `index` über die Reihenfolge; außerhalb liegende Indizes verworfen, doppelte Dokumente bleiben getrennt. Nicht verfolgt: `scoreAll` mit leerer Liste reicht ein leeres Dokument-Array an `handleRerank` weiter (native Seite, ohne Bibliothek nicht messbar). +- `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java` · `3cd7b3d` · 2026-10-20 · Fund F200 — `runEachOnItsOwnThread` hatte einen Thread je Anfrage ohne Obergrenze und warf einen `Error` vor dem Abwarten der übrigen Anfragen (beides F200, behoben). `submitAllThenReceive`: alle Anfragen werden vor dem ersten Empfang auf dem rufenden Thread abgeschickt, bei einem Fehler beim Abschicken die schon abgeschickten freigegeben, bei einem Fehler beim Empfang die übrigen trotzdem empfangen — kein Fund. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 61f2caf18..a0ff63ffb 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -21,9 +21,9 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F09](#f09) — hoch — *gefixt* - [F12](#f12) — mittel — *gefixt* - [F13](#f13) — mittel — *gefixt* -- [F16](#f16) — mittel — *gefixt* +- [F16](#f16) — mittel — *gefixt (JNI; die FFM-Hälfte als F198)* - [F17](#f17) — mittel — *gefixt* -- [F18](#f18) — mittel — *gefixt* +- [F18](#f18) — mittel — *gefixt (Fix `c1e5dcd`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `209ee6c`)* - [F24](#f24) — mittel — *gefixt* - [F25](#f25) — mittel — *gefixt* - [F26](#f26) — mittel — *gefixt* @@ -39,16 +39,16 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F55](#f55) — niedrig — *gefixt* - [F56](#f56) — niedrig — *gefixt* - [F57](#f57) — niedrig — *gefixt* -- [F59](#f59) — niedrig — *nur beschrieben* (CI-Bedarf: JDK 22+) +- [F59](#f59) — niedrig — *gefixt* - [F60](#f60) — niedrig — *gefixt* - [F61](#f61) — niedrig — *gefixt* -- [F62](#f62) — niedrig — *gefixt* (Duplikat von F94) +- [F62](#f62) — niedrig — *gefixt als F94 (Duplikat: derselbe Befund wurde in der Musterdurchsicht nach F60/F61 noch einmal aufgenommen und dort behoben; Nachtrag 2026-10-19)* - [F63](#f63) — niedrig — *gefixt* - [F64](#f64) — mittel — *gefixt* - [F65](#f65) — niedrig — *gefixt* -- [F67](#f67) — niedrig — *gefixt* (Patch 0021) +- [F67](#f67) — niedrig — *gefixt (zuerst Dokumentation, seit 2026-10-19 Patch 0021, siehe Nachtrag)* - [F68](#f68) — niedrig — *gefixt* -- [F69](#f69) — niedrig — *gefixt* (Dokumentation) +- [F69](#f69) — niedrig — *gefixt (Dokumentation)* - [F70](#f70) — mittel — *gefixt* - [F71](#f71) — niedrig — *gefixt* - [F72](#f72) — niedrig — *gefixt* @@ -102,7 +102,39 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F137](#f137) — niedrig — *gefixt* - [F138](#f138) — niedrig — *gefixt* - [F143](#f143) — niedrig — *gefixt* -- [F158](#f158) — niedrig — *Doku gefixt*, Verhalten offen (Entscheidung des Nutzers) +- [F158](#f158) — niedrig bis mittel — *gefixt* +- [F165](#f165) — mittel — *gefixt* +- [F166](#f166) — niedrig — *gefixt (Java-API-Pfad; Server-Übersetzungen als F172)* +- [F167](#f167) — niedrig bis mittel — *gefixt* +- [F168](#f168) — niedrig — *gefixt* +- [F172](#f172) — niedrig — *gefixt* +- [F173](#f173) — niedrig — *gefixt* +- [F174](#f174) — niedrig — *gefixt* +- [F175](#f175) — niedrig — *gefixt* +- [F176](#f176) — niedrig — *gefixt* +- [F177](#f177) — niedrig — *gefixt* +- [F178](#f178) — niedrig — *gefixt* +- [F179](#f179) — niedrig — *gefixt* +- [F180](#f180) — mittel — *gefixt (der Rest unter (14), Wahrscheinlichkeiten im gestreamten `/v1/completions`, mit F186)* +- [F181](#f181) — niedrig bis mittel — *gefixt* +- [F182](#f182) — mittel — *gefixt* +- [F183](#f183) — niedrig — *gefixt* +- [F184](#f184) — mittel — *gefixt* +- [F185](#f185) — niedrig bis mittel — *gefixt* +- [F186](#f186) — mittel — *gefixt* +- [F187](#f187) — mittel — *gefixt* +- [F188](#f188) — niedrig bis mittel — *gefixt* +- [F189](#f189) — niedrig bis mittel — *gefixt* +- [F190](#f190) — niedrig — *gefixt* +- [F197](#f197) — mittel — *gefixt* +- [F198](#f198) — mittel — *gefixt* +- [F199](#f199) — mittel — *gefixt* +- [F200](#f200) — niedrig bis mittel — *gefixt* +- [F201](#f201) — niedrig — *gefixt* +- [F202](#f202) — niedrig — *gefixt* +- [F203](#f203) — niedrig — *gefixt* +- [F204](#f204) — niedrig bis mittel — *gefixt* +- [F205](#f205) — niedrig — *gefixt* ## Funde @@ -193,7 +225,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `TextToSpeechLifecycleTest#close_whileSynthesizeIsInsideTheEngine_freesItOnlyAfterSynthesizeReturned` (Fake über die neue paketinterne Naht `TextToSpeech.Backend` hält `synthesize` im „nativen“ Aufruf fest), dazu `#synthesize_afterClose_throwsIllegalState`, `#close_calledTwice_freesTheEngineOnce`, `#synthesize_whileOpen_returnsTheBackendsWav` - **Fix:** `ReentrantReadWriteLock`: `synthesize` hält die Lesesperre über den ganzen nativen Aufruf, `close` die Schreibsperre. Gleichzeitige Synthesen bleiben möglich (die Engine ordnet sie selbst über `synthesize_mutex`), aber `close` gibt die Engine (samt diesem Mutex) erst frei, wenn keine mehr läuft. Die JNI-Aufrufe laufen über eine paketinterne Naht `Backend`; `LlamaLoader.initialize()` wanderte aus dem static-Block in deren `load`. - **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral eingezogen; dagegen rot 1/4 („the engine was freed while synthesize() still ran in it – Expected: is but: was “). Mit Fix grün 4/4 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot 1/4, wiederhergestellt grün 4/4. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. -- **Status:** gefixt für JNI; die FFM-Hälfte (`FfmTextToSpeech`, in „Datei“ genannt) blieb ungeändert und ist erst mit F198 behoben (Branch-Review 2026-10-20) +- **Status:** gefixt (JNI; die FFM-Hälfte als F198) - **Commit:** java-llama.cpp `128be3a` @@ -402,6 +434,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: das Lock aus `NativeServer`. `isRunning()` und `close()` laufen unter `lifecycleLock`, `closed` ist ein einfaches Feld unter diesem Lock. Die beiden nativen Aufrufe liegen hinter der paketinternen Naht `FfmNativeServer.Backend` (wie `NativeServer.Backend`), statt veränderlicher statischer Method-Handles. - **Tests:** `FfmNativeServerTest` (4, ohne Bibliothek): `close()` wartet auf ein `isRunning()` im nativen Aufruf und gibt erst danach frei; `isRunning()` nach `close()` fragt den Handle nicht ab; zweites `close()` ohne Wirkung; `isRunning()` fragt im offenen Zustand. - **Rot-Grün-Nachweis:** lokal mit JDK 25 (Temurin 25.0.1, Toolchain): ohne Lock (Naht beibehalten, `AtomicBoolean` wie vorher) rot (1 von 4, `close()` wartete nicht), mit Lock grün. +- **Nachtrag (Branch-Review 2026-10-20):** Dieselbe Prüfen-dann-Rufen-Lücke hatte `FfmTextToSpeech`; dort mit demselben Lock als F198 behoben. - **Status:** gefixt - **Commit:** c7c883d @@ -826,6 +859,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `applyLlamaCppSampling` (aus `applyCommonFields`) übernimmt die Felder mit den vorhandenen Settern; `json_schema` nur ohne `grammar`, wie upstream. `readMaxTokens` liest zuerst `n_predict`. Weiter offen (Verdacht bleibt): `logit_bias`, `n_probs`/`logprobs` (im gestreamten Completion-Chunk ohnehin nicht ausgegeben) und `n`. - **Reproduktion:** `OpenAiRequestMapperTest#llamaCppSamplingExtensionsReachTheStreamedCompletion`, `#nPredictWinsOverMaxTokensLikeUpstream`, `#jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven` - **Rot-Grün-Nachweis:** ohne Fix rot (3 Fehlschläge: „Expected: is a numeric value within <1.0E-4> of <0.05> but: <0.0>“, „Expected: is <7> but: was <50>“, „Expected: is "object" but: was ""“), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 19/19, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit allen Feldern in einer Methode meldete `CE_CLASS_ENVY`, eine Map-Tabelle `DMC_DUBIOUS_MAP_COLLECTION`; jetzt je Feld ein Aufruf über `withFloat`/`withInt`). +- **Nachtrag (Branch-Review 2026-10-20):** Das hier „weiter offen“ Genannte ist erledigt: `logit_bias` und `n_probs` mit F180, das gestreamte `/v1/completions` mit F186 (upstreams eigener Stream, Wahrscheinlichkeiten eingeschlossen), `n > 1` mit F180 und auf allen nativen Wegen mit F197. - **Status:** gefixt - **Commit:** java-llama.cpp `8ce9ffc` @@ -1178,9 +1212,11 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Messung (J1, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`):** bestätigt – `-2`: 1 Token, `stop_type="limit"`; `-1`: 2042 Tokens (bis Kontextende); `16`: 16. Dazu J2: `withNPredict(0)` erzeugt ebenfalls 1 Token, nicht 0. - **Nachtrag (Entscheidung des Nutzers nach der Messung):** `setPredict` lehnt Werte unter `-1` mit `IllegalArgumentException` ab; Javadoc von `setPredict` und `withNPredict` sagt „`0` verhält sich wie `1`“. Test `ModelParametersExtendedTest#testSetPredictBelowMinusOneIsRejected` (`-2`, `-3`, `Integer.MIN_VALUE`) ersetzt `testSetPredictFillContext`, dazu `testSetPredictZero`. - **Rot-Grün-Nachweis:** ohne die Prüfung rot (3 von 3), mit ihr grün (`ModelParametersExtendedTest`, `ModelParametersTest`, `WireNameRegistryTest`: 252 Tests). +- **Nachtrag (Branch-Review 2026-10-20):** Die Übersicht nannte bis zum Branch-Review noch „Doku gefixt, Verhalten offen“; das Verhalten ist seit `ae07a53` geändert (siehe oben). - **Status:** gefixt - **Commit:** a977a1e (Doku), ae07a53 (Verhalten) + ### F165 – java-llama.cpp: `OpenAiCompatServer` ohne `-c` bewirbt 8192 + 2048 Tokens statt des Fensters des geladenen Modells - **Zeit:** 2026-10-19 14:40 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`), `OpenAiCompatServer.java` (`main`) @@ -1193,6 +1229,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** 62413a9 + ### F166 – java-llama.cpp: Leere Werkzeug-Argumente im Verlauf lassen die ganze Folgeanfrage mit 400 scheitern - **Zeit:** 2026-10-19 15:00 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages(List)`) @@ -1206,6 +1243,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt (Java-API-Pfad; Server-Übersetzungen als F172) - **Commit:** afc40be + ### F167 – java-llama.cpp: `completeBatch`/`chatBatch` laufen auf dem gemeinsamen `ForkJoinPool` und nutzen auf wenigen Kernen nicht alle Slots - **Zeit:** 2026-10-19 15:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`completeBatch`, `completeBatchWithStats`, `chatBatch`), neu `BatchDispatch.java` @@ -1218,6 +1256,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** 5b4e409 + ### F168 – java-llama.cpp: `JllamaNative.takeStrings` gibt ein leeres Nicht-NULL-Array nicht frei - **Zeit:** 2026-10-19 16:30 UTC - **Datei:** `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` (`takeStrings`) @@ -1230,6 +1269,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** 8269149 + ### F172 – java-llama.cpp: Responses- und Anthropic-Übersetzung schreiben leere Tool-Argumente in den Verlauf - **Zeit:** 2026-10-19 21:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`function_call`), `AnthropicApiSupport.java` (`tool_use`) @@ -1242,6 +1282,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f1bf93b` + ### F173 – java-llama.cpp: Responses-Übersetzung verwirft `refusal`-Teile von Assistenten-Nachrichten im Verlauf - **Zeit:** 2026-10-19 21:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`inputContentText`, `inputContentWithImages`) @@ -1254,6 +1295,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0fd166f` + ### F174 – java-llama.cpp: `/props` meldet `total_slots` fest als 1 - **Zeit:** 2026-10-19 22:00 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`), `OpenAiServerConfig`, `OpenAiServerCli` (`toServerConfig`), `OpenAiCompatServer` (`main`, `handleProps`) @@ -1266,6 +1308,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `569415d` + ### F175 – java-llama.cpp: `RouterClient.findModel`/`awaitModelLoaded` erkennen einen Alias nicht - **Zeit:** 2026-10-19 22:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`findModel`), `value/RouterModel.java`, `json/RouterModelsResponseParser.java` @@ -1278,6 +1321,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `11c7477` + ### F176 – java-llama.cpp: `rerank(String, String...)` liefert die Dokumente in Hash-Reihenfolge - **Zeit:** 2026-10-19 22:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`rerank(String, String...)`), `json/RerankResponseParser.java` (`toDocumentScores`) @@ -1290,6 +1334,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3299e6a` + ### F177 – java-llama.cpp: `/v1/rerank`: `top_n: 0` liefert alle Ergebnisse, ein negatives `top_n` wird durchgereicht, `usage` fehlt - **Zeit:** 2026-10-19 22:55 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java`, `LlamaModelBackend.java` (`rerank`), `src/main/cpp/json_helpers.hpp` (`rerank_results_to_json`) @@ -1302,6 +1347,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `232985f` + ### F178 – java-llama.cpp: Ollama-Routen melden `done_reason` falsch und `/api/generate` keine Tokenzahlen - **Zeit:** 2026-10-19 23:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java`, `OpenAiCompatServer.java` (`streamOllamaChat`, `handleOllamaGenerate`) @@ -1314,6 +1360,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6eb8081` + ### F179 – java-llama.cpp: Ollama `/api/generate` liest weder `raw` noch `system` und vervollständigt immer roh - **Zeit:** 2026-10-19 23:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`isRaw`, `toOpenAiChatRequestFromGenerate`, `Generated.fromChat`), `OpenAiCompatServer.java` (`handleOllamaGenerate`) @@ -1326,6 +1373,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `75b3767` + ### F180 – java-llama.cpp: OpenAI-Mapper verwerfen `logit_bias`, `n_probs` und `n` still - **Zeit:** 2026-10-20 00:10 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyCommonFields`, `toCompletionParameters`), `parameters/InferenceParameters.java` (`withLogitBiasJson`) @@ -1335,9 +1383,10 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: `logit_bias` (Objekt oder Array) wird unverändert über die neue öffentliche `InferenceParameters.withLogitBiasJson` weitergereicht (ein JSON-Wert, wie die anderen Fragment-Setter geprüft), `n_probs` über `withNProbs`; `n > 1` ist eine ungültige Anfrage (400), weil diese Wege eine Antwort liefern; auf dem gestreamten `/v1/completions` sind `logprobs`/`n_probs` eine ungültige Anfrage mit dem Hinweis auf `stream: false`, bis (14) den Stream nativ macht. - **Tests:** `OpenAiRequestMapperTest#logitBiasAndNProbsAreForwardedOnBothPaths`, `#moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack`, `#probabilitiesOnTheStreamedCompletionAreRefused`, `#unknownFieldsIgnored` (ohne `n`); `InferenceParametersTest#withLogitBiasJson_storesTheValueAndRefusesASecondOne`. - **Rot-Grün-Nachweis:** mit Platzhalter-Setter und unverändertem Mapper rot (4 Tests), mit dem Fix grün (327 Tests der Mapper-, Parameter-, Registry- und Server-Klassen); SpotBugs grün. -- **Status:** gefixt (Wahrscheinlichkeiten im gestreamten `/v1/completions` offen unter (14)) +- **Status:** gefixt (der Rest unter (14), Wahrscheinlichkeiten im gestreamten `/v1/completions`, mit F186) - **Commit:** java-llama.cpp `3d001d2` + ### F181 – java-llama.cpp: JNI-Schalen geben Fehlermeldungen als Modified UTF-8 zurück - **Zeit:** 2026-10-20 01:10 UTC - **Datei:** `llama/src/main/cpp/jni_utf8.hpp` (neu), `jllama.cpp` (`throw_api_status`, Modell-Laden), `jni_helpers.hpp` (`report_cxx_exception_impl`), `native_server.cpp`, `rpc_bridge.cpp`, `train_engine.cpp` @@ -1350,6 +1399,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6f530e9` + ### F182 – java-llama.cpp: Gestreamte Anfragen antworten mit 200, bevor das Backend läuft - **Zeit:** 2026-10-20 01:50 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`, die fünf Streaming-Methoden); `llama-atmosphere-agent` (Tests, README) @@ -1362,6 +1412,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `395d9d1` + ### F183 – java-llama.cpp: Anthropic `stop_sequence` ist immer `null` - **Zeit:** 2026-10-20 02:25 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest`, `toAnthropicResponse`, `stopSequenceOf`, `messageDeltaEvent`), `AnthropicStreamTranslator.java`, `OpenAiRequestMapper.java`, `parameters/InferenceParameters.java` (`withVerbose`), `parameters/RequestField.java` (`VERBOSE`) @@ -1374,6 +1425,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `a575714` + ### F184 – java-llama.cpp: Mehrere Prompts in einer Anfrage: alle außer dem ersten still verworfen - **Zeit:** 2026-10-20 02:50 UTC - **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`populate_completion_task`) @@ -1386,6 +1438,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `c4062bd` + ### F185 – java-llama.cpp: OpenAI-Antworten ohne `id` und ohne `model` - **Zeit:** 2026-10-20 03:20 UTC - **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`name_completion_task`, aufgerufen von `blocking_completion` und `streaming_request`); `args/ModelOption.java` (`ALIAS`), `parameters/ModelParameters.java` (`setAlias`), `server/OpenAiServerCli.java` (`toModelParameters`) @@ -1398,6 +1451,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0be9b33` + ### F186 – java-llama.cpp: Das gestreamte `/v1/completions` ist ein selbstgebauter Stream ohne `usage`, Wahrscheinlichkeiten und Token-Prompt - **Zeit:** 2026-10-20 04:00 UTC - **Datei:** `llama/src/main/cpp/jllama_engine.{hpp,cpp}` (`completions_oai_stream_request`), `jllama_api.{h,cpp}` (`jllama_completions_oai_stream_request`), `jllama.cpp` + `jllama.h` (`requestCompletionOaiStream`), `LlamaModel.java` (`streamCompletionsOai`), `server/LlamaModelBackend.java` (`streamCompletions`), `server/OpenAiRequestMapper.java` und `OpenAiSseFormatter.java` (toter Completion-Weg entfernt); `llama-ffm` (`JllamaNative`, `FfmLlamaModel.completionsOaiStream`); `docs/c-api.md`, README @@ -1410,6 +1464,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d98a6ad` + ### F187 – java-llama.cpp: `addLoraScaledAdapter`/`addControlVectorScaled` senden `FNAME,SCALE`, upstream liest `FNAME:SCALE` - **Zeit:** 2026-10-19 23:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`addLoraScaledAdapter`, `addControlVectorScaled`, neu `scaledField`), `llama/src/test/java/net/ladenthin/llama/parameters/ModelParametersTest.java` @@ -1423,6 +1478,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `15f857a` + ### F188 – java-llama.cpp: Die vier „can be repeated“-Adapteroptionen ersetzen den vorigen Wert statt ihn zu ergänzen - **Zeit:** 2026-10-19 23:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` (neu `appendCsvValue`), `ModelParameters.java` (`addLoraAdapter`, `addLoraScaledAdapter`, `addControlVector`, `addControlVectorScaled`) @@ -1435,6 +1491,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `15f857a` + ### F189 – java-llama.cpp: Ohne `--model-id` nennen `/v1/models` und die Antworten dasselbe Modell verschieden - **Zeit:** 2026-10-19 23:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toModelParameters`), `OpenAiServerConfig.java` (Javadoc von `getModelId`), `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java` @@ -1448,6 +1505,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `15f857a` + ### F190 – java-llama.cpp: Das Skript-Backend des Agenten schreibt unmaskierte Steuerzeichen in seine JSON-Chunks (Modul baut auf Windows nicht grün) - **Zeit:** 2026-10-19 22:35 UTC - **Datei:** `llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ScriptedBackend.java` (`quote`), neu `ScriptedBackendTest.java` @@ -1460,6 +1518,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3b9d96b` + ### F197 – java-llama.cpp: `n` > 1 wird auf jedem nativen Completion-Weg still auf eine Antwort reduziert (das gestreamte `/v1/completions` verlor dabei den Schutz aus F180) - **Zeit:** 2026-10-20 10:30 UTC - **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`populate_completion_task`), `llama/src/test/cpp/test_c_api.cpp`, `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java` @@ -1472,6 +1531,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `447de71` + ### F198 – java-llama.cpp: `FfmTextToSpeech.close()` gibt die Engine frei, während `synthesize()` auf einem anderen Thread in ihr läuft (FFM-Hälfte von F16) - **Zeit:** 2026-10-20 11:00 UTC - **Datei:** `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java`, neu `llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmTextToSpeechTest.java` @@ -1484,6 +1544,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d6be2c5` + ### F199 – java-llama.cpp: Ein Heartbeat vor dem Eröffnungsevent verschluckt `message_start` (Anthropic) bzw. `response.created` (Responses) - **Zeit:** 2026-10-20 11:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`, `streamAnthropic`, `streamResponses`), neu `llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java` @@ -1496,6 +1557,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `bbdafda` + ### F200 – java-llama.cpp: `chatBatch` startet einen Thread pro Anfrage ohne Obergrenze, und ein `Error` beendet das Warten vor den anderen Anfragen - **Zeit:** 2026-10-20 11:45 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java`, `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`chatBatch`), `llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java` @@ -1508,6 +1570,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `3e996db` + ### F201 – java-llama.cpp: `--model-id` mit Komma (oder abgeleitet aus einem Dateinamen mit Komma) wird zu mehreren llama.cpp-Aliasen; `/v1/models` und die Antworten nennen das Modell verschieden - **Zeit:** 2026-10-20 12:00 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java`, `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java` @@ -1520,6 +1583,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `707f0f2` + ### F202 – java-llama.cpp: Die Anthropic-Route holt für das Stoppwort upstreams ganzes Debug-Objekt samt gerendertem Prompt durch JNI - **Zeit:** 2026-10-20 12:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java`, `OpenAiRequestMapper.java`, `parameters/InferenceParameters.java` (neu `withResponseFields`), `parameters/RequestField.java` (`RESPONSE_FIELDS`), `parameters/ParameterJsonSerializer.java` @@ -1532,6 +1596,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `05f356b` + ### F203 – java-llama.cpp: `chat_template_kwargs`: ein Wert mit mehr als einem JSON-Wert wird still auf den ersten gekürzt - **Zeit:** 2026-10-20 12:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildRawValueObject`; betrifft `InferenceParameters.withChatTemplateKwargs` und `ModelParameters.setChatTemplateKwargs`) @@ -1544,6 +1609,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `266d70d` + ### F204 – java-llama.cpp: `throw_utf8_impl`/`new_string_utf8_impl` rufen JNI über einer schon anstehenden Java-Exception auf und ersetzen sie - **Zeit:** 2026-10-20 12:50 UTC - **Datei:** `llama/src/main/cpp/jni_utf8.hpp`, `llama/src/test/cpp/test_jni_helpers.cpp` @@ -1556,6 +1622,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `2611a38` + ### F205 – java-llama.cpp: Ollama `/api/generate` ohne Prompt (das Vorladen eines Modells) erzeugt eine Antwort auf einen leeren Nutzer-Turn - **Zeit:** 2026-10-20 13:05 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `isLoadOnly`, `toOllamaLoadResponse`), `OpenAiCompatServer.java` (`handleOllamaGenerate`) From 71935567a89551df061ad335778a6d446811519d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:03:43 +0000 Subject: [PATCH 359/589] Correct the docs the branch review found stale jllama_api.h and docs/c-api.md: a completion request takes one prompt and one choice (F180/F197), the answer is one object. CLAUDE.md, the OpenAiCompatServer and server package Javadoc, and applyLlamaCppSampling: a streamed /v1/completions is forwarded verbatim (F186). README: the embedding example sets the alias the answers report (F189), /api/generate without a prompt (F205). TODO.md: F112 count_tokens and model unloading recorded as limits, the retired (n) numbering explained, the dangling TODO (n) comments point at their finding ids. C++ test table: 645 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- CLAUDE.md | 9 ++++----- README.md | 6 ++++-- TODO.md | 14 +++++++++++--- docs/c-api.md | 6 ++++++ llama/src/main/cpp/jllama_api.h | 8 ++++++-- .../ladenthin/llama/server/OpenAiCompatServer.java | 5 +++-- .../llama/server/OpenAiRequestMapper.java | 7 ++++--- .../net/ladenthin/llama/server/package-info.java | 7 +++++-- llama/src/test/cpp/test_c_api.cpp | 2 +- llama/src/test/cpp/test_jni_helpers.cpp | 2 +- .../llama/server/OpenAiCompatServerHttpTest.java | 2 +- .../OpenAiServerCompletionIntegrationTest.java | 2 +- 12 files changed, 47 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5bd57f36f..7ec32da0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2223,7 +2223,7 @@ If the local check passes (`BUILD SUCCESS`), the `mvn package` job in - `LlamaLoader` — Extracts the platform-specific native library from the JAR to a temp directory, or finds it on `java.library.path`. - `OSInfo` — Detects OS and architecture for library resolution. - **`server` package — OpenAI-compatible HTTP endpoint (a single implementation).** - - `server.OpenAiCompatServer` — built only on the JDK's `com.sun.net.httpserver` (no new dependency), embeddable and runnable via `java -cp net.ladenthin.llama.server.OpenAiCompatServer …` (the classes jar's `Main-Class`, `ServerLauncher`, runs `NativeServer` by default — see "Two server modes"). Serves `POST /v1/chat/completions` (streaming via SSE + non-streaming), `POST /v1/completions`, `POST /v1/embeddings`, `POST /v1/rerank`, `POST /infill`, `GET /v1/models` and `GET /health` (every route is also reachable without the `/v1` prefix), so editors that speak the OpenAI protocol (e.g. VS Code Copilot "Custom Endpoint", Cline, Roo Code, Continue) can drive a local model. Streaming chat uses the native OAI chunk path (`LlamaModel.streamChatCompletion` → `requestChatCompletionStream` / `receiveChatCompletionChunk` + the C++ `wrap_stream_chunk` helper), preserving `delta.tool_calls`; completions/embeddings/infill forward verbatim to the matching `LlamaModel.handle*`; rerank reshapes `handleRerank` into the OAI `results`/`data` shape. The chat mapper forwards `stream_options` and `response_format` and defaults `cache_prompt=true`; a CORS `Filter` answers `OPTIONS` preflights; `OpenAiSseFormatter.ensureUsageCachedTokens` guarantees `usage.prompt_tokens_details.cached_tokens` on the streamed usage chunk (Copilot crash fix, microsoft/vscode #273482). **Agentic tool-calling is the primary target**; a C++ guard (`test_server.cpp`) pins `tool_calls.function.arguments` as a JSON string (llama.cpp #20198). + - `server.OpenAiCompatServer` — built only on the JDK's `com.sun.net.httpserver` (no new dependency), embeddable and runnable via `java -cp net.ladenthin.llama.server.OpenAiCompatServer …` (the classes jar's `Main-Class`, `ServerLauncher`, runs `NativeServer` by default — see "Two server modes"). Serves `POST /v1/chat/completions` (streaming via SSE + non-streaming), `POST /v1/completions`, `POST /v1/embeddings`, `POST /v1/rerank`, `POST /infill`, `GET /v1/models` and `GET /health` (every route is also reachable without the `/v1` prefix), so editors that speak the OpenAI protocol (e.g. VS Code Copilot "Custom Endpoint", Cline, Roo Code, Continue) can drive a local model. Streaming chat uses the native OAI chunk path (`LlamaModel.streamChatCompletion` → `requestChatCompletionStream` / `receiveChatCompletionChunk` + the C++ `wrap_stream_chunk` helper), preserving `delta.tool_calls`; completions/embeddings/infill forward verbatim to the matching `LlamaModel.handle*`, and a streamed `/v1/completions` verbatim to `LlamaModel.streamCompletionsOai` (upstream's own `text_completion` chunks, `usage` on the last); rerank reshapes `handleRerank` into the OAI `results`/`data` shape. The chat mapper forwards `stream_options` and `response_format` and defaults `cache_prompt=true`; a CORS `Filter` answers `OPTIONS` preflights; `OpenAiSseFormatter.ensureUsageCachedTokens` guarantees `usage.prompt_tokens_details.cached_tokens` on the streamed usage chunk (Copilot crash fix, microsoft/vscode #273482). **Agentic tool-calling is the primary target**; a C++ guard (`test_server.cpp`) pins `tool_calls.function.arguments` as a JSON string (llama.cpp #20198). - **Alternative protocol surfaces** (pure translation over the OpenAI chat core — no second inference path; each reconstructs streamed tool calls via `ToolCallDeltaAccumulator`): **Ollama-native** (`GET /api/version`, `/api/tags`, `POST /api/show`, `/api/chat` with NDJSON streaming, `/api/generate` prompt-completion/FIM — `OllamaApiSupport`; `/api/show` advertises tools/insert/vision capabilities + context length for Copilot's Ollama provider), **Anthropic Messages** (`POST /v1/messages`, SSE event stream — `AnthropicApiSupport` + `AnthropicStreamTranslator`), and **OpenAI Responses** (`POST /v1/responses`, SSE event stream — `ResponsesApiSupport` + `ResponsesStreamTranslator`). The llama.cpp-native `GET /props` (context length + `modalities`) is served via `OpenAiSseFormatter.propsJson` for autocomplete clients that size their context from it. - Supporting classes: `OpenAiServerConfig` (builder; optional bearer auth; binds `127.0.0.1`; `corsAllowOrigin`; `supportsVision`), `OpenAiServerCli` (testable CLI arg parser → `ModelParameters` + `OpenAiServerConfig`; flags incl. `--mmproj`/`-mmdev,--mmproj-device`/`--embedding`/`--reranking`), `OpenAiRequestMapper` (OAI chat request → `InferenceParameters`), `OpenAiSseFormatter` (SSE/models/error JSON + usage normalization), `OaiRerankSupport` (pure rerank request/response shaping), and the model-free test seam `OpenAiBackend`/`ChunkSink` + `LlamaModelBackend`. The streaming envelope is parsed by `json.ChatStreamChunkParser`. - The `server` package is a dedicated top layer in the ArchUnit `layeredArchitecture` rule (the only layer allowed to access the root `Api`); `noInternalJdkImports` carries an explicit exception for the supported `com.sun.net.httpserver` (the exported `jdk.httpserver` module, which `module-info.java` `requires`). See README "OpenAI-compatible HTTP server". @@ -2610,7 +2610,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_json_helpers.cpp` | 72 | All functions in `json_helpers.hpp`: `get_result_error_message`, `get_result_error_code` (the HTTP code of a failed task result, from which `jllama.cpp` picks `InvalidRequestException` or `LlamaException`), `results_to_json`, `rerank_results_to_json` (incl. missing/out-of-range `index` rejection and the `tokens_evaluated` pass-through), `parse_encoding_format`, `extract_embedding_prompt`, `is_infill_request`, `parse_slot_prompt_similarity`, `parse_positive_int_config`, `wrap_stream_chunk`, `server_metrics_to_json`, `route_error_message` | | `src/test/cpp/test_log_helpers.cpp` | 13 | All functions in `log_helpers.hpp`: `log_level_name`, `format_log_as_json` | | `src/test/cpp/test_common_log_callback.cpp` | 7 | **The runnable guard for `patches/0014`**: `common_log_set_callback()` on a private `common_log_init()` instance (never `common_log_main()`, so the process-wide logger the other tests print through is untouched) — delivery of level + bare text, no prefix/timestamp even when both are on (what `common_init()` does), clearing stops delivery, a swap drains queued entries to the *previous* sink (the property behind `LlamaModel.setLogger(format, null)` being a synchronous flush), a `--log-file` keeps being written alongside the sink, every `ggml_log_level` passes through unchanged, and the sink is not gated on upstream's `common_log_set_stdo()` console switch (b11612: entries reach the callback with stdout/stderr output off). The Java half (`LlamaLoggerTest`, model-free) proves the JNI trampoline on top of it. | -| `src/test/cpp/test_jni_helpers.cpp` | 76 | All functions in `jni_helpers.hpp` using a zero-filled `JNINativeInterface_` mock (incl. the `utf8_to_jstring_impl` byte-array string path: emoji byte-preservation, truncated-UTF-8 replace-not-throw), and `jni_utf8.hpp` (six: an ASCII message stays on `ThrowNew`, a 4-byte character or an invalid byte reaches the exception constructor as the original bytes through `String(byte[], "UTF-8")`, the same for `new_string_utf8_impl` and for the JNI exception boundary). Seven of them pin `jni_guard_impl` — the JNI exception boundary every `Java_*` entry point runs inside — including the `catch (...)` arm that is the only backstop for a non-`std::exception` type, and its two refusals (never `ThrowNew` over a pending Java exception, never with a null class). | +| `src/test/cpp/test_jni_helpers.cpp` | 77 | All functions in `jni_helpers.hpp` using a zero-filled `JNINativeInterface_` mock (incl. the `utf8_to_jstring_impl` byte-array string path: emoji byte-preservation, truncated-UTF-8 replace-not-throw), and `jni_utf8.hpp` (six: an ASCII message stays on `ThrowNew`, a 4-byte character or an invalid byte reaches the exception constructor as the original bytes through `String(byte[], "UTF-8")`, the same for `new_string_utf8_impl` and for the JNI exception boundary; and seven: an exception already pending is left in place on both paths, as `jni_guard_impl` does). Seven of them pin `jni_guard_impl` — the JNI exception boundary every `Java_*` entry point runs inside — including the `catch (...)` arm that is the only backstop for a non-`std::exception` type, and its two refusals (never `ThrowNew` over a pending Java exception, never with a null class). | | `src/test/cpp/test_tts_wav.cpp` | 2 | The in-memory WAV writer `pcm_to_wav16_bytes` in `tts_wav.hpp` (WAV header/payload + little-endian clamping) — our own code, not upstream. The Qwen3-TTS pipeline it pairs with (`mtmd_helper::gen_audio`) is entirely upstream-owned (no project-side DSP to unit-test here). The load path is additionally covered by `test_tts_params.cpp` (3 tests over `tts_params.hpp`'s `build_tts_params`, plus 2 pinning the upstream `-1` default it depends on), which pins the CPU-thread resolution whose absence used to crash the JVM on every platform — see the `TODO.md` entry for the mechanism. End-to-end coverage is `TtsIntegrationTest`, which is model-gated. | | `src/test/cpp/test_tts_params.cpp` | 13 | The **three** builders every hand-assembled `common_params` goes through: `build_tts_params` (`tts_params.hpp`), `build_train_params` (`train_params.hpp`) and the shared `jllama::resolve_cpu_params` (`cpu_params.hpp`). Each builder is guarded separately on purpose — testing the resolver alone does **not** cover its call sites, because `train_engine.cpp` is compiled into `jllama` only, never into `jllama_test`, and `LlamaTrainerIntegrationTest` is gated on `net.ladenthin.llama.train.model`, which no CI job sets. Without these the JVM-abort bug could regress in the trainer on every platform, unseen. | | `src/test/cpp/test_prefetch.cpp` | 5 | **The runnable guard for `patches/0017`** (the x86 prefetch helper). The patch fixes two defects in the four `_mm_prefetch` calls of `ggml_vec_dot_q4_0_q8_0`'s SSSE3-without-AVX branch, and only one of them is a build error. The **type** half is caught by the compiler, but only by a recent one: plain clang on a `*-windows-msvc` target gets MSVC's `char const *` declaration (its own casting macro sits behind `#ifndef _MSC_VER`), and the resulting `-Wincompatible-pointer-types` is an **error by default from clang 22 on** -- measured on the real file at `-msse4.2`: 4 warnings on 20.1.8, 4 errors on 22.1.3 and 23.1.3, and 0/0 on all three once patched (`-msse4.2 -mavx` compiles either way, taking the AVX branch). So `GGML_CPU_ALL_VARIANTS=ON` stops building with plain clang on Windows once the compiler is new enough -- via the **`sse42`** variant, the only one that reaches `#elif defined(__SSSE3__)` (the `x64` baseline has no SSSE3 and takes the scalar branch; everything richer has AVX). Nothing in a test can cover that half. The **arithmetic** half is what this file guards, and it is silent: `&x[ib] + sizeof(block_q4_0)` adds `sizeof()` *elements*, so with the sizes `ggml-common.h` pins by `static_assert` (18 and 34) the prefetch targets +324 and +1156 bytes instead of +18 and +34, and the `+ 2 *` pair +648 instead of +36 -- wrong on **every** platform, including the ones where the call compiles. `ggml_prefetch_at()` casts to `const char *` first, which is what makes the offset byte-wise. **The case that makes the file worth having:** writing the cast around the whole expression, `(const char *)(&x[ib] + sizeof(block_q4_0))`, silences the compiler and keeps the wrong address -- that form builds and fails here. Verified both ways: 5/5 green against the patched header, 5/5 red against a deliberately element-wise one. Includes two internal headers (`ggml-common.h` for the block types, `prefetch.h` for the helper) via the `${llama.cpp_SOURCE_DIR}/ggml/src` and `.../ggml/src/ggml-cpu/arch/x86` include dirs added for it. **Measured, so the upstream report does not overclaim:** the wrong prefetch distance costs no measurable throughput (Ryzen 7 5800H forced onto the `sse42` kernel, 0.6B Q4_0: pp512 101.3 vs 100.9, tg128 37.3 vs 37.0, inside each other's error bars) -- it is a correctness and portability fix, not a performance one. | @@ -2618,13 +2618,12 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_model_flags.cpp` | 4 | **The contract between the Java CLI-flag registries and llama.cpp's server argument parser.** CMake reads `ModelFlag.java` + `ModelOption.java` (`cmake/extract-java-wire-names.cmake` → a generated header of `{name, contract}` pairs), and this file asserts every `SERVER_PARSER` name is in `common_params_parser_init(params, LLAMA_EXAMPLE_SERVER).options`. It exists because **no Java test can catch this class**: `ModelFlagTest`/`ModelParametersExtendedTest` pin the *string mapping* (`hasKey("--mlock")`), never that llama.cpp still accepts the string, so they stay green forever while the flag is dead — and `common_params_parse` treats an unregistered option as a hard error, so the affected builder method makes the model **unloadable**, not merely ineffective. **A grep over `arg.cpp` is not a substitute**: `--grp-attn-n`/`-w` are present there at every pinned tag but `set_examples()`-scoped to `LLAMA_EXAMPLE_COMPLETION`/`PASSKEY`, so the server parser rejects them exactly like a deleted flag — only the real option table sees that. `--vocab-only` is the one exemption, and it declares itself `CliContract.PROJECT_PSEUDO` on its own constant rather than appearing in a list inside this file; the test asserts such a name is **still unknown** to the parser (an exemption upstream later registers would be hiding a real check) and that the exempt set is non-empty. | | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). | | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. | - -| `src/test/cpp/test_c_api.cpp` | 30 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | +| `src/test/cpp/test_c_api.cpp` | 31 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | -**Current total: 643 tests (all passing).** +**Current total: 645 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/README.md b/README.md index 46ac5f4bd..17d65782c 100644 --- a/README.md +++ b/README.md @@ -998,7 +998,7 @@ path), all supporting tools and streaming: | Surface | Routes | For | |---|---|---| -| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does; the bare prompt with `raw: true`; FIM with `suffix`) | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | +| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does; the bare prompt with `raw: true`; FIM with `suffix`; without a prompt Ollama's "model loaded" answer) | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | | **Anthropic Messages** | `POST /v1/messages` (SSE event stream) | Claude-shaped clients (Claude Code); Copilot `messages` apiType | | **OpenAI Responses** | `POST /v1/responses` (SSE event stream) | Copilot `responses` apiType; Responses-API clients | @@ -1010,7 +1010,9 @@ clients such as llama.vscode read to size their context window. Embed it in your app: ```java -ModelParameters modelParams = new ModelParameters().setModel("models/model.gguf").setParallel(2); +// the alias is the name llama.cpp puts into every answer; set it to the id /v1/models advertises +ModelParameters modelParams = + new ModelParameters().setModel("models/model.gguf").setParallel(2).setAlias("local-model"); OpenAiServerConfig config = OpenAiServerConfig.builder().port(8080).modelId("local-model").build(); try (LlamaModel model = new LlamaModel(modelParams); OpenAiCompatServer server = new OpenAiCompatServer(model, config).start()) { diff --git a/TODO.md b/TODO.md index 468fd49df..286cfa67e 100644 --- a/TODO.md +++ b/TODO.md @@ -495,9 +495,9 @@ Everything the bug hunt left open, so nothing is lost when the session ends. The named in each item holds the analysis, the upstream references (b11639) and the reproduction; delete an item here when it is done. -**Open suspicions without a finding id** (each needs the owner's decision or a model to decide; -numbered for reference, the numbers stay when an item is done): none left -- the last ones were -fixed as F181 to F186 on 2026-10-20. The local measurement run of that brief +**Open suspicions without a finding id**: none left -- the last ones, numbered (1) to (16) while they +were open (comments and ledger entries still cite those numbers), were fixed as F181 to F186 on +2026-10-20. The local measurement run of that brief (`BUGHUNT-MEASUREMENTS.md`, M1 … M10, Windows) confirmed all of them with the real models and found four more, fixed as F187 … F190. It left two limits, both decided and documented, recorded here so they are not rediscovered: @@ -512,6 +512,14 @@ they are not rediscovered: of `OpenAiServerConfig.getModelId()` tells an embedding caller to load with `ModelParameters.setAlias(…)`. Rewriting the `model` field of every answer was rejected (it would claim something the engine did not do, and attach-mode `NativeServer` could not follow it). +- **`POST /v1/messages/count_tokens` and the Responses `input_tokens` route answer 404** (F112). + Upstream serves both; F112 stopped them from running as a full generation under the parent + route's handler, but counting tokens needs a tokenizer call through `OpenAiBackend`, which has + none -- an API addition (`OpenAiBackend.countTokens(JsonNode)` over `LlamaModel.encode` after the + chat template). Claude Code falls back to its own estimate on a 404, so nothing breaks; open + until a client needs the exact count. +- **`OpenAiCompatServer` cannot unload a model**: an Ollama `/api/generate` with `keep_alive: 0` + and no prompt gets the "load" answer (F205), the model stays resident (one model per server). ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) diff --git a/docs/c-api.md b/docs/c-api.md index c8a111aa4..760a4a2d6 100644 --- a/docs/c-api.md +++ b/docs/c-api.md @@ -78,6 +78,12 @@ And the `jllama_api.h` header ships in every natives directory, so a consumer of or out-parameter), `INTERRUPTED` (the model was freed under the call). On anything but `OK`, `jllama_last_error()` has the message. The Java side maps `INVALID_REQUEST` to `InvalidRequestException` and the rest to `LlamaException`, the same rule as before. +- **One request, one answer.** A completion or chat request takes one prompt and asks for one + choice: several prompts in one request (an array of strings or of token arrays) and `"n"` greater + than 1 are `INVALID_REQUEST` on the blocking, raw and streamed paths. llama.cpp's HTTP server + answers them with several choices from child tasks, which this engine does not create; answering + with one would drop the rest silently. Send separate requests (several slots, `-np`, run them at + once). - **Logging.** `jllama_set_log_callback(cb, user_data)` is process-wide and routes *every* line (llama.cpp, ggml, the server's `srv`/`slot` lines) to `cb` instead of the console, on the logger's worker thread. `jllama_set_log_callback(NULL, NULL)` drains the lines queued for the previous diff --git a/llama/src/main/cpp/jllama_api.h b/llama/src/main/cpp/jllama_api.h index 5d74eb6c0..0eff008b2 100644 --- a/llama/src/main/cpp/jllama_api.h +++ b/llama/src/main/cpp/jllama_api.h @@ -177,11 +177,15 @@ JLLAMA_API jllama_status jllama_model_meta(jllama_model *model, char **response_ /** * A raw completion (llama.cpp's /completion body: `{"prompt": ..., "n_predict": ..., ...}`), - * blocking; *response_json is the array of result objects. + * blocking; *response_json is the result object. A request takes one prompt and one completion: + * several prompts or "n" > 1 are JLLAMA_STATUS_INVALID_REQUEST (send separate requests). */ JLLAMA_API jllama_status jllama_completions(jllama_model *model, const char *request_json, char **response_json); -/** An OpenAI /v1/completions request, blocking; *response_json the text_completion object(s). */ +/** + * An OpenAI /v1/completions request, blocking; *response_json the text_completion object. One prompt + * and one choice, as for jllama_completions. + */ JLLAMA_API jllama_status jllama_completions_oai(jllama_model *model, const char *request_json, char **response_json); /** diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 2da179d28..89797311b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -47,8 +47,9 @@ *

  • {@code POST /v1/chat/completions} — streaming (Server-Sent Events) and non-streaming chat * completions, forwarded faithfully (messages/tools verbatim; streamed {@code delta.tool_calls} * preserved).
  • - *
  • {@code POST /v1/completions} — text completion, streaming (Server-Sent Events, token by token - * via {@code generate(...)}) when {@code stream:true} and non-streaming otherwise.
  • + *
  • {@code POST /v1/completions} — text completion, the body forwarded to llama.cpp verbatim: + * streaming (Server-Sent Events, upstream's own {@code text_completion} chunks with {@code usage} + * on the last) when {@code stream:true} and non-streaming otherwise.
  • *
  • {@code POST /v1/embeddings} — embeddings (requires the model to be loaded in embedding * mode).
  • *
  • {@code GET /v1/models} — advertises the single configured model.
  • diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 8adcdeeff..507ce51b9 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -193,9 +193,10 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo /** * Apply the llama.cpp sampling extensions upstream's request schema ({@code server-schema.cpp}, - * b11639) reads next to the OpenAI fields. The non-streamed {@code /v1/completions} forwards the body - * to that schema, so without them the streamed form of the same request would sample differently. - * {@code json_schema} applies only without a {@code grammar}, as upstream decides. + * b11639) reads next to the OpenAI fields. {@code /v1/completions} forwards its body to that schema + * verbatim, streamed or not; the chat paths are built here, so without these fields a chat request + * would sample differently from a completion carrying the same fields. {@code json_schema} applies + * only without a {@code grammar}, as upstream decides. * * @param params the parameters to extend * @param request the parsed request object diff --git a/llama/src/main/java/net/ladenthin/llama/server/package-info.java b/llama/src/main/java/net/ladenthin/llama/server/package-info.java index 148ba1102..b927ec3af 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/package-info.java +++ b/llama/src/main/java/net/ladenthin/llama/server/package-info.java @@ -20,8 +20,11 @@ * comes straight from the native OpenAI chunk formatter (see * {@link net.ladenthin.llama.LlamaModel#streamChatCompletion(net.ladenthin.llama.parameters.InferenceParameters, java.util.function.Consumer)}), * so streamed {@code delta.tool_calls} are preserved for agent-mode tool use. - *
  • {@code POST /v1/completions} and {@code POST /v1/embeddings} — non-streaming, forwarding the - * request body to the matching {@code LlamaModel.handle*} method.
  • + *
  • {@code POST /v1/completions} — the request body forwarded verbatim, to + * {@code LlamaModel.handleCompletionsOai} or, streamed, to + * {@code LlamaModel.streamCompletionsOai} (upstream's own {@code text_completion} chunks).
  • + *
  • {@code POST /v1/embeddings} — non-streaming, forwarding the request body to + * {@code LlamaModel.handleEmbeddings}.
  • *
  • {@code POST /v1/rerank} — document reranking for RAG (requires the model loaded in reranking * mode); the native result array is reshaped to {@code results}/{@code data} of * {@code {index, relevance_score}}.
  • diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index d0295fa52..b18f0ef23 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -369,7 +369,7 @@ TEST_F(CApi, SeveralCompletionsInOneRequestAreRefusedNotDropped) { } // The streamed /v1/completions: the body parsed as the blocking one, upstream's text_completion -// chunks, one id, the last with usage (TODO (14), F186). +// chunks, one id, the last with usage (F186). TEST_F(CApi, OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage) { int task = -1; ASSERT_EQ( diff --git a/llama/src/test/cpp/test_jni_helpers.cpp b/llama/src/test/cpp/test_jni_helpers.cpp index 6e27481ea..fa6efa063 100644 --- a/llama/src/test/cpp/test_jni_helpers.cpp +++ b/llama/src/test/cpp/test_jni_helpers.cpp @@ -896,7 +896,7 @@ TEST(JniGuard, DoesNotThrowWithoutAnExceptionClass) { } // ============================================================ -// jni_utf8.hpp — message text to Java as standard UTF-8 (TODO (6), F181) +// jni_utf8.hpp — message text to Java as standard UTF-8 (F181) // // ThrowNew and NewStringUTF read Modified UTF-8, where a 4-byte sequence is invalid input: // HotSpot mangles it, Android's CheckJNI aborts. An error message quotes paths and request text, diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index d88f01a1a..89ce35ab9 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -591,7 +591,7 @@ public void nativeRejectionOfACompletionReturns400() throws IOException { } // Upstream reads the first result before it starts a stream, so a request refused at once is answered - // with the error status, as without "stream" (TODO (3), F182). The response starts with the first + // with the error status, as without "stream" (F182). The response starts with the first // write -- a chunk, the protocol's opening events or a heartbeat -- and only a failure after that // travels as an event. diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java index 96865c3d8..23bab2c90 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java @@ -88,7 +88,7 @@ public void aCompletionWithoutAPromptIsAnswered400LikeUpstream() throws IOExcept // The streamed /v1/completions went through the Java generator and built its own chunks: no usage, // no probabilities, and a prompt given as a token array refused while the same request without - // "stream" was accepted. It now streams upstream's own text_completion chunks (TODO (14), F186). + // "stream" was accepted. It now streams upstream's own text_completion chunks (F186). @Test public void aStreamedCompletionIsUpstreamsStream() throws IOException { String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":true,\"max_tokens\":4,\"logprobs\":2," From 6ed6881f431c8c2c58af8127b497670e25b7ecdd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:10:07 +0000 Subject: [PATCH 360/589] Cover every user-visible bug-hunt fix in the Unreleased changelog 107 fixes had no line; they are grouped under Fixed by surface (server, Ollama, Anthropic, Responses, rerank, Java API, loader, FFM, langchain4j), the behaviour changes a caller must react to under Changed and Upgrading from 5.1 (items 8 to 13), and the new API under Added. The one internal finding id in the text is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- CHANGELOG.md | 286 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 283 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 677c9465e..3ef136c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,36 @@ the first two items break every existing consumer at upgrade. 7. **The Java floor is still 8.** The C API (`jllama_api.h` in every natives directory), the provenance record (`jllama-build.txt`, `LlamaModel.getJllamaBuildInfo()`) and upstream's own `libllama` next to `libjllama` are additions; nothing else about loading or calling changed. +8. **Values that were ignored or misread are refused when they are set** (`IllegalArgumentException`, + see Changed): `TrainingParameters` -- and with it both `LlamaTrainer.finetune` overloads -- checks + its documented bounds (`epochs` at least 1, `valSplit` in `[0, 1)`, exactly one of `trainingText` / + `trainingFile`, `modelPath` and `outputPath` set); `ModelParameters.setPredict` refuses values below + `-1` (`-2` stopped every request after one token; `0` behaves like `1`); + `addLoraScaledAdapter` / `addControlVectorScaled` refuse a path containing a colon, i.e. every + absolute Windows path, which llama.cpp's `FNAME:SCALE` format cannot express (load the adapter with + `addLoraAdapter` and set its scale with `LlamaModel.setLoraAdapter`, or use a relative path); an + empty or blank JSON fragment (`withJsonSchema("")`, `withToolsJson("")`, ...) and a `NaN` or + `+Infinity` token bias are refused instead of being sent as `null` or dropped. +9. **The repeatable adapter options add up.** A second `addLoraAdapter`, `addLoraScaledAdapter`, + `addControlVector` or `addControlVectorScaled` call adds to the first instead of replacing it, as + their Javadoc always said; code that relied on the last call winning now loads every adapter. +10. **One request, one prompt, one completion.** Several prompts in one request + (`"prompt": ["a", "b"]`) and `"n"` above 1 are refused as invalid requests + (`InvalidRequestException`, `400` in the servers) on every native path -- `LlamaModel.handle*` and + the streams, the C API, `llama-ffm` and every route of `OpenAiCompatServer` -- where the first prompt + and a single choice used to be answered without a word. Send separate requests; a token array is + still one prompt. +11. **`ServerMetrics.getCumulativeUsage()` counts the cached prompt tokens**: `getPromptTokens()` is now + evaluated + cached tokens, like the `usage` of a response. Read `getProcessedPromptTokens()` (or + `getCumulativeProcessedPromptTokens()`) where the evaluated tokens alone were meant. +12. **`LlamaModel.chatBatch` runs at most 64 requests at once** on threads of its own, and + `completeBatch` / `completeBatchWithStats` submit every request from the calling thread; neither + uses the common `ForkJoinPool` any more. +13. **`OpenAiCompatServer`**: `--model-id` / `--model-alias` refuse a comma, an empty value and + leading or trailing whitespace, and a model file name with a comma is advertised with `_` in its + place; `/api/generate` without `"raw": true` applies the model's chat template, as Ollama does + (send `raw: true` for a bare completion); a route answers its own path only, so a sub-path such as + `/v1/messages/count_tokens` is `404`; a negative rerank `top_n` is `400`. ### Added - **The C API of `libjllama` (`jllama_api.h`, shipped in every natives directory).** Every operation @@ -236,6 +266,23 @@ the first two items break every existing consumer at upgrade. starts it with the CPU natives of every desktop platform, no checkout and no download by hand. The release-asset jar without the core stays on the GitHub release. The agent's version now moves with the core's; `check-natives.py` fails when they differ. +- **`InferenceParameters.withLogitBiasJson(String)`**: `logit_bias` as one checked JSON value in either + form llama.cpp's request schema reads -- an array of `[token, bias]` pairs or OpenAI's object of + token ids to biases, `false` banning a token. The OpenAI request mapper forwards a client's + `logit_bias` with it, and `n_probs` with `withNProbs`, on every chat route. +- **`InferenceParameters.withResponseFields(String...)`** (request field `response_fields`, + `RequestField.RESPONSE_FIELDS`): upstream's projection of the native answer, and with `withVerbose` + of the `__verbose` object of an OpenAI answer, onto dot-separated paths. The OpenAI mapper forwards + a client's `response_fields` and answers `400` for anything but an array of strings. +- **`RouterModel.getAliases()` and `isNamed(String)`**: the aliases `GET /models` lists for a model + (`--alias` of a preset), parsed by `RouterModelsResponseParser`; `RouterClient.findModel` and + `awaitModelLoaded` match a model by its id or any alias, as the router resolves the name of a + request. +- **`OpenAiServerConfig.Builder.totalSlots(int)` / `getTotalSlots()`** (advertised as `total_slots` in + `GET /props`) and the `OpenAiServerCli.Options.toServerConfig` overloads taking the context size + of one slot and the slot count of the loaded model, which `OpenAiCompatServer.main` now passes. +- **`RerankResponseParser.toDocumentScores(...)`**: the document-to-score map of a rerank result in + the order of the documents (see Fixed). ### Changed - Dependencies and build tooling bumped in step with the sibling repositories: Jackson 2.22.3 -> @@ -245,6 +292,55 @@ the first two items break every existing consumer at upgrade. already had it); jsign 7.2 -> 7.6 for the Windows code signing (`sign-windows-natives.sh`, new SHA-256; same options, same eSigner authentication path); Gradle 9.8.1 -> 9.8.2 (`GRADLE_VERSION` and the shared `verify-signing-key-gradle` job); `anthropics/claude-code-action` v1.0.255. +- **Values that were ignored, misread or left to fail at load time are refused when they are set, + with an `IllegalArgumentException` naming the value:** + - `TrainingParameters.toJson()` (and so `LlamaTrainer.finetune`) checks the documented bounds before + the JNI call: `epochs(0)` ran no epoch and wrote the unchanged model to `outputPath` as a silent + success, both corpus fields set let `trainingFile` win, a `valSplit` outside `[0, 1)` (`NaN` + included) reached ggml unchecked, and a missing `modelPath` / `outputPath` was a bare + `NullPointerException`. + - `ModelParameters.setPredict` refuses values below `-1`. Its Javadoc promised `-2` = "until + context filled", which is a setting of llama.cpp's completion tool: in the server a slot counts + only `-1` as unlimited, so every request stopped after its first token without an error + (measured). The Javadoc of `setPredict` and `InferenceParameters.withNPredict` now says that `0` + behaves like `1`, and that the server answers a per-request `n_predict` of `-2` as an invalid + request. + - `setRopeScaling(LONGROPE)` and `setRopeScaling(MAX_VALUE)` throw, where they made the model + unloadable (llama.cpp's parser takes only `none`, `linear` and `yarn`); `UNSPECIFIED` removes the + option and leaves the model's default, as `setPoolingType(UNSPECIFIED)` now does too (it left an + earlier `--pooling` in place). + - A token bias of `NaN` or `+Infinity` (`withTokenIdBias`, `withTokenBias`) is refused, and + `-Infinity` bans the token: Jackson wrote non-finite floats as strings, which llama.cpp skips, so + banning a token that way had no effect. + - `JsonParameters` refuses an empty or blank fragment (`withJsonSchema("")`, + `withResponseFormat("")`, `withToolsJson("")`, ...), which the strict reader parsed to nothing and + the request carried as `"key": null`, i.e. generation ran without the schema the caller set. + - `OpenAiServerConfig.Builder.heartbeatMillis` refuses a value of `0` or below: as the period of the + heartbeat timer it made every streamed request fail with `200` and an + `IllegalArgumentException` error event, reported as the client's fault. + - `OpenAiServerCli` refuses a `--port` outside 0..65535 as a usage error before the model loads; + `OpenAiCompatServer.main` used to load the model, which can take minutes, and then die with an + uncaught "port out of range". +- **`LlamaModel.completeBatch`, `completeBatchWithStats` and `chatBatch` no longer run on the common + `ForkJoinPool`**, whose `availableProcessors() - 1` threads the application shares: 8 requests on + 8 slots ran 3 at a time on a 4-core machine (measured). `completeBatch` and + `completeBatchWithStats` submit every request from the calling thread and then receive the results + in order, releasing the requests already submitted when a submission fails; `chatBatch`, whose + native call submits and waits in one, runs each request on a daemon thread of its own, at most 64 + at once, and throws the first failure of any kind only after every request has finished (an + `Error` used to end the wait while other requests still ran). +- **Ollama `/api/generate` follows Ollama's semantics.** It completed every prompt raw; now a request + with `suffix` is fill-in-the-middle as before, one with `"raw": true` a bare completion (`system` + ignored, as in Ollama), and any other goes through the model's chat template with `system` as the + system message and the prompt's `images`, `format` and `think` honoured, answering `thinking` next + to `response`. A request with neither a prompt nor a suffix -- how clients preload a model -- is + answered at once with Ollama's load response (`done: true`, empty `response`, `done_reason` + `"load"`; a single object even when streamed) instead of answering an empty user turn. +- **`OpenAiCompatServer --model-id` / `--model-alias` take a value llama.cpp keeps as one alias.** + llama.cpp splits `--alias` at commas and strips every part, so `--model-id my,model` was advertised + as `my,model` in `/v1/models` and named `model` in every answer, and a blank id fell back to the + model path. A comma, an empty value and leading or trailing whitespace are a usage error now; an id + derived from a file name replaces a comma with `_` and is trimmed (blank: the default id). - **`libjllama` exports only its interface.** Hidden visibility for the project's own translation units, a version script on ELF and an exported-symbols list on macOS keep the statically linked llama, llama-common, mtmd, cpp-httplib and BoringSSL symbols local: the Linux library exported @@ -633,6 +729,180 @@ the first two items break every existing consumer at upgrade. changed meaning accordingly (see Added). ### Fixed +- **`OpenAiCompatServer`: what every surface shares.** + - **`--api-key` accepts Anthropic clients.** Only `Authorization: Bearer ` with a capital + `Bearer` passed, so every Anthropic SDK -- which sends `x-api-key` -- got `401` on `/v1/messages` + once a key was set; `x-api-key` is accepted now and the scheme compared case-insensitively + (RFC 7235), both still in constant time. + - **The CORS preflight allows the headers the browser asks for.** With `corsAllowOrigin` set it + always answered `Access-Control-Allow-Headers: Content-Type, Authorization`, so a browser dropped + the requests of the Anthropic SDK (`x-api-key`, `anthropic-version`, `x-stainless-*`). It mirrors + `Access-Control-Request-Headers` now (the origin stays the gate), and without one allows + `Content-Type, Authorization, x-api-key, anthropic-version`. + - **A route answers its own path only.** `com.sun.net.httpserver` dispatches by longest prefix, so + `POST /v1/messages/count_tokens` (Claude Code counts tokens there) ran a full generation and + `/v1/modelsXYZ` listed the models; every other path is `404` now, as upstream, and `/v1/reranking` + is registered as the alias of `/v1/rerank` it only was by prefix. + - **The advertised context matches what a request can use** (`maxInputTokens` / `maxOutputTokens`, + the context length of `/api/show` and `/props`, which Copilot & co. size their prompts by). The + input budget was `-c` minus 2048 whatever the output budget was (`-c 2048` advertised 1 input + token); with `--parallel N` the whole `-c` was advertised although each slot holds `-c / N`; and + without `-c` the fixed defaults 8192 + 2048 were advertised whatever the model loaded with (10240 + for slots of 2048, measured). The server now advertises the context of one slot of the loaded + model, else `-c` divided by an explicit `--parallel`, and splits it between input and output. + - **`/props` reports the slot count** of the loaded model (else `--parallel`, else 1) as + `total_slots`, where it was always 1. + - **`/v1/models` carries `created`**, one of the four required fields of OpenAI's model object; + strictly typed clients (Rust's `async-openai`, schema validators) could not read the list. + - **A heartbeat no longer swallows the opening event of a stream.** The heartbeat timer was + scheduled before the opening of `/v1/messages` and `/v1/responses` was set, so a heartbeat that + fired first started the stream without `message_start` / `response.created`, which Anthropic SDKs + reject. The opening is now always the first event. +- **`OpenAiCompatServer`: the chat request mapping**, which every chat request goes through -- OpenAI + directly and the Ollama, Anthropic and Responses routes after their translation: + - **The reasoning controls reach the model.** `chat_template_kwargs` (Qwen3's `enable_thinking`), + `reasoning_effort` (`"none"` switches thinking off through `enable_thinking: false`, another value + becomes the template variable `reasoning_effort`) and `reasoning_budget_tokens` / + `thinking_budget_tokens` were dropped; they follow the rules of upstream's chat parser now. + - **llama.cpp's own sampling fields are forwarded**: `min_p`, `typical_p`, `repeat_penalty`, + `repeat_last_n`, `top_n_sigma`, `xtc_*`, `dynatemp_*`, `min_keep`, `n_keep`, `ignore_eos`, + `grammar` and `json_schema` (unless a `grammar` is given), and `n_predict`, which wins over + `max_tokens` / `max_completion_tokens` as upstream; so are `logit_bias` and `n_probs`, and + `logprobs: true` becomes `n_probs` (`top_logprobs`, else 20, as upstream). + - **`id_slot: -1` means any slot**, upstream's default, instead of a `400`; and `max_tokens: 0` is + passed on as upstream reads it instead of meaning "no limit". + - **A `tool_choice` naming a function** (`{"type": "function", ...}`, also the Responses form) became + `auto`, so the model could answer without calling it; it is `required` now, the closest value + llama.cpp knows. +- **Ollama routes (`/api/*`).** + - **Images reach the model.** `/api/chat` ignored a message's `images`, although `/api/show` + advertises `vision` with `--mmproj`; they become `image_url` parts (the MIME type from the magic + bytes). + - **The second turn of a conversation with tools works.** Ollama sends earlier tool calls without + `type`, which llama.cpp's parser requires, so every request carrying one was refused with `400`; + `"function"` is set now. A tool result's `tool_name` becomes the `name` llama.cpp reads, and a + tool call without arguments is answered with `"arguments": {}` instead of `null`. + - **`think` works**: `false` switches thinking off, a level string (`"low"`, ...) is passed as + `reasoning_effort`. The thinking itself is answered as `message.thinking`, blocking and per stream + line, and `thinking` in the history goes back to the model as `reasoning_content`; both were lost. + - **The sampling options are forwarded on all three routes** (`/api/chat`, `/api/generate`, and + fill-in-the-middle with `suffix`, which took only `temperature` and `num_predict`): + `temperature`, `top_p`, `top_k`, `seed`, `stop`, `repeat_penalty`, `repeat_last_n`, `min_p`, + `typical_p`, `presence_penalty`, `frequency_penalty` and `num_keep` (as `n_keep`). Ollama's + `num_predict: -2` ("fill the context") is passed as `-1` instead of being refused, and + `repeat_last_n: -1` is left to the server default. + - **`done_reason` and the token counts are right.** `done_reason` is `length` for an answer cut off + by `num_predict` and `stop` otherwise on all four ways (the blocking chat passed OpenAI's + `tool_calls` through, the others always said `stop`); the streamed `/api/chat` done line and both + `/api/generate` forms report `prompt_eval_count` / `eval_count`. + - **`/api/version` reports `0.9.0`.** VS Code Copilot's Ollama provider refuses a server below + 0.6.4, and the route said `0.1.0`, so the whole Copilot path failed at that check. + - **`/api/show` names the context length where Continue reads it**: `parameters` carries + `num_ctx `. +- **Anthropic Messages (`/v1/messages`).** + - **Images reach the model**: `image` blocks of a message (`base64` or `url` source) and of a + `tool_result` become `image_url` parts; both were dropped. + - **Several text blocks of one message are joined with a newline**, as upstream does for a template + that takes text only; `first` + `second` became `firstsecond`. + - **Thinking works in both directions.** `thinking.budget_tokens` becomes the reasoning budget + (upstream's default 10000 without one), so a thinking block can no longer use up the whole answer; + the answer carries the reasoning as a `thinking` block first, and streams it as `thinking_delta`s + closed with a `signature_delta`; `thinking` blocks of earlier turns go back to the model as + `reasoning_content`. + - **A request without `max_tokens` is capped at 4096**, as upstream, instead of running to the end + of the context, and `chat_template_kwargs` is passed on. + - **`tool_choice: {"type": "none"}` forbids tool calls**; it ran as `auto`. + - **Tool calls without arguments**: `tool_use.input` is `{}` instead of `null`, and a `tool_use` + without `input` in the history is sent as `"{}"` -- `""` or `"null"` made llama.cpp refuse the whole + follow-up request. + - **Claude Code's prompt cache hits.** Its system prompt starts with a billing header whose `cch` + stamp changes on every request, so no prefix ever matched and every turn re-evaluated the whole + prompt; the stamp is normalized as upstream does. + - **A server error is an `api_error`**, a rejected request an `invalid_request_error`; every error + was called `invalid_request_error`, so a client deciding by the type whether to retry took a + server fault for its own. + - **The stop word is fetched alone**: the translated request asks for + `response_fields: ["stopping_word"]` with `verbose`, instead of upstream's whole debug object with + the rendered prompt. +- **OpenAI Responses (`/v1/responses`).** + - **Images and tool output parts reach the model**: `input_image` parts of a message and an + array-valued `function_call_output` (`input_text` / `input_image`) were flattened to their text or + to nothing. + - **The history is rebuilt the way upstream does**: text parts of one item are joined with a + newline; `reasoning` items become the `reasoning_content` of the assistant turn, where they were + turned into **user** messages; consecutive `function_call` items and a following assistant + `message` join the preceding assistant turn instead of opening one each; a `function_call` + without `arguments` is sent as `"{}"`; and a `refusal` part is kept as text instead of being + dropped. + - **Reasoning works in both directions**: `reasoning.effort` is passed on (`"none"` switches + thinking off), and the answer carries the reasoning as a `reasoning` item first, streamed as + `response.reasoning_text.delta`s. + - **The request fields the chat path reads are passed on** -- `top_k`, `seed`, `stop`, the + penalties, `chat_template_kwargs`, `response_format`, the cache fields and llama.cpp's sampling + fields; only `temperature`, `top_p` and `max_output_tokens` got through. **`text.format` + (structured output) becomes `response_format`**, so a JSON schema constrains the answer. + - **What cannot be served is refused with `400`** instead of answered as if it were absent: + `previous_response_id` (there is no response store; send the earlier turns in `input`) and + `input_file` parts, both with upstream's message. + - **A streamed failure ends with `response.failed`** (with `error.code` / `error.message`) before + the `error` event; Responses clients such as Codex read the message only from there and reported + "stream closed before response.completed". + - **`created_at` is the current time**, not `0`, and a response that only calls tools has no empty + `message` item in front of the `function_call` items, as upstream and the streamed answer. +- **Reranking.** `/rerank` accepts Hugging Face text-embeddings-inference requests (`texts` instead of + `documents`) and answers them in TEI's form, as upstream; `top_n: 0` returns no result instead of + all, a negative `top_n` is `400`, and the OpenAI-shaped answer carries `usage` (the native rerank + result passes `tokens_evaluated` on). `LlamaModel.rerank(String, String...)` returns its map in the + order of the documents instead of hash order. +- **Java API.** + - **`InferenceParameters.withMessages(List)` keeps `tool_calls` and `tool_call_id`**, + which only `ChatRequest` added, so a tool round built through it sent an assistant turn without + its calls and a result without its id. Empty tool-call arguments are written as `"{}"`, which + llama.cpp parses (`""` failed the whole follow-up request with `400`). + - **Five "`null` clears" withers clear.** `withPrompt`, `withInputPrefix`, `withInputSuffix`, + `withGrammar` and `withToolChoice` returned the request unchanged for `null`, so + `withGrammar(g).withGrammar(null)` still sent the grammar. + - **JSON values are taken as given or refused, never cut short.** A `ToolDefinition` schema with + trailing content (`{"type":"object"} {"required":["x"]}`) was sent as its first value, and a + `chat_template_kwargs` value holding two JSON values as the first of them; both now take the + documented fallback to a string, as does an empty schema or kwarg value, which became `null`. + - **`LlamaIterator` stays finished after `cancel()` from another thread**; a result that arrived + afterwards revived it, and the next `next()` failed with "Task not found". + - **`TextToSpeech.close()` waits for a running `synthesize()`** instead of freeing the engine under + it (a use-after-free, i.e. a JVM crash); syntheses still run side by side. + - **`NativeServer` start, `isRunning()` and `close()` are serialized**: two `close()` calls at once + (a shutdown hook and try-with-resources) could stop and free the native server twice, and + `isRunning()` could read a freed handle. + - **A failed `RpcServer.start` stops the server.** A stop that arrived before the native server + bound its socket was lost, so after a start timeout or an interrupt the server kept listening on a + daemon thread although `start` had thrown; the stop is now repeated until the thread ends. + - **`RouterClient`**: `awaitModelLoaded` with `Long.MAX_VALUE` overflowed into an immediate timeout, + and with `0` reported a timeout without asking; it polls at least once now, counts a `sleeping` + model as loaded (as the router itself does), and finds a model by its alias; an IPv6 literal host + (`"::1"`) is bracketed in the URL instead of failing with `MalformedURLException`. + - **`LlamaTrainer.finetune` accepts any Unicode**: a character outside the Basic Multilingual Plane + (an emoji) in the training text or a path failed the whole run with "invalid training config". + - **`GgufInspector.read` rejects nested arrays with an `IOException`**, as llama.cpp's reader does; a + crafted file ended it with a `StackOverflowError`. + - **`Session.fork` refuses a negative slot id before touching the model**, with the constructor's + `IllegalArgumentException`, instead of writing the slot file and failing in `restoreSlot`. + - **An offline load (`--offline`) of a path the file system cannot represent** throws the documented + `ModelUnavailableException` instead of an `InvalidPathException`; and `ChatStreamChunkParser` treats + an empty envelope as the end of the stream, as its Javadoc says. +- **`LlamaLoader` deletes only its own files from the temp directory.** The clean-up of the pre-6.0.0 + layout deleted, recursively, every entry of `java.io.tmpdir` (or `net.ladenthin.llama.tmpdir`) whose + name starts with `jllama`, `llama` or `ggml` -- a `/tmp/llama.cpp` checkout, a `/tmp/ggml-models/` + folder, a downloaded `/tmp/llama-2-7b.Q4_K_M.gguf`. It removes regular files with a library suffix + (`.so`, `.dll`, `.dylib`, `.jnilib`, `.metal`) now, nothing else. +- **`llama-ffm`.** `FfmTextToSpeech.close()` waits for a running `synthesize()` and + `FfmNativeServer.close()` for a running `isRunning()`, as their JNI twins do, instead of freeing the + native object under them; and an empty, non-null string array returned by the library is freed. +- **`llama-langchain4j`.** The blocking `JllamaChatModel` carries the reasoning as + `AiMessage.thinking()` like the streaming model; the streamed response carries the token usage (the + request now asks for the usage chunk); a streamed tool call no longer fails at its first fragment + without arguments or without a name (langchain4j's `PartialToolCall` refuses both, the fragment + still joins the assembled call); and several `TextContent`s of a user message are joined with a + newline instead of being glued together. - **A scaled LoRA adapter or control vector loads at all.** `ModelParameters.addLoraScaledAdapter` and `addControlVectorScaled` rendered their value as `FNAME,SCALE`, while llama.cpp has read these options as `FNAME:SCALE,...` since b7448 (#18056): the comma is the list separator, the colon the field @@ -656,7 +926,8 @@ the first two items break every existing consumer at upgrade. the model is already loaded; `OpenAiServerConfig.getModelId()` says so. - **A streamed `/v1/completions` is upstream's stream.** `OpenAiCompatServer` built the chunks of a streamed completion itself from the Java generator: no `usage` on the last chunk, no probabilities - (since F180 refused with a pointer to `stream: false`), and a prompt given as a token array refused + (a request for them was refused with a pointer to `stream: false`), and a prompt given as a token + array refused while the same request without `stream` was accepted. The body now goes to llama.cpp verbatim, as the blocking request does, and the chunks are upstream's own `text_completion` events. New `LlamaModel.streamCompletionsOai(String, Consumer)`, the C function @@ -671,7 +942,12 @@ the first two items break every existing consumer at upgrade. one prompt, and `/v1/completions` (and `handleCompletions`, the raw stream) took the first of `"prompt": ["a", "b"]` and dropped the rest without a word -- two prompts, one choice. Upstream answers each; here the request is now an invalid request (`400`) naming the count. A token array is - one prompt and stays accepted. + one prompt and stays accepted. **The same for `"n"` above 1:** llama.cpp's schema accepts up to the + slot count and answers the further completions in child tasks its server creates, which this + library's engine never did, so `n: 2` came back as one choice on every native path -- the C API, + `llama-ffm`, `LlamaModel.handle*`, both `/v1/completions` routes and the chat paths; the server's + request mapper had dropped `n` on the chat routes as well. It is an invalid request now, checked in + one place for the blocking, streamed and chat paths. - **`/v1/messages` reports the matched stop word as `stop_sequence`**, blocking and streamed, as upstream does (`stop_reason` stays `end_turn`); it was always `null`. The OpenAI answer the route translates from does not carry the word, so the translated request asks for llama.cpp's `verbose` @@ -693,7 +969,11 @@ the first two items break every existing consumer at upgrade. `missing-😀.gguf` came back as `missing-`, four wrong characters and `.g` -- and aborts the process under Android's CheckJNI. A non-ASCII message now crosses as bytes through `String(byte[], "UTF-8")` (`jni_utf8.hpp`), on every path that carries llama.cpp's text: the C API status, the model load, - the JNI exception boundary, `NativeServer`, `RpcServer` and `LlamaTrainer`. + the JNI exception boundary, `NativeServer`, `RpcServer` and `LlamaTrainer`. Called while a Java + exception is already pending (after a failed `GetStringUTFChars`, say), both helpers leave that + exception in place, as the JNI exception boundary already did, instead of replacing it with a less + precise one or calling JNI functions the specification forbids in that state, which Android's + CheckJNI aborts on. - **A LoRA adapter change no longer answers from the old cache** (`patches/0021`). After `setLoraAdapter(s)` -- and after a request with its own adapters -- a slot reused the prompt cache it had computed under the previous scales and answered like neither adapter set (measured with From 62958316e9b89efb8663b57d471bed50d5d6c3f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:59:56 +0000 Subject: [PATCH 361/589] Answer a non-object Ollama chat message with 400 (F213) handleOllamaChat translated the request before the try that maps a rejection to 400 and a failure to 500. The translator copies every messages element with a cast to ObjectNode, so a string, null or array element threw ClassCastException out of the handler; no headers had been sent, and the JDK server closed the connection without a status line, streamed or not. A messages value that was no array was silently read as no messages. The translator now refuses both with upstream's wording plus the index (common/chat.cpp: "Expected 'message' to be an object"), and the handler answers that IllegalArgumentException with 400 and Ollama's error body, as handleResponses does since F89. Any other runtime failure stays a logged server error. Red before the fix: the HTTP test failed with "SocketException: Unexpected end of file from server" and the translator test with "was: "; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++ .../llama/server/OllamaApiSupport.java | 12 +++++- .../llama/server/OpenAiCompatServer.java | 9 ++++- .../llama/server/OllamaApiSupportTest.java | 39 +++++++++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 22 +++++++++++ 5 files changed, 93 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a0ff63ffb..cfff6efc8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -135,6 +135,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F203](#f203) — niedrig — *gefixt* - [F204](#f204) — niedrig bis mittel — *gefixt* - [F205](#f205) — niedrig — *gefixt* +- [F213](#f213) — niedrig — *gefixt* ## Funde @@ -1635,6 +1636,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6c176d5` +### F213 – java-llama.cpp: Ollama `/api/chat` mit einem `messages`-Element, das kein Objekt ist, schließt die Verbindung ohne Antwort +- **Zeit:** 2026-10-20 19:55 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaChat`) +- **Schwere:** niedrig (eine Anfrage mit falscher Form bekommt statt 400 eine geschlossene Verbindung; sichtbar nur auf TRACE) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J1-1). Dieselbe Klasse wie F89 (`handleResponses` rief den Übersetzer außerhalb der Fehlerbehandlung auf); `/api/chat` war die letzte Ollama-Route mit diesem Aufbau. +- **Beschreibung:** `handleOllamaChat` rief `OllamaApiSupport.toOpenAiChatRequest` vor dem `try` auf, das Ablehnungen auf 400 und Fehler auf 500 abbildet. Der Übersetzer kopiert jedes Element von `messages` mit `ObjectNode message = ollamaMessage.deepCopy()`; für einen String, `null` oder ein Array wirft der vom Compiler eingefügte Cast eine `ClassCastException`. Sie verließ den Handler über `finally { exchange.close(); }` ohne gesendete Kopfzeilen, der JDK-Server schloss die Verbindung ohne Statuszeile und protokollierte nur auf TRACE — gestreamt wie ungestreamt (die Übersetzung liegt vor der Verzweigung). Ein `messages`, das kein Array ist, wurde zudem still als „keine Nachrichten“ gelesen. upstream (`common/chat.cpp`, b11639) lehnt beides mit `std::invalid_argument` ab, also 400. +- **Fix:** Der Übersetzer prüft die Form und wirft eine `IllegalArgumentException` mit upstreams Wortlaut plus Index (`Expected 'messages[1]' to be an object, got "hi"`, `Expected 'messages' to be an array, got "hi"`); fehlendes und `null` bleiben „keine Nachrichten“. `handleOllamaChat` fasst nur den Übersetzer in ein `try` und beantwortet die `IllegalArgumentException` mit 400 und Ollamas Fehlerkörper, wie `handleResponses` seit F89; eine andere `RuntimeException` bleibt ein protokollierter Serverfehler. +- **Tests:** `OpenAiCompatServerHttpTest.aNonObjectOllamaChatMessageIsA400NotADroppedConnection` (ungestreamt und gestreamt, das Backend wird nicht gerufen), `OllamaApiSupportTest.aNonObjectMessageIsRejectedWithItsIndex` (String, `null`, Array), `OllamaApiSupportTest.messagesThatAreNoArrayAreRejected`. +- **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, `aNonObjectMessageIsRejectedWithItsIndex` mit „Unexpected exception type thrown, expected: but was: “, `messagesThatAreNoArrayAreRejected` mit „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix grün (`OllamaApiSupportTest` 39, `OpenAiCompatServerHttpTest` 65, `OpenAiRequestMapperTest` 35, `LlamaArchitectureTest` 13), `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index d0c2203b2..904992f96 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -127,6 +127,8 @@ static boolean isStreaming(JsonNode ollamaRequest) { * * @param ollamaRequest the parsed Ollama request * @return an OpenAI {@code /v1/chat/completions} request object + * @throws IllegalArgumentException when {@code messages} is present but not an array, or one of its + * elements is not an object (upstream's {@code common/chat.cpp} refuses both the same way) */ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { ObjectNode openAi = OBJECT_MAPPER.createObjectNode(); @@ -137,7 +139,15 @@ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { // Messages: copy through, converting any assistant tool_calls.arguments object to the OpenAI // JSON-encoded string form. ArrayNode messages = openAi.putArray("messages"); - for (JsonNode message : ollamaRequest.path("messages")) { + JsonNode ollamaMessages = ollamaRequest.path("messages"); + if (!ollamaMessages.isArray() && !ollamaMessages.isMissingNode() && !ollamaMessages.isNull()) { + throw new IllegalArgumentException("Expected 'messages' to be an array, got " + ollamaMessages); + } + for (int i = 0; i < ollamaMessages.size(); i++) { + JsonNode message = ollamaMessages.get(i); + if (!message.isObject()) { + throw new IllegalArgumentException("Expected 'messages[" + i + "]' to be an object, got " + message); + } messages.add(toOpenAiMessage(message)); } diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 89797311b..5653e3ef5 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -542,7 +542,14 @@ private void handleOllamaChat(HttpExchange exchange) throws IOException { if (request == null) { return; } - JsonNode openAiRequest = OllamaApiSupport.toOpenAiChatRequest(request); + final JsonNode openAiRequest; + try { + openAiRequest = OllamaApiSupport.toOpenAiChatRequest(request); + } catch (IllegalArgumentException e) { + // A request the translation rejects (a message that is no object), answered like upstream. + sendJson(exchange, HTTP_BAD_REQUEST, ollamaError(message(e))); + return; + } String model = request.path("model").asText(config.getModelId()); if (OllamaApiSupport.isStreaming(request)) { streamOllamaChat(exchange, openAiRequest, model); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 3727f24fb..cd4a64791 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -5,7 +5,9 @@ package net.ladenthin.llama.server; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -455,4 +457,41 @@ public void aGenerateRequestWithoutAPromptOrSuffixOnlyLoadsTheModel() throws IOE assertThat(answer.path("done").asBoolean(false), is(true)); assertThat(answer.path("response").asText(null), is("")); } + + /** + * A {@code messages} element that is not an object (a string, {@code null}, an array) failed the + * translator's cast to an object with a {@link ClassCastException}, which left the HTTP handler + * without an answer. Upstream refuses it as {@code "Expected 'message' to be an object"} + * ({@code common/chat.cpp}); the translator now does the same and names the element. + */ + @Test + public void aNonObjectMessageIsRejectedWithItsIndex() throws IOException { + for (String element : new String[] {"\"hi\"", "null", "[1]"}) { + JsonNode request = read("{\"messages\":[{\"role\":\"user\",\"content\":\"a\"}," + element + "]}"); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> OllamaApiSupport.toOpenAiChatRequest(request)); + assertThat( + element, e.getMessage(), containsString("Expected 'messages[1]' to be an object, got " + element)); + } + } + + /** A {@code messages} that is present but no array was read as no messages at all. */ + @Test + public void messagesThatAreNoArrayAreRejected() throws IOException { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toOpenAiChatRequest(read("{\"messages\":\"hi\"}"))); + assertThat(e.getMessage(), is("Expected 'messages' to be an array, got \"hi\"")); + // absent and null stay "no messages", which the caller decides about + assertThat( + OllamaApiSupport.toOpenAiChatRequest(read("{}")) + .path("messages") + .size(), + is(0)); + assertThat( + OllamaApiSupport.toOpenAiChatRequest(read("{\"messages\":null}")) + .path("messages") + .size(), + is(0)); + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 89ce35ab9..e1e343425 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -736,6 +736,28 @@ public void nativeRejectionOnTheOllamaAndAnthropicSurfacesReturns400() throws IO } } + /** + * A non-object {@code messages} element made the Ollama chat translation throw a + * {@link ClassCastException} outside the handler's error handling: the connection was closed + * without a status line. It is a request the server refuses, so it is a 400, streamed or not. + */ + @Test + public void aNonObjectOllamaChatMessageIsA400NotADroppedConnection() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + for (String body : new String[] { + "{\"model\":\"test-model\",\"messages\":[\"hi\"],\"stream\":false}", + "{\"model\":\"test-model\",\"messages\":[\"hi\"]}" + }) { + Response response = post(server.getPort(), "/api/chat", body, ""); + assertThat(body, response.code, is(400)); + assertThat(body, response.body, containsString("{\"error\":")); + assertThat(body, response.body, containsString("messages[0]")); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + @Test public void aPlainLlamaExceptionStaysAServerError() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FailingBackend(), config()).start()) { From afc0be9714de4e4acf3da156bffe4360b56e4fcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:59:56 +0000 Subject: [PATCH 362/589] Record the commit of F213 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cfff6efc8..62cf13225 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1646,7 +1646,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.aNonObjectOllamaChatMessageIsA400NotADroppedConnection` (ungestreamt und gestreamt, das Backend wird nicht gerufen), `OllamaApiSupportTest.aNonObjectMessageIsRejectedWithItsIndex` (String, `null`, Array), `OllamaApiSupportTest.messagesThatAreNoArrayAreRejected`. - **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, `aNonObjectMessageIsRejectedWithItsIndex` mit „Unexpected exception type thrown, expected: but was: “, `messagesThatAreNoArrayAreRejected` mit „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix grün (`OllamaApiSupportTest` 39, `OpenAiCompatServerHttpTest` 65, `OpenAiRequestMapperTest` 35, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `538cafb` ## Offene Verdachte ohne Fund-ID From d00936bf43c66634e0c0142729cb72e99e3b11cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:09:43 +0000 Subject: [PATCH 363/589] Answer a backend IOException before the first chunk with 500 (F214) OpenAiBackend allows stream/streamCompletions to throw IOException when generation fails, and the blocking routes answer it with 500. The five streaming routes took any IOException for a client that had gone away: they logged it at DEBUG and wrote nothing. Since F182 the response starts with its first write, so such a failure ended the exchange with no headers and the JDK server dropped the connection without a status line. The IOException arms now answer 500 with the route's error body while the stream has not started, and keep the DEBUG line otherwise. ResponseStream records the start before it writes the headers, so a client that is noticed gone at the header write counts as started: the request is not answered a second time and a later write fails instead of sending the headers again. Red before the fix: the HTTP test over every streaming route failed with "SocketException: Unexpected end of file from server" and the ResponseStream test with "Expected: is but: was "; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++ .../llama/server/OpenAiCompatServer.java | 50 ++++++++++++++++--- .../server/OpenAiCompatServerHttpTest.java | 33 ++++++++++++ .../llama/server/ResponseStreamTest.java | 33 +++++++++++- 4 files changed, 120 insertions(+), 9 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 62cf13225..6ac817f0d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -136,6 +136,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F204](#f204) — niedrig bis mittel — *gefixt* - [F205](#f205) — niedrig — *gefixt* - [F213](#f213) — niedrig — *gefixt* +- [F214](#f214) — niedrig — *gefixt* ## Funde @@ -1648,6 +1649,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `538cafb` +### F214 – java-llama.cpp: Eine `IOException` des Backends vor dem ersten Chunk gilt als Verbindungsabbruch — keine Antwort, nur DEBUG +- **Zeit:** 2026-10-20 20:15 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`; die `IOException`-Zweige von `streamCompletions`, `streamChat`, `streamOllamaChat`, `streamAnthropic`, `streamResponses`) +- **Schwere:** niedrig (nur über die öffentliche Schnittstelle `OpenAiBackend` erreichbar; `LlamaModelBackend` reicht nur die `IOException` der Senke weiter) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J1-2). Eine Folge von F182: vorher sendete jede Streaming-Route die Kopfzeilen vor dem Backend-Aufruf, der Client sah wenigstens ein 200 mit leerem Stream. +- **Beschreibung:** `OpenAiBackend.stream`/`streamCompletions` dürfen laut Javadoc eine `IOException` werfen, wenn „generation fails“; der ungestreamte Pfad beantwortet sie mit 500. Die fünf Streaming-Routen fingen sie mit `LOG.debug("client disconnected during stream", e)` und schrieben nichts. Seit F182 beginnt die Antwort erst mit dem ersten Schreiben, also endete eine solche Anfrage mit `exchange.close()` ohne Kopfzeilen — der JDK-Server schloss die Verbindung ohne Statuszeile, entgegen dem CHANGELOG-Eintrag zu F182 („a failure before it is `400` or `500` with the route's own error body“). Dazu: Ein Client, der gegangen ist, fällt oft erst beim Schreiben der Kopfzeilen auf; `answerIfNotStarted` sah dann `os == null` und hätte die Anfrage ein zweites Mal beantworten wollen. +- **Fix:** Die fünf `IOException`-Zweige beantworten die Anfrage mit 500 und dem Fehlerkörper der Route, solange der Stream nicht begonnen hat (WARN), und bleiben sonst beim DEBUG für den Abbruch. `ResponseStream` merkt sich den Start vor dem Schreiben der Kopfzeilen (`started`), sodass ein dort gescheiterter Start als begonnen gilt: `answerIfNotStarted` gibt `false`, ein weiteres Schreiben wirft, statt die Kopfzeilen erneut zu senden. `answerIfNotStarted` beantwortet eine schon beantwortete Anfrage nicht ein zweites Mal. +- **Tests:** `OpenAiCompatServerHttpTest.aBackendIOExceptionBeforeTheFirstChunkIsA500OnEveryStreamingRoute` (alle fünf Streaming-Routen, `IoFailingBackend`), `ResponseStreamTest.aStreamWhoseHeadersCouldNotBeWrittenHasStarted`. +- **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, der `ResponseStream`-Test mit „Expected: is but: was “ (`answerIfNotStarted` nach gescheitertem Kopfzeilen-Schreiben); mit dem Fix grün (`ResponseStreamTest` 5, `OpenAiCompatServerHttpTest` 66, `OllamaApiSupportTest` 39, `LlamaArchitectureTest` 13). Modellgestützt mit dem Winzmodell `OpenAiCompatServerIntegrationTest` 13/13 grün; in `OpenAiServerCompletionIntegrationTest` scheitern nur die beiden Infill-Fälle (500, das Winzmodell hat keine FIM-Token), die keine Streaming-Route berühren. `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 5653e3ef5..fa78770c1 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -381,7 +381,12 @@ private void streamCompletions(HttpExchange exchange, JsonNode request) throws I OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))); } } catch (IOException e) { - LOG.debug("client disconnected during stream", e); + if (out.answerIfNotStarted( + HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) { + LOG.warn("streaming completion failed before the first chunk", e); + } else { + LOG.debug("client disconnected during stream", e); + } } catch (RuntimeException e) { LOG.warn("streaming completion failed", e); if (!out.answerIfNotStarted( @@ -475,7 +480,12 @@ private void streamChat(HttpExchange exchange, JsonNode request) throws IOExcept OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null))); } } catch (IOException e) { - LOG.debug("client disconnected during stream", e); + if (out.answerIfNotStarted( + HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) { + LOG.warn("streaming chat completion failed before the first chunk", e); + } else { + LOG.debug("client disconnected during stream", e); + } } catch (RuntimeException e) { LOG.warn("streaming chat completion failed", e); if (!out.answerIfNotStarted( @@ -603,7 +613,11 @@ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, Str out.writeQuietly(ollamaError(message(e)) + "\n"); } } catch (IOException e) { - LOG.debug("ollama client disconnected during stream", e); + if (out.answerIfNotStarted(HTTP_SERVER_ERROR, ollamaError(message(e)))) { + LOG.warn("ollama streaming chat failed before the first chunk", e); + } else { + LOG.debug("ollama client disconnected during stream", e); + } } catch (RuntimeException e) { LOG.warn("ollama streaming chat failed", e); if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, ollamaError(message(e)))) { @@ -728,7 +742,11 @@ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, Stri out.writeQuietly(AnthropicApiSupport.sseEvent("error", error)); } } catch (IOException e) { - LOG.debug("anthropic client disconnected during stream", e); + if (out.answerIfNotStarted(HTTP_SERVER_ERROR, anthropicError(message(e), ANTHROPIC_ERROR_SERVER))) { + LOG.warn("anthropic streaming failed before the first event", e); + } else { + LOG.debug("anthropic client disconnected during stream", e); + } } catch (RuntimeException e) { LOG.warn("anthropic streaming failed", e); String error = anthropicError(message(e), ANTHROPIC_ERROR_SERVER); @@ -829,7 +847,12 @@ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, Stri + "\n\n"); } } catch (IOException e) { - LOG.debug("responses client disconnected during stream", e); + if (out.answerIfNotStarted( + HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) { + LOG.warn("responses streaming failed before the first event", e); + } else { + LOG.debug("responses client disconnected during stream", e); + } } catch (RuntimeException e) { LOG.warn("responses streaming failed", e); String error = OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null); @@ -1088,6 +1111,12 @@ static final class ResponseStream implements AutoCloseable { private final Object lock = new Object(); private String prelude = ""; private @Nullable OutputStream os; + /** + * Set before the headers are written: once a start was attempted the request cannot be answered + * any more, even when the header write failed (a client that went away is often noticed there). + */ + private boolean started; + private boolean answered; ResponseStream(HttpExchange exchange, String contentType) { @@ -1121,6 +1150,10 @@ void startWith(String text) { } OutputStream out = os; if (out == null) { + if (started) { + throw new IOException("the " + contentType + " stream failed to start"); + } + started = true; exchange.getResponseHeaders().set("Content-Type", contentType); exchange.getResponseHeaders().set("Cache-Control", "no-cache"); exchange.sendResponseHeaders(HTTP_OK, 0); @@ -1165,12 +1198,13 @@ void writeQuietly(String text) { * Answer the request with {@code status} and a JSON body instead of a stream, if the stream has * not started yet. * - * @return {@code false} when the stream had already started; the caller then reports the error - * as an event of the stream + * @return {@code false} when the stream had already started (or failed to start), or the request + * was already answered; the caller then reports the error as an event of the stream, which is + * dropped once the request was answered */ boolean answerIfNotStarted(int status, String json) { synchronized (lock) { - if (os != null) { + if (started || answered) { return false; } answered = true; diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index e1e343425..058aa3837 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -655,6 +655,24 @@ public void aFailureBeforeTheFirstChunkIsA500OnEveryStreamingRoute() throws IOEx } } + /** + * {@link OpenAiBackend} lets a backend report a generation failure as an {@link IOException}. Since + * the response starts with its first write, such a failure before it left the request without any + * response: the route took it for a client that had gone away. It is a 500 with the route's error + * body, as without {@code stream}. + */ + @Test + public void aBackendIOExceptionBeforeTheFirstChunkIsA500OnEveryStreamingRoute() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new IoFailingBackend(), config()).start()) { + for (String[] route : STREAMING_ROUTES) { + Response response = post(server.getPort(), route[0], route[1], ""); + assertThat(route[0], response.code, is(500)); + assertThat(route[0], response.body, containsString(route[3])); + assertThat(route[0], response.body, containsString(IoFailingBackend.REASON)); + } + } + } + @Test public void aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent() throws IOException { ScriptedStreamBackend backend = @@ -1145,6 +1163,21 @@ public void streamCompletions(JsonNode request, ChunkSink sink) { } } + /** Backend whose streamed generation fails with an {@link IOException}, which the backend contract allows. */ + static final class IoFailingBackend extends FakeBackend { + static final String REASON = "disk full"; + + @Override + public void stream(JsonNode request, ChunkSink sink) throws IOException { + throw new IOException(REASON); + } + + @Override + public void streamCompletions(JsonNode request, ChunkSink sink) throws IOException { + throw new IOException(REASON); + } + } + /** Backend whose streams emit {@code chunks} chunks, wait {@code delayMillis}, then fail with {@code failure}. */ static final class ScriptedStreamBackend extends FakeBackend { private final int chunks; diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java index 2b9b48158..7705b29a2 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java @@ -5,6 +5,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpContext; @@ -12,6 +13,7 @@ import com.sun.net.httpserver.HttpPrincipal; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; @@ -32,6 +34,10 @@ private static final class RecordingExchange extends HttpExchange { final Headers responseHeaders = new Headers(); final ByteArrayOutputStream body = new ByteArrayOutputStream(); int status = -1; + int headerWrites; + /** When set, writing the headers fails like a client that has gone away. */ + @Nullable + IOException headerFailure; @Override public Headers getRequestHeaders() { @@ -72,7 +78,12 @@ public OutputStream getResponseBody() { } @Override - public void sendResponseHeaders(int rCode, long responseLength) { + public void sendResponseHeaders(int rCode, long responseLength) throws IOException { + headerWrites++; + IOException failure = headerFailure; + if (failure != null) { + throw failure; + } status = rCode; } @@ -176,4 +187,24 @@ void anErrorAfterTheFirstWriteIsLeftToTheStream() throws Exception { assertThat(exchange.status, is(200)); } + + /** + * A client that went away is often noticed only when the headers are written. The stream had + * started then, so the route must not take it for a failure before the first write: it would + * answer the request a second time (which fails as well) and log a disconnect as a server error. + */ + @Test + void aStreamWhoseHeadersCouldNotBeWrittenHasStarted() throws Exception { + RecordingExchange exchange = new RecordingExchange(); + exchange.headerFailure = new IOException("Broken pipe"); + try (OpenAiCompatServer.ResponseStream out = + new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) { + assertThrows(IOException.class, () -> out.writeStrict("DATA\n")); + assertThat(out.answerIfNotStarted(500, "{\"error\":\"x\"}"), is(false)); + out.writeQuietly(": ping\n"); + assertThrows(IOException.class, () -> out.writeStrict("DATA\n")); + } + + assertThat("the headers were written once", exchange.headerWrites, is(1)); + } } From ad113e9a79499e4594f50f3fbd80dae9ac77cfea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:09:43 +0000 Subject: [PATCH 364/589] Record the commit of F214 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6ac817f0d..163da8fd7 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1659,7 +1659,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.aBackendIOExceptionBeforeTheFirstChunkIsA500OnEveryStreamingRoute` (alle fünf Streaming-Routen, `IoFailingBackend`), `ResponseStreamTest.aStreamWhoseHeadersCouldNotBeWrittenHasStarted`. - **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, der `ResponseStream`-Test mit „Expected: is but: was “ (`answerIfNotStarted` nach gescheitertem Kopfzeilen-Schreiben); mit dem Fix grün (`ResponseStreamTest` 5, `OpenAiCompatServerHttpTest` 66, `OllamaApiSupportTest` 39, `LlamaArchitectureTest` 13). Modellgestützt mit dem Winzmodell `OpenAiCompatServerIntegrationTest` 13/13 grün; in `OpenAiServerCompletionIntegrationTest` scheitern nur die beiden Infill-Fälle (500, das Winzmodell hat keine FIM-Token), die keine Streaming-Route berühren. `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `03392c7` ## Offene Verdachte ohne Fund-ID From 7933eb06bd042d9f16f4405e22373239c9d1aae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:20:52 +0000 Subject: [PATCH 365/589] Refuse Ollama raw generate with system, template or context (F215) The raw branch of /api/generate sent only the prompt and the sampling options to the completion endpoint; a system, template or context next to "raw": true was dropped without a word, and the CHANGELOG, the F179 ledger entry and a test described that as Ollama's behaviour. Ollama's GenerateHandler refuses exactly that combination first, before its load answer and before the suffix path, with 400 "raw mode does not support template, system, or context". hasRawConflict checks Ollama's condition (non-empty system or template, non-empty context; empty values are no conflict), and the handler answers it right after reading the body with that 400 and without calling the backend, streamed or not. The test that pinned the silent drop is replaced by one expecting the refusal, next to a raw request without a conflict; the CHANGELOG line and the F179 fix line are corrected. Red before the fix: "Expected: is <400> but: was <200>" for a raw request with a system prompt; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 17 ++++++++-- CHANGELOG.md | 13 ++++---- .../llama/server/OllamaApiSupport.java | 25 ++++++++++++++- .../llama/server/OpenAiCompatServer.java | 5 +++ .../llama/server/OllamaApiSupportTest.java | 26 +++++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 32 +++++++++++++++++-- 6 files changed, 106 insertions(+), 12 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 163da8fd7..6e9f38453 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -137,6 +137,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F205](#f205) — niedrig — *gefixt* - [F213](#f213) — niedrig — *gefixt* - [F214](#f214) — niedrig — *gefixt* +- [F215](#f215) — niedrig — *gefixt* ## Funde @@ -1369,7 +1370,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** niedrig (still: ein Instruct-Modell setzt den Text fort statt zu antworten, `system` und Bilder gingen verloren) - **Historie:** Offener Verdacht vom 2026-10-18 (TODO (16)). Die Route war seit ihrer Einführung „prompt completion / FIM“. Ollama wendet ohne `"raw": true` die Chat-Vorlage des Modells an, mit `system` als Systemnachricht; Continue (`Ollama.ts`) setzt `raw` nur, wenn der Aufrufer es verlangt. - **Beschreibung:** Jede Anfrage ohne `suffix` ging als roher Prompt an `/v1/completions`; `system`, `images`, `format` und `think` wurden ignoriert. -- **Fix:** Entscheidung des Nutzers: Ollamas Semantik. Mit `suffix` wie bisher Infill, mit `raw: true` wie bisher die rohe Completion (`system` ignoriert, wie bei Ollama), sonst der Chat-Weg: aus der Anfrage wird eine `/api/chat`-Anfrage (`system` + Nutzer-`prompt` mit `images`, dazu `options`, `format`, `think`) und über die vorhandene Übersetzung geschickt; die Antwort trägt `response`, `thinking` (aus `reasoning_content`), `done_reason` und die Zählungen. README und `package-info` beschreiben die drei Wege; der modellgestützte `OpenAiServerCompletionIntegrationTest` bekam eine `raw`-Variante. +- **Fix:** Entscheidung des Nutzers: Ollamas Semantik. Mit `suffix` wie bisher Infill, mit `raw: true` wie bisher die rohe Completion (`system` ignoriert — **korrigiert in F215:** Ollama ignoriert es nicht, sondern lehnt `raw` mit `system`, `template` oder `context` mit 400 ab; so jetzt auch hier), sonst der Chat-Weg: aus der Anfrage wird eine `/api/chat`-Anfrage (`system` + Nutzer-`prompt` mit `images`, dazu `options`, `format`, `think`) und über die vorhandene Übersetzung geschickt; die Antwort trägt `response`, `thinking` (aus `reasoning_content`), `done_reason` und die Zählungen. README und `package-info` beschreiben die drei Wege; der modellgestützte `OpenAiServerCompletionIntegrationTest` bekam eine `raw`-Variante. - **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama` (Aufzeichnungs-Backend: Chat-Weg, `system`, Bild, `thinking`, Zählung), `#ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawRoundTrip` (Modell, CI). - **Rot-Grün-Nachweis:** ohne den Fix rot (`was "completions"`), mit ihm grün (96 Tests); der F178-Test gab nun explizit `raw: true` an, weil er die Completion-Abbildung prüft; SpotBugs grün. Die modellgestützte Variante läuft nur in CI bzw. beim lokalen Agenten. - **Status:** gefixt @@ -1650,7 +1651,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Commit:** java-llama.cpp `538cafb` ### F214 – java-llama.cpp: Eine `IOException` des Backends vor dem ersten Chunk gilt als Verbindungsabbruch — keine Antwort, nur DEBUG -- **Zeit:** 2026-10-20 20:15 UTC +- **Zeit:** 2026-10-20 20:09 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`; die `IOException`-Zweige von `streamCompletions`, `streamChat`, `streamOllamaChat`, `streamAnthropic`, `streamResponses`) - **Schwere:** niedrig (nur über die öffentliche Schnittstelle `OpenAiBackend` erreichbar; `LlamaModelBackend` reicht nur die `IOException` der Senke weiter) - **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J1-2). Eine Folge von F182: vorher sendete jede Streaming-Route die Kopfzeilen vor dem Backend-Aufruf, der Client sah wenigstens ein 200 mit leerem Stream. @@ -1661,6 +1662,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `03392c7` +### F215 – java-llama.cpp: Ollama `/api/generate` mit `raw: true` verwirft `system`, `template` und `context` still — Ollama antwortet 400 +- **Zeit:** 2026-10-20 20:20 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `hasRawConflict`, `RAW_CONFLICT_MESSAGE`; Javadoc von `isRaw`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaGenerate`) +- **Schwere:** niedrig (der Client bekommt eine plausible Antwort, die seinen Systemprompt nie gesehen hat) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Funde J1-3, J3-2, J16-1 — derselbe Fehler). Eingeführt mit F179, dessen Fix-Zeile, der CHANGELOG-Eintrag und der Test `ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem` das stille Verwerfen als Ollamas Verhalten beschrieben bzw. festschrieben. +- **Beschreibung:** Der `raw`-Zweig von `handleOllamaGenerate` schickte `toOpenAiCompletionRequest` an das Backend, das nur `prompt`, Sampling und `stop` übernimmt; `system`, `template` und `context` gingen ohne Meldung verloren. Ollamas `GenerateHandler` (`server/routes.go`, seit v0.3.0 unverändert) prüft zuerst `if req.Raw && (req.Template != "" || req.System != "" || len(req.Context) > 0)` und antwortet 400 `{"error":"raw mode does not support template, system, or context"}` — vor der Ladeantwort und vor dem Suffix-Weg. +- **Fix:** `OllamaApiSupport.hasRawConflict` prüft genau Ollamas Bedingung (nicht leeres `system`/`template`, nicht leeres `context`-Array; leere Werte sind kein Konflikt); `handleOllamaGenerate` beantwortet einen Konflikt direkt nach dem Einlesen, vor Ladeantwort und FIM, mit 400 und Ollamas Wortlaut, ohne das Backend zu rufen — gestreamt wie ungestreamt. CHANGELOG-Zeile und die Fix-Zeile von F179 sind korrigiert. Nicht Teil dieses Fixes: `format` und `images` im `raw`-Weg (Ollama behält sie, hier fallen sie weg) und `template`/`context` im Weg ohne `raw` (Ollama wendet sie an) — beides offen. +- **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithRawAndASystemPromptIsRejectedLikeOllama` (`system`, `template`, `context`, mit `suffix`, ohne Prompt; das Backend wird nicht gerufen) ersetzt `ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `ollamaGenerateWithRawCompletesTheBarePrompt` (der Weg ohne Konflikt); `OllamaApiSupportTest.rawWithSystemTemplateOrContextConflictsLikeOllama` (vier Konflikte, vier zulässige Formen). +- **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit einem `hasRawConflict`, das `false` liefert) rot: der HTTP-Test mit „Expected: is <400> but: was <200>“ für `{"model":"test-model","prompt":"once upon","system":"x","raw":true,"stream":false}`, der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 40, `OpenAiCompatServerHttpTest` 67, `LlamaArchitectureTest` 13), `spotbugs:check` und `javadoc:javadoc` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef136c9e..8b3acd35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -330,12 +330,13 @@ the first two items break every existing consumer at upgrade. at once, and throws the first failure of any kind only after every request has finished (an `Error` used to end the wait while other requests still ran). - **Ollama `/api/generate` follows Ollama's semantics.** It completed every prompt raw; now a request - with `suffix` is fill-in-the-middle as before, one with `"raw": true` a bare completion (`system` - ignored, as in Ollama), and any other goes through the model's chat template with `system` as the - system message and the prompt's `images`, `format` and `think` honoured, answering `thinking` next - to `response`. A request with neither a prompt nor a suffix -- how clients preload a model -- is - answered at once with Ollama's load response (`done: true`, empty `response`, `done_reason` - `"load"`; a single object even when streamed) instead of answering an empty user turn. + with `suffix` is fill-in-the-middle as before, one with `"raw": true` a bare completion (a `system`, + `template` or `context` next to it is refused with 400, as Ollama does), and any other goes through + the model's chat template with `system` as the system message and the prompt's `images`, `format` + and `think` honoured, answering `thinking` next to `response`. A request with neither a prompt nor + a suffix -- how clients preload a model -- is answered at once with Ollama's load response + (`done: true`, empty `response`, `done_reason` `"load"`; a single object even when streamed) + instead of answering an empty user turn. - **`OpenAiCompatServer --model-id` / `--model-alias` take a value llama.cpp keeps as one alias.** llama.cpp splits `--alias` at commas and strips every part, so `--model-id my,model` was advertised as `my,model` in `/v1/models` and named `model` in every answer, and a blank id fell back to the diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 904992f96..7a1acb349 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -31,6 +31,9 @@ final class OllamaApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** Ollama's answer to {@code "raw": true} with a {@code system}, {@code template} or {@code context}. */ + static final String RAW_CONFLICT_MESSAGE = "raw mode does not support template, system, or context"; + /** * Advertised Ollama API version. Clients gate on it: VS Code Copilot's Ollama provider refuses a server * below 0.6.4. 0.9.0 is the first Ollama with {@code think}, the newest request feature these routes @@ -565,7 +568,8 @@ static String toOllamaLoadResponse(String model) { /** * Whether the {@code /api/generate} request asks for the bare prompt ({@code "raw": true}). Without - * it Ollama applies the model's chat template, with {@code system} as the system message. + * it Ollama applies the model's chat template, with {@code system} as the system message; with it a + * {@code system}, {@code template} or {@code context} is refused ({@link #hasRawConflict}). * * @param request the parsed Ollama generate request * @return {@code true} if {@code raw} is {@code true} @@ -574,6 +578,25 @@ static boolean isRaw(JsonNode request) { return request.path("raw").asBoolean(false); } + /** + * Whether the {@code /api/generate} request combines {@code "raw": true} with a non-empty + * {@code system} or {@code template} or a non-empty {@code context}, which Ollama refuses with + * {@value #RAW_CONFLICT_MESSAGE} before anything else (its {@code GenerateHandler}): a raw prompt + * goes to the model unformatted, so there is nothing to apply them to. + * + * @param request the parsed Ollama generate request + * @return {@code true} if the request must be refused + */ + static boolean hasRawConflict(JsonNode request) { + if (!isRaw(request)) { + return false; + } + JsonNode context = request.path("context"); + return !request.path("system").asText("").isEmpty() + || !request.path("template").asText("").isEmpty() + || (context.isArray() && context.size() > 0); + } + /** * Translate an Ollama {@code /api/generate} request without {@code raw} into the internal OpenAI chat * request, as Ollama applies the chat template: {@code system} (when given) as the system message, diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index fa78770c1..dea392f11 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -633,6 +633,11 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { if (request == null) { return; } + if (OllamaApiSupport.hasRawConflict(request)) { + // refused before the load answer and fill-in-the-middle, in Ollama's order + sendJson(exchange, HTTP_BAD_REQUEST, ollamaError(OllamaApiSupport.RAW_CONFLICT_MESSAGE)); + return; + } String model = request.path("model").asText(config.getModelId()); if (OllamaApiSupport.isLoadOnly(request)) { // Ollama's preload: no prompt means "load the model", answered at once diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index cd4a64791..e78c8adc9 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -494,4 +494,30 @@ public void messagesThatAreNoArrayAreRejected() throws IOException { .size(), is(0)); } + + /** + * Ollama refuses {@code "raw": true} together with a {@code system}, a {@code template} or a + * {@code context} ({@code GenerateHandler}: "raw mode does not support template, system, or + * context"); empty values are no conflict, as {@code req.System != ""} and {@code len(req.Context) > 0} + * read them there. The server dropped them silently and answered as if they had not been sent. + */ + @Test + public void rawWithSystemTemplateOrContextConflictsLikeOllama() throws IOException { + for (String conflict : new String[] { + "{\"prompt\":\"p\",\"raw\":true,\"system\":\"x\"}", + "{\"prompt\":\"p\",\"raw\":true,\"template\":\"{{ .Prompt }}\"}", + "{\"prompt\":\"p\",\"raw\":true,\"context\":[1,2]}", + "{\"raw\":true,\"system\":\"x\"}" + }) { + assertThat(conflict, OllamaApiSupport.hasRawConflict(read(conflict)), is(true)); + } + for (String accepted : new String[] { + "{\"prompt\":\"p\",\"raw\":true}", + "{\"prompt\":\"p\",\"raw\":true,\"system\":\"\",\"template\":\"\",\"context\":[]}", + "{\"prompt\":\"p\",\"raw\":false,\"system\":\"x\",\"context\":[1]}", + "{\"prompt\":\"p\",\"system\":\"x\"}" + }) { + assertThat(accepted, OllamaApiSupport.hasRawConflict(read(accepted)), is(false)); + } + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 058aa3837..fcc9c9b64 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -453,11 +453,10 @@ public void ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama() throws IO } @Test - public void ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem() throws IOException { + public void ollamaGenerateWithRawCompletesTheBarePrompt() throws IOException { RecordingBackend backend = new RecordingBackend(); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { - String body = "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"system\":\"x\"," - + "\"raw\":true,\"stream\":false}"; + String body = "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"raw\":true,\"stream\":false}"; JsonNode out = READER.readTree(post(server.getPort(), "/api/generate", body, "").body); assertThat(backend.called, is("completions")); @@ -467,6 +466,33 @@ public void ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem() throws } } + /** + * Ollama refuses {@code "raw": true} next to a {@code system}, {@code template} or {@code context} + * with a 400, before its load answer and before fill-in-the-middle. The server dropped them silently + * and completed the bare prompt. + */ + @Test + public void ollamaGenerateWithRawAndASystemPromptIsRejectedLikeOllama() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + for (String body : new String[] { + "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"system\":\"x\",\"raw\":true,\"stream\":false}", + "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"template\":\"t\",\"raw\":true}", + "{\"model\":\"test-model\",\"prompt\":\"once upon\",\"context\":[1],\"raw\":true,\"stream\":false}", + "{\"model\":\"test-model\",\"prompt\":\"def f():\",\"suffix\":\"\",\"system\":\"x\",\"raw\":true}", + "{\"model\":\"test-model\",\"system\":\"x\",\"raw\":true}" + }) { + Response response = post(server.getPort(), "/api/generate", body, ""); + assertThat(body, response.code, is(400)); + assertThat( + body, + response.body, + is("{\"error\":\"raw mode does not support template, system, or context\"}")); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + /** * Ollama answers an {@code /api/generate} without a prompt by loading the model, which is how its * clients preload one: {@code done} at once, empty text, {@code done_reason "load"}. Ours sent an From e07155bf2dc4d06cd0bebcb024666a0df3c9370f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:20:52 +0000 Subject: [PATCH 366/589] Record the commit of F215 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6e9f38453..ec3e43c00 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1672,7 +1672,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithRawAndASystemPromptIsRejectedLikeOllama` (`system`, `template`, `context`, mit `suffix`, ohne Prompt; das Backend wird nicht gerufen) ersetzt `ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `ollamaGenerateWithRawCompletesTheBarePrompt` (der Weg ohne Konflikt); `OllamaApiSupportTest.rawWithSystemTemplateOrContextConflictsLikeOllama` (vier Konflikte, vier zulässige Formen). - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit einem `hasRawConflict`, das `false` liefert) rot: der HTTP-Test mit „Expected: is <400> but: was <200>“ für `{"model":"test-model","prompt":"once upon","system":"x","raw":true,"stream":false}`, der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 40, `OpenAiCompatServerHttpTest` 67, `LlamaArchitectureTest` 13), `spotbugs:check` und `javadoc:javadoc` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `d94fbd7` ## Offene Verdachte ohne Fund-ID From a4d060429ec84c177adcd5fc0d5ba199aada6110 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:27:24 +0000 Subject: [PATCH 367/589] Answer an Ollama chat without messages as a model load (F216) Ollama answers /api/chat without messages (absent, null or empty) by loading the model: one object with an empty assistant message, done and done_reason "load", whatever "stream" says. Clients preload a model that way through the chat route. Here the request was translated to an empty conversation, which the chat mapper refused with 400. handleOllamaChat now answers such a request before the translation and without calling the backend, as handleOllamaGenerate does since F205; keep_alive 0 gets the load answer too, since this server's model stays loaded. A messages value of the wrong type is still refused by the translation. The test backend records stream calls as well, so the test can show that nothing was generated on the streaming path. Red before the fix: "Expected: is but: was " for done on a body without messages; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++ .../llama/server/OllamaApiSupport.java | 33 ++++++++++++++++++ .../llama/server/OpenAiCompatServer.java | 9 +++++ .../llama/server/OllamaApiSupportTest.java | 24 +++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 34 +++++++++++++++++++ 5 files changed, 113 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ec3e43c00..6b9020b22 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -138,6 +138,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F213](#f213) — niedrig — *gefixt* - [F214](#f214) — niedrig — *gefixt* - [F215](#f215) — niedrig — *gefixt* +- [F216](#f216) — niedrig — *gefixt* ## Funde @@ -1674,6 +1675,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d94fbd7` +### F216 – java-llama.cpp: Ollama `/api/chat` ohne Nachrichten (Ollamas Vorladen) wird mit 400 abgewiesen statt mit der Ladeantwort beantwortet +- **Zeit:** 2026-10-20 20:27 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `isChatLoadOnly`, `toOllamaChatLoadResponse`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaChat`) +- **Schwere:** niedrig (Clients, die über die Chat-Route vorladen — `ollama-python` `chat(model)` ohne Nachrichten, Open WebUI —, bekommen einen Fehler) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J3-1). Das Gegenstück zu F205, das die Ladeantwort nur für `/api/generate` einführte. +- **Beschreibung:** Ollama beantwortet ein `/api/chat` ohne Nachrichten mit dem Laden des Modells (`ChatHandler`: `if len(req.Messages) == 0` → `message:{role:"assistant",content:""}`, `done: true`, `done_reason "load"`, ein Objekt ohne Blick auf `stream`; in Go ist `len` eines fehlenden oder `null`-Slices 0). Hier übersetzte `handleOllamaChat` die Anfrage zu `messages: []`, und `OpenAiRequestMapper` wies sie mit „'messages' must be a non-empty array“ ab → 400, auch im Streaming-Weg, den ein Body ohne `stream` nimmt. +- **Fix:** `isChatLoadOnly` (`messages` fehlt, ist `null` oder ein leeres Array; ein `messages` falschen Typs ist kein Vorladen und wird von der Übersetzung abgewiesen, F213) und `toOllamaChatLoadResponse` (Ollamas Form); `handleOllamaChat` antwortet damit vor der Übersetzung und ohne das Backend, auch bei `stream` ein einzelnes Objekt. `keep_alive: 0` (bei Ollama das Entladen) bekommt wie bei F205 die Ladeantwort: das Modell dieses Servers bleibt geladen. `/v1/chat/completions` weist leere `messages` weiter ab. +- **Tests:** `OpenAiCompatServerHttpTest.ollamaChatWithoutMessagesOnlyReportsTheModelLoaded` (ohne `messages`, `[]`, `null` mit `stream`, `keep_alive: 0`; das Backend wird nicht gerufen — `RecordingBackend` zeichnet dafür jetzt auch `stream` auf), `OllamaApiSupportTest.aChatRequestWithoutMessagesOnlyLoadsTheModel`. +- **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit Stubs, die `false` bzw. `{}` liefern) rot: der HTTP-Test mit „Expected: is but: was “ für `done` bei `{"model":"test-model"}` (das Testbackend streamte eine Antwort; das echte Backend weist die leere Konversation mit 400 ab), der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 41, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 7a1acb349..d3f27371b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -566,6 +566,39 @@ static String toOllamaLoadResponse(String model) { return root.toString(); } + /** + * Whether the {@code /api/chat} request only asks for the model to be loaded: Ollama answers a chat + * without messages ({@code messages} absent, {@code null} or empty) with {@code done} at once, which + * is how its clients preload a model through the chat route. Ollama unloads instead when + * {@code keep_alive} is {@code 0}; the model of this server stays loaded, so that is a load answer + * here too, as on {@code /api/generate}. + * + * @param request the parsed Ollama chat request + * @return {@code true} when the request carries no messages + */ + static boolean isChatLoadOnly(JsonNode request) { + JsonNode messages = request.path("messages"); + return messages.isMissingNode() || messages.isNull() || (messages.isArray() && messages.size() == 0); + } + + /** + * The answer to a load-only {@code /api/chat} request, as Ollama gives it: one object with an empty + * assistant message, {@code done} and {@code done_reason "load"}, whether streaming was asked for or + * not. + * + * @param model the model id to echo + * @return the answer + */ + static String toOllamaChatLoadResponse(String model) { + ObjectNode root = OBJECT_MAPPER.createObjectNode(); + root.put("model", model); + root.put("created_at", nowIso()); + root.putObject("message").put("role", "assistant").put("content", ""); + root.put("done", true); + root.put("done_reason", "load"); + return root.toString(); + } + /** * Whether the {@code /api/generate} request asks for the bare prompt ({@code "raw": true}). Without * it Ollama applies the model's chat template, with {@code system} as the system message; with it a diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index dea392f11..60f3f8eec 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -552,6 +552,15 @@ private void handleOllamaChat(HttpExchange exchange) throws IOException { if (request == null) { return; } + if (OllamaApiSupport.isChatLoadOnly(request)) { + // Ollama's preload through the chat route: no messages means "load the model" + sendJson( + exchange, + HTTP_OK, + OllamaApiSupport.toOllamaChatLoadResponse( + request.path("model").asText(config.getModelId()))); + return; + } final JsonNode openAiRequest; try { openAiRequest = OllamaApiSupport.toOpenAiChatRequest(request); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index e78c8adc9..15e9ff624 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -520,4 +520,28 @@ public void rawWithSystemTemplateOrContextConflictsLikeOllama() throws IOExcepti assertThat(accepted, OllamaApiSupport.hasRawConflict(read(accepted)), is(false)); } } + + /** + * Ollama answers an {@code /api/chat} without messages by loading the model ({@code ChatHandler}: + * {@code len(req.Messages) == 0}, which absent, {@code null} and {@code []} all are), the chat twin + * of the {@code /api/generate} preload. + */ + @Test + public void aChatRequestWithoutMessagesOnlyLoadsTheModel() throws IOException { + assertThat(OllamaApiSupport.isChatLoadOnly(read("{\"model\":\"m\"}")), is(true)); + assertThat(OllamaApiSupport.isChatLoadOnly(read("{\"messages\":null}")), is(true)); + assertThat(OllamaApiSupport.isChatLoadOnly(read("{\"messages\":[]}")), is(true)); + assertThat( + OllamaApiSupport.isChatLoadOnly(read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")), + is(false)); + // a messages value of the wrong type is no preload; the translation refuses it + assertThat(OllamaApiSupport.isChatLoadOnly(read("{\"messages\":\"hi\"}")), is(false)); + JsonNode answer = read(OllamaApiSupport.toOllamaChatLoadResponse("m")); + assertThat(answer.path("model").asText(), is("m")); + assertThat(answer.path("done").asBoolean(false), is(true)); + assertThat(answer.path("done_reason").asText(), is("load")); + assertThat(answer.path("message").path("role").asText(), is("assistant")); + assertThat(answer.path("message").path("content").asText(null), is("")); + assertThat(answer.path("created_at").isTextual(), is(true)); + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index fcc9c9b64..3bd26f889 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -519,6 +519,33 @@ public void ollamaGenerateWithoutAPromptOnlyReportsTheModelLoaded() throws IOExc } } + /** + * Ollama answers an {@code /api/chat} without messages by loading the model, which is how clients + * preload one through the chat route; ours ran the empty conversation into the chat mapper, which + * refused it with a 400. The answer is one object even when streaming was asked for (or defaulted). + */ + @Test + public void ollamaChatWithoutMessagesOnlyReportsTheModelLoaded() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + for (String body : new String[] { + "{\"model\":\"test-model\"}", + "{\"model\":\"test-model\",\"messages\":[],\"stream\":false}", + "{\"model\":\"test-model\",\"messages\":null,\"stream\":true}", + "{\"model\":\"test-model\",\"messages\":[],\"keep_alive\":0}" + }) { + Response response = post(server.getPort(), "/api/chat", body, ""); + assertThat(body, response.code, is(200)); + JsonNode out = READER.readTree(response.body); + assertThat(body, out.path("done").asBoolean(false), is(true)); + assertThat(body, out.path("done_reason").asText(), is("load")); + assertThat(body, out.path("message").path("content").asText(null), is("")); + assertThat(body, out.path("model").asText(), is("test-model")); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + @Test public void ollamaGenerateWithSuffixUsesInfill() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { @@ -1091,6 +1118,13 @@ public String completions(JsonNode request) { this.request = request; return super.completions(request); } + + @Override + public void stream(JsonNode request, ChunkSink sink) throws IOException { + this.called = "stream"; + this.request = request; + super.stream(request, sink); + } } /** Backend whose answers end with the given OpenAI finish reason (and a matching infill stop type). */ From 4dc1cd60826bc6155f640f74ec6c0bd1523b0b8f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:27:24 +0000 Subject: [PATCH 368/589] Record the commit of F216 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 6b9020b22..61dabe3e0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1685,7 +1685,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.ollamaChatWithoutMessagesOnlyReportsTheModelLoaded` (ohne `messages`, `[]`, `null` mit `stream`, `keep_alive: 0`; das Backend wird nicht gerufen — `RecordingBackend` zeichnet dafür jetzt auch `stream` auf), `OllamaApiSupportTest.aChatRequestWithoutMessagesOnlyLoadsTheModel`. - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit Stubs, die `false` bzw. `{}` liefern) rot: der HTTP-Test mit „Expected: is but: was “ für `done` bei `{"model":"test-model"}` (das Testbackend streamte eine Antwort; das echte Backend weist die leere Konversation mit 400 ab), der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 41, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `d99271c` ## Offene Verdachte ohne Fund-ID From eade620e24516132d7edbf3f9cefe1ecd1cf105d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:31:58 +0000 Subject: [PATCH 369/589] Forward Ollama think true as enable_thinking (F217) The Ollama translation turned "think": false into reasoning_effort "none" and a level string into reasoning_effort, but dropped "think": true. Upstream starts from the server's default (--reasoning off turns thinking off) and lets a request's chat_template_kwargs.enable_thinking true override it, so an OpenAI client can switch thinking on while an Ollama client asking for it was silently ignored by such a server. "think": true now becomes chat_template_kwargs {"enable_thinking": true}, a JSON boolean as upstream requires; /api/generate gets it through the same translation. The F78 ledger line that said true keeps the template's default is corrected. Red before the fix: "Expected: is but: was " for enable_thinking after the chat mapper; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 ++++++++++++- .../llama/server/OllamaApiSupport.java | 8 +++++-- .../llama/server/OllamaApiSupportTest.java | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 61dabe3e0..0231f3ce2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -139,6 +139,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F214](#f214) — niedrig — *gefixt* - [F215](#f215) — niedrig — *gefixt* - [F216](#f216) — niedrig — *gefixt* +- [F217](#f217) — niedrig — *gefixt* ## Funde @@ -653,7 +654,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java:143` (`toOpenAiChatRequest`) - **Schwere:** mittel - **Beschreibung:** Ollamas Chat-API steuert das Denken eines Modells mit dem Top-Level-Feld `think`: `false` schaltet es ab, `true` an, ein String wählt bei gpt-oss eine Stufe (`"low"`, `"medium"`, `"high"`). `OllamaApiSupport.toOpenAiChatRequest` las das Feld nicht. Ein Ollama-Client, der für schnelle Antworten `think: false` schickt (Open WebUI, ollama-python), bekam trotzdem einen vollen Denkblock samt seiner Token und Latenz. Gefunden in der Musterdurchsicht nach F77. -- **Fix:** `think: false` wird zu `reasoning_effort: "none"`, ein nicht leerer String zu `reasoning_effort: `; `true` bleibt bei der Vorgabe der Vorlage. Der Chat-Mapper wendet `reasoning_effort` seit F77 nach upstreams Regeln an (`enable_thinking: false` bzw. Template-Variable). +- **Fix:** `think: false` wird zu `reasoning_effort: "none"`, ein nicht leerer String zu `reasoning_effort: `; `true` bleibt bei der Vorgabe der Vorlage (**korrigiert in F217:** `true` wird zu `chat_template_kwargs.enable_thinking: true`, sonst überging ein Server mit abgeschaltetem Denken die Anfrage still). Der Chat-Mapper wendet `reasoning_effort` seit F77 nach upstreams Regeln an (`enable_thinking: false` bzw. Template-Variable). - **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestHonoursThinkLikeOllama` (Kette Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ – `enable_thinking` fehlte), mit Fix grün (24/24 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiRequestMapperTest` 21/21, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt @@ -1687,6 +1688,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d99271c` +### F217 – java-llama.cpp: Ollamas `think: true` wird verworfen — ein Server mit abgeschaltetem Denken übergeht es still +- **Zeit:** 2026-10-20 20:31 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest`; über `GENERATE_CHAT_FIELDS` auch `/api/generate`) +- **Schwere:** niedrig (nur bei einem Server, der das Denken per Vorgabe abschaltet; keine Regression — vor F78 wurde `think` ganz ignoriert) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J3-4). F78 übersetzte `think: false` und die Stufen, ließ `true` aber „bei der Vorgabe der Vorlage“; die Fix-Zeile von F78 ist korrigiert. +- **Beschreibung:** upstream (`server-common.cpp`, b11639) setzt `inputs.enable_thinking` zuerst auf die Vorgabe des Servers (`--reasoning off` schaltet sie ab, ebenso das veraltete `--chat-template-kwargs '{"enable_thinking":false}'`) und lässt dann ein `enable_thinking: true` der Anfrage darüber gewinnen. Ein OpenAI-Client kann das Denken also ausdrücklich einschalten; Ollamas `think: true` („should the model think before responding“) wurde dagegen verworfen und von einem solchen Server still übergangen. (`--reasoning-budget 0` ist ein Sampler-Budget, das `enable_thinking` nicht aufhebt; das bleibt unverändert.) +- **Fix:** `think: true` wird zu `chat_template_kwargs: {"enable_thinking": true}` als JSON-Boolean (upstream weist einen String ab); `false` und die Stufen bleiben wie mit F78. Auf einer Vorlage ohne Denken ist der Schalter wie bei upstreams OpenAI-Route eine wirkungslose Template-Variable, kein Fehler (Ollama selbst antwortet dort 400) — bewusst wie die OpenAI-Oberfläche, über die die Übersetzung läuft. +- **Tests:** `OllamaApiSupportTest.thinkTrueSwitchesThinkingOnExplicitly` (Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`, für `/api/chat` und `/api/generate`). +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is but: was “ (`enable_thinking` fehlte in `chat_template_kwargs`); mit dem Fix grün (`OllamaApiSupportTest` 42, `OpenAiRequestMapperTest` 35, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index d3f27371b..f801b629a 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -159,11 +159,15 @@ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { openAi.set("tools", tools.deepCopy()); } - // Ollama's "think": false switches a model's thinking off, a string picks a level (gpt-oss); - // true leaves the template's default. The chat mapper applies reasoning_effort as upstream does. + // Ollama's "think": false switches a model's thinking off, a string picks a level (gpt-oss); the + // chat mapper applies reasoning_effort as upstream does. "think": true switches it on, also over + // a server whose thinking is off by default (--reasoning off): upstream lets a request's + // enable_thinking override that, and it must stay a JSON boolean (a string is refused there). JsonNode think = ollamaRequest.path("think"); if (think.isBoolean() && !think.asBoolean()) { openAi.put("reasoning_effort", "none"); + } else if (think.isBoolean()) { + openAi.putObject("chat_template_kwargs").put("enable_thinking", true); } else if (think.isTextual() && !think.asText().isEmpty()) { openAi.put("reasoning_effort", think.asText()); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 15e9ff624..aec04167f 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -544,4 +544,25 @@ public void aChatRequestWithoutMessagesOnlyLoadsTheModel() throws IOException { assertThat(answer.path("message").path("content").asText(null), is("")); assertThat(answer.path("created_at").isTextual(), is(true)); } + + /** + * Ollama's {@code "think": true} asks the model to think. It was dropped, so a server whose thinking + * is off by default ({@code --reasoning off}) ignored it silently, while upstream lets a request's + * {@code chat_template_kwargs.enable_thinking: true} switch thinking on over that default + * ({@code server-common.cpp}). The value must stay a JSON boolean: upstream refuses a string. + */ + @Test + public void thinkTrueSwitchesThinkingOnExplicitly() throws IOException { + OpenAiRequestMapper mapper = new OpenAiRequestMapper(); + for (JsonNode openAi : new JsonNode[] { + OllamaApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"think\":true}")), + OllamaApiSupport.toOpenAiChatRequestFromGenerate(read("{\"prompt\":\"hi\",\"think\":true}")) + }) { + JsonNode kwargs = + read(mapper.toInferenceParameters(openAi).toJson()).path("chat_template_kwargs"); + assertThat(openAi.toString(), kwargs.path("enable_thinking").isBoolean(), is(true)); + assertThat(openAi.toString(), kwargs.path("enable_thinking").asBoolean(), is(true)); + } + } } From 323889b2e45b1db31a1c8c34871b6354cf919032 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:31:58 +0000 Subject: [PATCH 370/589] Record the commit of F217 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0231f3ce2..f60bffc77 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1698,7 +1698,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OllamaApiSupportTest.thinkTrueSwitchesThinkingOnExplicitly` (Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`, für `/api/chat` und `/api/generate`). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is but: was “ (`enable_thinking` fehlte in `chat_template_kwargs`); mit dem Fix grün (`OllamaApiSupportTest` 42, `OpenAiRequestMapperTest` 35, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `736d247` ## Offene Verdachte ohne Fund-ID From fea0a1dabf96f0aa9edf90351d2c67c249565dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:25:19 +0000 Subject: [PATCH 371/589] Forward json_schema next to grammar to upstream's chat parser (F218) The chat mapper dropped json_schema whenever a grammar was given, a rule taken from upstream's completion schema back when the mapper also served the streamed /v1/completions. Since that route forwards its body verbatim, the mapper feeds only the two chat routes, whose parser (oaicompat_chat_params_parse, b11639) refuses the pair with "Cannot use both json_schema and grammar". A chat client sending both therefore got a 200 without its schema where llama-server answers 400. Both fields are now forwarded independently and upstream decides; the Javadoc and the ledger's F95 rule are corrected. Red-green: jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair failed with 'Expected: is "object" but: was ""' before the change and passes after it; ErrorHandlingTest pins the resulting refusal end to end. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 ++++++++++++++- .../llama/server/OpenAiRequestMapper.java | 8 +++++--- .../net/ladenthin/llama/ErrorHandlingTest.java | 18 ++++++++++++++++++ .../llama/server/OpenAiRequestMapperTest.java | 11 ++++++++--- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f60bffc77..b7f06c215 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -140,6 +140,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F215](#f215) — niedrig — *gefixt* - [F216](#f216) — niedrig — *gefixt* - [F217](#f217) — niedrig — *gefixt* +- [F218](#f218) — mittel — *gefixt* ## Funde @@ -861,7 +862,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:200` (`applyLlamaCppSampling`, neu; `readMaxTokens`) - **Schwere:** mittel - **Beschreibung:** Aus dem offenen Verdacht vom 2026-10-18 (gestreamtes `/v1/completions`). Das nicht gestreamte `/v1/completions` reicht den Körper an upstreams Schema durch (`server-schema.cpp`, b11639), das gestreamte baut `InferenceParameters` im `OpenAiRequestMapper` aus einer festen Feldliste. Alles außerhalb dieser Liste ging verloren: `min_p`, `typical_p`, `repeat_penalty`, `repeat_last_n`, `top_n_sigma`, `xtc_*`, `dynatemp_*`, `min_keep`, `n_keep`, `ignore_eos`, `grammar`/`json_schema`, und `n_predict` (upstreams Name, `max_tokens`/`max_completion_tokens` sind seine Aliase und verlieren gegen ihn). Dieselbe Anfrage erzeugte gestreamt also andere Ausgaben, eine Grammatik wirkte nur ohne `stream`. Derselbe Mapper bedient beide Chat-Pfade, die Felder fehlten dort ebenso. Alle Felder haben bereits einen Setter; anders als im Verdacht befürchtet, braucht dieser Teil keinen API-Umbau. -- **Fix:** `applyLlamaCppSampling` (aus `applyCommonFields`) übernimmt die Felder mit den vorhandenen Settern; `json_schema` nur ohne `grammar`, wie upstream. `readMaxTokens` liest zuerst `n_predict`. Weiter offen (Verdacht bleibt): `logit_bias`, `n_probs`/`logprobs` (im gestreamten Completion-Chunk ohnehin nicht ausgegeben) und `n`. +- **Fix:** `applyLlamaCppSampling` (aus `applyCommonFields`) übernimmt die Felder mit den vorhandenen Settern; `json_schema` nur ohne `grammar`, wie upstream (**korrigiert in F218:** das gilt nur für upstreams Completion-Schema; der Chat-Parser, den der Mapper seit F186 allein bedient, weist das Paar mit 400 ab, also werden beide Felder durchgereicht). `readMaxTokens` liest zuerst `n_predict`. Weiter offen (Verdacht bleibt): `logit_bias`, `n_probs`/`logprobs` (im gestreamten Completion-Chunk ohnehin nicht ausgegeben) und `n`. - **Reproduktion:** `OpenAiRequestMapperTest#llamaCppSamplingExtensionsReachTheStreamedCompletion`, `#nPredictWinsOverMaxTokensLikeUpstream`, `#jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven` - **Rot-Grün-Nachweis:** ohne Fix rot (3 Fehlschläge: „Expected: is a numeric value within <1.0E-4> of <0.05> but: <0.0>“, „Expected: is <7> but: was <50>“, „Expected: is "object" but: was ""“), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 19/19, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit allen Feldern in einer Methode meldete `CE_CLASS_ENVY`, eine Map-Tabelle `DMC_DUBIOUS_MAP_COLLECTION`; jetzt je Feld ein Aufruf über `withFloat`/`withInt`). - **Nachtrag (Branch-Review 2026-10-20):** Das hier „weiter offen“ Genannte ist erledigt: `logit_bias` und `n_probs` mit F180, das gestreamte `/v1/completions` mit F186 (upstreams eigener Stream, Wahrscheinlichkeiten eingeschlossen), `n > 1` mit F180 und auf allen nativen Wegen mit F197. @@ -1700,6 +1701,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `736d247` +### F218 – java-llama.cpp: Chat-Anfrage mit `json_schema` und `grammar` verliert das Schema still statt upstreams 400 +- **Zeit:** 2026-10-20 21:24 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyLlamaCppSampling`) +- **Schwere:** mittel (stiller Fallback: der Client bekommt 200 und eine Antwort ohne sein Schema) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J2-1). F95 führte die Regel „`json_schema` nur ohne `grammar`, wie upstream“ ein; das stimmte für das Completion-Schema, das der Mapper damals auch für das gestreamte `/v1/completions` bediente (`server-schema.cpp`, der Alias-Handler lässt die Grammatik gewinnen). Seit F186 reicht `/v1/completions` den Körper wörtlich durch, der Mapper bedient nur noch die beiden Chat-Wege, und deren Parser entscheidet anders. +- **Beschreibung:** Beide Chat-Wege übergeben das gemappte JSON an upstreams `oaicompat_chat_params_parse` (`jllama_engine.cpp`, `parse_oai_chat_params`). Der weist einen Körper mit beiden Feldern ab: `throw std::runtime_error("Cannot use both json_schema and grammar")` (`server-common.cpp:1300-1304`, b11639) → `jllama_invalid_request` → `InvalidRequestException` → 400, so wie llama-server selbst antwortet. Der Mapper ließ das Schema neben einer Grammatik aber fallen, die Ablehnung konnte nie greifen, und `#jsonSchemaReachesTheMappedRequestUnlessAGrammarIsGiven` pinnte das Verwerfen. Nicht betroffen: `response_format` + `grammar` weist upstream nicht ab (`common/chat.cpp` lässt das Schema gewinnen); `response_format` reicht der Mapper ohnehin wörtlich durch. +- **Fix:** `grammar` und `json_schema` werden unabhängig voneinander übernommen; über das Paar entscheidet upstreams Chat-Parser mit seinem eigenen Text. Keine Ablehnung im Mapper, damit die Regel bei einem llama.cpp-Bump nicht auseinanderläuft. Javadoc von `applyLlamaCppSampling` berichtigt, die Fix-Zeile von F95 ebenso. +- **Tests:** `OpenAiRequestMapperTest#jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair` (ersetzt die zweite Hälfte von `#jsonSchemaReachesTheMappedRequestUnlessAGrammarIsGiven`, deren erste Hälfte als `#jsonSchemaReachesTheMappedRequest` bleibt); modellgestützt `ErrorHandlingTest#testJsonSchemaWithGrammarInAChatIsAnInvalidRequest` (ein Chat mit beiden Feldern ist eine `InvalidRequestException` mit upstreams Text „Cannot use both json_schema and grammar“ — der Beleg, dass das Durchreichen beim Client als 400 ankommt). +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is "object" but: was ""“ (`json_schema` fehlte im gemappten Körper); mit dem Fix grün (`OpenAiRequestMapperTest` 36/36). `ErrorHandlingTest#testJsonSchemaWithGrammarInAChatIsAnInvalidRequest` grün mit dem lokalen Winzmodell (CPU); in der Klasse scheitert unabhängig davon `#testHandleInfillMissingInputSuffix`, weil das Winzmodell keine FIM-Token hat. `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 507ce51b9..ac07ee05b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -195,8 +195,10 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo * Apply the llama.cpp sampling extensions upstream's request schema ({@code server-schema.cpp}, * b11639) reads next to the OpenAI fields. {@code /v1/completions} forwards its body to that schema * verbatim, streamed or not; the chat paths are built here, so without these fields a chat request - * would sample differently from a completion carrying the same fields. {@code json_schema} applies - * only without a {@code grammar}, as upstream decides. + * would sample differently from a completion carrying the same fields. {@code json_schema} and + * {@code grammar} are both forwarded: upstream's chat parser refuses a request carrying both + * ({@code Cannot use both json_schema and grammar}, 400), the answer llama-server gives, so the mapper + * does not decide for it. * * @param params the parameters to extend * @param request the parsed request object @@ -220,7 +222,7 @@ private InferenceParameters applyLlamaCppSampling(InferenceParameters params, Js } JsonNode grammar = request.path("grammar"); if (grammar.isTextual()) { - return params.withGrammar(grammar.asText()); + params = params.withGrammar(grammar.asText()); } JsonNode jsonSchema = request.path("json_schema"); return jsonSchema.isObject() ? params.withJsonSchema(jsonSchema.toString()) : params; diff --git a/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java b/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java index 6c97df68a..e18214f32 100644 --- a/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java +++ b/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java @@ -220,6 +220,24 @@ public void testNPredictMinusTwoPerRequestIsAnInvalidRequest() { assertTrue(e.getMessage().contains("n_predict"), "Error should name n_predict but was: " + e.getMessage()); } + @Test + public void testJsonSchemaWithGrammarInAChatIsAnInvalidRequest() { + // The OpenAI chat mapper forwards both fields and leaves the verdict to upstream's chat parser, + // which refuses the pair (server-common.cpp, b11639) -- the 400 llama-server answers. + InferenceParameters request = InferenceParameters.empty() + .withMessagesJson("[{\"role\":\"user\",\"content\":\"hi\"}]") + .withGrammar("root ::= \"a\"") + .withJsonSchema("{\"type\":\"object\"}") + .withNPredict(1); + + InvalidRequestException e = + assertThrows(InvalidRequestException.class, () -> modelNoEmbed.chatComplete(request)); + + assertTrue( + e.getMessage().contains("Cannot use both json_schema and grammar"), + "Error should be upstream's refusal but was: " + e.getMessage()); + } + // ------------------------------------------------------------------------- // configureParallelInference validation // ------------------------------------------------------------------------- diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index c2470683e..faf43fae9 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -364,14 +364,19 @@ public void nPredictWinsOverMaxTokensLikeUpstream() throws IOException { } @Test - public void jsonSchemaReachesTheMappedRequestUnlessAGrammarIsGiven() throws IOException { + public void jsonSchemaReachesTheMappedRequest() throws IOException { JsonNode schema = mapAndSerialize( "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"json_schema\":{\"type\":\"object\"}}"); assertThat(schema.path("json_schema").path("type").asText(), is("object")); - // upstream applies json_schema only without a grammar. + } + + @Test + public void jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair() throws IOException { + // upstream's chat parser answers a body carrying both with 400 "Cannot use both json_schema and + // grammar" (server-common.cpp, b11639); dropping the schema here answered 200 without it. JsonNode both = mapAndSerialize( "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"grammar\":\"root ::= \\\"a\\\"\",\"json_schema\":{\"type\":\"object\"}}"); - assertThat(both.has("json_schema"), is(false)); + assertThat(both.path("json_schema").path("type").asText(), is("object")); assertThat(both.path("grammar").asText(), is("root ::= \"a\"")); } From 2703f62849787f2f02106a440ef2f451a9d5a3f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:25:19 +0000 Subject: [PATCH 372/589] Record the commit of F218 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b7f06c215..cb0a4349c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1711,7 +1711,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair` (ersetzt die zweite Hälfte von `#jsonSchemaReachesTheMappedRequestUnlessAGrammarIsGiven`, deren erste Hälfte als `#jsonSchemaReachesTheMappedRequest` bleibt); modellgestützt `ErrorHandlingTest#testJsonSchemaWithGrammarInAChatIsAnInvalidRequest` (ein Chat mit beiden Feldern ist eine `InvalidRequestException` mit upstreams Text „Cannot use both json_schema and grammar“ — der Beleg, dass das Durchreichen beim Client als 400 ankommt). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is "object" but: was ""“ (`json_schema` fehlte im gemappten Körper); mit dem Fix grün (`OpenAiRequestMapperTest` 36/36). `ErrorHandlingTest#testJsonSchemaWithGrammarInAChatIsAnInvalidRequest` grün mit dem lokalen Winzmodell (CPU); in der Klasse scheitert unabhängig davon `#testHandleInfillMissingInputSuffix`, weil das Winzmodell keine FIM-Token hat. `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `6d78c47` ## Offene Verdachte ohne Fund-ID From 63f05e1cb79de9b8e8c7011e10b1cf1e78cb89eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:30:53 +0000 Subject: [PATCH 373/589] Refuse token caps below -1 and n below 1 like upstream (F219) The chat mapper set n_predict only for caps >= 0, so "max_tokens": -2 ran the mapped routes without a cap where upstream's request schema (hard limits -1..INT32_MAX) and the verbatim /v1/completions answer 400. It also refused only n > 1, while "n" never reaches upstream's n_cmpl (hard limits 1..n_parallel) from here, so 0 and -1 ran silently with one answer. Every cap except -1 is now forwarded and refused by upstream with its own message; an explicit -1 stays unset like an absent field (the server's --predict). Any n other than 1 is an invalid request. Red-green: tokenCapBelowMinusOneReachesUpstreamsRefusal failed with "Expected: is <-2> but: was <-1>" and zeroOrNegativeChoicesAreRefused- LikeUpstream with "Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." before the change; both pass after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 +++++++++- .../llama/server/OpenAiRequestMapper.java | 16 +++++----- .../ladenthin/llama/ErrorHandlingTest.java | 14 +++++++++ .../llama/server/OpenAiRequestMapperTest.java | 30 ++++++++++++++++++- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cb0a4349c..b4705795b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -141,6 +141,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F216](#f216) — niedrig — *gefixt* - [F217](#f217) — niedrig — *gefixt* - [F218](#f218) — mittel — *gefixt* +- [F219](#f219) — niedrig — *gefixt* ## Funde @@ -935,7 +936,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:164` (`applyCommonFields`) - **Schwere:** niedrig - **Beschreibung:** Musterdurchsicht nach F102 (Werte, die der Mapper anders behandelt als upstream). upstreams Schema (`server-schema.cpp`, `n_predict` mit den Aliasen `max_completion_tokens`/`max_tokens`, Grenzen -1..INT32_MAX, b11639): „When 0, no tokens will be generated but the prompt is evaluated into the cache“. Der Mapper übernahm die Obergrenze nur bei `> 0`; ein Client, der mit `0` den Cache vorwärmt, bekam auf den gemappten Wegen (beide Chat-Pfade, gestreamtes `/v1/completions`, und damit Anthropic/Responses/Ollama) eine Generierung ohne Obergrenze bis zum Kontextende, das wörtlich durchgereichte `/v1/completions` dagegen keine. -- **Fix:** Die Obergrenze wird bei `>= 0` gesetzt; `-1` bzw. kein Feld lässt upstreams Standard (unbegrenzt). +- **Fix:** Die Obergrenze wird bei `>= 0` gesetzt; `-1` bzw. kein Feld lässt upstreams Standard (unbegrenzt). (**Ergänzt in F219:** jeder Wert außer -1 wird durchgereicht, damit upstream -2 und darunter mit 400 abweist.) - **Reproduktion:** `OpenAiRequestMapperTest#zeroTokenCapOnlyWarmsTheCacheLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <0> but: was <-1>“), mit Fix grün (27/27 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt @@ -1713,6 +1714,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6d78c47` +### F219 – java-llama.cpp: Chat-Mapper normalisiert `max_tokens` unter -1 und `n` unter 1 still auf die Vorgaben +- **Zeit:** 2026-10-20 21:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyCommonFields`) +- **Schwere:** niedrig +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J2-3). Dieselbe Form wie F102/F103 (der Mapper behandelt einen Wert anders als upstream), nur auf der anderen Seite des Wertebereichs: F103 übernahm die Obergrenze ab `>= 0`, F180 lehnte `n > 1` ab. +- **Beschreibung:** upstreams Schema (`server-schema.cpp`, b11639) deklariert `n_predict` (Aliase `max_completion_tokens`, `max_tokens`) mit den harten Grenzen -1 … INT32_MAX und `n_cmpl` (Alias `n`) mit 1 … `n_parallel`; eine Verletzung ist `std::invalid_argument` → `InvalidRequestException` → 400. Der Mapper setzte `n_predict` nur ab `>= 0`: `"max_tokens": -2` (ebenso `n_predict`/`max_completion_tokens`) lief auf den gemappten Wegen ohne Obergrenze, das wörtlich durchgereichte `/v1/completions` antwortet 400. `"n"` erreicht upstream von hier nie (es ist kein `RequestField`), und der Mapper lehnte nur `> 1` ab — `"n": 0` und `-1` liefen still mit einer Antwort. Betroffen: beide Chat-Wege, damit auch Anthropic `/v1/messages` und Responses `/v1/responses` (sie kopieren die Obergrenze, wie der Client sie schickt); Ollama normalisiert `num_predict < -1` schon vorher auf -1 (F104). +- **Fix:** Jede Obergrenze außer -1 wird durchgereicht, upstreams Schema weist -2 und darunter mit seinem eigenen Text ab; ein ausdrückliches -1 bleibt wie ein fehlendes Feld ungesetzt (upstream nimmt dann `--predict` des Servers, `server-context.cpp`). `n` ungleich 1 ist eine ungültige Anfrage (`IllegalArgumentException` → 400, „'n' must be 1 (one answer is returned) but was N“); die Ablehnung bleibt im Mapper, weil `n` upstream von hier nicht erreicht. Der Kommentar über `0` („generate nothing“) ist aus dem Mapper entfernt, weil er `withNPredict` widersprach; die übrigen Stellen zu `0` gehören zu einem eigenen Fund. +- **Tests:** `OpenAiRequestMapperTest#tokenCapBelowMinusOneReachesUpstreamsRefusal`, `#zeroOrNegativeChoicesAreRefusedLikeUpstream` (`n` 0 mit Meldung, `n` -1), als Schutz `#explicitTokenCapMinusOneLeavesTheServerDefault`; modellgestützt `ErrorHandlingTest#testNPredictMinusTwoInAChatIsAnInvalidRequest` (ein Chat mit `n_predict` -2 ist eine `InvalidRequestException`, die `n_predict` nennt — der Beleg, dass das Durchreichen beim Client als 400 ankommt). +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is <-2> but: was <-1>“ (`n_predict` fehlte) und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ (`n: 0`); mit dem Fix grün (`OpenAiRequestMapperTest` 39/39, `OpenAiCompatServerHttpTest` 68/68, `AnthropicApiSupportTest` 26/26, `ResponsesApiSupportTest` 54/54, `OllamaApiSupportTest` 42/42). `ErrorHandlingTest#testNPredictMinusTwoInAChatIsAnInvalidRequest` grün mit dem lokalen Winzmodell (CPU); `#testHandleInfillMissingInputSuffix` scheitert unabhängig davon am Winzmodell ohne FIM-Token. `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index ac07ee05b..829557d11 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -97,12 +97,12 @@ InferenceParameters toInferenceParameters(JsonNode request) { * @return a new instance with the recognised fields applied */ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNode request) { - // Upstream reads "n" as n_cmpl (several answers); these paths return one, so more is refused - // rather than silently answered once. + // Upstream reads "n" as n_cmpl (several answers, hard limits [1, n_parallel]); these paths return + // one and "n" never reaches upstream from here, so every other value is refused rather than + // silently answered once. JsonNode choices = request.path("n"); - if (choices.isNumber() && choices.asInt() > 1) { - throw new IllegalArgumentException( - "'n' greater than 1 is not supported (one answer is returned) but was " + choices.asInt()); + if (choices.isNumber() && choices.asInt() != 1) { + throw new IllegalArgumentException("'n' must be 1 (one answer is returned) but was " + choices.asInt()); } // logit_bias as upstream's schema reads it: [token, bias] pairs or OpenAI's object form. JsonNode logitBias = request.path("logit_bias"); @@ -161,9 +161,11 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo params = params.withFrequencyPenalty((float) frequencyPenalty.asDouble()); } - // 0 is a valid cap upstream (evaluate the prompt into the cache, generate nothing); -1 is its default. + // -1 is upstream's default (the server's --predict), the same as no field, so it stays unset. Every + // other value is forwarded: upstream's hard limits [-1, INT32_MAX] refuse -2 and below with 400, as + // on the verbatim /v1/completions. int maxTokens = readMaxTokens(request); - if (maxTokens >= 0) { + if (maxTokens != -1) { params = params.withNPredict(maxTokens); } diff --git a/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java b/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java index e18214f32..8f00ef9fb 100644 --- a/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java +++ b/llama/src/test/java/net/ladenthin/llama/ErrorHandlingTest.java @@ -220,6 +220,20 @@ public void testNPredictMinusTwoPerRequestIsAnInvalidRequest() { assertTrue(e.getMessage().contains("n_predict"), "Error should name n_predict but was: " + e.getMessage()); } + @Test + public void testNPredictMinusTwoInAChatIsAnInvalidRequest() { + // The OpenAI chat mapper forwards a token cap below -1 (F219); the chat path runs the same request + // schema, so the client gets upstream's 400 naming the field instead of an uncapped answer. + InferenceParameters request = InferenceParameters.empty() + .withMessagesJson("[{\"role\":\"user\",\"content\":\"hi\"}]") + .withNPredict(-2); + + InvalidRequestException e = + assertThrows(InvalidRequestException.class, () -> modelNoEmbed.chatComplete(request)); + + assertTrue(e.getMessage().contains("n_predict"), "Error should name n_predict but was: " + e.getMessage()); + } + @Test public void testJsonSchemaWithGrammarInAChatIsAnInvalidRequest() { // The OpenAI chat mapper forwards both fields and leaves the verdict to upstream's chat parser, diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index faf43fae9..a66500676 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -88,6 +88,19 @@ public void moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack() throws IOExcep assertDoesNotThrow(() -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"n\":1}")); } + @Test + public void zeroOrNegativeChoicesAreRefusedLikeUpstream() { + // upstream's n_cmpl (alias "n") has the hard limits [1, n_parallel] (server-schema.cpp, b11639); + // "n" never reaches it from here, so 0 and -1 ran silently with one answer. + IllegalArgumentException zero = assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"n\":0}")); + assertThat(zero.getMessage(), is("'n' must be 1 (one answer is returned) but was 0")); + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"n\":-1}")); + } + @Test public void chatTemplateKwargsAreForwarded() throws IOException { // Qwen3 clients switch thinking off with chat_template_kwargs; upstream's chat parser merges the @@ -329,6 +342,21 @@ public void zeroTokenCapOnlyWarmsTheCacheLikeUpstream() throws IOException { assertThat(out.path("n_predict").asInt(-1), is(0)); } + @Test + public void tokenCapBelowMinusOneReachesUpstreamsRefusal() throws IOException { + // upstream's n_predict has the hard limits [-1, INT32_MAX] and refuses -2 with 400, as the verbatim + // /v1/completions does; dropping it here ran the mapped routes without a cap. + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":-2}"); + assertThat(out.path("n_predict").asInt(-1), is(-2)); + } + + @Test + public void explicitTokenCapMinusOneLeavesTheServerDefault() throws IOException { + // upstream treats -1 as "use the server's --predict" (server-context.cpp), the same as no field. + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":-1}"); + assertThat(out.has("n_predict"), is(false)); + } + @Test public void llamaCppSamplingExtensionsReachTheMappedRequest() throws IOException { // Upstream's schema reads these fields from a chat body too; the mapped chat routes must not @@ -383,7 +411,7 @@ public void jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair() thr @Test public void unknownFieldsIgnored() throws IOException { JsonNode out = mapAndSerialize( - // "n" is not unknown: more than one answer is refused (moreThanOneChoiceIsRefused...) + // "n" is not unknown: anything but one answer is refused (moreThanOneChoiceIsRefused...) "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + "\"some_future_field\":true}"); assertThat(out.path("messages").isArray(), is(true)); assertThat(out.has("some_future_field"), is(false)); From 5d0c777c3ac60221dd40647139c64ce606404a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:30:53 +0000 Subject: [PATCH 374/589] Record the commit of F219 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b4705795b..91a24a4e4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1724,7 +1724,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#tokenCapBelowMinusOneReachesUpstreamsRefusal`, `#zeroOrNegativeChoicesAreRefusedLikeUpstream` (`n` 0 mit Meldung, `n` -1), als Schutz `#explicitTokenCapMinusOneLeavesTheServerDefault`; modellgestützt `ErrorHandlingTest#testNPredictMinusTwoInAChatIsAnInvalidRequest` (ein Chat mit `n_predict` -2 ist eine `InvalidRequestException`, die `n_predict` nennt — der Beleg, dass das Durchreichen beim Client als 400 ankommt). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is <-2> but: was <-1>“ (`n_predict` fehlte) und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ (`n: 0`); mit dem Fix grün (`OpenAiRequestMapperTest` 39/39, `OpenAiCompatServerHttpTest` 68/68, `AnthropicApiSupportTest` 26/26, `ResponsesApiSupportTest` 54/54, `OllamaApiSupportTest` 42/42). `ErrorHandlingTest#testNPredictMinusTwoInAChatIsAnInvalidRequest` grün mit dem lokalen Winzmodell (CPU); `#testHandleInfillMissingInputSuffix` scheitert unabhängig davon am Winzmodell ohne FIM-Token. `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `1817210` ## Offene Verdachte ohne Fund-ID From ca1baa96e6c59fb49d08c8ee2fd16a74628f9a65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:40:27 +0000 Subject: [PATCH 375/589] Map chat logprobs and top_logprobs as upstream's parser does (F220) Upstream's chat parser reads "logprobs" only as a boolean, turns true into n_probs = top_logprobs (20 without it), refuses a non-null top_logprobs otherwise with "top_logprobs requires logprobs to be set to true", and copies the rest of the body through, where the request schema reads "logprobs" as an alias of n_probs. The mapper dropped a lone top_logprobs silently and read logprobs with Jackson's asBoolean, so "logprobs": 2 became n_probs 20 (upstream: 2), overrode an explicit n_probs, and the string "true" passed where upstream answers 400. applyLogprobs now follows those rules, with upstream's message verbatim; the ledger's F123 note is corrected. Red-green: four OpenAiRequestMapperTest cases failed before the change ("Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." for a lone top_logprobs, "Expected: is <2> but: was <20>" for an integer logprobs) and pass after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 ++++- llama/spotbugs-exclude.xml | 5 +- .../llama/server/OpenAiRequestMapper.java | 42 +++++++++++-- .../llama/server/OpenAiRequestMapperTest.java | 60 +++++++++++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 91a24a4e4..5b4e222e0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -142,6 +142,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F217](#f217) — niedrig — *gefixt* - [F218](#f218) — mittel — *gefixt* - [F219](#f219) — niedrig — *gefixt* +- [F220](#f220) — niedrig — *gefixt* ## Funde @@ -1118,7 +1119,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java:72` (`toInferenceParameters`) - **Schwere:** niedrig - **Beschreibung:** Eine OpenAI-Chat-Anfrage mit `"logprobs":true` (und optional `"top_logprobs":N`) bekam keine Token-Wahrscheinlichkeiten: Der Mapper übernahm keins der beiden Felder, `InferenceParameters` trug also kein `n_probs`. Upstream bildet sie in `oaicompat_chat_params_parse` (`server-common.cpp:1456`, b11639) auf `n_probs = top_logprobs` ab, Standard 20, und die nativen Chat-Ergebnisse tragen die Wahrscheinlichkeiten dann von selbst (blockierend und im OAI-Chunk-Pfad). Teil des offenen F95-Verdachts („`n_probs`/`logprobs`“), der als Blocker eine API-Erweiterung nannte – `withNProbs` gab es aber schon (Erkenntnis F95: Verdacht in Teile zerlegen). Gefunden per Probe-Klasse über den Mapper. -- **Fix:** Bei `logprobs: true` setzt der Chat-Mapper `withNProbs(top_logprobs)`, ohne `top_logprobs` 20 wie upstream. Nur im Chat-Pfad: Das `logprobs` des alten `/v1/completions` ist eine Zahl mit anderer Bedeutung. Nicht übernommen: upstreams zwei Ablehnungen (`top_logprobs` ohne `logprobs`; `logprobs` mit Werkzeugen im Stream). +- **Fix:** Bei `logprobs: true` setzt der Chat-Mapper `withNProbs(top_logprobs)`, ohne `top_logprobs` 20 wie upstream. Nur im Chat-Pfad: Das `logprobs` des alten `/v1/completions` ist eine Zahl mit anderer Bedeutung. Nicht übernommen: upstreams zwei Ablehnungen (`top_logprobs` ohne `logprobs`; `logprobs` mit Werkzeugen im Stream). (**Korrigiert in F220:** die erste Ablehnung gilt jetzt mit upstreams Text, und ein ganzzahliges `logprobs` ist wie bei upstream der Alias von `n_probs` statt der Chat-Form; die zweite bleibt offen.) - **Reproduktion:** `OpenAiRequestMapperTest#logprobsWithTopLogprobsBecomesNProbs`, `#logprobsWithoutTopLogprobsDefaultsToTwentyLikeUpstream` (dazu als Schutz `#logprobsFalseLeavesNProbsUnset`) - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: is <3> but: was <-1>“ bzw. „Expected: is <20> but: was <-1>“), mit Fix grün (31/31 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. Der erste Grün-Lauf scheiterte am Compiler (`NotJavadoc`: Konstante zwischen fremde Javadoc und Deklaration eingefügt, wie bei F108) und meldete trotzdem den alten roten Bericht – das Prüfskript löscht den Bericht jetzt auch vor dem Grün-Lauf. - **Status:** gefixt @@ -1726,6 +1727,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `1817210` +### F220 – java-llama.cpp: Chat-Mapper verwirft `top_logprobs` ohne `logprobs` still und liest ein ganzzahliges `logprobs` als `n_probs` 20 +- **Zeit:** 2026-10-20 21:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyLogprobs`, neu; vorher in `toInferenceParameters`) +- **Schwere:** niedrig +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J2-4). F123 übernahm `logprobs: true` → `n_probs`, ließ upstreams zwei Ablehnungen aber bewusst weg und las `logprobs` mit Jacksons `asBoolean`. +- **Beschreibung:** upstreams `oaicompat_chat_params_parse` (`server-common.cpp:1456-1473`, b11639) liest `logprobs` nur als Boolean (`json_value` liefert für eine Zahl oder einen String den Standard `false`), setzt bei `true` `n_probs = top_logprobs` (20 ohne), weist sonst ein nicht-null `top_logprobs` mit „top_logprobs requires logprobs to be set to true“ ab (400; die OpenAI-Spezifikation verlangt dasselbe) und kopiert den Rest des Körpers durch — das Schema liest `logprobs` dann als Alias von `n_probs` (`server-schema.cpp:179-180`, nur ohne `n_probs`). Der Mapper dagegen (a) verwarf ein `top_logprobs` ohne `logprobs` still, weil das gemappte JSON es nicht mehr trug, und (b) las mit `asBoolean` jede von 0 verschiedene Zahl und den String `"true"` als die Chat-Form: `"logprobs": 2` ergab `n_probs` 20 statt upstreams 2, `"n_probs": 5` daneben wurde überschrieben, und ein String, den upstreams Schema mit 400 abweist, lief durch. +- **Fix:** `applyLogprobs` folgt upstream: `logprobs: true` → `n_probs = top_logprobs` (eine Zahl, sonst 20 wie upstreams `json_value`); ein nicht-null `top_logprobs` ohne `logprobs: true` ist eine ungültige Anfrage mit upstreams Text wörtlich (die WEM-Unterdrückung in `spotbugs-exclude.xml` nennt die Methode deshalb); ohne `n_probs` wird eine Zahl als `n_probs` übernommen, jeder andere nicht-null, nicht-boolesche Wert ist eine ungültige Anfrage („'logprobs' must be a boolean or a number“), wie das Schema ihn abweist. `logprobs: false` lässt `n_probs` weiter ungesetzt (upstreams Alias läse 0, die Vorgabe). Weiter offen, nicht Teil dieses Funds: upstreams zweite Ablehnung („logprobs is not supported with tools + stream“) bleibt unerreichbar, weil der Mapper `logprobs` in `n_probs` übersetzt; die Fix-Zeile von F123 ist berichtigt. +- **Tests:** `OpenAiRequestMapperTest#topLogprobsWithoutLogprobsIsRefusedLikeUpstream` (ohne, mit `false`, mit `null` und neben einer Zahl), `#anIntegerLogprobsMapsToNProbsLikeUpstreamsSchemaAlias`, `#nProbsWinsOverAnIntegerLogprobsLikeUpstream`, `#aLogprobsThatIsNeitherBooleanNorNumberIsRefused` (mit `n_probs` daneben keine Ablehnung, wie upstream); als Schutz `#topLogprobsNullWithoutLogprobsIsTheDefault`, die drei F123-Tests unverändert grün. +- **Rot-Grün-Nachweis:** Ohne den Fix vier rot: „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ (`top_logprobs: 3` allein bzw. `logprobs: "true"`), „Expected: is <2> but: was <20>“ (`logprobs: 2`), „Expected: is <5> but: was <20>“ (`n_probs: 5` neben `logprobs: 2`); mit dem Fix grün (`OpenAiRequestMapperTest` 44/44, `OpenAiCompatServerHttpTest` 68/68, `AnthropicApiSupportTest` 26/26, `ResponsesApiSupportTest` 54/54, `OllamaApiSupportTest` 42/42, `LlamaArchitectureTest` 13/13). `spotless:apply` grün; `spotbugs:check` meldete zuerst zweimal `WEM_WEAK_EXCEPTION_MESSAGING` für die festen Texte, mit der Unterdrückung grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml index 65b0b78bc..5f6074e80 100644 --- a/llama/spotbugs-exclude.xml +++ b/llama/spotbugs-exclude.xml @@ -552,7 +552,9 @@ SPDX-License-Identifier: MIT ("'query' must be a string", "'messages' must be a non-empty array", ...). There is no additional state-dependent context worth adding at these guards: the value is malformed or absent by definition at the throw point. Same rationale as the ChatMessage.requireNonNull WEM - suppression above. + suppression above. OpenAiRequestMapper.applyLogprobs additionally keeps upstream's message + verbatim ("top_logprobs requires logprobs to be set to true", oaicompat_chat_params_parse), so + a client sees the same 400 text from both server modes. --> @@ -568,6 +570,7 @@ SPDX-License-Identifier: MIT + diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 829557d11..b763a4780 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -61,11 +61,7 @@ InferenceParameters toInferenceParameters(JsonNode request) { params = applyCommonFields(params, request); params = applyReasoningControls(params, request); - // Chat logprobs, as upstream's oaicompat_chat_params_parse maps them (the legacy completions - // "logprobs" is an integer and means something else, so this stays on the chat path). - if (request.path("logprobs").asBoolean(false)) { - params = params.withNProbs(request.path("top_logprobs").asInt(DEFAULT_TOP_LOGPROBS)); - } + params = applyLogprobs(params, request); // Tools are chat-only. JsonNode tools = request.path("tools"); @@ -88,6 +84,42 @@ InferenceParameters toInferenceParameters(JsonNode request) { return params; } + /** + * Apply {@code logprobs} / {@code top_logprobs} as upstream's chat parser and request schema read + * them ({@code oaicompat_chat_params_parse} and {@code server-schema.cpp}, b11639): {@code logprobs: + * true} is the chat form and becomes {@code n_probs = top_logprobs} (20 without it); a + * {@code top_logprobs} without it is refused with upstream's message; a number is the schema's alias + * of {@code n_probs} (the legacy completions form), read only without an {@code n_probs}, and any + * other non-null value read there is refused, as upstream's schema refuses it. + * + * @param params the parameters to extend + * @param request the parsed OpenAI request object + * @return a new instance with the probabilities applied + * @throws IllegalArgumentException for a request upstream refuses + */ + private static InferenceParameters applyLogprobs(InferenceParameters params, JsonNode request) { + JsonNode logprobs = request.path("logprobs"); + JsonNode topLogprobs = request.path("top_logprobs"); + if (logprobs.isBoolean() && logprobs.asBoolean()) { + return params.withNProbs(topLogprobs.isNumber() ? topLogprobs.asInt() : DEFAULT_TOP_LOGPROBS); + } + if (!topLogprobs.isMissingNode() && !topLogprobs.isNull()) { + throw new IllegalArgumentException("top_logprobs requires logprobs to be set to true"); + } + JsonNode nProbs = request.path("n_probs"); + if (logprobs.isMissingNode() + || logprobs.isNull() + || logprobs.isBoolean() + || (!nProbs.isMissingNode() && !nProbs.isNull())) { + // the schema reads n_probs first, its alias only without it + return params; + } + if (!logprobs.isNumber()) { + throw new IllegalArgumentException("'logprobs' must be a boolean or a number"); + } + return params.withNProbs(logprobs.asInt()); + } + /** * Apply the sampling / KV-cache / output-shaping fields of a chat request (temperature, top_p/top_k, seed, penalties, max tokens, stop, stream_options, response_format, * plus the llama.cpp cache extensions). Tools and messages are handled by the caller. diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index a66500676..c2dc08299 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -240,6 +240,66 @@ public void logprobsFalseLeavesNProbsUnset() throws IOException { assertThat(out.has("n_probs"), is(false)); } + @Test + public void topLogprobsWithoutLogprobsIsRefusedLikeUpstream() { + // upstream's chat parser: "top_logprobs requires logprobs to be set to true" (400); the mapper + // dropped top_logprobs, so that refusal could never fire. + IllegalArgumentException absent = assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"top_logprobs\":3}")); + assertThat(absent.getMessage(), is("top_logprobs requires logprobs to be set to true")); + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":false,\"top_logprobs\":3}")); + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":null,\"top_logprobs\":3}")); + // an integer logprobs is not the chat form, so upstream refuses top_logprobs next to it too + assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":2,\"top_logprobs\":3}")); + } + + @Test + public void topLogprobsNullWithoutLogprobsIsTheDefault() throws IOException { + // upstream refuses only a non-null top_logprobs + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"top_logprobs\":null}"); + assertThat(out.has("n_probs"), is(false)); + } + + @Test + public void anIntegerLogprobsMapsToNProbsLikeUpstreamsSchemaAlias() throws IOException { + // upstream's chat parser reads only a boolean logprobs, copies the rest of the body through, and its + // schema reads "logprobs" as an alias of n_probs: an integer is n_probs, not the chat form's 20. + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":2}"); + assertThat(out.path("n_probs").asInt(-1), is(2)); + } + + @Test + public void nProbsWinsOverAnIntegerLogprobsLikeUpstream() throws IOException { + // the schema reads n_probs first and its alias only without it + JsonNode out = + mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"n_probs\":5,\"logprobs\":2}"); + assertThat(out.path("n_probs").asInt(-1), is(5)); + } + + @Test + public void aLogprobsThatIsNeitherBooleanNorNumberIsRefused() throws IOException { + // upstream's schema reads the copied "logprobs" as a number and answers 400 for anything else; + // Jackson's asBoolean read the string "true" as the chat form. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":\"true\"}")); + assertThat(e.getMessage(), is("'logprobs' must be a boolean or a number")); + // with n_probs the schema never reads the alias, so upstream does not refuse it either + JsonNode out = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"n_probs\":4,\"logprobs\":\"true\"}"); + assertThat(out.path("n_probs").asInt(-1), is(4)); + } + @Test public void maxCompletionTokensPreferredOverMaxTokens() throws IOException { JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," From bb6cd21b5a2039754f5b97602363f7858ab0d93b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 21:40:27 +0000 Subject: [PATCH 376/589] Record the commit of F220 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5b4e222e0..dd2fecebf 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1737,7 +1737,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#topLogprobsWithoutLogprobsIsRefusedLikeUpstream` (ohne, mit `false`, mit `null` und neben einer Zahl), `#anIntegerLogprobsMapsToNProbsLikeUpstreamsSchemaAlias`, `#nProbsWinsOverAnIntegerLogprobsLikeUpstream`, `#aLogprobsThatIsNeitherBooleanNorNumberIsRefused` (mit `n_probs` daneben keine Ablehnung, wie upstream); als Schutz `#topLogprobsNullWithoutLogprobsIsTheDefault`, die drei F123-Tests unverändert grün. - **Rot-Grün-Nachweis:** Ohne den Fix vier rot: „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ (`top_logprobs: 3` allein bzw. `logprobs: "true"`), „Expected: is <2> but: was <20>“ (`logprobs: 2`), „Expected: is <5> but: was <20>“ (`n_probs: 5` neben `logprobs: 2`); mit dem Fix grün (`OpenAiRequestMapperTest` 44/44, `OpenAiCompatServerHttpTest` 68/68, `AnthropicApiSupportTest` 26/26, `ResponsesApiSupportTest` 54/54, `OllamaApiSupportTest` 42/42, `LlamaArchitectureTest` 13/13). `spotless:apply` grün; `spotbugs:check` meldete zuerst zweimal `WEM_WEAK_EXCEPTION_MESSAGING` für die festen Texte, mit der Unterdrückung grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ededfc5` ## Offene Verdachte ohne Fund-ID From 7e965d97d83da6355b59fbcaf832c63431b0af7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:31:41 +0000 Subject: [PATCH 377/589] Report a matched stop word as stop_reason stop_sequence (F221) The Messages API specifies that a response ended by one of the request's stop_sequences has stop_reason "stop_sequence" and the word in stop_sequence. The translation followed upstream's to_json_anthropic and answered end_turn next to the matched word (blocking and streamed), so a client that branches on stop_reason could not tell a stop-word stop from a natural end. A new anthropicStopReason(finishReason, stopSequence) returns stop_sequence for a "stop" that a word ended and keeps every other mapping; both paths use it, and its Javadoc records the deliberate departure from upstream. Red-green: four tests failed before the change ("Expected: is "stop_sequence" but: was "end_turn"" in AnthropicApiSupportTest, the same for the message_delta in AnthropicStreamTranslatorTest) and pass after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++ .../llama/server/AnthropicApiSupport.java | 34 ++++++++++++++-- .../server/AnthropicStreamTranslator.java | 2 +- .../llama/server/AnthropicApiSupportTest.java | 39 +++++++++++++++++-- .../server/AnthropicStreamTranslatorTest.java | 14 ++++++- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index dd2fecebf..8263f1dee 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -143,6 +143,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F218](#f218) — mittel — *gefixt* - [F219](#f219) — niedrig — *gefixt* - [F220](#f220) — niedrig — *gefixt* +- [F221](#f221) — mittel — *gefixt* ## Funde @@ -1739,6 +1740,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ededfc5` +### F221 – java-llama.cpp: Anthropic meldet ein getroffenes Stoppwort als `stop_reason` `end_turn` statt `stop_sequence` +- **Zeit:** 2026-10-20 22:30 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`, `anthropicStopReason(String, String)` neu), `AnthropicStreamTranslator.java` (`end`) +- **Schwere:** mittel (ein Client, der auf `stop_reason` verzweigt — die Anthropic-SDKs typisieren es als Aufzählung, Agentenschleifen setzen bei `end_turn` fort —, konnte einen Stopp durch das Wort nicht von einem natürlichen Ende unterscheiden) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J4-1). Folge von F183, das das Wort über `__verbose.stopping_word` erst verfügbar macht und `stop_reason` dabei bewusst wie upstream auf `end_turn` ließ. upstream (`server-task.cpp:723-726`/`:792-795`, b11639 und master) bildet `STOP_TYPE_WORD` wie `STOP_TYPE_EOS` auf `end_turn`/`tool_use` ab und testet den Wert der Spezifikation nirgends. +- **Beschreibung:** Die Messages-API legt fest: „If the model encounters one of the custom sequences, the response stop_reason value will be "stop_sequence" and the response stop_sequence value will contain the matched stop sequence.“ `toAnthropicResponse` und `AnthropicStreamTranslator.end()` bildeten den OpenAI-`finish_reason` unabhängig vom Wort ab, `anthropicStopReason` kannte kein `stop_sequence`: Eine Anfrage mit `stop_sequences: ["X"]`, deren Generierung an `X` endete, bekam das widersprüchliche Paar `stop_reason: "end_turn"`, `stop_sequence: "X"` — blockierend wie gestreamt. +- **Fix:** Neue Überladung `anthropicStopReason(finishReason, stopSequence)`: `stop` mit einem Wort ist `stop_sequence`, alles andere die bisherige Abbildung (`length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn`). `toAnthropicResponse` liest das Wort jetzt vor dem Stoppgrund, der Stream-Übersetzer übergibt sein gesammeltes Wort. Das Wort ist genau bei einem Wortstopp nicht leer (upstream setzt `stopping_word` nur dort, `server-context.cpp:630`), und ein solcher Stopp hat immer `finish_reason` `stop`, also bleiben `tool_use` und `max_tokens` unberührt. Die Javadoc der Überladung zitiert die Spezifikation und nennt die bewusste Abweichung von upstream. Nicht Teil dieses Commits: `CHANGELOG.md` (dort steht noch „stop_reason stays end_turn“, wird in einem späteren Durchgang nachgezogen); upstream-tauglich als Einzeiler in beiden `to_json_anthropic*`. +- **Tests:** `AnthropicApiSupportTest#aMatchedStopWordIsReportedAsStopReasonStopSequence`, `#theMatchedStopWordIsTheStopSequence` (Erwartung von `end_turn` auf `stop_sequence` umgestellt), `#onlyAStopThatAStopWordEndedIsStopSequence` (die drei unveränderten Zweige mit Wort und `stop` ohne Wort), als Schutz `#withoutAStopWordAStopIsStillEndTurn`; `AnthropicStreamTranslatorTest#aMatchedStopWordIsTheStopReasonStopSequenceOfTheMessageDelta`, `#theMatchedStopWordIsTheStopSequenceOfTheMessageDelta` (umgestellt). +- **Rot-Grün-Nachweis:** Ohne den Fix vier rot: „Expected: is "stop_sequence" but: was "end_turn"“ (beide blockierenden Fälle) und „Expected: a string containing "\"stop_reason\":\"stop_sequence\""“ (beide gestreamten); mit dem Fix grün (`AnthropicApiSupportTest` 29/29, `AnthropicStreamTranslatorTest` 8/8, `OpenAiCompatServerHttpTest` 68/68). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 17a7e1b72..38fdb0da8 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -417,7 +417,8 @@ static String toAnthropicResponse(String openAiCompletionJson, String model) { content.add(toolUseBlock(toolCall)); } } - stopReason = anthropicStopReason(choice.path("finish_reason").asText("stop")); + stopSequence = stopSequenceOf(completion); + stopReason = anthropicStopReason(choice.path("finish_reason").asText("stop"), stopSequence); JsonNode openAiUsage = completion.path("usage"); if (openAiUsage.isObject()) { long promptTokens = openAiUsage.path("prompt_tokens").asLong(0); @@ -429,7 +430,6 @@ static String toAnthropicResponse(String openAiCompletionJson, String model) { usage.put("cache_read_input_tokens", cachedTokens); usage.put("output_tokens", openAiUsage.path("completion_tokens").asLong(0)); } - stopSequence = stopSequenceOf(completion); } catch (IOException e) { stopReason = "end_turn"; } @@ -473,7 +473,8 @@ private static JsonNode parseToObject(JsonNode arguments) { } /** - * The stop word that ended the answer, as upstream reports it in {@code stop_sequence}: read from the + * The stop word that ended the answer, as upstream reports it in {@code stop_sequence} (the stop + * reason that goes with it is {@link #anthropicStopReason(String, String)}'s): read from the * {@code __verbose} object the translated request asks for, {@code null} when generation ended * otherwise or the object is absent. * @@ -485,7 +486,32 @@ private static JsonNode parseToObject(JsonNode arguments) { return word.isEmpty() ? null : word; } - /** Map an OpenAI finish_reason to an Anthropic stop_reason. */ + /** + * Map an OpenAI finish_reason and the matched stop word to an Anthropic stop_reason: a {@code stop} + * that a stop word ended is {@code stop_sequence}, as the Messages API specifies ("If the model + * encounters one of the custom sequences, the response stop_reason value will be "stop_sequence""), + * every other case is {@link #anthropicStopReason(String)}'s. + * + *

    This departs from upstream llama.cpp on purpose: its {@code to_json_anthropic} and + * {@code to_json_anthropic_stream} (b11639 and master) answer {@code end_turn} for + * {@code STOP_TYPE_WORD} as for the end-of-generation token, which leaves a client that branches on + * {@code stop_reason} unable to tell a stop-word stop from a natural end. The word is non-null + * exactly for a stop-word stop (upstream sets {@code stopping_word} only then), and such a stop + * always has the finish reason {@code stop}, so {@code tool_use} and {@code max_tokens} stay as they + * are. + * + * @param openAiFinishReason the OpenAI finish reason + * @param stopSequence the matched stop word ({@link #stopSequenceOf(JsonNode)}), or {@code null} + * @return the Anthropic stop reason + */ + static String anthropicStopReason(String openAiFinishReason, @Nullable String stopSequence) { + if (stopSequence != null && "stop".equals(openAiFinishReason)) { + return "stop_sequence"; + } + return anthropicStopReason(openAiFinishReason); + } + + /** Map an OpenAI finish_reason to an Anthropic stop_reason, without regard to a stop word. */ static String anthropicStopReason(String openAiFinishReason) { switch (openAiFinishReason) { case "length": diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java index 8fab8965b..a64e9539b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java @@ -136,7 +136,7 @@ String end() { } } out.append(AnthropicApiSupport.messageDeltaEvent( - AnthropicApiSupport.anthropicStopReason(finishReason), + AnthropicApiSupport.anthropicStopReason(finishReason, stopSequence), stopSequence, inputTokens, outputTokens, diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 28d7c02ed..aa76ad844 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -259,9 +259,10 @@ public void requestConcatenatesSystemBlocksAndMapsStopSequences() throws IOExcep assertThat(openAi.path("stop").get(0).asText(), is("X")); } - // Upstream reports the matched stop word as stop_sequence (stop_reason stays end_turn). The OpenAI - // answer the route translates from does not carry it, so the translation asks for upstream's - // "verbose" answer, whose __verbose.stopping_word does (F183). + // The matched stop word is the stop_sequence, and stop_reason is "stop_sequence" as the Messages API + // specifies (upstream reports end_turn, F221). The OpenAI answer the route translates from does not + // carry the word, so the translation asks for upstream's "verbose" answer, whose + // __verbose.stopping_word does (F183). @Test public void theTranslatedRequestAsksForTheStopWord() throws IOException { @@ -280,11 +281,33 @@ public void theMatchedStopWordIsTheStopSequence() throws IOException { "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"a\"}," + "\"finish_reason\":\"stop\"}],\"__verbose\":{\"stopping_word\":\"X\",\"prompt\":\"p\"}}", "m")); - assertThat(response.path("stop_reason").asText(), is("end_turn")); + assertThat(response.path("stop_reason").asText(), is("stop_sequence")); assertThat(response.path("stop_sequence").asText(), is("X")); assertThat("the debug object stays inside", response.has("__verbose"), is(false)); } + @Test + public void aMatchedStopWordIsReportedAsStopReasonStopSequence() throws IOException { + // The Messages API: "If the model encounters one of the custom sequences, the response + // stop_reason value will be "stop_sequence"". end_turn made a stop-word stop look like a natural + // end to a client that branches on stop_reason. + JsonNode response = read(AnthropicApiSupport.toAnthropicResponse( + "{\"choices\":[{\"message\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]," + + "\"__verbose\":{\"stopping_word\":\"X\"}}", + "m")); + assertThat(response.path("stop_reason").asText(), is("stop_sequence")); + assertThat(response.path("stop_sequence").asText(), is("X")); + } + + @Test + public void withoutAStopWordAStopIsStillEndTurn() throws IOException { + JsonNode response = read(AnthropicApiSupport.toAnthropicResponse( + "{\"choices\":[{\"message\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]," + + "\"__verbose\":{\"stopping_word\":\"\"}}", + "m")); + assertThat(response.path("stop_reason").asText(), is("end_turn")); + } + @Test public void noMatchedStopWordIsANullStopSequence() throws IOException { JsonNode eos = read(AnthropicApiSupport.toAnthropicResponse( @@ -375,6 +398,14 @@ public void stopReasonMapping() { assertThat(AnthropicApiSupport.anthropicStopReason("tool_calls"), is("tool_use")); } + @Test + public void onlyAStopThatAStopWordEndedIsStopSequence() { + assertThat(AnthropicApiSupport.anthropicStopReason("stop", "X"), is("stop_sequence")); + assertThat(AnthropicApiSupport.anthropicStopReason("stop", null), is("end_turn")); + assertThat(AnthropicApiSupport.anthropicStopReason("tool_calls", "X"), is("tool_use")); + assertThat(AnthropicApiSupport.anthropicStopReason("length", "X"), is("max_tokens")); + } + @Test public void sseEventBuildersAreWellFormed() throws IOException { String start = AnthropicApiSupport.messageStartEvent("msg_1", "m"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java index f35fbceee..31f6bb2c7 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java @@ -44,10 +44,22 @@ public void theMatchedStopWordIsTheStopSequenceOfTheMessageDelta() { + "\"__verbose\":{\"stopping_word\":\"END\"}}"); assertThat(last.contains("__verbose"), is(false)); String end = translator.end(); - assertThat(end, containsString("\"stop_reason\":\"end_turn\"")); + assertThat(end, containsString("\"stop_reason\":\"stop_sequence\"")); assertThat(end, containsString("\"stop_sequence\":\"END\"")); } + @Test + public void aMatchedStopWordIsTheStopReasonStopSequenceOfTheMessageDelta() { + // As the Messages API specifies for a matched stop sequence; upstream says end_turn (F221). + AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}"); + translator.onChunk("{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]," + + "\"__verbose\":{\"stopping_word\":\"END\"}}"); + String end = translator.end(); + assertThat(end, containsString("\"stop_reason\":\"stop_sequence\"")); + assertThat(end.contains("\"stop_reason\":\"end_turn\""), is(false)); + } + @Test public void withoutAStopWordTheStopSequenceIsNull() { AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); From e6a6f691e6219f69f31d8094780fc80d60a2bdbc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:31:41 +0000 Subject: [PATCH 378/589] Record the commit of F221 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 8263f1dee..265f75356 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1750,7 +1750,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#aMatchedStopWordIsReportedAsStopReasonStopSequence`, `#theMatchedStopWordIsTheStopSequence` (Erwartung von `end_turn` auf `stop_sequence` umgestellt), `#onlyAStopThatAStopWordEndedIsStopSequence` (die drei unveränderten Zweige mit Wort und `stop` ohne Wort), als Schutz `#withoutAStopWordAStopIsStillEndTurn`; `AnthropicStreamTranslatorTest#aMatchedStopWordIsTheStopReasonStopSequenceOfTheMessageDelta`, `#theMatchedStopWordIsTheStopSequenceOfTheMessageDelta` (umgestellt). - **Rot-Grün-Nachweis:** Ohne den Fix vier rot: „Expected: is "stop_sequence" but: was "end_turn"“ (beide blockierenden Fälle) und „Expected: a string containing "\"stop_reason\":\"stop_sequence\""“ (beide gestreamten); mit dem Fix grün (`AnthropicApiSupportTest` 29/29, `AnthropicStreamTranslatorTest` 8/8, `OpenAiCompatServerHttpTest` 68/68). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `a1be394` ## Offene Verdachte ohne Fund-ID From cb123a915ed19675b66e94a64708b1750eaf089c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:36:03 +0000 Subject: [PATCH 379/589] Close the text block before a new streamed thinking block (F222) Upstream's stream diffs emit a reasoning delta whenever the parsed reasoning grows, also after the content began (GPT-OSS and Gemma 4 interleave thinking and text). The reasoning branch of the Anthropic stream translator opened a second thinking block without closing the open text block, so reasoning a, text b, reasoning c, text d gave two blocks open at once and the delta "d" went to index 1 after block 2 had already stopped. A closeTextBlock helper, the counterpart of closeThinkingBlock, now closes it first (end() uses it too), so the blocks follow one another: 0 thinking, 1 text, 2 thinking, 3 text. Red-green: the new AnthropicStreamTranslatorTest case reasoningAfterTextClosesTheTextBlockBeforeANewThinkingBlock failed before the change ("Expected: a string containing "\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"d\"}"") and passes after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++ .../server/AnthropicStreamTranslator.java | 21 ++++++++--- .../server/AnthropicStreamTranslatorTest.java | 35 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 265f75356..2084a6cfe 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -144,6 +144,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F219](#f219) — niedrig — *gefixt* - [F220](#f220) — niedrig — *gefixt* - [F221](#f221) — mittel — *gefixt* +- [F222](#f222) — niedrig — *gefixt* ## Funde @@ -1752,6 +1753,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `a1be394` +### F222 – java-llama.cpp: Gestreamte Anthropic-Antwort öffnet bei Denkinhalt nach Text einen zweiten Thinking-Block, während der Textblock offen bleibt +- **Zeit:** 2026-10-20 22:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (`onChunk`, `end`, `closeTextBlock` neu) +- **Schwere:** niedrig (braucht einen Parser, der den Denkinhalt nach begonnenem Text verlängert; die SDK-Akkumulatoren sammeln nach Index und setzen den Inhalt trotzdem zusammen, ein sequenziell darstellender Client hängt Text an einen Block, der sichtbar hinter einem späteren Thinking-Block liegt) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J4-3). F81 führte die Thinking-Blöcke im Stream ein, mit dem erklärten Ziel, dass die Blöcke einander folgen, statt wie bei upstream bis zum Ende offen zu bleiben; der Textpfad schloss dafür einen offenen Thinking-Block, der Reasoning-Pfad den Textblock nicht. +- **Beschreibung:** `common_chat_msg_diff::compute_diffs` (`common/chat.cpp:236-252`, b11639) sendet ein `reasoning_content`-Delta, wann immer der geparste Denkinhalt wächst, unabhängig davon, ob schon Text kam, und die PEG-Mapper hängen jeden Reasoning-Knoten an (`chat-peg-parser.cpp:321`, „GPT OSS can have more than 1 reasoning block“; Gemma 4: `message = thought + content`, mehrfach). Für Denken „a“, Text „b“, Denken „c“, Text „d“ lieferte der Übersetzer: Thinking 0 (Start, Delta, Signatur, Stopp), Text 1 (Start, Delta „b“), Thinking 2 (Start, Delta „c“) bei noch offenem Text 1, Signatur und Stopp von 2, dann das Delta „d“ wieder an Index 1 und erst in `end()` den Stopp von 1 — zwei Blöcke gleichzeitig offen, ein Delta an einen Block nach dem Stopp des späteren. Die Streaming-Doku beschreibt „a series of content blocks, each of which has a content_block_start, one or more content_block_delta events, and a content_block_stop event“. +- **Fix:** `closeTextBlock` (Gegenstück zu `closeThinkingBlock`) schließt einen offenen Textblock, bevor ein Thinking-Block geöffnet wird; `end()` nutzt dieselbe Methode. Höchstens ein Block ist offen, das Beispiel ergibt 0 Thinking, 1 Text, 2 Thinking, 3 Text, wie Claude selbst mehrere Thinking-Blöcke streamt. Die Klassen-Javadoc nennt die Regel und die bewusste Asymmetrie: `toAnthropicResponse` kann die Verschränkung nicht abbilden, weil die Chat-Nachricht den Denkinhalt als einen verketteten String trägt. +- **Tests:** `AnthropicStreamTranslatorTest#reasoningAfterTextClosesTheTextBlockBeforeANewThinkingBlock` (zweiter Thinking-Start an Index 2, letztes Textdelta an Index 3, Stopp von 1 vor dem zweiten Thinking-Start, kein Delta an 1 nach dessen Stopp, `end()` schließt genau einen Block, Index 3); die übrigen Tests der Klasse unverändert grün. +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: a string containing "\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"d\"}"“ (das Delta „d“ ging an Index 1); mit dem Fix grün (`AnthropicStreamTranslatorTest` 9/9, `AnthropicApiSupportTest` 29/29, `OpenAiCompatServerHttpTest` 68/68). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java index a64e9539b..8a9b3c2a3 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java @@ -17,6 +17,13 @@ * {@code content_block_delta}s + {@code content_block_stop}) → one {@code tool_use} block per tool call * (start + {@code input_json_delta} + stop) → {@code message_delta} (stop reason) → {@code message_stop}. * + *

    The blocks follow one another, at most one is open at a time: reasoning that arrives after text + * closes the text block and opens a new thinking block, and text after it a new text block (a parser + * that interleaves thinking and text, e.g. GPT-OSS or Gemma 4, gives thinking, text, thinking, text), as + * Claude streams several thinking blocks. The non-streamed {@link AnthropicApiSupport#toAnthropicResponse} + * cannot mirror that, because the chat message carries the reasoning as one concatenated string; an + * interleaved generation therefore has more blocks streamed than blocking, deliberately. + * *

    Text deltas are emitted live; tool calls are reconstructed via {@link ToolCallDeltaAccumulator} and * emitted as whole blocks at the end (Anthropic expects each tool_use block's input as one * {@code input_json_delta}). Free of JNI / model dependencies; unit-testable by feeding chunk JSON. @@ -85,6 +92,7 @@ String onChunk(String openAiChunkJson) { // Reasoning streams as a thinking block, as in upstream's to_json_anthropic_stream. JsonNode reasoning = choice.path("delta").path("reasoning_content"); if (reasoning.isTextual() && !reasoning.asText().isEmpty()) { + closeTextBlock(out); if (!thinkingBlockOpen) { thinkingBlockIndex = nextIndex++; out.append(AnthropicApiSupport.thinkingBlockStartEvent(thinkingBlockIndex)); @@ -120,10 +128,7 @@ String onChunk(String openAiChunkJson) { String end() { StringBuilder out = new StringBuilder(); closeThinkingBlock(out); - if (textBlockOpen) { - out.append(AnthropicApiSupport.blockStopEvent(textBlockIndex)); - textBlockOpen = false; - } + closeTextBlock(out); if (accumulator.hasToolCalls()) { for (JsonNode toolCall : accumulator.toOpenAiToolCalls()) { int index = nextIndex++; @@ -153,4 +158,12 @@ private void closeThinkingBlock(StringBuilder out) { thinkingBlockOpen = false; } } + + /** Close an open text block, so the next text delta opens a new one after the block in between. */ + private void closeTextBlock(StringBuilder out) { + if (textBlockOpen) { + out.append(AnthropicApiSupport.blockStopEvent(textBlockIndex)); + textBlockOpen = false; + } + } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java index 31f6bb2c7..1cf807280 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicStreamTranslatorTest.java @@ -104,6 +104,41 @@ public void reasoningDeltasStreamAsAThinkingBlockClosedWithASignatureBeforeTheTe assertThat(events, containsString("\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"42\"}")); } + @Test + public void reasoningAfterTextClosesTheTextBlockBeforeANewThinkingBlock() { + // A parser can grow the reasoning after the content started (interleaved thinking: GPT-OSS, + // Gemma 4). The blocks must still follow one another, each start, deltas, stop, as Claude itself + // streams several thinking blocks: 0 thinking, 1 text, 2 thinking, 3 text (F222). + AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); + String events = translator.onChunk("{\"choices\":[{\"delta\":{\"reasoning_content\":\"a\"}}]}") + + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"b\"}}]}") + + translator.onChunk("{\"choices\":[{\"delta\":{\"reasoning_content\":\"c\"}}]}") + + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"d\"}}]}"); + String end = translator.end(); + events += end; + + assertThat(events, containsString("\"index\":2,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}")); + assertThat(events, containsString("\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"d\"}")); + int textStop = events.indexOf("\"type\":\"content_block_stop\",\"index\":1"); + int secondThinkingStart = + events.indexOf("{\"type\":\"thinking\",\"thinking\":\"\"}", events.indexOf("\"text\":\"b\"")); + assertThat( + "the text block stops before the second thinking block starts", + textStop >= 0 && textStop < secondThinkingStart, + is(true)); + assertThat( + "no delta reaches the text block after its stop", + events.lastIndexOf("\"index\":1,\"delta\"") < textStop, + is(true)); + int messageDelta = end.indexOf("event: message_delta"); + String closing = end.substring(0, messageDelta); + assertThat( + "end() closes exactly one block, the last text block", + closing.split("event: content_block_stop", -1).length - 1, + is(1)); + assertThat(closing, containsString("\"type\":\"content_block_stop\",\"index\":3")); + } + @Test public void accumulatedToolCallsBecomeToolUseBlocksAtEnd() { AnthropicStreamTranslator translator = new AnthropicStreamTranslator("msg_1", "m"); From 39c36fffb495e9b8ca522a5fe7ae9edbd68f030d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:36:03 +0000 Subject: [PATCH 380/589] Record the commit of F222 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2084a6cfe..ea2541da6 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1763,7 +1763,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicStreamTranslatorTest#reasoningAfterTextClosesTheTextBlockBeforeANewThinkingBlock` (zweiter Thinking-Start an Index 2, letztes Textdelta an Index 3, Stopp von 1 vor dem zweiten Thinking-Start, kein Delta an 1 nach dessen Stopp, `end()` schließt genau einen Block, Index 3); die übrigen Tests der Klasse unverändert grün. - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: a string containing "\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"d\"}"“ (das Delta „d“ ging an Index 1); mit dem Fix grün (`AnthropicStreamTranslatorTest` 9/9, `AnthropicApiSupportTest` 29/29, `OpenAiCompatServerHttpTest` 68/68). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `e1de898` ## Offene Verdachte ohne Fund-ID From 82bef4a38dd1bd75b4cbd619afad93047d3774b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:41:07 +0000 Subject: [PATCH 381/589] Refuse an Anthropic max_tokens that is not a number (F223) The translation applied the 4096 default whenever max_tokens was not a number, so "max_tokens": "100" or true was answered silently with a 4096 cap. Upstream copies the value and its schema refuses a string or a boolean with 400, while it treats null as absent. The translation now keeps the default for an absent or null value and throws IllegalArgumentException for anything else; handleAnthropicMessages translates inside its own try before the stream branch, as the Responses handler does, so the refusal is Anthropic's 400 invalid_request_error streamed or not instead of a dropped connection. Red-green: before the change the new AnthropicApiSupportTest case failed for "100" with "Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." and the new HTTP test with "Expected: is <400> but: was <200>"; both pass after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++++++ .../llama/server/AnthropicApiSupport.java | 12 +++++++--- .../llama/server/OpenAiCompatServer.java | 10 +++++++- .../llama/server/AnthropicApiSupportTest.java | 24 +++++++++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 21 ++++++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ea2541da6..93a0d51ee 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -145,6 +145,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F220](#f220) — niedrig — *gefixt* - [F221](#f221) — mittel — *gefixt* - [F222](#f222) — niedrig — *gefixt* +- [F223](#f223) — niedrig — *gefixt* ## Funde @@ -1765,6 +1766,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `e1de898` +### F223 – java-llama.cpp: Anthropic `max_tokens`, das keine Zahl ist, wird still durch die Vorgabe 4096 ersetzt statt abgewiesen +- **Zeit:** 2026-10-20 22:40 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest`), `OpenAiCompatServer.java` (`handleAnthropicMessages`) +- **Schwere:** niedrig (ein Client mit einem falsch typisierten `max_tokens`, etwa `"100"`, bekam ohne Hinweis eine Obergrenze von 4096 statt seiner) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J4-4). F96 führte die Vorgabe 4096 für ein fehlendes `max_tokens` ein und prüfte dafür „keine Zahl“ statt „fehlt“; vor F96 ließ `copyNumber` einen Nicht-Zahl-Wert ebenso still weg (dann unbegrenzt). +- **Beschreibung:** Die Messages-API typisiert `max_tokens` als Pflicht-Ganzzahl; upstream (`convert_anthropic_to_oai`, `server-chat.cpp:586-589`, b11639) kopiert den Wert wie er ist, und das Schema (`field_num::eval`, `server-schema.cpp:531-549`) weist einen String oder Boolean mit „Field 'max_tokens': … type must be number“ ab (400), behandelt `null` aber wie ein fehlendes Feld (`has_value`, „clients can send null to request the server default“). Die Übersetzung verzweigte auf `isNumber()`: `"max_tokens": "100"` und `true` liefen still mit 4096. Den Wert einfach durchzureichen hilft hier nicht, weil der Mapper (`OpenAiRequestMapper.readMaxTokens`) eine Nicht-Zahl selbst als -1 (unbegrenzt) liest, bevor upstreams Schema sie sieht. Außerdem rief `handleAnthropicMessages` die Übersetzung außerhalb des `try` auf, das eine `IllegalArgumentException` als 400 beantwortet: Eine Ablehnung wäre als abgebrochene Verbindung angekommen. +- **Fix:** Eine Zahl wird kopiert, ein fehlendes Feld oder `null` bekommt wie bisher 4096, jeder andere Wert ist eine `IllegalArgumentException` („'max_tokens' must be an integer but was …“). `handleAnthropicMessages` übersetzt in einem eigenen `try` vor der Verzweigung auf `stream` (wie `handleResponses`) und antwortet mit Anthropics 400 `invalid_request_error`, gestreamt wie blockierend, bevor ein SSE-Strom beginnt. Nicht Teil dieses Funds: Dieselbe stille Klasse besteht auf der OpenAI-Route selbst (`OpenAiRequestMapper.readMaxTokens` liest `"100"` als -1 = unbegrenzt, upstream antwortet 400) — eigener Fund. +- **Tests:** `AnthropicApiSupportTest#aMaxTokensThatIsNotANumberIsRefused` (`"100"` und `true`, Meldung nennt `max_tokens`), als Schutz `#requestWithNullMaxTokensIsCappedLikeAbsent` (vorher und nachher grün) und `#requestWithoutMaxTokensIsCappedLikeUpstream`; `OpenAiCompatServerHttpTest#aStringMaxTokensOnTheAnthropicRouteIsA400InvalidRequestError` (`stream` false und true: 400, `"type":"invalid_request_error"`, nichts generiert). +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „"100" ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „Expected: is <400> but: was <200>“; nur mit der Übersetzungsseite des Fixes scheiterte der HTTP-Test mit „java.net.SocketException: Unexpected end of file from server“ (der Beleg, dass die Ablehnung ohne das verlegte `try` als abgebrochene Verbindung ankäme); mit dem ganzen Fix grün (`AnthropicApiSupportTest` 31/31, `AnthropicStreamTranslatorTest` 9/9, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 38fdb0da8..cf9c49429 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -62,6 +62,7 @@ static boolean isStreaming(JsonNode request) { * * @param request the parsed Anthropic request * @return an OpenAI {@code /v1/chat/completions} request object + * @throws IllegalArgumentException if {@code max_tokens} is present, not {@code null} and not a number */ static ObjectNode toOpenAiChatRequest(JsonNode request) { ObjectNode openAi = OBJECT_MAPPER.createObjectNode(); @@ -108,11 +109,16 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { } // As upstream's convert_anthropic_to_oai: the protocol requires max_tokens, a request without - // it is capped at upstream's default instead of generating until the context is full. - if (request.path("max_tokens").isNumber()) { + // it is capped at upstream's default instead of generating until the context is full. null + // counts as absent, as in upstream's schema ("clients can send null to request the server + // default"); any other value that is not a number upstream's schema refuses, and so does this. + JsonNode maxTokens = request.path("max_tokens"); + if (maxTokens.isNumber()) { copyNumber(request, "max_tokens", openAi, "max_tokens"); - } else { + } else if (maxTokens.isMissingNode() || maxTokens.isNull()) { openAi.put("max_tokens", DEFAULT_MAX_TOKENS); + } else { + throw new IllegalArgumentException("'max_tokens' must be an integer but was " + maxTokens); } copyNumber(request, "temperature", openAi, "temperature"); copyNumber(request, "top_p", openAi, "top_p"); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 60f3f8eec..0e42edcb4 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -705,7 +705,15 @@ private void handleAnthropicMessages(HttpExchange exchange) throws IOException { if (request == null) { return; } - JsonNode openAiRequest = AnthropicApiSupport.toOpenAiChatRequest(request); + final JsonNode openAiRequest; + try { + openAiRequest = AnthropicApiSupport.toOpenAiChatRequest(request); + } catch (IllegalArgumentException e) { + // A request the translation refuses (a max_tokens that is not a number), answered before + // any stream starts, like upstream. + sendJson(exchange, HTTP_BAD_REQUEST, anthropicError(message(e), ANTHROPIC_ERROR_REQUEST)); + return; + } String model = request.path("model").asText(config.getModelId()); if (AnthropicApiSupport.isStreaming(request)) { streamAnthropic(exchange, openAiRequest, model); diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index aa76ad844..c87b9feb6 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -5,7 +5,9 @@ package net.ladenthin.llama.server; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -159,6 +161,28 @@ public void requestWithoutMaxTokensIsCappedLikeUpstream() throws IOException { assertThat(openAi.path("max_tokens").asInt(), is(4096)); } + @Test + public void aMaxTokensThatIsNotANumberIsRefused() throws IOException { + // The protocol types max_tokens as an integer, and upstream's schema refuses a string or a + // boolean (Field 'max_tokens': type must be number). The default for an absent value answered + // such a request silently with a 4096 cap (F223). + for (String value : new String[] {"\"100\"", "true"}) { + JsonNode request = + read("{\"max_tokens\":" + value + ",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> AnthropicApiSupport.toOpenAiChatRequest(request), value); + assertThat(e.getMessage(), containsString("max_tokens")); + } + } + + @Test + public void requestWithNullMaxTokensIsCappedLikeAbsent() throws IOException { + // upstream's schema treats null as absent ("clients can send null to request the server default") + JsonNode openAi = AnthropicApiSupport.toOpenAiChatRequest( + read("{\"max_tokens\":null,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")); + assertThat(openAi.path("max_tokens").asInt(), is(4096)); + } + @Test public void requestPassesChatTemplateKwargsLikeUpstream() throws IOException { // upstream passes chat_template_kwargs through (e.g. Qwen3's enable_thinking); the shared chat diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 3bd26f889..dfcd7de23 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -829,6 +829,27 @@ public void aNonObjectOllamaChatMessageIsA400NotADroppedConnection() throws IOEx } } + /** + * A {@code max_tokens} that is not a number is refused by the Anthropic translation (F223); the + * refusal must reach the client as Anthropic's 400, streamed or not, before any SSE starts and + * without generating anything. + */ + @Test + public void aStringMaxTokensOnTheAnthropicRouteIsA400InvalidRequestError() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + for (String stream : new String[] {"false", "true"}) { + String body = "{\"model\":\"test-model\",\"max_tokens\":\"100\",\"stream\":" + stream + + ",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + Response response = post(server.getPort(), "/v1/messages", body, ""); + assertThat(body, response.code, is(400)); + assertThat(body, response.body, containsString("\"type\":\"invalid_request_error\"")); + assertThat(body, response.body, containsString("max_tokens")); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + @Test public void aPlainLlamaExceptionStaysAServerError() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FailingBackend(), config()).start()) { From 2639344ca30328811bd924be45427000a1dbece6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:41:08 +0000 Subject: [PATCH 382/589] Record the commit of F223 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 93a0d51ee..3478e6576 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1776,7 +1776,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#aMaxTokensThatIsNotANumberIsRefused` (`"100"` und `true`, Meldung nennt `max_tokens`), als Schutz `#requestWithNullMaxTokensIsCappedLikeAbsent` (vorher und nachher grün) und `#requestWithoutMaxTokensIsCappedLikeUpstream`; `OpenAiCompatServerHttpTest#aStringMaxTokensOnTheAnthropicRouteIsA400InvalidRequestError` (`stream` false und true: 400, `"type":"invalid_request_error"`, nichts generiert). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „"100" ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „Expected: is <400> but: was <200>“; nur mit der Übersetzungsseite des Fixes scheiterte der HTTP-Test mit „java.net.SocketException: Unexpected end of file from server“ (der Beleg, dass die Ablehnung ohne das verlegte `try` als abgebrochene Verbindung ankäme); mit dem ganzen Fix grün (`AnthropicApiSupportTest` 31/31, `AnthropicStreamTranslatorTest` 9/9, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `4849673` ## Offene Verdachte ohne Fund-ID From e3720194223080e03ebaf0ca0e5ea607016ec90b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:04:02 +0000 Subject: [PATCH 383/589] Refuse Responses images without a URL and foreign tool parts (F224) An input_image given by file_id carried no URL and was dropped, so the message reached the model as text alone; a function_call_output part of another type, without a type, or an image in the {url} object form was skipped, leaving the tool message incomplete or empty. Upstream's convert_responses_to_chatcmpl answers 400 in each case. Both paths now read the image URL through one helper (string or {url}) and refuse the rest with upstream's messages plus the file_id or call_id, which handleResponses already answers as 400. The five new ResponsesApiSupportTest cases were red without the fix ("Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." four times, 'Expected: is "image_url" but: was ""' once) and are green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 +++++ .../llama/server/ResponsesApiSupport.java | 63 ++++++++++++++----- .../llama/server/ResponsesApiSupportTest.java | 62 ++++++++++++++++++ 3 files changed, 124 insertions(+), 16 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3478e6576..141d3f631 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -146,6 +146,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F221](#f221) — mittel — *gefixt* - [F222](#f222) — niedrig — *gefixt* - [F223](#f223) — niedrig — *gefixt* +- [F224](#f224) — niedrig — *gefixt* ## Funde @@ -247,6 +248,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Der Translator flachte jedes Content-Array einer `message` auf seinen Text ab (`inputContentText` liest nur `text`). Ein `{"type":"input_image","image_url":"data:…"}`-Teil kam nie im Chat-Kern an; ein Vision-Modell antwortete, als wäre kein Bild geschickt worden – still, ohne Fehler. Gleiche Fehlerklasse wie der Ollama-`images`-Fund oben. - **Fix:** Enthält das Array `input_image`-Teile, wird ein OpenAI-Content-Part-Array gebaut (`text`-Teile + `image_url`-Teile; `image_url` als String oder `{url}`). Reine Text-Nachrichten bleiben unverändert ein String. `file_id`-Bilder (ohne URL) werden weiterhin ignoriert. - **Reproduktion:** `ResponsesApiSupportTest#requestTurnsInputImagePartsIntoImageUrlParts` (vorher rot, nachher 10/10 grün) +- **Nachtrag (Branch-Review 2026-10-20):** Der Satz „`file_id`-Bilder (ohne URL) werden weiterhin ignoriert“ gilt nicht mehr: ein `input_image` ohne URL wird seit F224 wie upstream mit 400 abgewiesen. - **Status:** gefixt - **Commit:** java-llama.cpp `4c08a90` @@ -760,6 +762,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** ein Array-`output` wird zu Inhaltsteilen (`input_text` → `text`, `input_image` → `image_url`), andere Teiltypen werden übersprungen (upstream weist sie ab); ein String bleibt wie bisher. - **Reproduktion:** `ResponsesApiSupportTest#functionCallOutputArrayKeepsItsPartsLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "text" but: was ""“; die erste Fassung des Tests lief über `get(0)` in eine NPE und wurde auf `path(0)` umgestellt, damit der Fehler an der Assertion sichtbar wird), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Nachtrag (Branch-Review 2026-10-20):** Andere Teiltypen, ein Teil ohne `type` und ein Bild ohne URL werden seit F224 wie upstream mit 400 abgewiesen statt übersprungen; die Objektform `{url}` wird wie im Nachrichtenzweig gelesen. - **Status:** gefixt - **Commit:** java-llama.cpp `28054ba` @@ -1778,6 +1781,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4849673` +### F224 – java-llama.cpp: Responses `input_image` ohne URL und fremde Werkzeugergebnis-Teile werden still verworfen statt abgewiesen +- **Zeit:** 2026-10-20 23:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`inputContentWithImages`, `toolOutputParts`, neu `imageUrl`) +- **Schwere:** niedrig (ein Bild per `file_id` oder ein Werkzeugergebnis mit einem `output_text`-Teil kam ohne Hinweis nicht beim Modell an; es antwortete, als wäre es nicht geschickt worden) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-1). F17 ließ `file_id`-Bilder bewusst weg („werden weiterhin ignoriert“), F86 übersprang fremde Teiltypen eines Werkzeugergebnisses („upstream weist sie ab“); F89 hatte dieselbe Klasse für `input_file` schon in eine 400 verwandelt. +- **Beschreibung:** `inputContentWithImages` nahm einen `input_image`-Teil nur mit einer nicht leeren URL auf; `{"type":"input_image","file_id":"file-1"}` (nach dem OpenAI-Typ `ResponseInputImageParam` gültig, `image_url` und `file_id` sind beide optional) fiel weg, die Nachricht ging als reiner Text ans Modell. `toolOutputParts` übernahm nur `input_text` und ein `input_image` mit einem String-`image_url`; jeder andere Teil, ein Teil ohne `type` und die Objektform `{"url": …}` (die der Nachrichtenzweig annimmt) fielen weg, ein `[{"type":"output_text",…}]` ergab eine Werkzeugnachricht mit leerem Inhalt. upstream (`convert_responses_to_chatcmpl`, `server-chat.cpp:83-85, 207-216`, b11639) antwortet in jedem dieser Fälle 400: „'image_url' is required“, „Output of tool call missing 'type' field“, „Output of tool call should be 'Input text' or 'Input image'“. +- **Fix:** Ein gemeinsamer Helfer `imageUrl` liest die URL auf beiden Wegen gleich (String oder `{url}`, eine bewusst beibehaltene Erweiterung von F17). Ein `input_image` ohne URL ist eine `IllegalArgumentException` mit upstreams Text plus der `file_id`; im Werkzeugergebnis ebenso ein Teil ohne `type` und ein Teil eines anderen Typs, jeweils mit upstreams Text, dem Typ und der `call_id`. `handleResponses` beantwortet die Ausnahme schon als 400 (F89). Bewusst schärfer als upstream: ein leerer `image_url`-String gilt als fehlend (upstream scheitert daran später in `handle_media`). Nicht geändert: unbekannte Teiltypen einer `message` bleiben Text bzw. werden übergangen, weil derselbe Zweig Assistenten-Nachrichten mit `output_text`/`refusal` trägt (F173). +- **Tests:** `ResponsesApiSupportTest#inputImageWithoutAUrlIsRejectedLikeUpstream`, `#toolOutputPartOfAnotherTypeIsRejectedLikeUpstream`, `#toolOutputPartWithoutATypeIsRejectedLikeUpstream`, `#toolOutputImageWithoutAUrlIsRejectedLikeUpstream`, `#toolOutputImageInObjectFormKeepsItsUrl`; die 400-Abbildung pinnt schon `OpenAiCompatServerHttpTest#responsesWithAnInputFileIsRejectedLikeUpstream`. +- **Rot-Grün-Nachweis:** Ohne den Fix rot (5 Fehlschläge: viermal „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, einmal „Expected: is "image_url" but: was ""“), mit dem Fix grün (`ResponsesApiSupportTest` 59/59, `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index eb3478d7d..ebbc3fd2b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -226,7 +226,9 @@ private static void appendInput(ArrayNode messages, JsonNode input) { toolMessage.put("tool_call_id", item.path("call_id").asText("")); JsonNode output = item.path("output"); if (output.isArray()) { - toolMessage.set("content", toolOutputParts(output)); + toolMessage.set( + "content", + toolOutputParts(output, item.path("call_id").asText(""))); } else { toolMessage.put("content", output.asText("")); } @@ -258,7 +260,10 @@ private static void appendInput(ArrayNode messages, JsonNode input) { * The OpenAI content-part array for a Responses content array that carries {@code input_image} * parts, or {@code null} when it carries none. The OpenAI parser reads images only from * {@code image_url} parts, so flattening such a message to its text dropped the image and a - * vision model answered as if none had been sent. + * vision model answered as if none had been sent. An {@code input_image} without a URL (one given + * by {@code file_id}) is refused as upstream refuses it; dropping it answered the text alone. + * + * @throws IllegalArgumentException for an {@code input_image} part without a URL */ private static @Nullable ArrayNode inputContentWithImages(JsonNode content) { if (!content.isArray()) { @@ -268,17 +273,16 @@ private static void appendInput(ArrayNode messages, JsonNode input) { boolean hasImage = false; for (JsonNode part : content) { if ("input_image".equals(part.path("type").asText(""))) { - JsonNode imageUrl = part.path("image_url"); - String url = imageUrl.isTextual() - ? imageUrl.asText() - : imageUrl.path("url").asText(""); - if (!url.isEmpty()) { - parts.addObject() - .put("type", "image_url") - .putObject("image_url") - .put("url", url); - hasImage = true; + String url = imageUrl(part); + if (url.isEmpty()) { + throw new IllegalArgumentException("'image_url' is required (input_image with file_id " + + part.path("file_id").asText("") + " is not supported)"); } + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", url); + hasImage = true; } else { String text = partText(part); if (text != null) { @@ -311,26 +315,53 @@ private static ArrayNode assistantToolCalls(ArrayNode messages) { * The content parts of a {@code function_call_output} whose {@code output} is an array: {@code * input_text} becomes a {@code text} part, {@code input_image} an {@code image_url} part, as in * upstream's {@code convert_responses_to_chatcmpl}. Reading such an output as text gave the tool - * message an empty content. Other part types are skipped (upstream rejects them). + * message an empty content. A part without a type, of another type, or an image without a URL is + * refused with upstream's messages; skipping it handed the model an incomplete tool result. + * + * @param output the {@code output} array + * @param callId the {@code call_id} of the item, named in a refusal + * @return the content parts of the tool message + * @throws IllegalArgumentException for a part upstream refuses */ - private static ArrayNode toolOutputParts(JsonNode output) { + private static ArrayNode toolOutputParts(JsonNode output, String callId) { ArrayNode parts = OBJECT_MAPPER.createArrayNode(); for (JsonNode part : output) { + if (!part.has("type")) { + throw new IllegalArgumentException("Output of tool call missing 'type' field (call_id " + callId + ")"); + } String type = part.path("type").asText(""); if ("input_text".equals(type)) { parts.addObject() .put("type", "text") .put("text", part.path("text").asText("")); - } else if ("input_image".equals(type) && part.path("image_url").isTextual()) { + } else if ("input_image".equals(type)) { + String url = imageUrl(part); + if (url.isEmpty()) { + throw new IllegalArgumentException( + "'image_url' is required (call_id " + callId + ", input_image with file_id " + + part.path("file_id").asText("") + " is not supported)"); + } parts.addObject() .put("type", "image_url") .putObject("image_url") - .put("url", part.path("image_url").asText()); + .put("url", url); + } else { + throw new IllegalArgumentException("Output of tool call should be 'Input text' or 'Input image' (got '" + + type + "', call_id " + callId + ")"); } } return parts; } + /** + * The URL of an {@code input_image} part: {@code image_url} as the string the Responses API defines, + * or as {@code {"url": ...}} (accepted on both paths alike); {@code ""} when there is none. + */ + private static String imageUrl(JsonNode part) { + JsonNode imageUrl = part.path("image_url"); + return imageUrl.isTextual() ? imageUrl.asText() : imageUrl.path("url").asText(""); + } + /** The last message if it is an assistant message, else {@code null} (upstream's {@code merge_prev}). */ private static @Nullable ObjectNode lastAssistant(ArrayNode messages) { int size = messages.size(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 17c1f6777..32e14c58c 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -157,6 +157,68 @@ public void functionCallOutputArrayKeepsItsPartsLikeUpstream() throws IOExceptio tool.path("content").path(1).path("image_url").path("url").asText(), is("data:image/png;base64,AA==")); } + @Test + public void inputImageWithoutAUrlIsRejectedLikeUpstream() throws IOException { + // An input_image given by file_id carries no URL; it used to be dropped, so the model answered + // the text as if no image had been sent. upstream's convert_responses_to_chatcmpl answers 400. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":[" + + "{\"type\":\"input_text\",\"text\":\"what\"}," + + "{\"type\":\"input_image\",\"file_id\":\"file-1\"}]}]}"))); + assertThat(e.getMessage(), containsString("'image_url' is required")); + assertThat(e.getMessage(), containsString("file-1")); + } + + @Test + public void toolOutputPartOfAnotherTypeIsRejectedLikeUpstream() throws IOException { + // A tool result part other than input_text / input_image was skipped, so the tool message + // reached the model empty; upstream refuses it with 400. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":[" + + "{\"type\":\"output_text\",\"text\":\"x\"}]}]}"))); + assertThat(e.getMessage(), containsString("Output of tool call should be 'Input text' or 'Input image'")); + assertThat(e.getMessage(), containsString("output_text")); + } + + @Test + public void toolOutputPartWithoutATypeIsRejectedLikeUpstream() throws IOException { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":[" + + "{\"text\":\"x\"}]}]}"))); + assertThat(e.getMessage(), containsString("Output of tool call missing 'type' field")); + } + + @Test + public void toolOutputImageWithoutAUrlIsRejectedLikeUpstream() throws IOException { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":[" + + "{\"type\":\"input_image\",\"file_id\":\"file-2\"}]}]}"))); + assertThat(e.getMessage(), containsString("'image_url' is required")); + assertThat(e.getMessage(), containsString("file-2")); + } + + @Test + public void toolOutputImageInObjectFormKeepsItsUrl() throws IOException { + // The message branch reads image_url as a string or as {url}; the tool-output branch read only + // the string and dropped the object form. Both read it the same way now. + JsonNode content = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call_output\",\"call_id\":\"c1\",\"output\":[" + + "{\"type\":\"input_image\",\"image_url\":{\"url\":\"data:image/png;base64,AA==\"}}]}]}")) + .path("messages") + .path(0) + .path("content"); + assertThat(content.path(0).path("type").asText(), is("image_url")); + assertThat(content.path(0).path("image_url").path("url").asText(), is("data:image/png;base64,AA==")); + } + @Test public void consecutiveFunctionCallsJoinThePrecedingAssistantMessageLikeUpstream() throws IOException { // Parallel tool calls follow the assistant's text as separate items; upstream's From c247160025d808143b92536ec86701b1150e2aee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:04:02 +0000 Subject: [PATCH 384/589] Record the commit of F224 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 141d3f631..f23cf7456 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1791,7 +1791,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#inputImageWithoutAUrlIsRejectedLikeUpstream`, `#toolOutputPartOfAnotherTypeIsRejectedLikeUpstream`, `#toolOutputPartWithoutATypeIsRejectedLikeUpstream`, `#toolOutputImageWithoutAUrlIsRejectedLikeUpstream`, `#toolOutputImageInObjectFormKeepsItsUrl`; die 400-Abbildung pinnt schon `OpenAiCompatServerHttpTest#responsesWithAnInputFileIsRejectedLikeUpstream`. - **Rot-Grün-Nachweis:** Ohne den Fix rot (5 Fehlschläge: viermal „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, einmal „Expected: is "image_url" but: was ""“), mit dem Fix grün (`ResponsesApiSupportTest` 59/59, `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `ffd3ae4` ## Offene Verdachte ohne Fund-ID From 4913acf9c73c5c90daf1d2f28e273278a2648461 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:08:12 +0000 Subject: [PATCH 385/589] Refuse Responses function_call arguments that are not a string (F225) Read as text, an object-valued function_call.arguments is "" in Jackson, which the F172 rule then turned into "{}": the history told the model it had called the tool without the arguments it was called with. The type is a JSON string, and upstream's convert_responses_to_chatcmpl accepts a function_call only with string arguments and answers 400 otherwise. Any value that is neither missing, null nor a string is now refused with the node type and call_id; missing, null and blank stay "{}" as F172 decided. functionCallWithArgumentsThatAreNotAStringIsRejectedLikeUpstream was red without the fix ('{"city":"Paris"} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.') and is green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +++++++++++++ .../llama/server/ResponsesApiSupport.java | 12 +++++++++-- .../llama/server/ResponsesApiSupportTest.java | 20 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f23cf7456..9880a813b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -147,6 +147,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F222](#f222) — niedrig — *gefixt* - [F223](#f223) — niedrig — *gefixt* - [F224](#f224) — niedrig — *gefixt* +- [F225](#f225) — niedrig — *gefixt* ## Funde @@ -1293,6 +1294,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: dieselbe Regel wie F166 – leere oder nur aus Leerraum bestehende Argumente (Responses) bzw. fehlender oder `null`-`input` (Anthropic) werden `"{}"`. - **Tests:** `ResponsesApiSupportTest#functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject` (fehlend, `""`, Leerraum), `AnthropicApiSupportTest#toolUseWithoutInputBecomesAnEmptyArgumentsObject` (fehlend, `null`). - **Rot-Grün-Nachweis:** ohne den Fix rot (beide, `was ""`), mit ihm grün (75 Tests der beiden Klassen). +- **Nachtrag (Branch-Review 2026-10-20):** Auf der Responses-Route wurden auch Objekt-`arguments` zu `"{}"` (Jacksons `asText` eines Containers ist `""`); seit F225 ist ein Wert, der weder fehlt noch `null` noch ein String ist, wie upstream eine 400. - **Status:** gefixt - **Commit:** java-llama.cpp `f1bf93b` @@ -1793,6 +1795,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ffd3ae4` +### F225 – java-llama.cpp: Responses `function_call` mit Objekt-`arguments` wird still zu `"{}"` +- **Zeit:** 2026-10-20 23:07 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, Fall `function_call`) +- **Schwere:** niedrig (ein Client, der die Argumente eines früheren Aufrufs als Objekt statt als JSON-Text zurückschickt, bekam einen Verlauf, in dem der Aufruf ohne Argumente gemacht wurde) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-2). F172 machte fehlende, leere und nur aus Leerraum bestehende `arguments` zu `"{}"` (Entscheidung des Nutzers, wie F166); vor F172 ergab ein Objekt `""`, das upstreams `json::parse` mit 400 abwies. +- **Beschreibung:** Die Übersetzung las `item.path("arguments").asText("")`. Für ein Objekt, ein Array, eine Zahl oder einen Wahrheitswert liefert Jacksons `asText` bei Containern `""` (Zahlen und Booleans als ihren Text), also wurde `"arguments": {"city":"Paris"}` zu `"{}"` – das Modell sah einen Aufruf, den es so nie gemacht hatte, ohne jeden Hinweis. Der OpenAI-Typ (`ResponseFunctionToolCallParam`) verlangt einen JSON-String, und upstream (`convert_responses_to_chatcmpl`, `server-chat.cpp:163-168, 256-257`, b11639) nimmt ein `function_call`-Item nur mit String-`arguments` an und antwortet sonst 400 („Cannot determine type of 'item'“). F172s Entscheidung deckte nur fehlende/leere Argumente, nicht diesen Fall. +- **Fix:** Ein `arguments`-Wert, der weder fehlt noch `null` noch ein String ist, ist eine `IllegalArgumentException` („'arguments' of a function_call must be a JSON string (got OBJECT, call_id c1)“), die `handleResponses` als 400 beantwortet. Fehlend, `null` und leer bleiben nach F172 `"{}"`, ein String geht unverändert weiter. Die verlustfreie Alternative (das Objekt als JSON-Text weiterreichen) wurde nicht gewählt: sie ginge über den OpenAI-Typ und upstream hinaus. +- **Tests:** `ResponsesApiSupportTest#functionCallWithArgumentsThatAreNotAStringIsRejectedLikeUpstream` (Objekt, Array, Zahl, Boolean; Meldung nennt `'arguments'` und die `call_id`); `#functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject` um `"arguments":null` erweitert (vorher und nachher grün, pinnt, dass `null` weiter `"{}"` ist). +- **Rot-Grün-Nachweis:** Ohne den Fix rot („{"city":"Paris"} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 60/60, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index ebbc3fd2b..6da8cf66c 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -216,8 +216,16 @@ private static void appendInput(ArrayNode messages, JsonNode input) { ObjectNode function = toolCall.putObject("function"); function.put("name", item.path("name").asText("")); // upstream json::parse()s every arguments string of the history, so an empty one - // fails the whole request; a call without arguments is "{}" (F166) - String arguments = item.path("arguments").asText(""); + // fails the whole request; a call without arguments is "{}" (F166). Arguments are a + // JSON string: upstream answers 400 for any other value, and read as text an object + // was "" and became "{}", a call the model never made that way. + JsonNode argumentsNode = item.path("arguments"); + if (!argumentsNode.isMissingNode() && !argumentsNode.isNull() && !argumentsNode.isTextual()) { + throw new IllegalArgumentException("'arguments' of a function_call must be a JSON string (got " + + argumentsNode.getNodeType() + ", call_id " + + item.path("call_id").asText("") + ")"); + } + String arguments = argumentsNode.asText(""); function.put("arguments", arguments.trim().isEmpty() ? "{}" : arguments); break; case "function_call_output": diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 32e14c58c..38a8f3a56 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -87,7 +87,8 @@ public void reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream() public void functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject() throws IOException { // Upstream parses every function.arguments of the history with json::parse, so an empty string // fails the whole request with 400; a call without arguments is "{}" (F166). - for (String arguments : new String[] {"", ",\"arguments\":\"\"", ",\"arguments\":\" \""}) { + for (String arguments : + new String[] {"", ",\"arguments\":\"\"", ",\"arguments\":\" \"", ",\"arguments\":null"}) { JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"now\"" + arguments + "}]}")) @@ -104,6 +105,23 @@ public void functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject() throws I } } + @Test + public void functionCallWithArgumentsThatAreNotAStringIsRejectedLikeUpstream() throws IOException { + // arguments is a JSON string; an object read as text was "" and became "{}", so the model saw a + // call without the arguments it was made with. upstream accepts a function_call only with string + // arguments and answers 400 otherwise. + for (String arguments : new String[] {"{\"city\":\"Paris\"}", "[1]", "3", "true"}) { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"function_call\",\"call_id\":\"c1\",\"name\":\"f\",\"arguments\":" + + arguments + "}]}")), + arguments); + assertThat(arguments, e.getMessage(), containsString("'arguments'")); + assertThat(arguments, e.getMessage(), containsString("c1")); + } + } + @Test public void refusalPartsOfTheHistoryAreKeptAsText() throws IOException { // A refusal part carries "refusal", not "text"; it was dropped, so the model never saw that it From baa8b8481e77aff75a31fc1a13c86983dcf563b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:08:12 +0000 Subject: [PATCH 386/589] Record the commit of F225 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9880a813b..9ca8733cd 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1805,7 +1805,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#functionCallWithArgumentsThatAreNotAStringIsRejectedLikeUpstream` (Objekt, Array, Zahl, Boolean; Meldung nennt `'arguments'` und die `call_id`); `#functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject` um `"arguments":null` erweitert (vorher und nachher grün, pinnt, dass `null` weiter `"{}"` ist). - **Rot-Grün-Nachweis:** Ohne den Fix rot („{"city":"Paris"} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 60/60, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `fdc800e` ## Offene Verdachte ohne Fund-ID From 88f823ef9127fbd57846441314171253b0ce0597 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:12:45 +0000 Subject: [PATCH 387/589] Forward logit_bias and n_probs on the Responses route (F226) The Responses translation copies only the fields of CHAT_PASSTHROUGH_FIELDS, whose Javadoc said they are the ones the chat mapper reads. The mapper learned logit_bias and n_probs later (F180) and the list was not updated, so a /v1/responses request ran without them while upstream's whole-body copy honours them. Both are in the list now, and the Javadoc names what is left out on purpose: the structural fields translated above, verbose and response_fields (the Responses output carries neither the debug object nor a projection), and chat's logprobs/top_logprobs (no probabilities in that output). The two new rows of requestFieldsTheChatMapperReadsPassThroughLikeUpstream were red without the fix ('Expected: is <{"42":-100}> but: was <>', 'Expected: is <3> but: was <>') and are green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 ++++++++++++++ .../llama/server/ResponsesApiSupport.java | 18 ++++++++++++++---- .../llama/server/ResponsesApiSupportTest.java | 4 +++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9ca8733cd..3cafae61a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -148,6 +148,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F223](#f223) — niedrig — *gefixt* - [F224](#f224) — niedrig — *gefixt* - [F225](#f225) — niedrig — *gefixt* +- [F226](#f226) — niedrig — *gefixt* ## Funde @@ -1399,6 +1400,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: `logit_bias` (Objekt oder Array) wird unverändert über die neue öffentliche `InferenceParameters.withLogitBiasJson` weitergereicht (ein JSON-Wert, wie die anderen Fragment-Setter geprüft), `n_probs` über `withNProbs`; `n > 1` ist eine ungültige Anfrage (400), weil diese Wege eine Antwort liefern; auf dem gestreamten `/v1/completions` sind `logprobs`/`n_probs` eine ungültige Anfrage mit dem Hinweis auf `stream: false`, bis (14) den Stream nativ macht. - **Tests:** `OpenAiRequestMapperTest#logitBiasAndNProbsAreForwardedOnBothPaths`, `#moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack`, `#probabilitiesOnTheStreamedCompletionAreRefused`, `#unknownFieldsIgnored` (ohne `n`); `InferenceParametersTest#withLogitBiasJson_storesTheValueAndRefusesASecondOne`. - **Rot-Grün-Nachweis:** mit Platzhalter-Setter und unverändertem Mapper rot (4 Tests), mit dem Fix grün (327 Tests der Mapper-, Parameter-, Registry- und Server-Klassen); SpotBugs grün. +- **Nachtrag (Branch-Review 2026-10-20):** Die Beschreibung stimmte für die Responses-Übersetzung nicht: deren Feldliste `CHAT_PASSTHROUGH_FIELDS` filtert, was den Mapper erreicht, und enthielt `logit_bias`/`n_probs` nicht; nachgezogen mit F226. - **Status:** gefixt (der Rest unter (14), Wahrscheinlichkeiten im gestreamten `/v1/completions`, mit F186) - **Commit:** java-llama.cpp `3d001d2` @@ -1807,6 +1809,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `fdc800e` +### F226 – java-llama.cpp: Responses-Route verwirft `logit_bias` und `n_probs` still, obwohl der Chat-Mapper sie liest +- **Zeit:** 2026-10-20 23:12 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`CHAT_PASSTHROUGH_FIELDS` und seine Javadoc) +- **Schwere:** niedrig (ein per `logit_bias` gesperrtes Token wurde auf `/v1/responses` trotzdem erzeugt, ohne Hinweis) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-3). Die Liste entstand mit F106 (`87635b2`) gegen den damaligen Mapper; F180 (`3d001d2`) brachte dem Mapper `logit_bias` und `n_probs` bei und vermerkte, das erreiche auch die Responses-Übersetzung – die Liste filtert aber, was kopiert wird, und wurde nicht nachgezogen. F183/F202 fügten `verbose` und `response_fields` hinzu, ebenfalls ohne die Liste. +- **Beschreibung:** Upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:15`, b11639) beginnt mit `json chatcmpl_body = response_body;` und kopiert damit jedes Feld; ein `logit_bias` auf upstreams `/v1/responses` erreicht das Schema (`server-schema.cpp:382`) und wirkt. Hier kopierte die Übersetzung nur die Felder ihrer Liste, deren Javadoc behauptete, es seien die, die der Chat-Mapper liest; `logit_bias` und `n_probs` fehlten, die Anfrage lief ohne sie. Der parametrisierte Test zählte nur die Felder der Liste auf und konnte eine Lücke nicht sehen. Auch die CHANGELOG-Zeile zu F180 (alle Chat-Routen einschließlich Responses reichen `logit_bias`/`n_probs` weiter) stimmte damit nicht. +- **Fix:** `logit_bias` und `n_probs` stehen in der Liste (`n_probs` aus Parität mit upstreams Annahme, sichtbar wird es nicht: die Responses-Ausgabe trägt hier wie upstream keine Wahrscheinlichkeiten). Die Javadoc sagt jetzt, was die Liste ist (die vom Mapper gelesenen Felder, deren Wirkung einen Responses-Client erreichen kann) und nennt die bewussten Auslassungen: die oben übersetzten Strukturfelder, `verbose`/`response_fields` (die Responses-Ausgabe trägt weder das Debug-Objekt noch eine Projektion; weitergereicht zöge `verbose` nur upstreams Debug-Objekt durch JNI, um es zu verwerfen, die Kosten, die F202 auf der Anthropic-Route entfernte) und chats `logprobs`/`top_logprobs` (die Responses-Form ist `top_logprobs` mit `include`, die Ausgabe trägt keine Wahrscheinlichkeiten). Der vorgeschlagene reflektive Wächter über die `request.path(…)`-Literale des Mappers wurde nicht gebaut (er müsste Quelltext lesen und eine große Ausnahmeliste pflegen); die Ausnahmeliste der Javadoc ist der Vertrag. +- **Tests:** `ResponsesApiSupportTest#requestFieldsTheChatMapperReadsPassThroughLikeUpstream` um die Zeilen `logit_bias|{"42":-100}` und `n_probs|3` erweitert. +- **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is <{"42":-100}> but: was <>“ und „Expected: is <3> but: was <>“), mit dem Fix grün (`ResponsesApiSupportTest` 62/62, `OpenAiCompatServerHttpTest` 69/69, `OpenAiRequestMapperTest` 44/44). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 6da8cf66c..c340aefd7 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -40,9 +40,17 @@ final class ResponsesApiSupport { /** * Request fields handed on to the chat request unchanged. Upstream's {@code - * convert_responses_to_chatcmpl} copies the whole body; these are the ones the shared chat mapper - * reads (OpenAI sampling and llama.cpp's extensions). The structural fields ({@code input}, {@code - * tools}, {@code tool_choice}) are translated above, not copied. + * convert_responses_to_chatcmpl} copies the whole body; these are the fields the shared chat mapper + * reads (OpenAI sampling and llama.cpp's extensions) whose effect can reach a Responses client. + * Left out on purpose: the structural fields translated in {@link #toOpenAiChatRequest} ({@code + * input}, {@code instructions}, {@code tools}, {@code tool_choice}, {@code parallel_tool_calls}, + * {@code max_output_tokens}, {@code reasoning}, {@code text.format}); {@code verbose} and {@code + * response_fields}, because the Responses output (here as upstream's {@code to_json_oaicompat_resp}) + * carries neither the debug object nor a projection, so forwarding them would only pull the debug + * object through JNI to be discarded; and chat's {@code logprobs} / {@code top_logprobs}, because + * the Responses form of probabilities ({@code top_logprobs} with {@code include}) is not produced: + * the output carries no logprobs, here or upstream. {@code n_probs} is forwarded as upstream would + * accept it, although the output does not show the probabilities either. */ private static final List CHAT_PASSTHROUGH_FIELDS = Collections.unmodifiableList(Arrays.asList( "top_k", @@ -71,7 +79,9 @@ final class ResponsesApiSupport { "json_schema", "n_predict", "reasoning_budget_tokens", - "thinking_budget_tokens")); + "thinking_budget_tokens", + "logit_bias", + "n_probs")); private ResponsesApiSupport() {} diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 38a8f3a56..c289f2311 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -346,7 +346,9 @@ public void toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream() "json_schema|{\"type\":\"object\"}", "n_predict|16", "reasoning_budget_tokens|100", - "thinking_budget_tokens|100" + "thinking_budget_tokens|100", + "logit_bias|{\"42\":-100}", + "n_probs|3" }) public void requestFieldsTheChatMapperReadsPassThroughLikeUpstream(String field, String json) throws IOException { // upstream's convert_responses_to_chatcmpl copies the whole body into the chat request; the From ea8389a5cfef39a7d59e68f2f6f75710b784a8e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:12:45 +0000 Subject: [PATCH 388/589] Record the commit of F226 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3cafae61a..60d5a22ca 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1819,7 +1819,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#requestFieldsTheChatMapperReadsPassThroughLikeUpstream` um die Zeilen `logit_bias|{"42":-100}` und `n_probs|3` erweitert. - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is <{"42":-100}> but: was <>“ und „Expected: is <3> but: was <>“), mit dem Fix grün (`ResponsesApiSupportTest` 62/62, `OpenAiCompatServerHttpTest` 69/69, `OpenAiRequestMapperTest` 44/44). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `365fcdb` ## Offene Verdachte ohne Fund-ID From 740938d7bc89c5e931557a94ccd26f75793324bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:22:21 +0000 Subject: [PATCH 389/589] Forward only the tool a named tool_choice asks for (F227) A tool_choice naming one function became "required" over the whole tool list, on the chat route and in the Responses translation (F109). llama.cpp knows only auto/none/required and builds the tool grammar over every tool it gets, so with two tools the model could call the other one. The named function is now enforced by forwarding that tool alone with "required"; a name no function tool carries, and a function tool_choice without a name, are refused with 400 instead of running over all tools. Both paths share OpenAiRequestMapper.namedToolChoice. The other tool definitions are then not rendered into the prompt, which the Javadoc states. The three new tests in ResponsesApiSupportTest and OpenAiRequestMapperTest were red without the fix ("Expected: is <1> but: was <2>" and twice "Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." per class) and are green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +++++ .../llama/server/OpenAiRequestMapper.java | 54 ++++++++++++++++--- .../llama/server/ResponsesApiSupport.java | 18 +++++-- .../llama/server/OpenAiRequestMapperTest.java | 37 +++++++++++++ .../llama/server/ResponsesApiSupportTest.java | 42 +++++++++++++++ 5 files changed, 154 insertions(+), 11 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 60d5a22ca..980e3bbd2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -149,6 +149,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F224](#f224) — niedrig — *gefixt* - [F225](#f225) — niedrig — *gefixt* - [F226](#f226) — niedrig — *gefixt* +- [F227](#f227) — niedrig — *gefixt* ## Funde @@ -1021,6 +1022,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#toolChoiceNamingAFunctionRequiresAToolCall`, `ResponsesApiSupportTest#toolChoiceNamingAFunctionRequiresAToolCall` - **Rot-Grün-Nachweis:** ohne Fix rot, je Weg für sich (2 Fehlschläge, je „Expected: is "required" but: was ""“), mit Fix grün (28/28 und 51/51, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1 + 1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesStreamTranslatorTest` 5/5, `OllamaApiSupportTest` 32/32, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Nachtrag (2026-10-18, nur beschrieben):** Zwei Kommentare neben dem Fix sind nicht nachgezogen: die Klassen-Javadoc von `OpenAiRequestMapper` nennt `tool_choice` unter den Feldern, die „verbatim as raw JSON“ weitergehen (galt schon vorher nur für die Textform), und der Kommentar über dem Werkzeugblock in `ResponsesApiSupport.toOpenAiChatRequest` beschreibt nur die Textform. Reine Doku-Abweichung, nach der Nutzerregel ohne Test nicht nachträglich geändert; Vorschlag: `tool_choice` aus der Verbatim-Aufzählung nehmen und „ein benanntes Werkzeug wird zu `required`“ ergänzen. +- **Nachtrag (Branch-Review 2026-10-20):** Mit mehreren Werkzeugen ließ `required` jeden Aufruf zu; seit F227 geht auf beiden Wegen nur das benannte Werkzeug weiter, ein unbekannter oder fehlender Name ist 400. - **Status:** gefixt - **Commit:** java-llama.cpp `b7151fc` @@ -1821,6 +1823,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `365fcdb` +### F227 – java-llama.cpp: `tool_choice`, das eine Funktion benennt, lässt bei mehreren Werkzeugen jedes zu +- **Zeit:** 2026-10-20 23:21 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest`, Werkzeugblock), `OpenAiRequestMapper.java` (`toInferenceParameters`, neu `namedToolChoice`) +- **Schwere:** niedrig (mit mehreren Werkzeugen durfte das Modell statt des verlangten ein anderes aufrufen) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-4). F109 bildete `{"type":"function","name":…}` (Responses) und `{"type":"function","function":{"name":…}}` (Chat) auf `required` ab und vermerkte, das sei nur „mit einem einzigen Werkzeug genau das verlangte“. +- **Beschreibung:** llama.cpp kennt für `tool_choice` nur `auto`/`none`/`required` (`common_chat_tool_choice_parse_oaicompat`, `common/chat.cpp:314-325`, b11639) und baut die Werkzeug-Grammatik über alle übergebenen Werkzeuge. `required` über die ganze Liste ließ deshalb jeden Aufruf zu; die OpenAI-Semantik („force the model to call a specific function“, `ToolChoiceFunctionParam`) galt nur bei genau einem Werkzeug. Ein unbekannter Name und ein `{"type":"function"}` ohne Namen liefen still als `required` über alle Werkzeuge; OpenAI weist einen unbekannten Namen mit 400 ab. upstream selbst liest das Objekt gar nicht (`json_value(body, "tool_choice", "auto")` fällt auf `auto` zurück), die Übersetzung war upstream also schon voraus. +- **Fix:** Ein benanntes `tool_choice` wird durch Einengen der weitergereichten Werkzeugliste erzwungen: nur das genannte Funktionswerkzeug geht weiter, dazu `required`; so lässt die Grammatik keinen anderen Aufruf zu. Ein Name, den kein Funktionswerkzeug trägt, ist eine `IllegalArgumentException` („tool_choice names unknown tool 'g'“), ein Objekt vom Typ `function` ohne Namen ebenso (beides 400). Beide Wege nutzen denselben Helfer `OpenAiRequestMapper.namedToolChoice`; die Responses-Übersetzung filtert schon beim Kopieren der Werkzeuge. Folge, in der Javadoc genannt: die Chat-Vorlage zeigt dem Modell die anderen Werkzeugdefinitionen nicht mehr (OpenAI lässt sie sichtbar und beschränkt nur die Ausgabe) – mit llama.cpps dreiwertigem `tool_choice` der Preis der Genauigkeit. Die Klassen-Javadoc des Mappers nennt `tool_choice` nur noch in seiner String-Form als unverändert weitergereicht. Nicht Teil dieses Funds: Anthropics `{"type":"tool","name":…}` wird weiter zu `required` über alle Werkzeuge, und ein `tool_choice`-Objekt anderen Typs (`allowed_tools`, `mcp`, …) fällt auf beiden Wegen weiter still auf `auto`. +- **Tests:** `ResponsesApiSupportTest#toolChoiceNamingAFunctionForwardsOnlyThatTool`, `#toolChoiceNamingAnUnknownFunctionIsRejected`, `#toolChoiceOfTypeFunctionWithoutANameIsRejected` und dieselben drei in `OpenAiRequestMapperTest`; die F109-Tests `#toolChoiceNamingAFunctionRequiresAToolCall` (ein Werkzeug) beider Klassen bleiben grün. +- **Rot-Grün-Nachweis:** Ohne den Fix rot (6 Fehlschläge: je Klasse „Expected: is <1> but: was <2>“ und zweimal „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 65/65, `OpenAiRequestMapperTest` 47/47, `OpenAiCompatServerHttpTest` 69/69, `AnthropicApiSupportTest` 31/31, `OllamaApiSupportTest` 42/42, `LlamaArchitectureTest` 13/13). `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit fester Meldung ohne Namen fiel bei SpotBugs als `WEM_WEAK_EXCEPTION_MESSAGING` durch; die Meldung nennt jetzt das `tool_choice`). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index b763a4780..14976b15b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -5,21 +5,25 @@ package net.ladenthin.llama.server; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.BiFunction; import net.ladenthin.llama.parameters.InferenceParameters; +import org.jspecify.annotations.Nullable; /** * Pure mapping from an OpenAI {@code /v1/chat/completions} request body to {@link InferenceParameters}. * - *

    The structural fields — {@code messages}, {@code tools}, {@code tool_choice}, and + *

    The structural fields — {@code messages}, {@code tools}, the string form of {@code tool_choice}, and * {@code parallel_tool_calls} — are forwarded * verbatim as raw JSON so the full OpenAI shape (assistant {@code tool_calls}, * {@code role:"tool"} results with {@code tool_call_id}, and vision {@code image_url} content parts) - * round-trips untouched into the native chat-template parser. Sampling fields are translated to the + * round-trips untouched into the native chat-template parser. A {@code tool_choice} naming one function + * becomes {@code required} with only that tool forwarded (see {@link #namedToolChoice}). Sampling fields are translated to the * matching {@code InferenceParameters.with*} setter, as are the llama.cpp sampling extensions upstream's * request schema reads ({@code min_p}, {@code repeat_penalty}, {@code grammar}, ...) and the reasoning * controls upstream's chat @@ -66,13 +70,26 @@ InferenceParameters toInferenceParameters(JsonNode request) { // Tools are chat-only. JsonNode tools = request.path("tools"); if (tools.isArray() && tools.size() > 0) { - params = params.withToolsJson(tools.toString()); JsonNode toolChoice = request.path("tool_choice"); + String namedTool = + namedToolChoice(toolChoice, toolChoice.path("function").path("name")); + if (namedTool == null) { + params = params.withToolsJson(tools.toString()); + } else { + ArrayNode named = JsonNodeFactory.instance.arrayNode(); + for (JsonNode tool : tools) { + if (namedTool.equals(tool.path("function").path("name").asText(""))) { + named.add(tool); + } + } + if (named.size() == 0) { + throw new IllegalArgumentException("tool_choice names unknown tool '" + namedTool + "'"); + } + params = params.withToolsJson(named.toString()); + } if (toolChoice.isTextual()) { params = params.withToolChoice(toolChoice.asText()); - } else if ("function".equals(toolChoice.path("type").asText(""))) { - // A named function: llama.cpp's chat parser reads tool_choice only as a string and would - // fall back to "auto"; "required" is the closest it allows. + } else if (namedTool != null) { params = params.withToolChoice("required"); } JsonNode parallelToolCalls = request.path("parallel_tool_calls"); @@ -84,6 +101,31 @@ InferenceParameters toInferenceParameters(JsonNode request) { return params; } + /** + * The function a {@code tool_choice} object of type {@code function} names, or {@code null} for any + * other {@code tool_choice}. llama.cpp's chat parser reads {@code tool_choice} only as {@code auto}, + * {@code none} or {@code required}; a named function is enforced by forwarding that tool alone with + * {@code required}, so the grammar admits no other call. The chat template then does not render the + * other tool definitions either, which the model may not call anyway (OpenAI keeps them visible and + * constrains only the output). Shared by the chat mapping ({@code tool_choice.function.name}) and + * the Responses translation ({@code tool_choice.name}). + * + * @param toolChoice the request's {@code tool_choice} + * @param name the node holding the function name in that request shape + * @return the named function, or {@code null} + * @throws IllegalArgumentException for a {@code tool_choice} of type {@code function} without a name + */ + static @Nullable String namedToolChoice(JsonNode toolChoice, JsonNode name) { + if (!"function".equals(toolChoice.path("type").asText(""))) { + return null; + } + if (!name.isTextual() || name.asText().isEmpty()) { + throw new IllegalArgumentException( + "tool_choice of type 'function' needs the function's 'name' (got " + toolChoice + ")"); + } + return name.asText(); + } + /** * Apply {@code logprobs} / {@code top_logprobs} as upstream's chat parser and request schema read * them ({@code oaicompat_chat_params_parse} and {@code server-schema.cpp}, b11639): {@code logprobs: diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index c340aefd7..fea9de116 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -100,7 +100,8 @@ static boolean isStreaming(JsonNode request) { * * @param request the parsed Responses request * @return an OpenAI {@code /v1/chat/completions} request object - * @throws IllegalArgumentException if the request continues an earlier response by {@code previous_response_id} + * @throws IllegalArgumentException if the request continues an earlier response by {@code previous_response_id}, + * names an unknown tool in {@code tool_choice}, or carries input this translation refuses as upstream does */ static ObjectNode toOpenAiChatRequest(JsonNode request) { // As upstream's convert_responses_to_chatcmpl: there is no response store, so a turn that refers @@ -125,9 +126,15 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { JsonNode tools = request.path("tools"); if (tools.isArray() && tools.size() > 0) { + JsonNode toolChoice = request.path("tool_choice"); + // {"type":"function","name":...}: llama.cpp knows only auto/none/required, so the named + // function is forwarded alone with "required" (see OpenAiRequestMapper.namedToolChoice). + String namedTool = OpenAiRequestMapper.namedToolChoice(toolChoice, toolChoice.path("name")); ArrayNode openAiTools = openAi.putArray("tools"); for (JsonNode tool : tools) { - if (!"function".equals(tool.path("type").asText(""))) { + if (!"function".equals(tool.path("type").asText("")) + || (namedTool != null + && !namedTool.equals(tool.path("name").asText("")))) { continue; } ObjectNode openAiTool = openAiTools.addObject(); @@ -141,14 +148,15 @@ static ObjectNode toOpenAiChatRequest(JsonNode request) { function.set("parameters", tool.path("parameters").deepCopy()); } } + if (namedTool != null && openAiTools.size() == 0) { + throw new IllegalArgumentException("tool_choice names unknown tool '" + namedTool + "'"); + } // The Responses API uses the same tool_choice + parallel_tool_calls fields as chat; forward // them so the shared chat core honors them. The mapper consumes the string form of // tool_choice ("auto"/"none"/"required"), which is what we forward here. - JsonNode toolChoice = request.path("tool_choice"); if (toolChoice.isTextual()) { openAi.put("tool_choice", toolChoice.asText()); - } else if ("function".equals(toolChoice.path("type").asText(""))) { - // {"type":"function","name":...}: the chat side knows only strings, "required" is closest. + } else if (namedTool != null) { openAi.put("tool_choice", "required"); } if (request.path("parallel_tool_calls").isBoolean()) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index c2dc08299..916bc04bf 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -332,6 +333,42 @@ public void toolChoiceNamingAFunctionRequiresAToolCall() throws IOException { assertThat(out.path("tool_choice").asText(), is("required")); } + @Test + public void toolChoiceNamingAFunctionForwardsOnlyThatTool() throws IOException { + // "required" over every tool let the model call another one than the one named; llama.cpp knows + // only auto/none/required, so the named function is enforced by forwarding it alone. + JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + + "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}," + + "{\"type\":\"function\",\"function\":{\"name\":\"g\",\"description\":\"gee\"}}]," + + "\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"g\"}}}"); + assertThat(out.path("tools").size(), is(1)); + assertThat(out.path("tools").path(0).path("function").path("name").asText(), is("g")); + assertThat( + out.path("tools").path(0).path("function").path("description").asText(), is("gee")); + assertThat(out.path("tool_choice").asText(), is("required")); + } + + @Test + public void toolChoiceNamingAnUnknownFunctionIsRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + + "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]," + + "\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"g\"}}}")); + assertThat(e.getMessage(), containsString("tool_choice names unknown tool 'g'")); + } + + @Test + public void toolChoiceOfTypeFunctionWithoutANameIsRejected() { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + + "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]," + + "\"tool_choice\":{\"type\":\"function\"}}")); + assertThat(e.getMessage(), containsString("tool_choice")); + assertThat(e.getMessage(), containsString("name")); + } + @Test public void parallelToolCallsForwarded() throws IOException { JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index c289f2311..9b7a88920 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -398,6 +398,48 @@ public void toolChoiceNamingAFunctionRequiresAToolCall() throws IOException { assertThat(openAi.path("tool_choice").asText(), is("required")); } + @Test + public void toolChoiceNamingAFunctionForwardsOnlyThatTool() throws IOException { + // "required" over every tool let the model call another one than the one named; llama.cpp knows + // only auto/none/required, so the named function is enforced by forwarding it alone. + JsonNode openAi = ResponsesApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"input\":\"hi\"," + + "\"tools\":[{\"type\":\"function\",\"name\":\"f\",\"parameters\":{\"type\":\"object\"}}," + + "{\"type\":\"function\",\"name\":\"g\",\"description\":\"gee\"," + + "\"parameters\":{\"type\":\"object\"}}]," + + "\"tool_choice\":{\"type\":\"function\",\"name\":\"g\"}}")); + assertThat(openAi.path("tools").size(), is(1)); + assertThat(openAi.path("tools").path(0).path("function").path("name").asText(), is("g")); + assertThat( + openAi.path("tools") + .path(0) + .path("function") + .path("description") + .asText(), + is("gee")); + assertThat(openAi.path("tool_choice").asText(), is("required")); + } + + @Test + public void toolChoiceNamingAnUnknownFunctionIsRejected() throws IOException { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"input\":\"hi\"," + + "\"tools\":[{\"type\":\"function\",\"name\":\"f\"}]," + + "\"tool_choice\":{\"type\":\"function\",\"name\":\"g\"}}"))); + assertThat(e.getMessage(), containsString("tool_choice names unknown tool 'g'")); + } + + @Test + public void toolChoiceOfTypeFunctionWithoutANameIsRejected() throws IOException { + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"input\":\"hi\"," + + "\"tools\":[{\"type\":\"function\",\"name\":\"f\"}]," + + "\"tool_choice\":{\"type\":\"function\"}}"))); + assertThat(e.getMessage(), containsString("tool_choice")); + assertThat(e.getMessage(), containsString("name")); + } + @Test public void previousResponseIdIsRejectedLikeUpstream() throws IOException { // There is no response store: answering a turn that continues by previous_response_id without From 0a2f0f32ce1548536c6da0265b2b31dc81843c94 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:22:22 +0000 Subject: [PATCH 390/589] Record the commit of F227 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 980e3bbd2..37e4736b2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1833,7 +1833,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#toolChoiceNamingAFunctionForwardsOnlyThatTool`, `#toolChoiceNamingAnUnknownFunctionIsRejected`, `#toolChoiceOfTypeFunctionWithoutANameIsRejected` und dieselben drei in `OpenAiRequestMapperTest`; die F109-Tests `#toolChoiceNamingAFunctionRequiresAToolCall` (ein Werkzeug) beider Klassen bleiben grün. - **Rot-Grün-Nachweis:** Ohne den Fix rot (6 Fehlschläge: je Klasse „Expected: is <1> but: was <2>“ und zweimal „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 65/65, `OpenAiRequestMapperTest` 47/47, `OpenAiCompatServerHttpTest` 69/69, `AnthropicApiSupportTest` 31/31, `OllamaApiSupportTest` 42/42, `LlamaArchitectureTest` 13/13). `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit fester Meldung ohne Namen fiel bei SpotBugs als `WEM_WEAK_EXCEPTION_MESSAGING` durch; die Meldung nennt jetzt das `tool_choice`). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `f888bde` ## Offene Verdachte ohne Fund-ID From beaffccb7b3acbac6a7af32c67ccd5e600264525 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:26:35 +0000 Subject: [PATCH 391/589] Refuse Responses input items of an unknown type (F228) The message case shared its branch with default, so any item type the translation does not know (custom_tool_call, custom_tool_call_output, item_reference, mcp_call, ...) went through the message path without a role or content and became {"role":"user","content":""}: the model saw empty user turns where its own calls had been. Upstream's convert_responses_to_chatcmpl ends every unrecognised item with "Cannot determine type of 'item'" (400). The message case now ends with break and default throws with upstream's text and the type; an item without a type still maps to a message. inputItemOfAnUnknownTypeIsRejectedLikeUpstream was red without the fix ("Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.") and is green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++++++ .../llama/server/ResponsesApiSupport.java | 10 ++++--- .../llama/server/ResponsesApiSupportTest.java | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 37e4736b2..211e47254 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -150,6 +150,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F225](#f225) — niedrig — *gefixt* - [F226](#f226) — niedrig — *gefixt* - [F227](#f227) — niedrig — *gefixt* +- [F228](#f228) — niedrig — *gefixt* ## Funde @@ -1835,6 +1836,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f888bde` +### F228 – java-llama.cpp: Responses-Eingabe-Items unbekannten Typs werden zu leeren Nutzernachrichten statt abgewiesen +- **Zeit:** 2026-10-20 23:26 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `switch` über den Item-Typ; Klassen-Javadoc) +- **Schwere:** niedrig (ein Verlauf mit z. B. Codex' `custom_tool_call`/`custom_tool_call_output` oder einem `item_reference` erreichte das Modell mit leeren Nutzerzügen an Stelle der Aufrufe, ohne Hinweis) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-5). Der gemeinsame `case "message": default:`-Zweig stammt aus der Zeit vor dem Branch; der Branch schrieb den `switch` um (Reasoning, Zusammenführung, Werkzeugteile, `input_file`, F17/F86/F89/F99) und kündigt im CHANGELOG an, was nicht bedient werden kann, werde mit 400 abgewiesen. +- **Beschreibung:** `appendInput` las `item.path("type").asText("message")` und führte jeden unbekannten Typ durch den Nachrichtenzweig. Ohne `role` und meist ohne `content` wurde daraus `{"role":"user","content":""}`; die OpenAI-Union `ResponseInputItemParam` kennt über 30 Item-Typen (`custom_tool_call`, `custom_tool_call_output`, `item_reference`, `mcp_call`, `apply_patch_call`, …). upstream (`convert_responses_to_chatcmpl`, `server-chat.cpp:256-257`, b11639) beendet jedes nicht erkannte Item mit „Cannot determine type of 'item'“ (400). +- **Fix:** Der `message`-Fall endet mit `break`; `default` wirft eine `IllegalArgumentException` mit upstreams Text und dem Typ („Cannot determine type of 'item' (custom_tool_call)“), die `handleResponses` als 400 beantwortet. Ein Item ohne `type` (die EasyInputMessage-Form, deren `type` optional ist) landet wie bisher über den Vorgabewert `"message"` im Nachrichtenzweig. Die Klassen-Javadoc nennt die Item-Typen und die Abweisung. Bewusst lockerer als upstream geblieben: eine Nachricht mit einer Rolle außerhalb von user/system/developer/assistant und eine Assistenten-Nachricht ohne `type` werden weiter angenommen (vom Fund nicht verlangt). +- **Tests:** `ResponsesApiSupportTest#inputItemOfAnUnknownTypeIsRejectedLikeUpstream` (Meldung nennt upstreams Text und `custom_tool_call`), als Schutz `#inputItemWithoutATypeStillMapsToAMessage` (vorher und nachher grün). +- **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 67/67, `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index fea9de116..4bbad7872 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -21,8 +21,9 @@ * newer Responses protocol (e.g. Copilot's {@code responses} apiType) drive the local model. * *

    Request mapping: {@code instructions} becomes a system message; {@code input} (a string, or an array - * of {@code message} / {@code function_call} / {@code function_call_output} items) is flattened to OpenAI - * messages; Responses function tools ({@code {type:"function",name,description,parameters}}) become OpenAI + * of {@code message} / {@code function_call} / {@code function_call_output} / {@code reasoning} items; an + * item without {@code type} is a message, any other type is refused with 400 as upstream refuses it) is + * flattened to OpenAI messages; Responses function tools ({@code {type:"function",name,description,parameters}}) become OpenAI * function tools. Responses replies wrap the assistant turn in a {@code response} object whose * {@code output} array holds a {@code message} item (with {@code output_text} content) and one * {@code function_call} item per tool call. @@ -263,7 +264,6 @@ private static void appendInput(ArrayNode messages, JsonNode input) { appendReasoningItem(messages, item); break; case "message": - default: rejectInputFiles(item.path("content")); String role = item.path("role").asText("user"); JsonNode imageContent = inputContentWithImages(item.path("content")); @@ -278,6 +278,10 @@ private static void appendInput(ArrayNode messages, JsonNode input) { message.set("content", content); } break; + default: + // As upstream's convert_responses_to_chatcmpl: an item this translation cannot + // serve (custom_tool_call, item_reference, mcp_call, ...) became an empty user turn. + throw new IllegalArgumentException("Cannot determine type of 'item' (" + type + ")"); } } } diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 9b7a88920..ae0dd6fdf 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -122,6 +122,32 @@ public void functionCallWithArgumentsThatAreNotAStringIsRejectedLikeUpstream() t } } + @Test + public void inputItemOfAnUnknownTypeIsRejectedLikeUpstream() throws IOException { + // An item type the translation does not know (Codex's custom_tool_call, item_reference, ...) + // became an empty user message, so the model saw empty turns where its own calls had been. + // upstream's convert_responses_to_chatcmpl answers 400 "Cannot determine type of 'item'". + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"input\":[" + + "{\"type\":\"message\",\"role\":\"user\",\"content\":\"q\"}," + + "{\"type\":\"custom_tool_call\",\"call_id\":\"c1\",\"name\":\"apply_patch\"," + + "\"input\":\"*** Begin Patch\"}]}"))); + assertThat(e.getMessage(), containsString("Cannot determine type of 'item'")); + assertThat(e.getMessage(), containsString("custom_tool_call")); + } + + @Test + public void inputItemWithoutATypeStillMapsToAMessage() throws IOException { + // An input message may leave out its type (EasyInputMessage); it stays a chat message. + JsonNode messages = ResponsesApiSupport.toOpenAiChatRequest( + read("{\"input\":[{\"role\":\"user\",\"content\":\"q\"}]}")) + .path("messages"); + assertThat(messages.size(), is(1)); + assertThat(messages.path(0).path("role").asText(), is("user")); + assertThat(messages.path(0).path("content").asText(), is("q")); + } + @Test public void refusalPartsOfTheHistoryAreKeptAsText() throws IOException { // A refusal part carries "refusal", not "text"; it was dropped, so the model never saw that it From 546354ccdaffc761dea24d8f46a1c1869fe1bf8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:26:36 +0000 Subject: [PATCH 392/589] Record the commit of F228 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 211e47254..c3549abe6 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1846,7 +1846,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#inputItemOfAnUnknownTypeIsRejectedLikeUpstream` (Meldung nennt upstreams Text und `custom_tool_call`), als Schutz `#inputItemWithoutATypeStillMapsToAMessage` (vorher und nachher grün). - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 67/67, `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `647dd57` ## Offene Verdachte ohne Fund-ID From ad8b0b14e1f30b941e955ae320699c4d76af70d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:33:44 +0000 Subject: [PATCH 393/589] Report a Responses answer cut off by a limit as incomplete (F229) Both the streamed and the blocking Responses answer were built with status "completed" and the stream always ended with response.completed; finish_reason was never read. An answer cut off at max_output_tokens (llama.cpp's finish_reason "length") therefore looked complete. The translator now records the last textual finish_reason (the trailing usage-only chunk does not reset it), and for "length" the response is "incomplete" with incomplete_details.reason "max_output_tokens", its message item is "incomplete" and the terminal event is response.incomplete; the blocking path reads choices[0].finish_reason the same way. Upstream reports every answer as completed; this goes beyond it as vLLM does. The new translator and support tests were red without the fix ('Expected: is "incomplete" but: was "completed"', 'Expected: a string containing "event: response.incomplete"') and are green with it. The model-backed Responses round trips expected "completed" for a 16-token cap; they now ask for an answer that cannot fit into 4 tokens and expect incomplete, which the unchanged versions showed with the local model. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++ .../llama/server/ResponsesApiSupport.java | 45 +++++++++++++++++-- .../server/ResponsesStreamTranslator.java | 31 +++++++++---- .../OpenAiCompatServerIntegrationTest.java | 18 +++++--- .../llama/server/ResponsesApiSupportTest.java | 13 ++++++ .../server/ResponsesStreamTranslatorTest.java | 33 ++++++++++++++ 6 files changed, 137 insertions(+), 16 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c3549abe6..099fdbf31 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -151,6 +151,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F226](#f226) — niedrig — *gefixt* - [F227](#f227) — niedrig — *gefixt* - [F228](#f228) — niedrig — *gefixt* +- [F229](#f229) — niedrig — *gefixt* ## Funde @@ -1848,6 +1849,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `647dd57` +### F229 – java-llama.cpp: Abgeschnittene Responses-Antwort (`finish_reason` `length`) wird als `completed` gemeldet +- **Zeit:** 2026-10-20 23:33 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (`onChunk`, `end`), `ResponsesApiSupport.java` (`toResponsesResponse`, neu `isTruncated`, `markIncomplete`) +- **Schwere:** niedrig (ein Client, dessen Antwort an `max_output_tokens` abgeschnitten wurde, sah eine vollständige Antwort) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-7). Seit es die Responses-Route gibt; upstreams `to_json_oaicompat_resp` (`server-task.cpp:579`, `:688`, b11639) meldet ebenfalls immer `completed`, es ist also Parität mit upstream, keine Regression. Bewusst über upstream hinaus wie F107 (Anthropics `tool_choice` `none`); vLLM verfährt so (PR #24561). +- **Beschreibung:** `ResponsesStreamTranslator.end()` und `ResponsesApiSupport.toResponsesResponse` bauten die Antwort fest mit `status` `completed`, der Strom endete immer mit `response.completed`; `finish_reason` wurde nie gelesen. Die Übersetzung bildet `max_output_tokens` auf `max_tokens` ab, upstreams Chunks tragen bei `STOP_TYPE_LIMIT` `finish_reason` `"length"`. Die Responses-API definiert dafür `status` `incomplete`, `incomplete_details.reason` `max_output_tokens`, das Ereignis `response.incomplete` und den Nachrichtenstatus `incomplete` (openai-python `Response`, `ResponseIncompleteEvent`, `ResponseOutputMessage`). +- **Fix:** Der Translator merkt sich das letzte textuelle `finish_reason` (der angehängte reine Usage-Chunk mit `"choices":[]` überschreibt es nicht, `null` der Zwischenchunks ebenso wenig). Bei `"length"` sind die Antwort `incomplete` mit `incomplete_details: {"reason":"max_output_tokens"}`, das Nachrichten-Item (in `response.output_item.done` und im `output`) `incomplete` und das letzte Ereignis `response.incomplete`; Reasoning- und `function_call`-Items bleiben `completed`, `completed_at` wird nicht gesetzt (die Hülle trug es nie). Dasselbe blockierend aus `choices[0].finish_reason`. Die Javadoc von `isTruncated` sagt, dass llama.cpps `"length"` auch einen vollen Kontext ohne Kontextverschiebung, `n_indent` und `t_max_predict_ms` umfasst; `max_output_tokens` ist der nächste der definierten Gründe, die Meldung also grob, nicht still. +- **Tests:** `ResponsesStreamTranslatorTest#maxTokensCutOffEndsWithResponseIncomplete` (Text, `length`, dann der reine Usage-Chunk; `response.incomplete`, kein `response.completed`, Status und Grund, Nachrichten-Item `incomplete`), `#aStoppedAnswerStaysCompleted`; `ResponsesApiSupportTest#responseCutOffAtTheTokenLimitIsIncomplete`, `#responseWrapsOutputMessageWithOutputTextAndUsage` prüft zusätzlich, dass eine `stop`-Antwort kein `incomplete_details` trägt. Die modellgestützten `OpenAiCompatServerIntegrationTest#responsesNonStreamingRoundTrip`/`#responsesStreamingRoundTrip` erwarteten mit `max_output_tokens` 16 `completed`; sie fragen jetzt eine Antwort ab, die in 4 Token nicht passt („Count from 1 to 100 …“), und erwarten `incomplete`/`response.incomplete` mit dem Grund – deterministisch statt davon abhängig, ob das Modell vor 16 Token aufhört. +- **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is "incomplete" but: was "completed"“ und „Expected: a string containing "event: response.incomplete" but: was "event: response.output_text.done …"“); umgekehrt scheiterten nach dem Fix die unveränderten Integrationstests mit dem lokalen Winzmodell („Expected: is "completed" but: was "incomplete"“, der Beweis, dass der Fix den echten Server erreicht); mit dem Fix und den angepassten Integrationstests grün (`ResponsesApiSupportTest` 68/68, `ResponsesStreamTranslatorTest` 7/7, `OpenAiCompatServerHttpTest` 69/69, `OpenAiCompatServerIntegrationTest` 13/13 mit dem Winzmodell, zweimal). `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java index 4bbad7872..f4e355462 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java @@ -35,6 +35,12 @@ final class ResponsesApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** The status of a finished response and of its message item. */ + static final String STATUS_COMPLETED = "completed"; + + /** The status of a response (and its message item) cut off by a limit, see {@link #isTruncated}. */ + static final String STATUS_INCOMPLETE = "incomplete"; + /** The keys of a Responses {@code json_schema} format that chat nests under {@code json_schema}. */ private static final List JSON_SCHEMA_FORMAT_FIELDS = Collections.unmodifiableList(Arrays.asList("name", "description", "schema", "strict")); @@ -515,7 +521,9 @@ private static void copyNumber(JsonNode from, String fromKey, ObjectNode to, Str } /** - * Translate a non-streaming OpenAI {@code chat.completion} into a Responses API response object. + * Translate a non-streaming OpenAI {@code chat.completion} into a Responses API response object. A + * cut-off answer ({@code finish_reason} {@code "length"}) is {@code incomplete}, as is its message + * item (see {@link #markIncomplete}). * * @param openAiCompletionJson the OpenAI completion body * @param model the model id to echo @@ -523,7 +531,7 @@ private static void copyNumber(JsonNode from, String fromKey, ObjectNode to, Str * @return the Responses object serialized as JSON */ static String toResponsesResponse(String openAiCompletionJson, String model, String responseId) { - ObjectNode root = newResponseShell(model, responseId, "completed"); + ObjectNode root = newResponseShell(model, responseId, STATUS_COMPLETED); ArrayNode output = root.putArray("output"); ObjectNode usage = root.putObject("usage"); usage.put("input_tokens", 0); @@ -534,6 +542,11 @@ static String toResponsesResponse(String openAiCompletionJson, String model, Str try { JsonNode completion = OBJECT_MAPPER.readTree(openAiCompletionJson); JsonNode message = completion.path("choices").path(0).path("message"); + boolean truncated = isTruncated( + completion.path("choices").path(0).path("finish_reason").asText("")); + if (truncated) { + markIncomplete(root); + } // As upstream's to_json_oaicompat_resp: the reasoning comes first, as a reasoning item. String reasoning = message.path("reasoning_content").asText(""); if (!reasoning.isEmpty()) { @@ -553,7 +566,7 @@ static String toResponsesResponse(String openAiCompletionJson, String model, Str ObjectNode messageItem = output.addObject(); messageItem.put("type", "message"); messageItem.put("id", "msg_" + responseId); - messageItem.put("status", "completed"); + messageItem.put("status", truncated ? STATUS_INCOMPLETE : STATUS_COMPLETED); messageItem.put("role", "assistant"); ArrayNode content = messageItem.putArray("content"); ObjectNode textPart = content.addObject(); @@ -601,6 +614,32 @@ static ObjectNode functionCallItem(JsonNode openAiToolCall) { return item; } + /** + * Whether a chat {@code finish_reason} is a cut-off answer: llama.cpp's {@code "length"} + * ({@code STOP_TYPE_LIMIT}). Besides {@code max_output_tokens} it also covers a full context without + * context shift and the {@code n_indent} / {@code t_max_predict_ms} limits; {@code + * max_output_tokens} is the closest of the reasons the Responses API defines, so the report is + * coarse rather than silent. Upstream's {@code to_json_oaicompat_resp} reports every answer as + * {@code completed}. + * + * @param finishReason the chat {@code finish_reason}, {@code ""} if none was seen + * @return {@code true} for a truncated answer + */ + static boolean isTruncated(String finishReason) { + return "length".equals(finishReason); + } + + /** + * Mark a response object as cut off: status {@code incomplete} with {@code incomplete_details.reason} + * {@code max_output_tokens} (see {@link #isTruncated}). + * + * @param response the response object to mark + */ + static void markIncomplete(ObjectNode response) { + response.put("status", STATUS_INCOMPLETE); + response.putObject("incomplete_details").put("reason", "max_output_tokens"); + } + /** A bare Responses API object shell (no output/usage), used by both the final reply and events. */ static ObjectNode newResponseShell(String model, String responseId, String status) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); diff --git a/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java b/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java index 6d97f3a5b..bb101a11d 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java +++ b/llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java @@ -20,8 +20,9 @@ * {@code response.content_part.added} + {@code response.output_text.delta}* + * {@code response.output_text.done} + {@code response.content_part.done} + * {@code response.output_item.done} → (per tool call) a {@code function_call} item with - * {@code response.function_call_arguments.done} → {@code response.completed}. Each event carries a - * monotonic {@code sequence_number}. + * {@code response.function_call_arguments.done} → {@code response.completed}, or {@code + * response.incomplete} for an answer cut off by a limit ({@code finish_reason} {@code "length"}, see + * {@link ResponsesApiSupport#isTruncated}). Each event carries a monotonic {@code sequence_number}. * *

    Text deltas are emitted live; tool calls are reconstructed via {@link ToolCallDeltaAccumulator} and * emitted as whole {@code function_call} items at the end. Free of JNI / model dependencies; @@ -49,6 +50,8 @@ final class ResponsesStreamTranslator { private long inputTokens; private long outputTokens; private long cachedTokens; + /** The last textual {@code finish_reason} seen; the trailing usage-only chunk carries none. */ + private String finishReason = ""; ResponsesStreamTranslator(String model, String responseId) { this.model = model; @@ -82,6 +85,10 @@ String onChunk(String openAiChunkJson) { JsonNode chunk = OBJECT_MAPPER.readTree(openAiChunkJson); accumulator.accept(chunk); captureUsage(chunk.path("usage")); + JsonNode chunkFinishReason = chunk.path("choices").path(0).path("finish_reason"); + if (chunkFinishReason.isTextual()) { + finishReason = chunkFinishReason.asText(); + } // Reasoning streams as a reasoning output item, as in upstream's to_json_oaicompat_resp_stream. JsonNode deltaNode = chunk.path("choices").path(0).path("delta"); JsonNode reasoningDelta = deltaNode.path("reasoning_content"); @@ -116,11 +123,13 @@ String onChunk(String openAiChunkJson) { /** * The closing events: finish the text content part / message item, emit a {@code function_call} item - * per accumulated tool call, then {@code response.completed} carrying the assembled output and usage. + * per accumulated tool call, then {@code response.completed} carrying the assembled output and usage + * ({@code response.incomplete}, with the message item {@code incomplete} too, for a cut-off answer). * * @return the framed SSE events, concatenated */ String end() { + boolean truncated = ResponsesApiSupport.isTruncated(finishReason); StringBuilder out = new StringBuilder(); ArrayNode output = OBJECT_MAPPER.createArrayNode(); @@ -154,7 +163,8 @@ String end() { part.put("text", text.toString()); out.append(ResponsesApiSupport.sseEvent("response.content_part.done", sequence++, partDone)); - ObjectNode messageItem = completedMessageItem(); + ObjectNode messageItem = finishedMessageItem( + truncated ? ResponsesApiSupport.STATUS_INCOMPLETE : ResponsesApiSupport.STATUS_COMPLETED); output.add(messageItem); out.append(outputItemDone(messageOutputIndex, messageItem)); } @@ -174,7 +184,11 @@ String end() { } ObjectNode completed = ResponsesApiSupport.dataObject(); - ObjectNode response = ResponsesApiSupport.newResponseShell(model, responseId, "completed"); + ObjectNode response = + ResponsesApiSupport.newResponseShell(model, responseId, ResponsesApiSupport.STATUS_COMPLETED); + if (truncated) { + ResponsesApiSupport.markIncomplete(response); + } response.set("output", output); ObjectNode usage = response.putObject("usage"); usage.put("input_tokens", inputTokens); @@ -182,7 +196,8 @@ String end() { usage.put("total_tokens", inputTokens + outputTokens); usage.putObject("input_tokens_details").put("cached_tokens", cachedTokens); completed.set("response", response); - out.append(ResponsesApiSupport.sseEvent("response.completed", sequence++, completed)); + out.append(ResponsesApiSupport.sseEvent( + truncated ? "response.incomplete" : "response.completed", sequence++, completed)); return out.toString(); } @@ -251,11 +266,11 @@ private ObjectNode messageItemShell() { return item; } - private ObjectNode completedMessageItem() { + private ObjectNode finishedMessageItem(String status) { ObjectNode item = OBJECT_MAPPER.createObjectNode(); item.put("type", "message"); item.put("id", messageItemId); - item.put("status", "completed"); + item.put("status", status); item.put("role", "assistant"); ObjectNode textPart = item.putArray("content").addObject(); textPart.put("type", "output_text"); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java index 15b8484f0..4d724b5ae 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java @@ -191,24 +191,32 @@ public void anthropicMessagesStreamingRoundTrip() throws IOException { assertThat(response.body, containsString("event: message_stop")); } + /** + * An answer that cannot fit into {@code max_output_tokens} ends with finish_reason "length", which the + * Responses API reports as {@code incomplete} with reason {@code max_output_tokens} (F229); it was + * reported as {@code completed}. + */ @Test public void responsesNonStreamingRoundTrip() throws IOException { - String body = "{\"model\":\"" + MODEL_ID + "\",\"max_output_tokens\":16,\"input\":\"Say hello in one word.\"}"; + String body = "{\"model\":\"" + MODEL_ID + "\",\"max_output_tokens\":4," + + "\"input\":\"Count from 1 to 100, one number per line.\"}"; Response response = post(port, "/v1/responses", body, ""); assertThat(response.code, is(200)); JsonNode json = MAPPER.readTree(response.body); assertThat(json.path("object").asText(), is("response")); - assertThat(json.path("status").asText(), is("completed")); + assertThat(json.path("status").asText(), is("incomplete")); + assertThat(json.path("incomplete_details").path("reason").asText(), is("max_output_tokens")); } @Test public void responsesStreamingRoundTrip() throws IOException { - String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":true,\"max_output_tokens\":16," - + "\"input\":\"Say hello in one word.\"}"; + String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":true,\"max_output_tokens\":4," + + "\"input\":\"Count from 1 to 100, one number per line.\"}"; Response response = post(port, "/v1/responses", body, ""); assertThat(response.code, is(200)); assertThat(response.body, containsString("event: response.created")); - assertThat(response.body, containsString("event: response.completed")); + assertThat(response.body, containsString("event: response.incomplete")); + assertThat(response.body, containsString("\"reason\":\"max_output_tokens\"")); } @Test diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index ae0dd6fdf..446696c04 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -573,6 +573,18 @@ public void requestMapsAssistantMessageItemAndSkipsNonFunctionTools() throws IOE assertThat(openAi.path("tools").get(0).path("function").path("name").asText(), is("f")); } + @Test + public void responseCutOffAtTheTokenLimitIsIncomplete() throws IOException { + // finish_reason "length" is a cut-off answer; it was reported as "completed", so a client that hit + // max_output_tokens could not tell. The Responses status for it is "incomplete". + String openAi = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"hel\"}," + + "\"finish_reason\":\"length\"}]}"; + JsonNode out = read(ResponsesApiSupport.toResponsesResponse(openAi, "m", "resp_1")); + assertThat(out.path("status").asText(), is("incomplete")); + assertThat(out.path("incomplete_details").path("reason").asText(), is("max_output_tokens")); + assertThat(out.path("output").path(0).path("status").asText(), is("incomplete")); + } + @Test public void responseWrapsOutputMessageWithOutputTextAndUsage() throws IOException { String openAi = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"hello\"}," @@ -581,6 +593,7 @@ public void responseWrapsOutputMessageWithOutputTextAndUsage() throws IOExceptio JsonNode out = read(ResponsesApiSupport.toResponsesResponse(openAi, "m", "resp_1")); assertThat(out.path("object").asText(), is("response")); assertThat(out.path("status").asText(), is("completed")); + assertThat(out.has("incomplete_details"), is(false)); JsonNode messageItem = out.path("output").get(0); assertThat(messageItem.path("type").asText(), is("message")); assertThat(messageItem.path("content").get(0).path("type").asText(), is("output_text")); diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java index 775f30efb..a5cabf4aa 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesStreamTranslatorTest.java @@ -7,6 +7,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; import org.junit.jupiter.api.Test; @@ -86,4 +87,36 @@ public void toolCallsBecomeFunctionCallItemsBeforeCompleted() { assertThat(end, containsString("event: response.function_call_arguments.done")); assertThat(end, containsString("event: response.completed")); } + + @Test + public void maxTokensCutOffEndsWithResponseIncomplete() { + // finish_reason "length" is a cut-off answer; it was reported as response.completed, so a client + // that hit max_output_tokens could not tell. The usage-only chunk the server appends afterwards + // ("choices":[]) must not reset the recorded reason. + ResponsesStreamTranslator translator = new ResponsesStreamTranslator("m", "resp_1"); + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}"); + translator.onChunk("{\"choices\":[{\"delta\":{},\"finish_reason\":\"length\"}]}"); + translator.onChunk("{\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":1}}"); + String end = translator.end(); + + assertThat(end, containsString("event: response.incomplete")); + assertThat(end, not(containsString("event: response.completed"))); + String terminal = end.substring(end.indexOf("event: response.incomplete")); + assertThat(terminal, containsString("\"status\":\"incomplete\"")); + assertThat(terminal, containsString("\"incomplete_details\":{\"reason\":\"max_output_tokens\"}")); + String messageDone = end.substring(end.indexOf("event: response.output_item.done")); + assertThat( + messageDone.substring(0, messageDone.indexOf("\n\n")), + containsString("\"type\":\"message\",\"id\":\"msg_resp_1\",\"status\":\"incomplete\"")); + } + + @Test + public void aStoppedAnswerStaysCompleted() { + ResponsesStreamTranslator translator = new ResponsesStreamTranslator("m", "resp_1"); + translator.onChunk("{\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}"); + String end = translator.end(); + + assertThat(end, containsString("event: response.completed")); + assertThat(end, not(containsString("incomplete"))); + } } From dd4f69c032854c0cc84467d124599393a0f34a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:33:44 +0000 Subject: [PATCH 394/589] Record the commit of F229 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 099fdbf31..a0816a8b8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1859,7 +1859,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesStreamTranslatorTest#maxTokensCutOffEndsWithResponseIncomplete` (Text, `length`, dann der reine Usage-Chunk; `response.incomplete`, kein `response.completed`, Status und Grund, Nachrichten-Item `incomplete`), `#aStoppedAnswerStaysCompleted`; `ResponsesApiSupportTest#responseCutOffAtTheTokenLimitIsIncomplete`, `#responseWrapsOutputMessageWithOutputTextAndUsage` prüft zusätzlich, dass eine `stop`-Antwort kein `incomplete_details` trägt. Die modellgestützten `OpenAiCompatServerIntegrationTest#responsesNonStreamingRoundTrip`/`#responsesStreamingRoundTrip` erwarteten mit `max_output_tokens` 16 `completed`; sie fragen jetzt eine Antwort ab, die in 4 Token nicht passt („Count from 1 to 100 …“), und erwarten `incomplete`/`response.incomplete` mit dem Grund – deterministisch statt davon abhängig, ob das Modell vor 16 Token aufhört. - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is "incomplete" but: was "completed"“ und „Expected: a string containing "event: response.incomplete" but: was "event: response.output_text.done …"“); umgekehrt scheiterten nach dem Fix die unveränderten Integrationstests mit dem lokalen Winzmodell („Expected: is "completed" but: was "incomplete"“, der Beweis, dass der Fix den echten Server erreicht); mit dem Fix und den angepassten Integrationstests grün (`ResponsesApiSupportTest` 68/68, `ResponsesStreamTranslatorTest` 7/7, `OpenAiCompatServerHttpTest` 69/69, `OpenAiCompatServerIntegrationTest` 13/13 mit dem Winzmodell, zweimal). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `69a0791` ## Offene Verdachte ohne Fund-ID From f4164c78a5e238963c203d03e84e52c1ca311018 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 23:48:55 +0000 Subject: [PATCH 395/589] Point the recorded commit hashes at the rebased history The branch was rebased onto main (two commits there: the dependency, plugin and shared-pin bumps), which gave every commit of the branch a new hash. The bug-hunt ledgers, the measurement notes, the TODO and the JPF harness documentation cite commits of this branch by hash; each citation now names the rebased commit with the same change (matched by author date and subject, 1:1 for every non-merge commit). Nothing else changed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 308 ++++++++++++++++++++-------------------- BUGHUNT-MEASUREMENTS.md | 6 +- 3 files changed, 158 insertions(+), 158 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index b044e44bf..e453c6979 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -273,7 +273,7 @@ Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsiche - `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` (Probe mit 7 Formen) · `707296e` · 2026-10-18 · kein Fund — `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) - `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` (Probe mit 9 Formen) · `6f2a458` · 2026-10-18 · kein Fund — liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab - `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639) · `6a637ed` · 2026-10-18 · kein Fund — liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/*.java` ↔ langchain4j-core 1.22.0 (Musterdurchsicht nach F128: Vorbedingungen der Fremdklassen, `javap -c`) · `4d599fa` · 2026-10-18 · kein Fund — `AiMessage(String)` `ensureNotNull` (`ChatResponse.getFirstContent()` liefert nie null, `Optional.map` macht aus einem null-Inhalt ""), `AiMessage(List)` `ensureNotEmpty` (nicht benutzt, der Builder prüft nichts), `PartialThinking` `ensureNotEmpty` (vorher auf leer geprüft), `CompleteToolCall`/`PartialToolCall` `ensureNotNegative(index)` (Index aus upstreams `tool_call_index`, nie negativ), `ChatResponse` `ensureNotNull(aiMessage)` (immer gesetzt), `Embedding(float[])` `ensureNotNull` (natives `embed` liefert ein Array), `ToolExecutionRequest`/`TokenUsage` ohne Prüfungen. `llama-kotlin` baut keine Fremdobjekte mit Vorbedingungen. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/*.java` ↔ langchain4j-core 1.22.0 (Musterdurchsicht nach F128: Vorbedingungen der Fremdklassen, `javap -c`) · `f7f3f0f` · 2026-10-18 · kein Fund — `AiMessage(String)` `ensureNotNull` (`ChatResponse.getFirstContent()` liefert nie null, `Optional.map` macht aus einem null-Inhalt ""), `AiMessage(List)` `ensureNotEmpty` (nicht benutzt, der Builder prüft nichts), `PartialThinking` `ensureNotEmpty` (vorher auf leer geprüft), `CompleteToolCall`/`PartialToolCall` `ensureNotNegative(index)` (Index aus upstreams `tool_call_index`, nie negativ), `ChatResponse` `ensureNotNull(aiMessage)` (immer gesetzt), `Embedding(float[])` `ensureNotNull` (natives `embed` liefert ein Array), `ToolExecutionRequest`/`TokenUsage` ohne Prüfungen. `llama-kotlin` baut keine Fremdobjekte mit Vorbedingungen. - `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` ↔ `AnthropicStreamTranslator` / `ResponsesStreamTranslator` / `OllamaApiSupport` (Musterdurchsicht nach F128: Fragmente ohne Argumente oder vor dem Namen) · `58a9bab` · 2026-10-18 · kein Fund — der Akkumulator übernimmt `id`/`name` und hängt Argumente an, ohne etwas weiterzumelden; alle drei Übersetzer geben `tool_use`- bzw. `function_call`-Blöcke und Ollama-`tool_calls` erst am Ende aus dem fertigen Stand aus (`asText("")` für einen nie gelieferten Namen), es gibt keinen Teilaufruf-Event mit Vorbedingungen. Die frühere Prüfzeile zu `StreamingChunkAssembler` (17.10., „gleiche Semantik“) hatte die langchain4j-Vorbedingungen nicht betrachtet – siehe F128. - `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a0816a8b8..37c7b73cd 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -23,7 +23,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F13](#f13) — mittel — *gefixt* - [F16](#f16) — mittel — *gefixt (JNI; die FFM-Hälfte als F198)* - [F17](#f17) — mittel — *gefixt* -- [F18](#f18) — mittel — *gefixt (Fix `c1e5dcd`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `209ee6c`)* +- [F18](#f18) — mittel — *gefixt (Fix `5df523f`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `d5c2622`)* - [F24](#f24) — mittel — *gefixt* - [F25](#f25) — mittel — *gefixt* - [F26](#f26) — mittel — *gefixt* @@ -165,7 +165,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `toJson()` ruft `validate()`: `modelPath`/`outputPath` per `Objects.requireNonNull` mit Feldnamen, genau eins von `trainingText`/`trainingFile`, `epochs >= 1`, `valSplit` in `[0, 1)` (NaN eingeschlossen) – sonst `IllegalArgumentException` vor dem JNI-Aufruf. - **Rot-Grün-Nachweis:** ohne Fix rot, 4 von 7 („Expected IllegalArgumentException to be thrown, but nothing was thrown“ bzw. bare NPE ohne Feldnamen); mit Fix grün 7/7 (skipped 0), `WireNameRegistryTest` 8/8; Gegenprobe per `git stash` rot 4/7, nach `stash pop` grün 7/7. `WireNameRegistryTest#everyTrainingFieldIsWrittenByAFullyPopulatedConfiguration` setzte beide Korpus-Felder zugleich und nutzt jetzt zwei Konfigurationen. - **Status:** gefixt -- **Commit:** java-llama.cpp `0aa7c94` +- **Commit:** java-llama.cpp `aaa7b5a` - **Doku-Lücke aus diesem Fix (2026-10-17, nur beschrieben):** Seit dem Fix wirft `TrainingParameters.toJson()` – und damit beide `LlamaTrainer.finetune`-Überladungen – bei ungültiger Konfiguration `IllegalArgumentException`; die Javadoc beider `finetune` nennt nur `LlamaException`. Reine Doku, kein Test kann sie rot machen, daher nach der Nutzerregel unverändert. @@ -177,7 +177,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Weitere Beobachtung (nicht geändert):** Ungültiges JSON wird absichtlich als **String** unter `parameters` weitergereicht, `""` und Leerzeichen werden zu `"parameters":null`. Ob eine `IllegalArgumentException` (wie in `JsonParameters`) besser wäre, ist eine API-Entscheidung. - **Reproduktion:** `ChatRequestTest$…#buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent` (vorher rot, danach grün), plus Positivtest `buildToolsJsonEmbedsAValidSchemaAsAnObject`; `ChatRequestTest` 24/24 grün. `spotbugs:check` nicht gelaufen. - **Status:** gefixt -- **Commit:** java-llama.cpp `399ff0a` +- **Commit:** java-llama.cpp `d6a9be5` ### F06 – java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab @@ -187,7 +187,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** `System.currentTimeMillis() + timeoutMillis` läuft bei `Long.MAX_VALUE` („so lange wie nötig“, eine naheliegende Aufrufform) über und wird negativ. Die Schleife pollt nie und wirft „did not reach LOADED within 9223372036854775807 ms (last status: null)“, auch wenn das Modell längst geladen ist. Fix: Deadline sättigt bei `Long.MAX_VALUE`. - **Reproduktion:** `RouterClientTest#awaitModelLoaded_longMaxValueTimeoutStillPolls` (vorher rot mit genau dieser Meldung, danach grün) - **Status:** gefixt -- **Commit:** java-llama.cpp `ec12331` +- **Commit:** java-llama.cpp `a4e5b22` ### F07 – java-llama.cpp: `RouterClient` mit IPv6-Literal (`"::1"`) kann nie verbinden @@ -197,7 +197,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Die URL wurde als `"http://" + host + ":" + port` gebaut. Für `new RouterClient("::1", port)` (Router mit `--host ::1`; Javadoc: „host name or address“) entsteht `http://::1:port/models` → `MalformedURLException: Error at index 0 in ":1:8080"` vor jedem Verbindungsversuch. Workaround war `"[::1]"`. Fix: IPv6-Literal wird geklammert, bereits geklammerte Hosts bleiben unverändert. Nicht abgedeckt: Zonen-IDs (`fe80::1%eth0`) bräuchten zusätzlich `%25`-Kodierung. `toString()` zeigt den Host weiterhin ungeklammert. - **Reproduktion:** `RouterClientTest#ipv6LiteralHostIsBracketedInTheRequestUrl` (vorher rot: `MalformedURLException`, danach andere `IOException`, weil auf Port 1 nichts lauscht). Der Sandbox fehlt IPv6, ein echter Server auf `::1` war hier nicht testbar. - **Status:** gefixt -- **Commit:** java-llama.cpp `0f84984` +- **Commit:** java-llama.cpp `5b98dc7` ### F09 – java-llama.cpp: Loader löscht fremde `llama*`/`ggml*`-Einträge im Temp-Verzeichnis, Verzeichnisse rekursiv @@ -208,7 +208,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LlamaLoaderTest#staleExtractionLeavesForeignEntriesWithAMatchingPrefixAlone` (vorher rot, danach grün); `LlamaLoaderTest` 63/63, inkl. der bestehenden Fälle `jllama-3f2a.so` / `ggml-metal.metal` (werden weiter gelöscht). - **Bitte prüfen:** Die genauen Dateinamen des alten Layouts konnte ich nicht verifizieren, weil der Clone flach ist und keine Historie vor 6.0.0 enthält. Falls es damals auch Endungen wie `.tmp` oder `.lck` gab, bleiben die jetzt liegen. Das ist harmlos, nur Restmüll. - **Status:** gefixt -- **Commit:** java-llama.cpp `3ae1b98` +- **Commit:** java-llama.cpp `61ea8f5` ### F12 – java-llama.cpp: Ollama-`/api/chat` verwirft Bilder (`images`), obwohl `/api/show` „vision“ meldet @@ -219,7 +219,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestTurnsOllamaImagesIntoImageUrlParts` (vorher rot, danach grün); `OllamaApiSupportTest` 20/20, `OpenAiCompatServerHttpTest` 43/43. Ende-zu-Ende mit echtem Vision-Modell nicht geprüft (kein Modell im Sandbox). - **Nebenbefund (nicht geändert):** Die gestreamte Abschlusszeile (`toOllamaDoneLine`) setzt `done_reason` immer auf `"stop"`, auch bei Abbruch durch `length`; die nicht-gestreamte Antwort übernimmt `finish_reason` korrekt. - **Status:** gefixt -- **Commit:** java-llama.cpp `d31164b` +- **Commit:** java-llama.cpp `ef7295c` ### F13 – java-llama.cpp: `setRopeScaling(UNSPECIFIED | LONGROPE | MAX_VALUE)` macht das Modell unladbar @@ -230,7 +230,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `UNSPECIFIED` entfernt die Option (wie `setPoolingType`), `LONGROPE`/`MAX_VALUE` → `IllegalArgumentException` beim Aufruf. Kein vorher funktionierender Pfad geht verloren (diese drei Werte scheiterten ohnehin beim Laden). Die Enum-Konstanten selbst bleiben (API-Kompatibilität) – ob sie gelöscht werden sollen, ist deine Entscheidung (CLAUDE.md: „A name with no counterpart is deleted, never deprecated“). - **Reproduktion:** `ModelParametersExtendedTest#testSetRopeScalingUnspecifiedLeavesTheModelDefault`, `#testSetRopeScalingRejectsValuesTheParserDoesNotAccept` (beide vorher rot), `#testSetRopeScalingAllValuesTheParserAccepts` (ersetzt den alten Test). `WireNameRegistryTest`-Treiber nimmt bei Ablehnung die nächste Konstante. Grün: ModelParametersExtendedTest 148, ModelParametersTest 92, WireNameRegistryTest 8, RopeScalingTypeTest 24. Nicht gelaufen: C++-`test_model_flags` (prüft nur Optionsnamen, nicht Werte), `spotbugs:check`. - **Status:** gefixt -- **Commit:** java-llama.cpp `8be77fe` +- **Commit:** java-llama.cpp `abe776c` ### F16 – java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) @@ -243,7 +243,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `ReentrantReadWriteLock`: `synthesize` hält die Lesesperre über den ganzen nativen Aufruf, `close` die Schreibsperre. Gleichzeitige Synthesen bleiben möglich (die Engine ordnet sie selbst über `synthesize_mutex`), aber `close` gibt die Engine (samt diesem Mutex) erst frei, wenn keine mehr läuft. Die JNI-Aufrufe laufen über eine paketinterne Naht `Backend`; `LlamaLoader.initialize()` wanderte aus dem static-Block in deren `load`. - **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral eingezogen; dagegen rot 1/4 („the engine was freed while synthesize() still ran in it – Expected: is but: was “). Mit Fix grün 4/4 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot 1/4, wiederhergestellt grün 4/4. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt (JNI; die FFM-Hälfte als F198) -- **Commit:** java-llama.cpp `128be3a` +- **Commit:** java-llama.cpp `6ed949e` ### F17 – java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild @@ -255,7 +255,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#requestTurnsInputImagePartsIntoImageUrlParts` (vorher rot, nachher 10/10 grün) - **Nachtrag (Branch-Review 2026-10-20):** Der Satz „`file_id`-Bilder (ohne URL) werden weiterhin ignoriert“ gilt nicht mehr: ein `input_image` ohne URL wird seit F224 wie upstream mit 400 abgewiesen. - **Status:** gefixt -- **Commit:** java-llama.cpp `4c08a90` +- **Commit:** java-llama.cpp `558422a` ### F18 – java-llama.cpp: `NativeServer.close()` nicht atomar → doppeltes Freigeben des nativen Servers bei parallelen Aufrufen @@ -265,9 +265,9 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** `close()` las `handle`, prüfte auf 0 und setzte es danach auf 0 – ohne Synchronisation. Zwei gleichzeitige `close()` (Shutdown-Hook + try-with-resources, oder in `NativeServer.main` der Hook und der `finally`-Block, wenn der Worker genau beim Ctrl-C selbst endet) kamen beide durch und riefen `stopNativeServer(h)` doppelt: zweimal `join()` und zweimal `delete` desselben `native_server` → JVM-Absturz. Ebenso konnte `isRunning()` einen Handle abfragen, den ein paralleles `close()` gerade freigab (Use-after-free). Der Kommentar in `main` („the finally and the hook both firing is safe“) gilt nur nacheinander. - **Fix:** privates `lifecycleLock` serialisiert `start`, `isRunning` und `close` (keine `synchronized`-Methoden, SpotBugs USO_UNSAFE_METHOD_SYNCHRONIZATION). `spotbugs:check` grün, `NativeServerSmokeTest` + `LlamaArchitectureTest` grün. - **Reproduktion:** `NativeServerLifecycleTest` über die neue paketinterne Naht `NativeServer.Backend` (ein Fake statt der JNI-Aufrufe, damit kein erfundener Handle libjllama erreicht): `close_whileIsRunningIsInsideTheNativeCall_stopsOnlyAfterItReturned`, `close_whileStartIsStillStarting_stopsTheServerOnceItStarted`, `close_calledTwice_stopsTheHandleOnce`. Der Fake hält `isRunning` bzw. `start` im „nativen“ Aufruf fest, dadurch sind beide Race-Fenster deterministisch. (Die alte Probe `/home/user/BUGHUNT-2026-10-17-F18-probe.java`: vorher 4 / 20 000 doppelte Stopps, nachher 0 / 20 000.) -- **Rot-Grün-Nachweis:** Gegen den Lebenszyklus vor `c1e5dcd` (ohne `lifecycleLock`, Naht beibehalten) **rot**, 2 von 3: „the handle was stopped while isRunning() still used it – Expected: is but: was “ und „close() returned while the server was still starting, so it kept running – Expected: is <1> but: was <0>“. Mit Fix **grün** 3/3 (skipped 0), dreimal hintereinander, ebenso `NativeServerSmokeTest` 10/10, `NativeServerWorkerCommandTest` 5/5, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. Das doppelte `close()` aus zwei Threads selbst ist mit der Naht nicht steuerbar (das Fenster liegt zwischen Lesen und Nullsetzen des Feldes); `close_calledTwice_stopsTheHandleOnce` deckt nur den sequentiellen Fall ab. -- **Status:** gefixt (Fix `c1e5dcd`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `209ee6c`) -- **Commit:** java-llama.cpp `c1e5dcd` (Fix), Test und Naht `669a472` +- **Rot-Grün-Nachweis:** Gegen den Lebenszyklus vor `5df523f` (ohne `lifecycleLock`, Naht beibehalten) **rot**, 2 von 3: „the handle was stopped while isRunning() still used it – Expected: is but: was “ und „close() returned while the server was still starting, so it kept running – Expected: is <1> but: was <0>“. Mit Fix **grün** 3/3 (skipped 0), dreimal hintereinander, ebenso `NativeServerSmokeTest` 10/10, `NativeServerWorkerCommandTest` 5/5, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. Das doppelte `close()` aus zwei Threads selbst ist mit der Naht nicht steuerbar (das Fenster liegt zwischen Lesen und Nullsetzen des Feldes); `close_calledTwice_stopsTheHandleOnce` deckt nur den sequentiellen Fall ab. +- **Status:** gefixt (Fix `5df523f`; roter Regressionstest nachgereicht, bis dahin „nur beschrieben“, siehe `d5c2622`) +- **Commit:** java-llama.cpp `5df523f` (Fix), Test und Naht `8845eca` ### F24 – java-llama.cpp: `OpenAiServerCli` bewirbt bei kleinem `-c` fast kein Eingabebudget (`-c 2048` → 1 Token) @@ -278,7 +278,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `maxInputTokens = max(1, ctx − maxOutputTokens)` mit dem tatsächlich gesetzten Ausgabebudget; 8192 → 2048/6144 unverändert. - **Reproduktion:** `OpenAiServerCliTest#smallContextSizeSplitsTheWholeWindowBetweenInputAndOutput` (ctx 1024/2048/3000; vorher `Expected 1024 but was 513`, nachher 38/38 grün). - **Status:** gefixt -- **Commit:** java-llama.cpp `b210ca4` +- **Commit:** java-llama.cpp `2cf3640` ### F25 – java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung @@ -289,7 +289,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `x-api-key` wird zusätzlich akzeptiert, das Schema case-insensitiv geprüft; beide Vergleiche weiterhin zeitkonstant (`MessageDigest.isEqual`). `spotbugs:check` grün. - **Reproduktion:** `OpenAiCompatServerHttpTest#anthropicApiKeyHeaderAndLowercaseBearerSchemeAreAccepted` (vorher 401 statt 200, nachher Klasse 44/44 grün). - **Status:** gefixt -- **Commit:** java-llama.cpp `e98e290` +- **Commit:** java-llama.cpp `78fd0af` ### F26 – java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert @@ -300,7 +300,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `-Infinity` wird als `false` (llama.cpps Schreibweise für ein Verbot, wie schon bei `withDisabledTokenIds`) gesendet, `NaN`/`+Infinity` werfen `IllegalArgumentException`. - **Reproduktion:** `ParameterJsonSerializerTest#testBuildTokenIdBiasArray_negativeInfinityBansTheToken`, `#testBuildTokenIdBiasArray_nanOrPositiveInfinityIsRejected` (vorher rot, nachher 34/34); `InferenceParametersTest` 105/105, `WireNameRegistryTest` 8/8 grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ea721a2` +- **Commit:** java-llama.cpp `d38840e` ### F28 – java-llama.cpp: `InferenceParameters.withMessages(List)` verwirft `tool_calls` und `tool_call_id` @@ -311,7 +311,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** die beiden Felder schreibt jetzt der Serializer; die Duplikat-Schleife in `ChatRequest` entfällt (Ausgabe dort identisch). `spotbugs:check` grün. - **Reproduktion:** `InferenceParametersTest#withChatMessagesKeepsToolCallsAndToolCallIds` (vorher rot, nachher 106/106); `ChatRequestTest` 24/24, `ParameterJsonSerializerTest` 34/34, `OpenAiCompatServerHttpTest` 44/44 grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `855d22e` +- **Commit:** java-llama.cpp `2c32c84` ### F29 – java-llama.cpp: Anthropic-Messages-API (`POST /v1/messages`) verwirft `image`-Blöcke → Vision-Modell sieht kein Bild @@ -322,7 +322,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Bild-Blöcke werden zu OpenAI-`image_url`-Teilen (Data-URL für `base64`, die URL selbst für `url`-Quellen); eine Nachricht mit Bild bekommt Content als Teile-Array in Blockreihenfolge, Tool-Calls bleiben erhalten. Nachrichten ohne Bild unverändert. `spotbugs:check` grün. - **Reproduktion:** `AnthropicApiSupportTest#requestTurnsImageBlocksIntoImageUrlParts` (vorher rot, nachher 12/12); `OpenAiCompatServerHttpTest` 44/44 grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d8c653c` +- **Commit:** java-llama.cpp `3d38dd0` ### F30 – java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt @@ -332,7 +332,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Läuft der Start in den Timeout (z. B. langsame Geräte-Initialisierung) oder ist der aufrufende Thread unterbrochen, schickt `start` genau **einen** `stop()` und gibt den Single-Instance-Slot frei. Der native Server (patches/0015) setzt aber beim Binden des Sockets `g_rpc_server_stop = false` – ein Stopp vor dem Bind geht verloren. Der Server lauscht danach als verwaister Daemon-Thread weiter, obwohl `start` eine `LlamaException` geworfen hat; ein zweiter `start` belegt dann denselben globalen Server-Zustand. Beim unterbrochenen Aufrufer kehrte `join` zudem sofort zurück, statt zu warten. Fix: `stopAndJoin` wiederholt den Stopp alle 100 ms, solange der Thread lebt (bis `STOP_TIMEOUT_MILLIS`), und stellt das Interrupt-Flag danach wieder her. - **Reproduktion:** `RpcServerLifecycleTest#aStopThatArrivesBeforeTheBindDoesNotLeaveTheServerRunning` (Fake-Backend, das wie der native Server das Stopp-Flag beim Binden zurücksetzt; ohne Fix rot nach 31 s, mit Fix 15/15 grün) - **Status:** gefixt -- **Commit:** 32d465d +- **Commit:** e824ae5 ### F36 – java-llama.cpp: `LlamaIterator` wird nach `cancel()` aus einem zweiten Thread durch ein spät eintreffendes Ergebnis wiederbelebt @@ -344,7 +344,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LlamaIteratorCancelTest#cancel_whileNextWaitsForAResultOnAnotherThread_iteratorStaysFinished` (der Fake hält `receiveCompletionJson` fest, bis `cancel()` gelaufen ist), dazu `#next_untilTheStopResult_releasesTheTaskOnceAndEnds`, `#close_beforeTheStopResult_cancelsTheTaskOnce` - **Rot-Grün-Nachweis:** Naht zuerst verhaltensneutral; dagegen rot 1/3 („a result that arrived after cancel() revived the iterator – Expected: is but: was “). Mit Fix grün 3/3 (skipped 0); Gegenprobe mit dem Stand ohne Fix rot, wiederhergestellt grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `f4407bb` +- **Commit:** java-llama.cpp `79fa666` ### F37 – java-llama.cpp: Ollama-Tool-Call mit leeren Argumenten kommt als `"arguments": null` statt `{}` an @@ -355,7 +355,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toOllamaChatResponseTurnsEmptyToolCallArgumentsIntoAnEmptyObject` (Jackson-Verhalten vorab per Probe bestätigt: `readTree("")` → `MissingNode` → `"arguments":null`) - **Rot-Grün-Nachweis:** ohne Fix rot 1/21 („Expected: is but: was “: kein Objekt), mit Fix grün 21/21 (skipped 0); Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `6590af2` +- **Commit:** java-llama.cpp `2940edd` ### F38 – java-llama.cpp: `JsonParameters` nimmt einen leeren oder nur aus Leerzeichen bestehenden Wert still an und sendet `"key":null` @@ -367,7 +367,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `JsonParametersTest#withRaw_rejectsEmptyAndBlankValues` - **Rot-Grün-Nachweis:** ohne Fix rot 1/25 („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün 25/25 (skipped 0); Gegenprobe per `git stash` rot, nach `stash pop` grün. Paket `parameters` plus Server-Mapping- und HTTP-Tests: 775 Tests, 0 Fehler. PIT auf `JsonParameters` (Schwelle 100): alle Mutanten getötet. `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `41b5ad3` +- **Commit:** java-llama.cpp `2a5c835` ### F39 – java-llama.cpp: Anthropic-`tool_use` mit leeren Argumenten bekommt `"input": null` statt `{}` @@ -379,7 +379,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot 1/13 („Expected: is but: was “), mit Fix grün 13/13 (skipped 0); Gegenprobe per `git stash` rot, danach grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Durchsicht der übrigen `readTree`-Aufrufer (2026-10-17):** 35 Aufrufe in 24 Dateien. Betroffen waren nur F37 (Ollama), F38 (`JsonParameters`) und F39. Die übrigen lesen Antworten des eigenen Servers (Completion-/Chat-/Stream-Parser, Rerank, LoRA, Router) und greifen danach über `path(...)` zu, was auf einem `MissingNode` sicher leere/Default-Werte liefert; `ChatRequest.buildToolsJson` liest ein Tool-Schema, das ein leerer String zu `null` macht (bereits als F05-Umfeld bekannt), `StreamingChunkAssembler` wirft bei unparsebarem Chunk bewusst. - **Status:** gefixt -- **Commit:** java-llama.cpp `6effd44` +- **Commit:** java-llama.cpp `6946b77` ### F53 – java-llama.cpp: leerer Streaming-Umschlag beendet den Chat-Stream nicht @@ -391,7 +391,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ChatStreamChunkParserTest#feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing` (`""`, `" "`, `"\n"`) - **Rot-Grün-Nachweis:** ohne Fix rot („envelope '' Expected: is but: was “), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatStreamChunkParserTest` 7/7, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `27d6477` +- **Commit:** java-llama.cpp `4d19a04` ### F54 – java-llama.cpp: leerer Wert in `chat_template_kwargs` wird zu `null` statt zum Text @@ -403,7 +403,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ParameterJsonSerializerTest#testBuildRawValueObject_emptyOrBlankValueFallsBackToString` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "{"empty":"","blank":" "}" but: was "{"empty":null,"blank":null}"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ParameterJsonSerializerTest` 35/35, `ModelParametersTest` 92/92, `InferenceParametersTest` 106/106, `WireNameRegistryTest` 8/8, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `37303d0` +- **Commit:** java-llama.cpp `3eb7a6e` ### F55 – java-llama.cpp: leeres Tool-Parameterschema wird zu `"parameters": null` @@ -415,7 +415,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ChatRequestTest#buildToolsJsonKeepsAnEmptySchemaAsTheStringFallbackInsteadOfNull` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"parameters\":\"\"" but: was "[{"type":"function","function":{"name":"f","description":"d","parameters":null}}]"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatRequestTest` 25/25, `ToolCallingAgentTest` 6/6, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ed6236d` +- **Commit:** java-llama.cpp `8a13aa9` ### F56 – java-llama.cpp: nicht darstellbarer Offline-Modellpfad ergibt `InvalidPathException` statt `ModelUnavailableException` @@ -427,7 +427,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LlamaModelOfflineTest#check_offlineAndUnrepresentablePath_throwsModelUnavailable` - **Rot-Grün-Nachweis:** ohne Fix rot („expected: but was: “, Nul character not allowed), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaModelOfflineTest` 9/9, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `4f610bd` +- **Commit:** java-llama.cpp `f3b8aed` ### F57 – java-llama.cpp: `ServerMetrics.getCumulativeUsage()` liefert ein `Usage` gegen dessen eigenen Vertrag @@ -440,7 +440,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ServerMetricsTest#cumulativeUsage` (schrieb das alte Verhalten fest, jetzt 800/700/100/1000 aus 100 ausgewerteten + 700 gecachten) und neu `#cumulativeUsage_readsLikeTheUsageOfAChatResponse` (10 + 4 → `new Usage(14, 3, 4)`). - **Rot-Grün-Nachweis:** ohne Fix rot (2 von 23), mit Fix grün (23 von 23). - **Status:** gefixt -- **Commit:** java-llama.cpp `a529c5c` +- **Commit:** java-llama.cpp `6853464` ### F59 – java-llama.cpp: `FfmNativeServer.isRunning()` kann nach einem parallelen `close()` freigegebenen Speicher lesen @@ -448,13 +448,13 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` (`isRunning`, `close`) - **Schwere:** niedrig - **Beschreibung:** `isRunning()` prüft `closed` und ruft dann `jllama_server_is_running(handle)`. `close()` setzt `closed` und ruft `jllama_server_stop`, das laut `jllama_api.h` den Handle freigibt. Läuft `close()` (etwa aus einem Shutdown-Hook) zwischen der Prüfung und dem nativen Aufruf eines anderen Threads, liest `jllama_server_is_running` einen freigegebenen `jllama_server` – undefiniertes Verhalten im nativen Code, schlimmstenfalls ein JVM-Absturz. Der JNI-Zwilling `server.NativeServer` hält genau deshalb `isRunning()` und `close()` unter einem gemeinsamen `lifecycleLock`; die FFM-Klasse dokumentiert keine Einschränkung. (`FfmLlamaModel` hat dasselbe Muster, schreibt aber ausdrücklich, dass `close()` keinen neuen Aufruf überholen darf – dort ein dokumentierter Vertrag, kein Fund.) -- **Messung (J7, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`, JDK 25):** mit einer Naht rot – `close()` gab den Handle frei, während `isRunning()` noch im nativen Aufruf war; ohne Naht 862 Mio. Aufrufe in 30 Runden ohne sichtbaren Schaden (das Fenster ist schmal). +- **Messung (J7, `BUGHUNT-MEASUREMENTS.md` in `4c58e8f`, JDK 25):** mit einer Naht rot – `close()` gab den Handle frei, während `isRunning()` noch im nativen Aufruf war; ohne Naht 862 Mio. Aufrufe in 30 Runden ohne sichtbaren Schaden (das Fenster ist schmal). - **Fix:** Entscheidung des Nutzers: das Lock aus `NativeServer`. `isRunning()` und `close()` laufen unter `lifecycleLock`, `closed` ist ein einfaches Feld unter diesem Lock. Die beiden nativen Aufrufe liegen hinter der paketinternen Naht `FfmNativeServer.Backend` (wie `NativeServer.Backend`), statt veränderlicher statischer Method-Handles. - **Tests:** `FfmNativeServerTest` (4, ohne Bibliothek): `close()` wartet auf ein `isRunning()` im nativen Aufruf und gibt erst danach frei; `isRunning()` nach `close()` fragt den Handle nicht ab; zweites `close()` ohne Wirkung; `isRunning()` fragt im offenen Zustand. - **Rot-Grün-Nachweis:** lokal mit JDK 25 (Temurin 25.0.1, Toolchain): ohne Lock (Naht beibehalten, `AtomicBoolean` wie vorher) rot (1 von 4, `close()` wartete nicht), mit Lock grün. - **Nachtrag (Branch-Review 2026-10-20):** Dieselbe Prüfen-dann-Rufen-Lücke hatte `FfmTextToSpeech`; dort mit demselben Lock als F198 behoben. - **Status:** gefixt -- **Commit:** c7c883d +- **Commit:** bd479b3 ### F60 – java-llama.cpp: Textblöcke einer Anthropic-Nachricht werden ohne Trenner zusammengeklebt @@ -466,7 +466,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#requestJoinsTheTextBlocksOfOneMessageWithANewline` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, 14 Tests, 1 Fehlschlag), mit Fix grün 14/14; Gegenprobe per `git stash` rot, nach `stash pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `76a3370` +- **Commit:** java-llama.cpp `29ba25c` - **Präzisierung (2026-10-17, 22:45 UTC):** Upstream verbindet Text-Parts nur dann mit `\n`, wenn die Chat-Vorlage ausschließlich String-Inhalt annimmt (`messages_inp_normalizer`, Zweig `only_string`, in `common/chat.cpp`); eine Vorlage mit typisiertem Inhalt bekommt die Parts als Array und rendert sie selbst, unter Umständen ohne Trenner. Die Übersetzung reicht aber keine Parts weiter, sondern einen fertigen String – sie muss sich also für einen Trenner entscheiden, und `\n` ist der, den upstream wählt, wenn es selbst verbindet. Der Fix bleibt damit sinnvoll; die frühere Aussage „upstream verbindet mit Zeilenumbruch“ galt nur für String-Vorlagen. Alternative für den Nutzer: Text-Blöcke wie Bilder als Content-Parts weiterreichen und die Entscheidung der Vorlage überlassen. @@ -479,7 +479,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#requestJoinsTheTextPartsOfOneInputItemWithANewline` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, 11 Tests, 1 Fehlschlag), mit Fix grün 11/11; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `745fed5` +- **Commit:** java-llama.cpp `196e76f` - **Präzisierung (2026-10-17, 22:45 UTC):** Upstream verbindet Text-Parts nur dann mit `\n`, wenn die Chat-Vorlage ausschließlich String-Inhalt annimmt (`messages_inp_normalizer`, Zweig `only_string`, in `common/chat.cpp`); eine Vorlage mit typisiertem Inhalt bekommt die Parts als Array und rendert sie selbst, unter Umständen ohne Trenner. Die Übersetzung reicht aber keine Parts weiter, sondern einen fertigen String – sie muss sich also für einen Trenner entscheiden, und `\n` ist der, den upstream wählt, wenn es selbst verbindet. Der Fix bleibt damit sinnvoll; die frühere Aussage „upstream verbindet mit Zeilenumbruch“ galt nur für String-Vorlagen. Alternative für den Nutzer: Text-Blöcke wie Bilder als Content-Parts weiterreichen und die Entscheidung der Vorlage überlassen. @@ -492,7 +492,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `UserMessage.from(TextContent.from("Hello"), TextContent.from("world"))` → `getContent()` = `"Helloworld"`. - **Rot-Grün-Nachweis:** keiner (kein Fix ohne Entscheidung). - **Status:** gefixt als F94 (Duplikat: derselbe Befund wurde in der Musterdurchsicht nach F60/F61 noch einmal aufgenommen und dort behoben; Nachtrag 2026-10-19) -- **Commit:** java-llama.cpp `ee99d85` (F94) +- **Commit:** java-llama.cpp `980c48d` (F94) ### F63 – java-llama.cpp: Responses-API-Objekte tragen `created_at: 0` @@ -504,7 +504,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#responseCarriesTheCurrentUnixTimeAsCreatedAt` - **Rot-Grün-Nachweis:** ohne Fix rot („a value equal to or greater than <1792275470L> <0L> was less than <1792275470L>“, 12 Tests, 1 Fehlschlag), mit Fix grün 12/12; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `aba327e` +- **Commit:** java-llama.cpp `fffb318` ### F64 – java-llama.cpp: Ollama-Werkzeugaufrufe im Verlauf ohne `type` – llama.cpp lehnt die Anfrage ab @@ -517,7 +517,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "function" but: was """, 22 Tests, 1 Fehlschlag), mit Fix grün 22/22; Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 44/44, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **CI-Bedarf:** der Ende-zu-Ende-Beweis (die 400 von upstream) braucht ein Modell; der modellgebundene `OpenAiCompatServerIntegrationTest` lief hier übersprungen. - **Status:** gefixt -- **Commit:** java-llama.cpp `f4c3ed6` +- **Commit:** java-llama.cpp `39b008b` ### F65 – java-llama.cpp: CORS-Preflight erlaubt nur `Content-Type, Authorization` – Browser-SDKs werden abgewiesen @@ -529,7 +529,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#optionsPreflightAllowsTheRequestedHeaders` (roher Socket, `OPTIONS /v1/messages` mit `content-type, x-api-key, anthropic-version, x-stainless-os`) - **Rot-Grün-Nachweis:** ohne Fix rot (Antwort `access-control-allow-headers: content-type, authorization`, 45 Tests, 1 Fehlschlag), mit Fix grün 45/45; Gegenprobe per `git stash` rot, nach `stash pop` grün. `OllamaApiSupportTest` 22/22, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `f782c7a` +- **Commit:** java-llama.cpp `9958f31` ### F67 – java-llama.cpp: `setLoraAdapters` verspricht ein Leeren der KV-Caches, das upstream auf diesem Weg nicht macht @@ -541,11 +541,11 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** keine lauffähige: es braucht ein Modell und einen dazu passenden LoRA-Adapter (nicht in `models.csv`), und in dieser Sandbox gibt es keine native Bibliothek und kein `gguf-py`. Ein Test würde denselben Prompt vor und nach `setLoraAdapter(0, 0f)` mit Greedy-Sampling schicken und mit einem frisch geladenen Modell ohne Adapter vergleichen. - **CI-Bedarf:** ein kleines Modell mit LoRA-Adapter im Modellsatz (oder ein zur Laufzeit erzeugtes Zufallsmodell samt Adapter, wie `test_c_api.cpp` es für das Modell macht) – nur, falls das Verhalten geändert werden soll. - **Status:** gefixt (zuerst Dokumentation, seit 2026-10-19 Patch 0021, siehe Nachtrag) -- **Commit:** a977a1e -- **Nachtrag 2026-10-19 (Verhalten geändert):** J9 hat F67 bestätigt (`b689d7b`: nach `setLoraAdapter(0, 0f)` lieferte der warme Slot „. He saw a picture of a bird …“ statt „ was a little girl …“ wie ein geleerter Slot). Entscheidung des Nutzers: upstream-fähiger Patch `0021` statt eines Leerens in `engine::set_lora_adapters`, weil upstreams eigene HTTP-Route `POST /lora-adapters` denselben Fehler hat. Der `else`-Zweig von `launch_slot_with_task` wendet jetzt dieselbe Regel an wie LoRA pro Anfrage (`lora_should_clear_cache`, aLoRA behält den Cache). Das deckt auch den zweiten Weg ab, den die Durchsicht fand: eine Anfrage mit eigenen Adaptern füllt den Cache, die nächste ohne läuft mit den Server-Adaptern darauf weiter. Nicht abgedeckt, in Javadoc/README genannt: der Host-RAM-Prompt-Cache (`--cache-ram`, standardmäßig an) speichert Slot-Zustände ohne ihre Adapter. Javadoc `setLoraAdapters`, README, CHANGELOG, CLAUDE.md (Patch- und Testtabelle), `docs/patches.md`. Upstream-master bewusst nicht geprüft (Entscheidung des Nutzers: zeigt sich beim nächsten Bump über den Drop-Check). +- **Commit:** cb8a3ea +- **Nachtrag 2026-10-19 (Verhalten geändert):** J9 hat F67 bestätigt (`4c58e8f`: nach `setLoraAdapter(0, 0f)` lieferte der warme Slot „. He saw a picture of a bird …“ statt „ was a little girl …“ wie ein geleerter Slot). Entscheidung des Nutzers: upstream-fähiger Patch `0021` statt eines Leerens in `engine::set_lora_adapters`, weil upstreams eigene HTTP-Route `POST /lora-adapters` denselben Fehler hat. Der `else`-Zweig von `launch_slot_with_task` wendet jetzt dieselbe Regel an wie LoRA pro Anfrage (`lora_should_clear_cache`, aLoRA behält den Cache). Das deckt auch den zweiten Weg ab, den die Durchsicht fand: eine Anfrage mit eigenen Adaptern füllt den Cache, die nächste ohne läuft mit den Server-Adaptern darauf weiter. Nicht abgedeckt, in Javadoc/README genannt: der Host-RAM-Prompt-Cache (`--cache-ram`, standardmäßig an) speichert Slot-Zustände ohne ihre Adapter. Javadoc `setLoraAdapters`, README, CHANGELOG, CLAUDE.md (Patch- und Testtabelle), `docs/patches.md`. Upstream-master bewusst nicht geprüft (Entscheidung des Nutzers: zeigt sich beim nächsten Bump über den Drop-Check). - **Tests (Patch):** `src/test/cpp/test_lora_cache.cpp` (4): Zufallsmodell + Zufalls-LoRA auf K, V und FFN-Ausgang, ein Slot, `--cache-ram 0`, greedy; nach Skala 1→0, 0→1 und nach einer Anfrage mit eigenen Adaptern muss der warme Slot wie ein geleerter antworten; ein vierter Fall schließt das leere Bestehen aus (Adapter ändert die Antwort, Wiederholung nutzt den Cache). Das Modellschreiben liegt jetzt in `tiny_llama_gguf.hpp`, gemeinsam mit `test_c_api.cpp`. - **Rot-Grün-Nachweis (Patch):** ohne Patch rot (3 von 4; z. B. „mYmq5*mq“ statt „C+oM~kM~“), mit Patch grün (4 von 4, drei Läufe); ganze C++-Suite 633/633; alle 21 Patches wenden sich der Reihe nach sauber auf b11639 an; `check-patches.py` 0 Probleme. -- **Commit (Patch):** java-llama.cpp `c4a23c5` +- **Commit (Patch):** java-llama.cpp `e5a3efc` ### F68 – java-llama.cpp: `RouterClient.awaitModelLoaded` läuft bei einem schlafenden Modell in den Timeout @@ -557,7 +557,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `RouterClientTest#awaitModelLoaded_sleepingModelIsReady` (Stub-Server liefert `sleeping`) - **Rot-Grün-Nachweis:** ohne Fix rot (`IllegalStateException: Model 'qwen' did not reach LOADED within 2000 ms (last status: qwen [sleeping])`), mit Fix grün, `RouterClientTest` 19/19; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `352057b` +- **Commit:** java-llama.cpp `70833e5` ### F69 – java-llama.cpp: `withNPredict` dokumentiert `-2 = until context filled`, der Server lehnt `-2` ab @@ -570,7 +570,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **CI-Bedarf:** ein modellgestützter Java-Test (jedes Modell aus `models.csv` genügt), falls `-2` zusätzlich in Java abgewiesen werden soll. - **Nachtrag 2026-10-19 (geschlossen):** J2a hat die Ablehnung gemessen (`InvalidRequestException`, „Field 'n_predict': Value must be between -1 <= value <= 2147483647“). Entscheidung des Nutzers: kein Java-Check (er würde denselben Fehler nur früher melden und upstreams Grenze ein zweites Mal pflegen), dafür die Ablehnung modellgestützt festgeschrieben: `ErrorHandlingTest#testNPredictMinusTwoPerRequestIsAnInvalidRequest` (läuft in jedem `test-java-*`-Job mit dem CI-Modell). Lokal mit einem zufälligen Zwei-Schicht-GGUF geprüft: mit `-2` grün, als Gegenprobe mit `-1` rot („Expected InvalidRequestException to be thrown, but nothing was thrown“). - **Status:** gefixt (Dokumentation) -- **Commit:** a977a1e +- **Commit:** cb8a3ea ### F70 – java-llama.cpp: `OpenAiServerCli` bewirbt mit `--parallel N` das ganze `-c` statt des Kontexts je Slot @@ -582,7 +582,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiServerCliTest#parallelSlotsShareTheContextSoTheBudgetIsPerSlot` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2048> but: was <8192>“), mit Fix grün, `OpenAiServerCliTest` 39/39; Gegenprobe per `git stash` rot, nach `stash pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `c1eaa50` +- **Commit:** java-llama.cpp `657e792` ### F71 – java-llama.cpp: Feintuning scheitert an jedem Zeichen außerhalb der BMP (Emoji) im Trainingstext @@ -595,7 +595,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <0L> but: was <6L>“ Nicht-ASCII-Zeichen), mit Fix grün, `TrainingParametersTest` 8/8; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **CI-Bedarf:** ein Ende-zu-Ende-Beweis bräuchte `LlamaTrainerIntegrationTest` mit einem Emoji im Trainingstext (Modell `stories260K.gguf` liegt in CI vor). - **Status:** gefixt -- **Commit:** java-llama.cpp `ca6ccdf` +- **Commit:** java-llama.cpp `4867189` ### F72 – java-llama.cpp: `GgufInspector` liest verschachtelte Arrays rekursiv – `StackOverflowError` statt `IOException` @@ -607,7 +607,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `GgufInspectorTest#nestedArrayIsRejectedLikeUpstream` (2 Ebenen) und `#deeplyNestedArraysFailWithIOExceptionNotStackOverflow` (100 000 Ebenen) - **Rot-Grün-Nachweis:** ohne Fix rot („expected: but was: “ bzw. „Expected java.io.IOException to be thrown, but nothing was thrown“), mit Fix grün, `GgufInspectorTest` 14 (1 übersprungen: der bestehende Test mit echtem Modell); Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `79f3ee9` +- **Commit:** java-llama.cpp `5a0b834` ### F74 – java-llama.cpp: Ollama-`/api/generate` mit `suffix` (FIM) verwirft `stop`, `top_p`, `top_k` und `seed` @@ -619,7 +619,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#generateWithSuffixKeepsTheSamplingOptionsOfAPlainGenerate` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "\n\n" but: was ""“), mit Fix grün (23/23 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `367592f` +- **Commit:** java-llama.cpp `b641834` ### F75 – java-llama.cpp: Responses-API verwirft `reasoning.effort` – `"none"` schaltet das Denken nicht ab @@ -632,7 +632,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Nachtrag (2026-10-18):** wirkt erst zusammen mit [F77](#f77): der Chat-Mapper, durch den jede übersetzte Anfrage läuft, verwarf das erzeugte Feld bis dahin. - **Status:** gefixt -- **Commit:** java-llama.cpp `485f153` +- **Commit:** java-llama.cpp `eeba74d` ### F76 – java-llama.cpp: Anthropic-`/v1/messages` verwirft `thinking.budget_tokens` – das Modell denkt unbegrenzt @@ -646,7 +646,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nicht übernommen:** upstreams Vorgabe `max_tokens` 4096, wenn es fehlt (bei uns unbegrenzt; das Anthropic-Protokoll verlangt das Feld), und das Durchreichen von `chat_template_kwargs` – beide bleiben als offener Verdacht stehen. - **Nachtrag (2026-10-18):** wirkt erst zusammen mit [F77](#f77): der Chat-Mapper, durch den jede übersetzte Anfrage läuft, verwarf das erzeugte Feld bis dahin. - **Status:** gefixt -- **Commit:** java-llama.cpp `5117413` +- **Commit:** java-llama.cpp `262b41d` ### F77 – java-llama.cpp: `OpenAiRequestMapper` verwirft `chat_template_kwargs`, `reasoning_effort` und das Reasoning-Budget jedes Chat-Requests @@ -659,7 +659,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix alle fünf rot (u. a. „Expected: is <512> but: was <0>“, „Expected: is "low" but: was ""“, „Expected: is <1024> but: was <0>“), mit Fix 21/21 der Klasse grün, skipped 0; Gegenprobe per `git stash` wieder 5 rot, nach `pop` grün. `AnthropicApiSupportTest` 15/15, `ResponsesApiSupportTest` 13/13, `OllamaApiSupportTest` 23/23, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **CI-Bedarf:** ein modellgestützter Lauf (`OpenAiCompatServerIntegrationTest` mit dem Reasoning-Modell) würde zeigen, dass `reasoning_effort: "none"` den Denkblock wirklich unterdrückt; hier gibt es kein Modell. - **Status:** gefixt -- **Commit:** java-llama.cpp `60c2b9f` +- **Commit:** java-llama.cpp `f6057ad` ### F78 – java-llama.cpp: Ollama-`/api/chat` ignoriert `think` – `"think": false` schaltet das Denken nicht ab @@ -671,7 +671,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestHonoursThinkLikeOllama` (Kette Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ – `enable_thinking` fehlte), mit Fix grün (24/24 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiRequestMapperTest` 21/21, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d252f84` +- **Commit:** java-llama.cpp `6374dc8` ### F79 – java-llama.cpp: gestreamtes `/v1/completions` ignoriert `suffix`, `best_of` und `echo`, die der nicht gestreamte Weg abweist @@ -683,7 +683,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, die drei Übersetzer-Tests, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `7545586` +- **Commit:** java-llama.cpp `4a40a1d` ### F80 – java-llama.cpp: Anthropic-Antwort verwirft den Denkinhalt – kein `thinking`-Block @@ -696,7 +696,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2> but: was <1>“), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Nicht im Fix:** der gestreamte Pfad (`AnthropicStreamTranslator`, upstream `thinking_delta`) und die Responses-Antwort (upstream ein `reasoning`-Item mit `reasoning_text`) – offener Verdacht. - **Status:** gefixt -- **Commit:** java-llama.cpp `e709e5f` +- **Commit:** java-llama.cpp `fdf21f1` ### F81 – java-llama.cpp: gestreamte Anthropic-Antwort verwirft den Denkinhalt – keine `thinking_delta`s @@ -708,7 +708,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicStreamTranslatorTest#reasoningDeltasStreamAsAThinkingBlockClosedWithASignatureBeforeTheText` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}" but: was ""“), mit Fix grün (5/5 der Klasse, skipped 0); Gegenprobe per `git stash` beider Dateien rot, nach `pop` grün. `AnthropicApiSupportTest` 16/16, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d2afd35` +- **Commit:** java-llama.cpp `7fdc684` ### F82 – java-llama.cpp: Responses-Antwort verwirft den Denkinhalt – kein `reasoning`-Item @@ -720,7 +720,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#responseCarriesTheReasoningAsAReasoningItemFirstLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <2> but: was <1>“), mit Fix grün (14/14 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 4/4, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `070dbce` +- **Commit:** java-llama.cpp `935342c` ### F83 – java-llama.cpp: gestreamte Responses-Antwort verwirft den Denkinhalt – keine `response.reasoning_text.delta`s @@ -732,7 +732,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesStreamTranslatorTest#reasoningDeltasStreamAsAReasoningItemLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "event: response.output_item.added" but: was ""“), mit Fix grün (5/5 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesApiSupportTest` 14/14, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (erst nach dem Helfer). - **Status:** gefixt -- **Commit:** java-llama.cpp `17ad313` +- **Commit:** java-llama.cpp `f327156` ### F84 – java-llama.cpp: Anthropic-Verlauf verwirft `thinking`-Blöcke früherer Runden @@ -744,7 +744,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#requestKeepsThinkingBlocksOfTheHistoryAsReasoningContentLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7" but: was ""“), mit Fix grün (17/17 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `fb226b3` +- **Commit:** java-llama.cpp `638036f` ### F85 – java-llama.cpp: Responses-`reasoning`-Eingabe wird zur Benutzernachricht @@ -756,7 +756,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#reasoningInputItemsBecomeAssistantReasoningNotUserTextLikeUpstream` (neue Assistenten-Nachricht nach einem Nutzer, Zusammenführung mit einem `function_call`, verschlüsseltes Item übersprungen) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "q" but: was "plan"“ – der Denktext als Benutzernachricht), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (nach Herausziehen zweier doppelter Aufrufe, PRMC). - **Status:** gefixt -- **Commit:** java-llama.cpp `7a8b262` +- **Commit:** java-llama.cpp `ad15d14` ### F86 – java-llama.cpp: Responses-Werkzeugergebnis als Teil-Array kommt leer beim Modell an @@ -769,7 +769,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "text" but: was ""“; die erste Fassung des Tests lief über `get(0)` in eine NPE und wurde auf `path(0)` umgestellt, damit der Fehler an der Assertion sichtbar wird), mit Fix grün (16/16 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Nachtrag (Branch-Review 2026-10-20):** Andere Teiltypen, ein Teil ohne `type` und ein Bild ohne URL werden seit F224 wie upstream mit 400 abgewiesen statt übersprungen; die Objektform `{url}` wird wie im Nachrichtenzweig gelesen. - **Status:** gefixt -- **Commit:** java-llama.cpp `28054ba` +- **Commit:** java-llama.cpp `a2f8de1` ### F87 – java-llama.cpp: Responses-`function_call`-Items werden einzelne Assistenten-Runden statt einer Runde mit mehreren Aufrufen @@ -781,7 +781,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#consecutiveFunctionCallsJoinThePrecedingAssistantMessageLikeUpstream`; der F85-Test wurde auf eine realistische Werkzeugschleife umgestellt (Denktext, Aufruf, Ergebnis, Denktext, Antwort), weil seine erste Folge die getrennten Nachrichten der alten Variante voraussetzte. - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <4> but: was <6>“, ebenso der umgestellte F85-Test), mit Fix grün (17/17 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `77a6903` +- **Commit:** java-llama.cpp `a4ac92a` ### F88 – java-llama.cpp: Anthropic-`tool_result` verwirft Bilder @@ -793,7 +793,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#toolResultImagesReachTheToolMessageLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "cat.png" but: was ""“ – der Inhalt war ein String), mit Fix grün (18/18 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 45/45, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün (nach `new TextNode(...)` statt `textNode(...)`, AI_ANNOTATION_ISSUES_NEEDS_NULLABLE). - **Status:** gefixt -- **Commit:** java-llama.cpp `ba93540` +- **Commit:** java-llama.cpp `3ddf132` ### F89 – java-llama.cpp: Responses-`input_file` wird still verworfen statt abgewiesen @@ -805,7 +805,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#responsesWithAnInputFileIsRejectedLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <400> but: was <200>“), mit Fix grün (46/46 der Klasse, skipped 0); Gegenprobe per `git stash` beider Dateien rot, nach `pop` grün. `ResponsesApiSupportTest` 17/17, `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `3280821` +- **Commit:** java-llama.cpp `576a886` ### F90 – java-llama.cpp: Claude Codes wechselnder `cch`-Stempel im System-Prompt verhindert jeden Prefix-Cache-Treffer @@ -818,7 +818,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot (Stempel `a5145` unverändert), mit Fix grün (19/19 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 46/46, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **CI-Bedarf:** dass der Cache danach tatsächlich trifft (`cached_tokens` > 0 in der zweiten Runde), zeigt nur ein modellgestützter Lauf. - **Status:** gefixt -- **Commit:** java-llama.cpp `7d08a0c` +- **Commit:** java-llama.cpp `70779b3` ### F91 – java-llama.cpp: Responses-Verlauf spaltet jede Assistenten-Runde in Denk- und Antwortnachricht @@ -830,7 +830,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#anAssistantMessageJoinsThePrecedingAssistantTurnLikeUpstream`; der F85-Test erwartet seine abschließende Antwort jetzt in der Nachricht mit dem zweiten Denktext. - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <3> but: was <4>“, ebenso der angepasste F85-Test), mit Fix grün (18/18 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 22/22, `OpenAiCompatServerHttpTest` 46/46, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `04d4cdb` +- **Commit:** java-llama.cpp `8b0872b` ### F92 – java-llama.cpp: blockierende LangChain4j-Antwort verliert den Denkinhalt @@ -842,7 +842,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LangChain4jMappingTest#blockingResponseCarriesTheThinkingLikeTheStreamedOne` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "6 times 7"“), mit Fix grün (21/21 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün; alle Modultests grün (Integrationstests ohne Modell übersprungen). Das Modul deklariert weder Spotless noch SpotBugs (weder eigene noch Eltern-POM), diese Gates entfallen hier. - **Status:** gefixt -- **Commit:** java-llama.cpp `6caa722` +- **Commit:** java-llama.cpp `7a64dd9` ### F93 – java-llama.cpp: gestreamte LangChain4j-Antwort hat nie eine Token-Nutzung @@ -855,7 +855,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1 Fehlschlag), nach `pop` grün; alle Modultests grün (`StreamingChunkAssemblerTest` 7/7, `JsonSchemaElementSerializerTest` 8/8, Integrationstests ohne Modell übersprungen). Spotless/SpotBugs sind im Modul nicht deklariert (siehe F92). - **CI-Bedarf:** Der Integrationsjob `test-java-llama-langchain4j-integration` läuft mit Modell; ein Ende-zu-Ende-Nachweis der Zählung im Stream wäre dort möglich, ist aber nicht Voraussetzung. - **Status:** gefixt -- **Commit:** java-llama.cpp `cde7159` +- **Commit:** java-llama.cpp `6c2adf8` ### F94 – java-llama.cpp: Textteile einer LangChain4j-Nutzernachricht werden ohne Trenner zusammengeklebt @@ -867,7 +867,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `LangChain4jMappingTest#joinsTextPartsWithANewlineLikeUpstream`, dazu `#flattensMultiTextUserMessageToText` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "first\nsecond" but: was "firstsecond"“, ebenso der angepasste Test), mit Fix grün (23/23 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2 Fehlschläge), nach `pop` grün; alle Modultests grün (Integrationstests ohne Modell übersprungen). Spotless/SpotBugs sind im Modul nicht deklariert (siehe F92). - **Status:** gefixt -- **Commit:** java-llama.cpp `ee99d85` +- **Commit:** java-llama.cpp `980c48d` ### F95 – java-llama.cpp: gestreamtes `/v1/completions` und der Chat-Pfad verwerfen llama.cpps Sampling-Felder @@ -880,7 +880,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot (3 Fehlschläge: „Expected: is a numeric value within <1.0E-4> of <0.05> but: <0.0>“, „Expected: is <7> but: was <50>“, „Expected: is "object" but: was ""“), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 19/19, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit allen Feldern in einer Methode meldete `CE_CLASS_ENVY`, eine Map-Tabelle `DMC_DUBIOUS_MAP_COLLECTION`; jetzt je Feld ein Aufruf über `withFloat`/`withInt`). - **Nachtrag (Branch-Review 2026-10-20):** Das hier „weiter offen“ Genannte ist erledigt: `logit_bias` und `n_probs` mit F180, das gestreamte `/v1/completions` mit F186 (upstreams eigener Stream, Wahrscheinlichkeiten eingeschlossen), `n > 1` mit F180 und auf allen nativen Wegen mit F197. - **Status:** gefixt -- **Commit:** java-llama.cpp `8ce9ffc` +- **Commit:** java-llama.cpp `1149533` ### F96 – java-llama.cpp: Anthropic-Anfrage ohne `max_tokens` läuft unbegrenzt, `chat_template_kwargs` geht verloren @@ -890,9 +890,9 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Aus dem offenen Verdacht „Anthropic-Rest nach F76“. upstreams `convert_anthropic_to_oai` (`server-chat.cpp:585`, b11639) setzt `max_tokens` 4096, wenn die Anfrage keines trägt, und reicht `chat_template_kwargs` durch. `/v1/messages` übernahm `max_tokens` nur, wenn vorhanden, eine Anfrage ohne generierte also, bis der Kontext voll war; und `chat_template_kwargs` (etwa Qwen3s `enable_thinking`) wurde verworfen, obwohl der nachgeschaltete `OpenAiRequestMapper` es seit F77 liest. - **Fix:** Ohne Zahl in `max_tokens` wird `DEFAULT_MAX_TOKENS` (4096) gesetzt; ein Objekt in `chat_template_kwargs` wird kopiert. - **Reproduktion:** `AnthropicApiSupportTest#requestWithoutMaxTokensIsCappedLikeUpstream`, `#requestPassesChatTemplateKwargsLikeUpstream` (zunächst ein Sammeltest, nach der Regel „ein Test je Richtung“ aufgeteilt) -- **Rot-Grün-Nachweis:** gegen die Produktionsdatei vor `d29e084` rot, jeder Teil für sich (2 Fehlschläge: „Expected: is <4096> but: was <0>“, „Expected: is but: was “ für `chat_template_kwargs`), mit Fix grün (21/21 der Klasse, skipped 0). `OpenAiCompatServerHttpTest` 46/46, `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 25/25, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Rot-Grün-Nachweis:** gegen die Produktionsdatei vor `4b20c57` rot, jeder Teil für sich (2 Fehlschläge: „Expected: is <4096> but: was <0>“, „Expected: is but: was “ für `chat_template_kwargs`), mit Fix grün (21/21 der Klasse, skipped 0). `OpenAiCompatServerHttpTest` 46/46, `AnthropicStreamTranslatorTest` 5/5, `OpenAiRequestMapperTest` 25/25, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d29e084` +- **Commit:** java-llama.cpp `4b20c57` ### F97 – java-llama.cpp: Ollama-`/api/chat` verliert den Denkinhalt in beide Richtungen @@ -902,9 +902,9 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Aus dem offenen Verdacht „Ollama-Antworten tragen den Denkinhalt nicht“. Ollama legt den Denkinhalt eines Modells in `message.thinking` ab, blockierend und je Stream-Zeile, und Clients senden ihn im Verlauf zurück. `OllamaApiSupport` übernahm aus OpenAIs Antwort nur `content` und `tool_calls`; im Stream lieferte `toOllamaContentLine` für einen Chunk ohne Text `null`, Denk-Deltas fielen also ganz weg. Seit F78 kann ein Client mit `think` das Denken steuern, sah es aber nie. Im Verlauf blieb `thinking` als unbekannter Schlüssel stehen, den llama.cpps Parser ignoriert (`common/chat.cpp:422` liest `reasoning_content`), wie bei F84 für Anthropic. - **Fix:** Blockierend und im Stream wird `reasoning_content` zu `message.thinking` (eine Denk-Zeile trägt `content` leer); im Verlauf wird `thinking` zu `reasoning_content`. - **Reproduktion:** `OllamaApiSupportTest#blockingChatAnswerCarriesTheThinkingLikeOllama`, `#streamedThinkingDeltaBecomesAThinkingLineLikeOllama`, `#historyThinkingBecomesReasoningContent` (zunächst ein Sammeltest, nach der Regel „ein Test je Richtung“ aufgeteilt) -- **Rot-Grün-Nachweis:** gegen die Produktionsdatei vor `9eab6e0` rot, jede Richtung für sich (3 Fehlschläge: „Expected: is "6 times 7" but: was ""“ blockierend und im Verlauf, „Expected: is "6 ti" but: was ""“ im Stream), mit Fix grün (27/27 der Klasse, skipped 0). `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 20/20, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. +- **Rot-Grün-Nachweis:** gegen die Produktionsdatei vor `fb4dd64` rot, jede Richtung für sich (3 Fehlschläge: „Expected: is "6 times 7" but: was ""“ blockierend und im Verlauf, „Expected: is "6 ti" but: was ""“ im Stream), mit Fix grün (27/27 der Klasse, skipped 0). `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 20/20, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `9eab6e0` +- **Commit:** java-llama.cpp `fb4dd64` ### F98 – java-llama.cpp: gestreamtes Ollama-`/api/chat` meldet keine Tokenzahlen @@ -916,7 +916,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#ollamaChatStreamingDoneLineCarriesTheTokenCountsLikeTheBlockingAnswer` (das `FakeBackend` sendet den Usage-Chunk wie upstream nur bei `include_usage`) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <12> but: was <-1>“), mit Fix grün (47/47 der Klasse, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (1), nach `pop` grün. `OllamaApiSupportTest` 27/27, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 18/18, `OpenAiRequestMapperTest` 25/25, `AnthropicStreamTranslatorTest` 5/5, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `0cd9f8a` +- **Commit:** java-llama.cpp `95af953` ### F99 – java-llama.cpp: blockierende Responses-Antwort trägt ein leeres `message`-Item neben Werkzeugaufrufen @@ -928,7 +928,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#toolCallOnlyResponseHasNoEmptyMessageItemLikeUpstreamAndTheStream`, dazu `#responseEmitsFunctionCallItemsForToolCalls` - **Rot-Grün-Nachweis:** ohne Fix rot (2 Fehlschläge: „Expected: is <1> but: was <2>“, „Expected: is "function_call" but: was "message"“), mit Fix grün (19/19 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 27/27, `OpenAiRequestMapperTest` 25/25, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `9d0a021` +- **Commit:** java-llama.cpp `1939435` ### F102 – java-llama.cpp: Chat-Anfrage mit `id_slot: -1` wird mit 400 abgewiesen @@ -940,7 +940,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#slotMinusOneMeansAnySlotLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception thrown: java.lang.IllegalArgumentException: slotId must be non-negative but was -1“, als Assertion über `assertDoesNotThrow`), mit Fix grün (26/26 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ded2762` +- **Commit:** java-llama.cpp `57cbdc0` ### F103 – java-llama.cpp: `max_tokens: 0` generiert unbegrenzt statt nur den Cache zu füllen @@ -952,7 +952,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#zeroTokenCapOnlyWarmsTheCacheLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <0> but: was <-1>“), mit Fix grün (27/27 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OllamaApiSupportTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `bcbafbc` +- **Commit:** java-llama.cpp `d7f91db` ### F104 – java-llama.cpp: Ollama-Routen verwerfen `repeat_penalty`, `min_p` und weitere Optionen; `num_predict: -2` wird abgewiesen @@ -964,7 +964,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#chatCopiesTheLlamaCppSamplingOptions`, `#generateCopiesTheLlamaCppSamplingOptions`, `#infillCopiesTheLlamaCppSamplingOptions`, `#numPredictMinusTwoFillsTheContextAsUpstreamsMinusOne`; `#repeatLastNMinusOneIsLeftToTheServerDefault` schützt die Ausnahme (ohne Fix grün, weil gar nichts kopiert wurde) - **Rot-Grün-Nachweis:** ohne Fix rot (4 Fehlschläge: dreimal „Expected: is <0.05> but: was <0.0>“, einmal „Expected: is <-1> but: was <-2>“), mit Fix grün (32/32 der Klasse, skipped 0); Gegenprobe per `git stash` rot (4), nach `pop` grün. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `ResponsesApiSupportTest` 19/19, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `0eba89d` +- **Commit:** java-llama.cpp `ce983cf` ### F105 – java-llama.cpp: Responses-Anfrage mit `previous_response_id` wird ohne Verlauf beantwortet @@ -976,7 +976,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#previousResponseIdIsRejectedLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (20/20 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `b9bfb45` +- **Commit:** java-llama.cpp `f9e680a` ### F106 – java-llama.cpp: Responses-Anfrage verliert `top_k`, `seed`, `stop` und llama.cpps Erweiterungen @@ -988,7 +988,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#requestFieldsTheChatMapperReadsPassThroughLikeUpstream` (parametrisiert, ein Fall je Feld, 27 Fälle) - **Rot-Grün-Nachweis:** ohne Fix rot, jeder Fall für sich (27 Fehlschläge, z. B. „Expected: is <0.05> but: was <>“), mit Fix grün (47/47 der Klasse, skipped 0); Gegenprobe per `git stash` rot (27), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 21/21, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `87635b2` +- **Commit:** java-llama.cpp `b370643` ### F107 – java-llama.cpp: Anthropic-`tool_choice` `none` läuft als `auto` @@ -1000,7 +1000,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `AnthropicApiSupportTest#toolChoiceNoneForbidsToolCalls` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "none" but: was ""“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `AnthropicStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `ResponsesApiSupportTest` 47/47, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `b85eed8` +- **Commit:** java-llama.cpp `d58662f` ### F108 – java-llama.cpp: Responses-`text.format` (strukturierte Ausgabe) wird ignoriert @@ -1012,7 +1012,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ResponsesApiSupportTest#textFormatJsonSchemaBecomesTheChatResponseFormat`, `#textFormatJsonObjectBecomesTheChatResponseFormat`; `#textFormatTextLeavesTheResponseFormatOut` schützt den `text`-Fall (ohne Fix grün) - **Rot-Grün-Nachweis:** ohne Fix rot (2 Fehlschläge: „Expected: is "json_schema" but: was ""“, „Expected: is "json_object" but: was ""“), mit Fix grün (50/50 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 27/27, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit einem Array-Literal meldete `SACM_STATIC_ARRAY_CREATED_IN_METHOD`, jetzt eine Konstante). - **Status:** gefixt -- **Commit:** java-llama.cpp `941357f` +- **Commit:** java-llama.cpp `8e91168` ### F109 – java-llama.cpp: `tool_choice`, das eine Funktion benennt, läuft als `auto` @@ -1026,7 +1026,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag (2026-10-18, nur beschrieben):** Zwei Kommentare neben dem Fix sind nicht nachgezogen: die Klassen-Javadoc von `OpenAiRequestMapper` nennt `tool_choice` unter den Feldern, die „verbatim as raw JSON“ weitergehen (galt schon vorher nur für die Textform), und der Kommentar über dem Werkzeugblock in `ResponsesApiSupport.toOpenAiChatRequest` beschreibt nur die Textform. Reine Doku-Abweichung, nach der Nutzerregel ohne Test nicht nachträglich geändert; Vorschlag: `tool_choice` aus der Verbatim-Aufzählung nehmen und „ein benanntes Werkzeug wird zu `required`“ ergänzen. - **Nachtrag (Branch-Review 2026-10-20):** Mit mehreren Werkzeugen ließ `required` jeden Aufruf zu; seit F227 geht auf beiden Wegen nur das benannte Werkzeug weiter, ein unbekannter oder fehlender Name ist 400. - **Status:** gefixt -- **Commit:** java-llama.cpp `b7151fc` +- **Commit:** java-llama.cpp `c5823a5` ### F111 – java-llama.cpp: Rerank im TEI-Format (`texts`) wird mit 400 abgewiesen @@ -1038,7 +1038,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OaiRerankSupportTest#readDocumentsAcceptsTheTeiTexts`, `#teiRequestGetsTheTeiArrayWithScoreLikeUpstream`, `#teiReturnTextAddsTheDocumentText` - **Rot-Grün-Nachweis:** gegen den Stand mit der neutralen Naht rot, jeder Teil für sich (3 Fehlschläge: „Unexpected exception thrown: … 'documents' must be a non-empty array“, „Expected: is but: was “, „Expected: is "b" but: was ""“), mit Fix grün (13/13 der Klasse, skipped 0). Die `git stash`-Gegenprobe ging hier nicht (ohne Naht kompiliert der Test nicht; der danach gelesene Bericht war der alte) und wurde durch den Lauf gegen den Naht-Stand ersetzt. `OpenAiCompatServerHttpTest` 47/47, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `2831610` +- **Commit:** java-llama.cpp `6ff8eea` ### F112 – java-llama.cpp: Unterpfade einer Route laufen durch deren Handler (`/v1/messages/count_tokens` erzeugt eine ganze Antwort) @@ -1051,7 +1051,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <404> but: was <200>“), mit Fix grün (49/49 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1, Bericht vor dem Lauf gelöscht), nach `pop` grün. Alle 25 Testklassen des Pakets `server` grün, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. Der Agent (`llama-atmosphere-agent`) ruft keine Unterpfade auf. - **Offen (nicht geändert):** upstream bietet `/v1/messages/count_tokens` und die `input_tokens`-Routen tatsächlich an; sie hier nachzubauen bräuchte eine Tokenzählung über das Backend (`OpenAiBackend` hat keine) – ein API-Zusatz. - **Status:** gefixt -- **Commit:** java-llama.cpp `609b53e` +- **Commit:** java-llama.cpp `909edc5` ### F113 – java-llama.cpp: `/v1/models` lässt das Pflichtfeld `created` weg @@ -1063,7 +1063,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiSseFormatterTest#modelsJsonCarriesTheRequiredCreatedTimestampLikeUpstream` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “ für `isIntegralNumber`), mit Fix grün (15/15 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 49/49, `OaiRerankSupportTest` 13/13, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OllamaApiSupportTest` 32/32, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `9c165a8` +- **Commit:** java-llama.cpp `b313ad2` ### F114 – java-llama.cpp: `/api/version` meldet 0.1.0, VS Code Copilots Ollama-Anbieter lehnt den Server ab @@ -1075,7 +1075,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#versionPassesCopilotsMinimumOllamaVersion` (Copilots Vergleich nachgebildet) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is but: was “), mit Fix grün (33/33 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. `OpenAiCompatServerHttpTest` 49/49, `OpenAiSseFormatterTest` 15/15, `OaiRerankSupportTest` 13/13, `AnthropicApiSupportTest` 22/22, `ResponsesApiSupportTest` 51/51, `OpenAiRequestMapperTest` 28/28, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `fbffef0` +- **Commit:** java-llama.cpp `03ad149` ### F115 – java-llama.cpp: `/api/show` verschweigt Continue die Kontextlänge @@ -1087,7 +1087,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#showParametersCarryTheContextLengthWhereContinueReadsIt` (mit Continues regulärem Ausdruck) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "4096" but: was null“), mit Fix grün (34/34 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. Breite Liste wie F114 grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `4d45be1` +- **Commit:** java-llama.cpp `3e2f3b5` ### F116 – java-llama.cpp: Ollama-Werkzeugergebnisse verlieren den Werkzeugnamen @@ -1099,7 +1099,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OllamaApiSupportTest#toolResultNameBecomesTheNameLlamaCppReads` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is "get_weather" but: was ""“), mit Fix grün (35/35 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1), nach `pop` grün. Breite Liste wie F114 grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `a5e86bf` +- **Commit:** java-llama.cpp `e4f54a6` ### F117 – java-llama.cpp: Gestreamte Responses-Fehler erreichen Responses-Clients nicht @@ -1111,7 +1111,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#rejectedStreamedResponseEndsWithAResponseFailedEvent`, `#failedStreamedResponseEndsWithAResponseFailedEvent` - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: a string containing "event: response.failed" but: was "event: response.created …"“), mit Fix grün (51/51 der Klasse, skipped 0); Gegenprobe per `git stash` beider Produktionsdateien rot (2), nach `pop` grün. Breite Liste (Server-Tests inkl. `ResponsesStreamTranslatorTest`, `LlamaArchitectureTest`) grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `011f5c3` +- **Commit:** java-llama.cpp `bd3db6a` ### F118 – java-llama.cpp: Anthropic-Serverfehler heißen `invalid_request_error` @@ -1123,7 +1123,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#aFailedAnthropicMessageIsAnApiError`, `#aFailedStreamedAnthropicMessageIsAnApiError` (dazu als Schutz `#aRejectedStreamedAnthropicMessageStaysAnInvalidRequestError`) - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: a string containing "\"error\":{\"type\":\"api_error\"" but: was "{"type":"error","error":{"type":"invalid_request_error","message":"decoding failed"}}"“), mit Fix grün (54/54 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ccba21a` +- **Commit:** java-llama.cpp `ae72c29` ### F123 – java-llama.cpp: `logprobs`/`top_logprobs` einer Chat-Anfrage gehen verloren @@ -1135,7 +1135,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#logprobsWithTopLogprobsBecomesNProbs`, `#logprobsWithoutTopLogprobsDefaultsToTwentyLikeUpstream` (dazu als Schutz `#logprobsFalseLeavesNProbsUnset`) - **Rot-Grün-Nachweis:** ohne Fix beide rot („Expected: is <3> but: was <-1>“ bzw. „Expected: is <20> but: was <-1>“), mit Fix grün (31/31 der Klasse, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Breite Liste wie F117 grün, `spotless:apply` und `spotbugs:check` grün. Der erste Grün-Lauf scheiterte am Compiler (`NotJavadoc`: Konstante zwischen fremde Javadoc und Deklaration eingefügt, wie bei F108) und meldete trotzdem den alten roten Bericht – das Prüfskript löscht den Bericht jetzt auch vor dem Grün-Lauf. - **Status:** gefixt -- **Commit:** java-llama.cpp `3e86360` +- **Commit:** java-llama.cpp `578614c` ### F125 – java-llama.cpp: `OpenAiServerCli` nimmt einen Port außerhalb von 0..65535 an; der Fehler kommt erst nach dem Modell-Laden @@ -1147,7 +1147,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiServerCliTest#portOutsideTheTcpRangeThrows` (`-1`, `65536`, `70000`), Grenzen in `#portAtTheEndsOfTheTcpRangeIsAccepted` (0, 1, 65535) - **Rot-Grün-Nachweis:** ohne Fix rot in allen drei Fällen („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (45/45 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. Breite Liste: `OpenAiCompatServerHttpTest` 54/54, `ServerLauncherTest` 7/7, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ba26cdc` +- **Commit:** java-llama.cpp `72cf2ea` ### F128 – java-llama.cpp: Gestreamte Werkzeugaufrufe in `llama-langchain4j` brechen am ersten Fragment ohne Argumente oder ohne Namen ab @@ -1160,7 +1160,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix beide rot („Unexpected exception thrown: java.lang.IllegalArgumentException: partialArguments cannot be null or empty“ bzw. „… name cannot be null or blank“), mit Fix grün (2/2, skipped 0); Gegenprobe per `git stash` rot (2), nach `pop` grün. Ganzes Modul grün (`StreamingChunkAssemblerTest` 9/9, `LangChain4jMappingTest` 23/23, `JsonSchemaElementSerializerTest` 8/8, `TestModelPathsTest` 8/8; die Integrationstests überspringen sich ohne Modell wie zuvor). Das Modul hat weder Spotless noch SpotBugs. - **CI-Bedarf:** `JllamaToolCallingIntegrationTest#streamedRequiredToolCallComesBackAsToolExecutionRequest` (Job `test-java-llama-langchain4j-integration`, kein Release-Gate) im nächsten Lauf ansehen: ob er vor dem Fix rot war, hängt davon ab, ob das CI-Modell den Namen allein in einem Fragment sendet. - **Status:** gefixt -- **Commit:** java-llama.cpp `4d599fa` +- **Commit:** java-llama.cpp `f7f3f0f` ### F133 – java-llama.cpp: `setPoolingType(UNSPECIFIED)` lässt ein vorher gesetztes `--pooling` stehen @@ -1172,7 +1172,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `ModelParametersTest#testSetPoolingTypeUnspecifiedRemovesAnEarlierPoolingType` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: not map containing ["--pooling"->ANYTHING] but: was <{--fit=on, --pooling=mean}>“, die übrigen 92 Tests der Klasse grün), mit Fix 93/93 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `LlamaArchitectureTest` 13/13, `WireNameRegistryTest` 8/8, `ModelParametersExtendedTest` 148/148; `spotless:check` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `7231e71` +- **Commit:** java-llama.cpp `b4630d5` ### F134 – java-llama.cpp: fünf `InferenceParameters`-Wither „`null` clears“ behalten den alten Wert @@ -1184,7 +1184,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `InferenceParametersTest#test{Prompt,InputPrefix,InputSuffix,Grammar,ToolChoice}NullClearsAnEarlierValue`, `JsonParametersTest#withOptionalJson_nullRemovesAnEarlierValue` - **Rot-Grün-Nachweis:** ohne Fix rot – alle fünf Wither-Tests (z. B. „Expected: not map containing ["grammar"->ANYTHING] but: was <{grammar="x"}>“) und der Basistest („expected: not same but was: “); mit Fix `InferenceParametersTest` 111/111 und `JsonParametersTest` 26/26 (skipped 0), Gegenprobe per `git stash` wieder rot. PIT auf `JsonParameters`: 18/18 getötet. `LlamaArchitectureTest` 13/13, `WireNameRegistryTest` 8/8, `OpenAiRequestMapperTest` 31/31; `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `f35e7f2` +- **Commit:** java-llama.cpp `5bba245` ### F137 – java-llama.cpp: `Session.fork` prüft eine negative Ziel-Slot-ID erst nach Sichern und Wiederherstellen @@ -1196,7 +1196,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `SessionTest#forkToANegativeSlotIdRejectedBeforeTheModelIsTouched` (mit `null`-Modell: jeder Modellaufruf zeigt sich als `NullPointerException`) - **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception type thrown, expected: but was: “), mit Fix 3/3 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `SessionStateTest` 13/13, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. Den nativen Pfad (Datei geschrieben, `LlamaException` aus `restoreSlot`) habe ich nicht laufen lassen: keine native Bibliothek und kein Modell in dieser Umgebung. - **Status:** gefixt -- **Commit:** java-llama.cpp `965b540` +- **Commit:** java-llama.cpp `7f79298` ### F138 – java-llama.cpp: `OpenAiServerConfig.heartbeatMillis(0)` lässt jeden Streaming-Request als Client-Fehler scheitern @@ -1208,7 +1208,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiServerConfigTest#heartbeatIntervalOfZeroIsRejected`; das Laufzeitsymptom zeigte eine nicht übernommene Probe in `OpenAiCompatServerHttpTest` (Streaming-Chat gegen `FakeBackend` mit `heartbeatMillis(0)`). - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix 7/7 (skipped 0), Gegenprobe per `git stash` rot, danach wieder grün. `OpenAiCompatServerHttpTest` 54/54, `OpenAiServerCliTest` 45/45, `LlamaArchitectureTest` 13/13; `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `6a13092` +- **Commit:** java-llama.cpp `806571d` ### F143 – java-llama.cpp: `RouterClient.awaitModelLoaded(id, 0)` meldet für ein geladenes Modell einen Timeout, ohne abzufragen @@ -1220,7 +1220,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `RouterClientTest#awaitModelLoaded_zeroTimeoutStillPollsOnce` - **Rot-Grün-Nachweis:** ohne Fix rot („Unexpected exception thrown: java.lang.IllegalStateException: Model 'qwen' did not reach LOADED within 0 ms (last status: null)“), mit Fix 20/20 (skipped 0, die Timeout- und Sofort-Fehler-Tests unverändert grün), Gegenprobe per `git stash` rot, danach wieder grün. `LlamaArchitectureTest` 13/13; `spotless:check` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `058153f` +- **Commit:** java-llama.cpp `017ac95` ### F158 – java-llama.cpp: `ModelParameters.setPredict(-2)` verspricht „until context filled“, der Server erzeugt damit je Anfrage nur ein Token @@ -1229,12 +1229,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Schwere:** niedrig bis mittel (still: keine Fehlermeldung, nur abgeschnittene Antworten) - **Beschreibung:** Gefunden beim Nachprüfen von F69. Upstreams `-n` (`common/arg.cpp:1661`, b11639) nennt „-2 = until context filled“ **nur** für `LLAMA_EXAMPLE_COMPLETION`, für den Server lautet die Beschreibung „-1 = infinity“; der Parser übernimmt jeden Wert. Im Server übernimmt ein Slot `params_base.n_predict`, wenn die Anfrage `-1` sendet oder das Feld weglässt (`server-context.cpp:1964`), und `has_budget()` (`:495`) zählt nur `-1` als unbegrenzt: bei `-2` ist `n_remaining()` negativ, und die Grenzprüfung (`:2040`, `n_gen > 0 && !has_budget()`) beendet die Erzeugung nach dem ersten Token mit `STOP_TYPE_LIMIT`. Aus dem Quelltext abgeleitet, **nicht gemessen**. - **Fix:** Dokumentation korrigiert (ohne Test, Nutzerregel): Javadoc nennt nur noch `-1 = infinity` und erklärt, dass `-2` eine Einstellung des Completion-Tools ist und hier nach dem ersten Token stoppt. **Offen, Entscheidung des Nutzers:** `-2` in `ModelParameters.setPredict` mit `IllegalArgumentException` abweisen – modellfrei testbar, ändert aber das Verhalten eines öffentlichen Setters. -- **Messung (J1, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`):** bestätigt – `-2`: 1 Token, `stop_type="limit"`; `-1`: 2042 Tokens (bis Kontextende); `16`: 16. Dazu J2: `withNPredict(0)` erzeugt ebenfalls 1 Token, nicht 0. +- **Messung (J1, `BUGHUNT-MEASUREMENTS.md` in `4c58e8f`):** bestätigt – `-2`: 1 Token, `stop_type="limit"`; `-1`: 2042 Tokens (bis Kontextende); `16`: 16. Dazu J2: `withNPredict(0)` erzeugt ebenfalls 1 Token, nicht 0. - **Nachtrag (Entscheidung des Nutzers nach der Messung):** `setPredict` lehnt Werte unter `-1` mit `IllegalArgumentException` ab; Javadoc von `setPredict` und `withNPredict` sagt „`0` verhält sich wie `1`“. Test `ModelParametersExtendedTest#testSetPredictBelowMinusOneIsRejected` (`-2`, `-3`, `Integer.MIN_VALUE`) ersetzt `testSetPredictFillContext`, dazu `testSetPredictZero`. - **Rot-Grün-Nachweis:** ohne die Prüfung rot (3 von 3), mit ihr grün (`ModelParametersExtendedTest`, `ModelParametersTest`, `WireNameRegistryTest`: 252 Tests). -- **Nachtrag (Branch-Review 2026-10-20):** Die Übersicht nannte bis zum Branch-Review noch „Doku gefixt, Verhalten offen“; das Verhalten ist seit `ae07a53` geändert (siehe oben). +- **Nachtrag (Branch-Review 2026-10-20):** Die Übersicht nannte bis zum Branch-Review noch „Doku gefixt, Verhalten offen“; das Verhalten ist seit `be4d4a2` geändert (siehe oben). - **Status:** gefixt -- **Commit:** a977a1e (Doku), ae07a53 (Verhalten) +- **Commit:** cb8a3ea (Doku), be4d4a2 (Verhalten) ### F165 – java-llama.cpp: `OpenAiCompatServer` ohne `-c` bewirbt 8192 + 2048 Tokens statt des Fensters des geladenen Modells @@ -1242,12 +1242,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`), `OpenAiCompatServer.java` (`main`) - **Schwere:** mittel (ein Client, der seinen Prompt nach `/props`/`/api/show` bemisst, schickt bis zu 5× zu viel Kontext) - **Historie:** Die Budgets kamen nur aus `-c` (seit F70 als ganzes Slot-Fenster aufgeteilt); ohne `-c` blieben die festen Vorgaben stehen. -- **Messung (J3, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`):** Draft-Modell ohne `-c`: `/props` `n_ctx` 10240, `/api/show` `num_ctx 10240`, `/slots` `n_ctx` 2048 (= `n_ctx_train`). +- **Messung (J3, `BUGHUNT-MEASUREMENTS.md` in `4c58e8f`):** Draft-Modell ohne `-c`: `/props` `n_ctx` 10240, `/api/show` `num_ctx 10240`, `/slots` `n_ctx` 2048 (= `n_ctx_train`). - **Fix:** Entscheidung des Nutzers: das echte `n_ctx` bewerben. Neue Überladung `toServerConfig(supportsVision, modelFtype, slotContextSize)`; `main` übergibt `n_ctx` des ersten Slots aus `getMetricsTyped()`. Ein bekanntes Slot-Fenster geht vor `-c` (die Last kann kleiner ausfallen, etwa durch `--fit`), sonst gilt wie bisher `-c` bzw. die Vorgabe. - **Tests:** `OpenAiServerCliTest#loadedSlotContextIsAdvertisedWithoutContextSize`, `#loadedSlotContextWinsOverTheContextSizeArgument`, `#unknownSlotContextFallsBackToTheContextSizeArgument`. - **Rot-Grün-Nachweis:** mit der alten Auswahl (nur `-c`) rot (2 von 3, der Rückfalltest ist beidseitig grün), mit dem Fix grün (`OpenAiServerCliTest`, `OpenAiCompatServer*Test`: 114 Tests). Die Verdrahtung in `main` braucht ein Modell und ist hier nur übersetzt; Prüfung mit dem Befehl aus J3 (erwartet: 2048 in `/props` und `/api/show`). - **Status:** gefixt -- **Commit:** 62413a9 +- **Commit:** 490f43b ### F166 – java-llama.cpp: Leere Werkzeug-Argumente im Verlauf lassen die ganze Folgeanfrage mit 400 scheitern @@ -1255,13 +1255,13 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages(List)`) - **Schwere:** niedrig (mit dem gemessenen Modell nicht erreichbar, im Eintrittsfall aber Totalausfall der Folgeanfrage) - **Historie:** `buildMessages` schreibt `ToolCall.getArgumentsJson()` seit den Werkzeugaufrufen im Verlauf wörtlich. -- **Messung (J4, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`):** Qwen2.5-1.5B liefert für ein Werkzeug ohne Parameter 20/20 `"{}"`, nie `""`; mit `""` im Verlauf antwortet der Server `InvalidRequestException: Failed to parse tool call arguments as JSON … attempting to parse an empty input`, mit `"{}"` normal. +- **Messung (J4, `BUGHUNT-MEASUREMENTS.md` in `4c58e8f`):** Qwen2.5-1.5B liefert für ein Werkzeug ohne Parameter 20/20 `"{}"`, nie `""`; mit `""` im Verlauf antwortet der Server `InvalidRequestException: Failed to parse tool call arguments as JSON … attempting to parse an empty input`, mit `"{}"` normal. - **Fix:** Entscheidung des Nutzers: leere (auch nur aus Leerraum bestehende) Argumente als `"{}"` schreiben, alles andere unverändert. - **Tests:** `ParameterJsonSerializerTest#testBuildMessages_emptyToolCallArguments_areWrittenAsAnEmptyObject` (`""`, `" "`, `"\n"`), `#testBuildMessages_toolCallArguments_arePassedOnVerbatim`. - **Rot-Grün-Nachweis:** ohne den Fix rot (3 von 3), mit ihm grün (186 Tests in den Serializer-, Multimodal-, `InferenceParameters`- und `ChatRequest`-Klassen). - **Nicht abgedeckt (offen):** Die Übersetzungen des Servers bauen den Verlauf ohne `buildMessages`: `ResponsesApiSupport.java:218` (`asText("")`) und `AnthropicApiSupport.java:229` (`input` fehlt → `""`) können weiterhin `""` schreiben. **Nachtrag 2026-10-19:** behoben als F172. - **Status:** gefixt (Java-API-Pfad; Server-Übersetzungen als F172) -- **Commit:** afc40be +- **Commit:** 7f19450 ### F167 – java-llama.cpp: `completeBatch`/`chatBatch` laufen auf dem gemeinsamen `ForkJoinPool` und nutzen auf wenigen Kernen nicht alle Slots @@ -1269,12 +1269,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`completeBatch`, `completeBatchWithStats`, `chatBatch`), neu `BatchDispatch.java` - **Schwere:** niedrig bis mittel (Durchsatz; die Javadoc versprach die `setParallel`-Slots) - **Historie:** Jede Anfrage lief über `CompletableFuture.supplyAsync`, also auf dem gemeinsamen Pool mit `availableProcessors() - 1` Threads, den sie zudem mit der Anwendung teilte. -- **Messung (J5, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`):** 8 Slots, 8 Anfragen: 16 Kerne → 8 gleichzeitig (3225 ms); 4 Kerne → 3 (14067 ms); 16 Kerne mit Pool-Parallelität 3 → 3. +- **Messung (J5, `BUGHUNT-MEASUREMENTS.md` in `4c58e8f`):** 8 Slots, 8 Anfragen: 16 Kerne → 8 gleichzeitig (3225 ms); 4 Kerne → 3 (14067 ms); 16 Kerne mit Pool-Parallelität 3 → 3. - **Fix:** Entscheidung des Nutzers: `completeBatch`/`completeBatchWithStats` übergeben alle Anfragen im Aufrufer-Thread (`requestCompletion`) und empfangen dann der Reihe nach; scheitert eine Übergabe, werden die bereits übergebenen freigegeben (`releaseTask`). Für `chatBatch` gibt es keine Trennung von Übergabe und Warten (`handleChatCompletions` wartet in einem nativen Aufruf), daher läuft dort jede Anfrage auf einem eigenen Daemon-Thread eines Executors, der genau so groß ist wie der Batch und mit dem Aufruf endet. Beides in `BatchDispatch` (ohne native Bibliothek testbar, `LlamaModel` lädt sie im statischen Initialisierer). Die SpotBugs-Ausnahme für den bewussten Rethrow ist von `LlamaModel` dorthin gezogen, zwei neue mit Begründung (`HES_LOCAL_EXECUTOR_SERVICE`, `ITC_INHERITANCE_TYPE_CHECKING`). - **Tests:** `BatchDispatchTest` (8): alle Übergaben vor dem ersten Empfang, Übergabe im Aufrufer-Thread, Freigabe nach gescheiterter Übergabe, Empfang aller trotz Fehler, mehr gleichzeitige Anfragen als der gemeinsame Pool Threads hat (Latch über `commonPool().getParallelism() + 2`), Reihenfolge, Fehler nach den anderen, leere Eingabe. - **Rot-Grün-Nachweis:** mit dem alten Mechanismus (`supplyAsync` je Anfrage, keine Freigabe) rot (4 von 8; die übrigen 4 prüfen unverändertes Verhalten), mit dem Fix grün. Ein modellgestützter Lauf (J5-Probe) steht aus: erwartet `maxProcessingSlots=8` auch auf 4 Kernen für `completeBatch`. - **Status:** gefixt -- **Commit:** 5b4e409 +- **Commit:** 8130463 ### F168 – java-llama.cpp: `JllamaNative.takeStrings` gibt ein leeres Nicht-NULL-Array nicht frei @@ -1282,12 +1282,12 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` (`takeStrings`) - **Schwere:** niedrig (8 Byte je Aufruf mit leerem Ergebnis, praktisch unerreichbar) - **Historie:** Die Prüfung `pointer == NULL || count == 0` vor dem `try` stammt aus der ersten Fassung der FFM-Bindungen; `jllama_rpc_server_devices` legt auch für 0 Einträge ein Array an. -- **Messung (J8, `BUGHUNT-MEASUREMENTS.md` in `b689d7b`):** ein absichtlich falsch ausgerichteter Nicht-NULL-Zeiger mit Anzahl 0: `jllama_strings_free` direkt bricht mit `free(): invalid pointer` ab (Exit 134), über `takeStrings` überlebt die JVM – die Freigabe wurde nie gerufen. +- **Messung (J8, `BUGHUNT-MEASUREMENTS.md` in `4c58e8f`):** ein absichtlich falsch ausgerichteter Nicht-NULL-Zeiger mit Anzahl 0: `jllama_strings_free` direkt bricht mit `free(): invalid pointer` ab (Exit 134), über `takeStrings` überlebt die JVM – die Freigabe wurde nie gerufen. - **Fix:** Entscheidung des Nutzers: bei Nicht-NULL immer freigeben. Nur noch `NULL` kehrt früh zurück; die Freigabefunktion ist über eine paketinterne Überladung `takeStrings(out, length, free)` einsetzbar. - **Tests:** `JllamaNativeTest#takeStringsFreesANonNullArrayEvenWhenItIsEmpty`, `#takeStringsDoesNotFreeANullArray`. - **Rot-Grün-Nachweis:** lokal mit JDK 25 (Toolchain) und einem selbst gebauten `libjllama.so` (b11639, statisch): mit der alten Prüfung rot (1 von 9, der neue Test), mit dem Fix grün; `mvn -f llama-ffm/pom.xml verify` grün (29 Tests, 16 modellgestützte übersprungen, kein Modell). - **Status:** gefixt -- **Commit:** 8269149 +- **Commit:** 631d0ab ### F172 – java-llama.cpp: Responses- und Anthropic-Übersetzung schreiben leere Tool-Argumente in den Verlauf @@ -1301,7 +1301,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne den Fix rot (beide, `was ""`), mit ihm grün (75 Tests der beiden Klassen). - **Nachtrag (Branch-Review 2026-10-20):** Auf der Responses-Route wurden auch Objekt-`arguments` zu `"{}"` (Jacksons `asText` eines Containers ist `""`); seit F225 ist ein Wert, der weder fehlt noch `null` noch ein String ist, wie upstream eine 400. - **Status:** gefixt -- **Commit:** java-llama.cpp `f1bf93b` +- **Commit:** java-llama.cpp `ce6e5a7` ### F173 – java-llama.cpp: Responses-Übersetzung verwirft `refusal`-Teile von Assistenten-Nachrichten im Verlauf @@ -1314,7 +1314,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#refusalPartsOfTheHistoryAreKeptAsText` (Text + Ablehnung, nur Ablehnung), `#refusalPartsNextToAnImageAreKeptAsTextParts`. - **Rot-Grün-Nachweis:** ohne den Fix rot (2 von 54: `was "a"`, `was ""`), mit ihm grün (54 von 54). - **Status:** gefixt -- **Commit:** java-llama.cpp `0fd166f` +- **Commit:** java-llama.cpp `2db54c9` ### F174 – java-llama.cpp: `/props` meldet `total_slots` fest als 1 @@ -1327,7 +1327,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#propsEndpointReportsTheSlotCountOfTheConfiguration` (über HTTP), `OpenAiSseFormatterTest#propsJsonReportsContextLengthAndModalities` (ergänzt), `OpenAiServerConfigTest#totalSlotsDefaultsToOneAndKeepsTheConfiguredCount`, `#totalSlotsBelowOneIsRejected`, `OpenAiServerCliTest#slotCountOfTheLoadedModelIsAdvertised`, `#unknownSlotCountFallsBackToTheParallelArgumentAndThenToOne`. - **Rot-Grün-Nachweis:** mit einem Platzhalter, der das alte Verhalten behält (Getter liefert 1, `propsJson` ignoriert den Parameter), rot (5 Tests), mit dem Fix grün (129 Tests der vier Klassen); SpotBugs grün. Der Starter selbst (`main`) braucht ein Modell und ist nur übersetzt. - **Status:** gefixt -- **Commit:** java-llama.cpp `569415d` +- **Commit:** java-llama.cpp `cb21ecf` ### F175 – java-llama.cpp: `RouterClient.findModel`/`awaitModelLoaded` erkennen einen Alias nicht @@ -1340,7 +1340,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `RouterClientTest#findModel_matchesByAlias` (Stub-Router, auch `awaitModelLoaded` über den Alias), `RouterModelsResponseParserTest#parsesAliasesAndDefaultsThemToEmpty`, `RouterModelTest#aliasesDefaultToEmptyAndAreKeptUnmodifiable`, `#isNamed_matchesTheIdAndEveryAliasAndNothingElse`. - **Rot-Grün-Nachweis:** mit Platzhaltern (`getAliases()` leer, `isNamed` nur Id) rot (4 Tests), mit dem Fix grün (54 Tests, 2 modellgestützte übersprungen); PIT auf `RouterModel` 18/18 getötet; SpotBugs grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `11c7477` +- **Commit:** java-llama.cpp `0dd5e41` ### F176 – java-llama.cpp: `rerank(String, String...)` liefert die Dokumente in Hash-Reihenfolge @@ -1353,7 +1353,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `RerankResponseParserTest#toDocumentScores_keepsTheOrderOfTheDocuments`, `#toDocumentScores_equalDocumentsShareOneEntryAtTheirFirstPosition`. - **Rot-Grün-Nachweis:** mit der `HashMap` rot (1 von 14, `item 1: was "mid"`), mit der `LinkedHashMap` grün (14 von 14); PIT auf `RerankResponseParser` 6/6 getötet; SpotBugs grün (Parameter `Collection`, vorbelegte Größe). - **Status:** gefixt -- **Commit:** java-llama.cpp `3299e6a` +- **Commit:** java-llama.cpp `ef443b0` ### F177 – java-llama.cpp: `/v1/rerank`: `top_n: 0` liefert alle Ergebnisse, ein negatives `top_n` wird durchgereicht, `usage` fehlt @@ -1366,7 +1366,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++: `RerankResultsToJson.TokensEvaluatedArePassedOnAndDefaultToZero`. Java: `OaiRerankSupportTest#negativeTopNIsAnInvalidRequest`, `#topNZeroReturnsNoResultsLikeUpstream`, `#toOaiResponseCarriesTheUsageOfTheEvaluatedTokens`, `#readTopNReturnsValueOrNegativeOne` (um 0 ergänzt). - **Rot-Grün-Nachweis:** C++ ohne den Fix rot (`Which is: -1`), mit ihm grün (9/9 der Suite); Java ohne den Fix rot (3 von 16), mit ihm grün (16/16, mit `OpenAiCompatServerHttpTest` 71); SpotBugs grün. Ende zu Ende mit einem Reranker (CI: `OpenAiServerRerankIntegrationTest`) nicht lokal gelaufen. - **Status:** gefixt -- **Commit:** java-llama.cpp `232985f` +- **Commit:** java-llama.cpp `68f81b6` ### F178 – java-llama.cpp: Ollama-Routen melden `done_reason` falsch und `/api/generate` keine Tokenzahlen @@ -1379,7 +1379,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#ollamaChatDoneReasonIsLengthWhenCutOffAndStopForToolCalls` (blockierend und gestreamt, `length`/`tool_calls`/`stop`), `#ollamaGenerateDoneReasonAndCountsFollowTheCompletion` (blockierend und gestreamt), `#ollamaGenerateWithSuffixReadsTheInfillStopType`; `OllamaApiSupportTest#generateTextExtractors` (umgestellt), `#generateWithoutCountsOmitsThemAndToolCallsAreStop`. - **Rot-Grün-Nachweis:** ohne den Fix rot (3 HTTP-Tests, `was "stop"`), mit ihm grün (94 Tests der beiden Klassen; Pakete `server`, `json`, `value` 417 grün); SpotBugs grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `6eb8081` +- **Commit:** java-llama.cpp `887eb93` ### F179 – java-llama.cpp: Ollama `/api/generate` liest weder `raw` noch `system` und vervollständigt immer roh @@ -1392,7 +1392,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama` (Aufzeichnungs-Backend: Chat-Weg, `system`, Bild, `thinking`, Zählung), `#ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawRoundTrip` (Modell, CI). - **Rot-Grün-Nachweis:** ohne den Fix rot (`was "completions"`), mit ihm grün (96 Tests); der F178-Test gab nun explizit `raw: true` an, weil er die Completion-Abbildung prüft; SpotBugs grün. Die modellgestützte Variante läuft nur in CI bzw. beim lokalen Agenten. - **Status:** gefixt -- **Commit:** java-llama.cpp `75b3767` +- **Commit:** java-llama.cpp `f12d970` ### F180 – java-llama.cpp: OpenAI-Mapper verwerfen `logit_bias`, `n_probs` und `n` still @@ -1406,7 +1406,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** mit Platzhalter-Setter und unverändertem Mapper rot (4 Tests), mit dem Fix grün (327 Tests der Mapper-, Parameter-, Registry- und Server-Klassen); SpotBugs grün. - **Nachtrag (Branch-Review 2026-10-20):** Die Beschreibung stimmte für die Responses-Übersetzung nicht: deren Feldliste `CHAT_PASSTHROUGH_FIELDS` filtert, was den Mapper erreicht, und enthielt `logit_bias`/`n_probs` nicht; nachgezogen mit F226. - **Status:** gefixt (der Rest unter (14), Wahrscheinlichkeiten im gestreamten `/v1/completions`, mit F186) -- **Commit:** java-llama.cpp `3d001d2` +- **Commit:** java-llama.cpp `cceaaa0` ### F181 – java-llama.cpp: JNI-Schalen geben Fehlermeldungen als Modified UTF-8 zurück @@ -1419,7 +1419,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `JniUtf8.*` (6 in `test_jni_helpers.cpp`: ASCII bleibt bei `ThrowNew`, 4-Byte-Zeichen und ungültiges Byte erreichen den Konstruktor als Originalbytes, dasselbe für `new_string_utf8_impl` und für `jni_guard_impl`); Java `LlamaTrainerTest#aFourByteCharacterInAPathComesBackIntactInTheErrorMessage` (modellfrei, braucht nur `libjllama`; überspringt, wo die Dateinamen-Kodierung den Pfad nicht darstellen kann) und `#aFourByteCharacterInTheTrainingTextReachesTheTrainer` (hält die Jackson-Maskierung fest, von der die Eingabeseite abhängt). - **Rot-Grün-Nachweis:** C++: mit durchreichenden Platzhaltern 4 von 6 rot, mit dem Fix 6 von 6 grün, die ganze Suite 640/640. Java gegen die alte Bibliothek rot (`but: was "…/missing-ð.g"`), gegen die neu gebaute grün; `LlamaLoggerTest`, `NativeLibraryLoadSmokeTest`, `RpcServerTest` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `6f530e9` +- **Commit:** java-llama.cpp `50b45f8` ### F182 – java-llama.cpp: Gestreamte Anfragen antworten mit 200, bevor das Backend läuft @@ -1432,7 +1432,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#aRejectionBeforeTheFirstChunkIsA400OnEveryStreamingRoute`, `#aFailureBeforeTheFirstChunkIsA500OnEveryStreamingRoute`, `#aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent`, `#aRejectionAfterAHeartbeatIsAnEvent`; die vier bestehenden Ereignis-Tests (Responses `response.failed`, Anthropic-Fehlertypen) laufen jetzt mit einem Backend, das nach einem Chunk scheitert (`ScriptedStreamBackend`). Im Agenten `AtmosphereWireContractTest#engineFailureBeforeTheFirstChunkFailsTheTurn` (neu) und `#midStreamEngineFailureCompletesSilentlyRatherThanErroring` (jetzt wirklich nach einem Chunk). - **Rot-Grün-Nachweis:** vor dem Umbau rot (die beiden Vor-dem-ersten-Chunk-Tests), danach 63/63 in `OpenAiCompatServerHttpTest` und alle Server-Testklassen grün; der Agent gegen den installierten neuen Kern: vorher rot (`API returned 500: model exploded` statt `null`), nach der Aufteilung `verify` grün (278 Tests, 63 übersprungen, modell- oder bildschirmgebunden); SpotBugs grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `395d9d1` +- **Commit:** java-llama.cpp `9587087` ### F183 – java-llama.cpp: Anthropic `stop_sequence` ist immer `null` @@ -1445,7 +1445,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#theTranslatedRequestAsksForTheStopWord`, `#theMatchedStopWordIsTheStopSequence`, `#noMatchedStopWordIsANullStopSequence`; `AnthropicStreamTranslatorTest#theMatchedStopWordIsTheStopSequenceOfTheMessageDelta`, `#withoutAStopWordTheStopSequenceIsNull`; `OpenAiRequestMapperTest#verboseIsForwarded`; `InferenceParametersTest` (`withVerbose`); C++ `test_wire_contracts.cpp` (`verbose` im Schema, 5/5). - **Rot-Grün-Nachweis:** mit wirkungslosem Platzhalter-Setter rot (5 Tests, die beiden Null-Fälle waren schon grün), mit dem Fix grün (251 Tests der berührten Klassen, `OpenAiCompatServerHttpTest` und `WireNameRegistryTest` eingeschlossen); SpotBugs grün. Zusätzlich Ende-zu-Ende mit einem Zufallsmodell über den echten Server: ohne Stoppwort `max_tokens`/`null`, mit einem Stück der eigenen Greedy-Ausgabe als Stoppwort blockierend und gestreamt `end_turn` mit `stop_sequence: "j%j"`, kein `__verbose` in der Antwort. - **Status:** gefixt -- **Commit:** java-llama.cpp `a575714` +- **Commit:** java-llama.cpp `2b62481` ### F184 – java-llama.cpp: Mehrere Prompts in einer Anfrage: alle außer dem ersten still verworfen @@ -1458,7 +1458,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `CApi.SeveralPromptsInOneRequestAreRefusedNotDropped` (`test_c_api.cpp`, Zufallsmodell): zwei Prompts auf allen drei Wegen abgelehnt, mit der Anzahl in der Meldung; eine Token-Liste und ein Array mit einem Prompt angenommen. - **Rot-Grün-Nachweis:** vor dem Fix rot (alle drei Wege `JLLAMA_STATUS_OK`), danach grün; ganze C++-Suite 641/641. - **Status:** gefixt -- **Commit:** java-llama.cpp `c4062bd` +- **Commit:** java-llama.cpp `ffb3a8c` ### F185 – java-llama.cpp: OpenAI-Antworten ohne `id` und ohne `model` @@ -1471,7 +1471,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `CApi.OpenAiAnswersCarryAnIdAndTheModelName` (`test_c_api.cpp`, Zufallsmodell: blockierende Completion und Chat mit `chatcmpl-`-Id und dem Modellnamen aus den Meta-Daten, verschiedene Ids, ein Stream mit genau einer Id über alle Chunks); Java `ModelParametersTest#testSetAlias`, `OpenAiServerCliTest#anExplicitModelIdBecomesTheModelsAlias`; `test_model_flags.cpp` (`--alias` im Server-Parser). - **Rot-Grün-Nachweis:** C++ vor dem Fix rot (`id` und `model` leer), danach grün, Suite 642/642; Java mit wirkungslosem Platzhalter rot (2), danach grün (304 Tests der Parameter-, CLI- und Registry-Klassen). Ende-zu-Ende über den echten Server mit `--model-id my-model`: `/v1/models` und die Chat-Antwort nennen beide `my-model`. - **Status:** gefixt -- **Commit:** java-llama.cpp `0be9b33` +- **Commit:** java-llama.cpp `a8faaef` ### F186 – java-llama.cpp: Das gestreamte `/v1/completions` ist ein selbstgebauter Stream ohne `usage`, Wahrscheinlichkeiten und Token-Prompt @@ -1484,7 +1484,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** Java `OpenAiServerCompletionIntegrationTest#aStreamedCompletionIsUpstreamsStream` (modellgestützt: Token-Prompt, `logprobs`, eine Id, `usage` mit `prompt_tokens` 2, `finish_reason`, `[DONE]`); C++ `CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` (Zufallsmodell, dazu die Ablehnungen ohne Prompt, mit zwei Prompts und mit Null-Argument); FFM `FfmLlamaModelIntegrationTest#oaiCompletionStreamEndsWithUsage`; `OpenAiRequestMapperTest` umgestellt (33). - **Rot-Grün-Nachweis:** Der Java-Integrationstest mit einem Zufallsmodell vor dem Umbau rot (`400` für den Token-Prompt), danach grün; C++ 643/643; `llama-ffm verify` grün (17 + 4 + 9) gegen die neu gebaute Bibliothek; `OpenAiCompatServerHttpTest` 63, `NativeLibraryLoadSmokeTest` 6 grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d98a6ad` +- **Commit:** java-llama.cpp `4fb82c1` ### F187 – java-llama.cpp: `addLoraScaledAdapter`/`addControlVectorScaled` senden `FNAME,SCALE`, upstream liest `FNAME:SCALE` @@ -1498,7 +1498,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ModelParametersTest#testAddLoraScaledAdapter` und `#testAddControlVectorScaled` auf `FNAME:SCALE` umgestellt; neu `#aScaledPathContainingAColonIsRefusedWithTheReason` (beide Optionen, Meldung nennt Flag, Format und Pfad, relativer Pfad als Gegenprobe im selben Test). - **Rot-Grün-Nachweis:** Vorher scheiterte `new LlamaModel(base().addLoraScaledAdapter(adapter, 1f))` mit `Failed to parse model parameters`. Nachher, end-to-end mit dem Adapterpaar aus M6 (`ggml-org/stories15M_MOE`, Wegwerf-Probe, danach entfernt): `--lora-scaled :1.0` lädt und liefert denselben Text wie die `--lora`-Referenz, `:0.0` denselben wie ohne Adapter, und der Server meldet `"scale":0.0` zurück — die Skala wirkt also, was über `ModelParameters` vorher gar nicht möglich war. Danach alle Module grün: Kern 2154/0/0/18 (2136 ausgeführt), `ctest` 643/643, SpotBugs 0, Agent 281/0/0/60, `llama-ffm` 30/0/0/0, `llama-langchain4j` 55/0/0/7, `llama-kotlin` 6/0/0/0. - **Status:** gefixt -- **Commit:** java-llama.cpp `15f857a` +- **Commit:** java-llama.cpp `5fff6b8` ### F188 – java-llama.cpp: Die vier „can be repeated“-Adapteroptionen ersetzen den vorigen Wert statt ihn zu ergänzen @@ -1511,7 +1511,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ModelParametersTest#theRepeatableAdapterOptionsAccumulateInsteadOfReplacing` (alle vier Optionen, je zwei Aufrufe, mit den erwarteten Listenwerten einschließlich `c.gguf:0.5,d.gguf:1.0`) und `#aValueCarryingACommaOrAQuoteIsWrittenAsAQuotedCsvField` (Komma, Anführungszeichen, und ein einfacher Wert vor einem zu maskierenden). - **Rot-Grün-Nachweis:** Die Akkumulationszusicherungen sind gegen `putString` rot (der erste Wert fehlt). Die End-to-end-Probe aus F187 zeigt zwei skalierte Adapter als einen Wert: `--lora-scaled ..\models\moe_shakespeare15M.gguf:0.5,second.gguf:0.25`. Modullaufzahlen wie in F187. - **Status:** gefixt -- **Commit:** java-llama.cpp `15f857a` +- **Commit:** java-llama.cpp `5fff6b8` ### F189 – java-llama.cpp: Ohne `--model-id` nennen `/v1/models` und die Antworten dasselbe Modell verschieden @@ -1525,7 +1525,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiServerCliTest#theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag` — ersetzt `anExplicitModelIdBecomesTheModelsAlias`, prüft beide Fälle und dass der Alias und `getModelId()` derselbe String sind. - **Rot-Grün-Nachweis:** Der alte Test schrieb das Gegenteil fest und ist gegen den Fix rot. End-to-end (Wegwerf-Probe, danach entfernt), Server ohne `--model-id` über `ServerLauncher`: `/v1/models`, die blockierende Antwort und jeder Stream-Chunk nennen alle `AMD-Llama-135m-code.Q2_K.gguf`, kein absoluter Pfad erreicht eine Antwort, `id` ist `chatcmpl-…` und je Stream eine. Modullaufzahlen wie in F187. - **Status:** gefixt -- **Commit:** java-llama.cpp `15f857a` +- **Commit:** java-llama.cpp `5fff6b8` ### F190 – java-llama.cpp: Das Skript-Backend des Agenten schreibt unmaskierte Steuerzeichen in seine JSON-Chunks (Modul baut auf Windows nicht grün) @@ -1538,20 +1538,20 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** Neu `ScriptedBackendTest` (3): jeder Chunk eines Text-Zuges parst als JSON (mit `System.lineSeparator()`, CRLF, LF und einzelnem CR); **jedes** der 32 Steuerzeichen einzeln, also der `default`-Zweig und nicht die vier benannten Fälle; und der Text kommt unverändert aus dem Chunk zurück. - **Rot-Grün-Nachweis:** Mit der alten `quote`-Fassung kurz zurückgesetzt: `ScriptedBackendTest` 2 Failures + 1 Error (`System.lineSeparator(): chunk is not JSON`, `U+0000: chunk is not JSON`, `JsonParse Illegal unquoted character ((CTRL-CHAR, code 13))`) und `AgentSessionTest` 1 Failure. Mit dem Fix (Datei danach byte-identisch wiederhergestellt) beide dreimal hintereinander grün, und das ganze Modul 281/0/0/60 — die 60 sind die dokumentierten JLine-Fälle. `spotless:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `3b9d96b` +- **Commit:** java-llama.cpp `d68aa3d` ### F197 – java-llama.cpp: `n` > 1 wird auf jedem nativen Completion-Weg still auf eine Antwort reduziert (das gestreamte `/v1/completions` verlor dabei den Schutz aus F180) - **Zeit:** 2026-10-20 10:30 UTC - **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`populate_completion_task`), `llama/src/test/cpp/test_c_api.cpp`, `llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java` - **Schwere:** mittel (zwei Antworten verlangt, eine geliefert, ohne Hinweis — dieselbe Klasse wie F184) -- **Historie:** Gefunden beim Review des Branches am 2026-10-20. F180 hatte `n > 1` im Java-Mapper für den gestreamten `/v1/completions` abgelehnt; F186 (`d98a6ad`) verlegte diesen Weg auf den nativen OAI-Pfad und entfernte `toCompletionParameters` und damit die Ablehnung samt ihrer Testzusicherung. +- **Historie:** Gefunden beim Review des Branches am 2026-10-20. F180 hatte `n > 1` im Java-Mapper für den gestreamten `/v1/completions` abgelehnt; F186 (`4fb82c1`) verlegte diesen Weg auf den nativen OAI-Pfad und entfernte `toCompletionParameters` und damit die Ablehnung samt ihrer Testzusicherung. - **Beschreibung:** Upstreams Schema liest `n` als `n_cmpl` mit den harten Grenzen 1 … `n_parallel` (`server-schema.cpp:62`) und beantwortet die weiteren Completions in Kind-Tasks, die der Server anlegt (`server-context.cpp:4888`). Die Engine stellt genau einen Task ein und legt keine Kinder an. Jedes `n` zwischen 2 und der Slot-Zahl wurde also angenommen und mit einer Wahl beantwortet — gemessen vom Reviewer mit dem Zufallsmodell und `setParallel(2)`: `handleCompletionsOai` mit `n:2` eine Wahl, `streamCompletionsOai` ebenso; erst `n:3` scheiterte am Schema. Betroffen: C-API, FFM, `LlamaModel.handle*`, das blockierende `/v1/completions` (leitet den Körper wörtlich weiter) und seit F186 das gestreamte. - **Fix:** `populate_completion_task` lehnt `task.params.n_cmpl > 1` nach dem Schema mit `jllama_invalid_request` ab ("a request takes one completion, this one asks for 'n' = N; send separate requests"), wie mehrere Prompts (F184). Das deckt die blockierenden, gestreamten und Chat-Wege an einer Stelle. - **Tests:** `CApi.SeveralCompletionsInOneRequestAreRefusedNotDropped` (eigenes Modell mit `-np 2`; `jllama_completions_oai`, `jllama_completions` mit `n_cmpl`, der Stream-Request und `jllama_chat_completions` werden abgelehnt, `n:1` liefert eine Wahl), `OpenAiServerCompletionIntegrationTest#aCompletionAskingForSeveralChoicesIsAnswered400OnBothRoutes` (beide `/v1/completions`-Routen, das Testmodell jetzt mit `setParallel(2)`), `#severalChoicesAreRefusedOnTheModelsOwnCompletionAndChatPaths`. - **Rot-Grün-Nachweis:** Der C-API-Test gegen den alten Code rot (alle vier Wege `OK` statt `INVALID_REQUEST`), mit dem Fix grün; `ctest` grün. Die beiden Java-Tests lokal mit dem Zufallsmodell grün (die zwei Infill-Tests der Klasse scheitern dort erwartungsgemäß, das Zufallsmodell hat keine FIM-Tokens); in CI mit dem echten Modell. - **Status:** gefixt -- **Commit:** java-llama.cpp `447de71` +- **Commit:** java-llama.cpp `7a51bbc` ### F198 – java-llama.cpp: `FfmTextToSpeech.close()` gibt die Engine frei, während `synthesize()` auf einem anderen Thread in ihr läuft (FFM-Hälfte von F16) @@ -1564,7 +1564,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `FfmTextToSpeechTest` (5, ohne Bibliothek und Modell): `closeWaitsForASynthesisInsideTheNativeCall`, `synthesizeAfterCloseIsRefusedWithoutTouchingTheHandle`, `closeTwiceFreesOnce`, `synthesizeReturnsTheBackendsWav`, `synthesizeWithoutTextIsRefused`. - **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne Lock: `closeWaitsForASynthesisInsideTheNativeCall` rot (`close()` kehrte zurück, während die Synthese noch lief); mit dem Lock grün. `llama-ffm` `verify` 35/35 mit dem Zufallsmodell. - **Status:** gefixt -- **Commit:** java-llama.cpp `d6be2c5` +- **Commit:** java-llama.cpp `c07aad0` ### F199 – java-llama.cpp: Ein Heartbeat vor dem Eröffnungsevent verschluckt `message_start` (Anthropic) bzw. `response.created` (Responses) @@ -1577,7 +1577,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponseStreamTest` (4, über einen `HttpExchange`-Fake ohne Socket): `theOpeningIsWrittenFirstWhenItIsSetBeforeTheFirstWrite`, `anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten`, `anErrorBeforeTheFirstWriteIsAnsweredWithItsStatusAndNoOpening`, `anErrorAfterTheFirstWriteIsLeftToTheStream`. - **Rot-Grün-Nachweis:** Ohne den Fix `anOpeningSetAfterAHeartbeatStartedTheStreamIsStillWritten` rot (erwartet `: ping\nOPEN\nDATA\n`, war `: ping\nDATA\n`); mit ihm grün. `OpenAiCompatServerHttpTest` 63/63. - **Status:** gefixt -- **Commit:** java-llama.cpp `bbdafda` +- **Commit:** java-llama.cpp `aa58171` ### F200 – java-llama.cpp: `chatBatch` startet einen Thread pro Anfrage ohne Obergrenze, und ein `Error` beendet das Warten vor den anderen Anfragen @@ -1590,7 +1590,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `BatchDispatchTest`: neu `runEachOnItsOwnThread_runsNoMoreRequestsAtOnceThanTheBound` (12 Anfragen, Grenze 3, höchstens 3 gleichzeitig), `runEachOnItsOwnThread_anErrorIsThrownAfterTheOthersFinished`, `runEachOnItsOwnThread_aBoundBelowOneIsRefused`. - **Rot-Grün-Nachweis:** Mit der neuen Signatur, aber ohne Grenze und ohne den Error-Pfad: die drei neuen Tests rot (12 gleichzeitig statt 3; zwei statt null fertige Anfragen fehlten beim Wurf; keine Ablehnung von 0); mit dem Fix 11/11 grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `3e996db` +- **Commit:** java-llama.cpp `a2bfad8` ### F201 – java-llama.cpp: `--model-id` mit Komma (oder abgeleitet aus einem Dateinamen mit Komma) wird zu mehreren llama.cpp-Aliasen; `/v1/models` und die Antworten nennen das Modell verschieden @@ -1603,7 +1603,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiServerCliTest`: `aModelIdWithACommaIsRefused`, `aModelIdLlamaCppWouldStripIsRefused`, `aFileNameWithACommaIsAdvertisedAsItsAlias`. - **Rot-Grün-Nachweis:** Ohne den Fix alle drei rot (keine Ablehnung; abgeleitete ID `a,b.gguf`); mit ihm 54/54 grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `707f0f2` +- **Commit:** java-llama.cpp `8e2642f` ### F202 – java-llama.cpp: Die Anthropic-Route holt für das Stoppwort upstreams ganzes Debug-Objekt samt gerendertem Prompt durch JNI @@ -1616,7 +1616,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest.theTranslatedRequestAsksForTheStopWord` (erweitert), `OpenAiRequestMapperTest.responseFieldsAreForwarded`, `responseFieldsThatAreNotStringsAreRefused`, `InferenceParametersTest.withResponseFieldsWritesTheProjectionAsAStringArray`, modellgestützt `OpenAiCompatServerIntegrationTest.theDebugObjectTheAnthropicRouteAsksForCarriesOnlyTheStopWord` (`__verbose` hat genau den Schlüssel `stopping_word`); C++ `test_wire_contracts` prüft den neuen Schlüssel gegen upstreams Schema. - **Rot-Grün-Nachweis:** Mit `RequestField`/`withResponseFields`, aber dem alten Mapper und der alten Übersetzung: drei Tests rot; mit dem Fix grün (221 Tests der fünf Klassen, Integrationstest 13/13 mit dem Zufallsmodell, ctest Wire 6/6). - **Status:** gefixt -- **Commit:** java-llama.cpp `05f356b` +- **Commit:** java-llama.cpp `29ff8c1` ### F203 – java-llama.cpp: `chat_template_kwargs`: ein Wert mit mehr als einem JSON-Wert wird still auf den ersten gekürzt @@ -1629,7 +1629,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ParameterJsonSerializerTest.testBuildRawValueObject_trailingTokensFallBackToString`, `InferenceParametersTest.aChatTemplateKwargHoldingTwoValuesIsKeptAsText` (beide öffentlichen Setter). - **Rot-Grün-Nachweis:** Ohne den Fix rot (`{"two":true,…,"object":{"a":1}}`); mit ihm grün (40 + 114). - **Status:** gefixt -- **Commit:** java-llama.cpp `266d70d` +- **Commit:** java-llama.cpp `af84cb5` ### F204 – java-llama.cpp: `throw_utf8_impl`/`new_string_utf8_impl` rufen JNI über einer schon anstehenden Java-Exception auf und ersetzen sie @@ -1642,7 +1642,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** C++ `JniUtf8.APendingExceptionIsLeftInPlaceOnBothPaths` (ASCII- und 4-Byte-Pfad, kein `ThrowNew`, kein String gebaut, nichts geworfen; `new_string_utf8_impl` ohne `NewStringUTF`). - **Rot-Grün-Nachweis:** Ohne den Fix rot; mit ihm grün, ctest 645/645. - **Status:** gefixt -- **Commit:** java-llama.cpp `2611a38` +- **Commit:** java-llama.cpp `da6fa8e` ### F205 – java-llama.cpp: Ollama `/api/generate` ohne Prompt (das Vorladen eines Modells) erzeugt eine Antwort auf einen leeren Nutzer-Turn @@ -1655,7 +1655,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithoutAPromptOnlyReportsTheModelLoaded` (drei Formen, das Backend wird nicht gerufen), `OllamaApiSupportTest.aGenerateRequestWithoutAPromptOrSuffixOnlyLoadsTheModel`. - **Rot-Grün-Nachweis:** Ohne den Fix der HTTP-Test rot (das Backend generierte); mit ihm grün (64 + 37). - **Status:** gefixt -- **Commit:** java-llama.cpp `6c176d5` +- **Commit:** java-llama.cpp `200cc90` ### F213 – java-llama.cpp: Ollama `/api/chat` mit einem `messages`-Element, das kein Objekt ist, schließt die Verbindung ohne Antwort - **Zeit:** 2026-10-20 19:55 UTC @@ -1667,7 +1667,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.aNonObjectOllamaChatMessageIsA400NotADroppedConnection` (ungestreamt und gestreamt, das Backend wird nicht gerufen), `OllamaApiSupportTest.aNonObjectMessageIsRejectedWithItsIndex` (String, `null`, Array), `OllamaApiSupportTest.messagesThatAreNoArrayAreRejected`. - **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, `aNonObjectMessageIsRejectedWithItsIndex` mit „Unexpected exception type thrown, expected: but was: “, `messagesThatAreNoArrayAreRejected` mit „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix grün (`OllamaApiSupportTest` 39, `OpenAiCompatServerHttpTest` 65, `OpenAiRequestMapperTest` 35, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `538cafb` +- **Commit:** java-llama.cpp `6295831` ### F214 – java-llama.cpp: Eine `IOException` des Backends vor dem ersten Chunk gilt als Verbindungsabbruch — keine Antwort, nur DEBUG - **Zeit:** 2026-10-20 20:09 UTC @@ -1679,7 +1679,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.aBackendIOExceptionBeforeTheFirstChunkIsA500OnEveryStreamingRoute` (alle fünf Streaming-Routen, `IoFailingBackend`), `ResponseStreamTest.aStreamWhoseHeadersCouldNotBeWrittenHasStarted`. - **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, der `ResponseStream`-Test mit „Expected: is but: was “ (`answerIfNotStarted` nach gescheitertem Kopfzeilen-Schreiben); mit dem Fix grün (`ResponseStreamTest` 5, `OpenAiCompatServerHttpTest` 66, `OllamaApiSupportTest` 39, `LlamaArchitectureTest` 13). Modellgestützt mit dem Winzmodell `OpenAiCompatServerIntegrationTest` 13/13 grün; in `OpenAiServerCompletionIntegrationTest` scheitern nur die beiden Infill-Fälle (500, das Winzmodell hat keine FIM-Token), die keine Streaming-Route berühren. `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `03392c7` +- **Commit:** java-llama.cpp `d00936b` ### F215 – java-llama.cpp: Ollama `/api/generate` mit `raw: true` verwirft `system`, `template` und `context` still — Ollama antwortet 400 - **Zeit:** 2026-10-20 20:20 UTC @@ -1691,7 +1691,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithRawAndASystemPromptIsRejectedLikeOllama` (`system`, `template`, `context`, mit `suffix`, ohne Prompt; das Backend wird nicht gerufen) ersetzt `ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `ollamaGenerateWithRawCompletesTheBarePrompt` (der Weg ohne Konflikt); `OllamaApiSupportTest.rawWithSystemTemplateOrContextConflictsLikeOllama` (vier Konflikte, vier zulässige Formen). - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit einem `hasRawConflict`, das `false` liefert) rot: der HTTP-Test mit „Expected: is <400> but: was <200>“ für `{"model":"test-model","prompt":"once upon","system":"x","raw":true,"stream":false}`, der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 40, `OpenAiCompatServerHttpTest` 67, `LlamaArchitectureTest` 13), `spotbugs:check` und `javadoc:javadoc` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d94fbd7` +- **Commit:** java-llama.cpp `7933eb0` ### F216 – java-llama.cpp: Ollama `/api/chat` ohne Nachrichten (Ollamas Vorladen) wird mit 400 abgewiesen statt mit der Ladeantwort beantwortet - **Zeit:** 2026-10-20 20:27 UTC @@ -1703,7 +1703,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest.ollamaChatWithoutMessagesOnlyReportsTheModelLoaded` (ohne `messages`, `[]`, `null` mit `stream`, `keep_alive: 0`; das Backend wird nicht gerufen — `RecordingBackend` zeichnet dafür jetzt auch `stream` auf), `OllamaApiSupportTest.aChatRequestWithoutMessagesOnlyLoadsTheModel`. - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit Stubs, die `false` bzw. `{}` liefern) rot: der HTTP-Test mit „Expected: is but: was “ für `done` bei `{"model":"test-model"}` (das Testbackend streamte eine Antwort; das echte Backend weist die leere Konversation mit 400 ab), der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 41, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `d99271c` +- **Commit:** java-llama.cpp `a4d0604` ### F217 – java-llama.cpp: Ollamas `think: true` wird verworfen — ein Server mit abgeschaltetem Denken übergeht es still - **Zeit:** 2026-10-20 20:31 UTC @@ -1715,7 +1715,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OllamaApiSupportTest.thinkTrueSwitchesThinkingOnExplicitly` (Übersetzer → `OpenAiRequestMapper` → `InferenceParameters.toJson()`, für `/api/chat` und `/api/generate`). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is but: was “ (`enable_thinking` fehlte in `chat_template_kwargs`); mit dem Fix grün (`OllamaApiSupportTest` 42, `OpenAiRequestMapperTest` 35, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `736d247` +- **Commit:** java-llama.cpp `eade620` ### F218 – java-llama.cpp: Chat-Anfrage mit `json_schema` und `grammar` verliert das Schema still statt upstreams 400 - **Zeit:** 2026-10-20 21:24 UTC @@ -1727,7 +1727,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair` (ersetzt die zweite Hälfte von `#jsonSchemaReachesTheMappedRequestUnlessAGrammarIsGiven`, deren erste Hälfte als `#jsonSchemaReachesTheMappedRequest` bleibt); modellgestützt `ErrorHandlingTest#testJsonSchemaWithGrammarInAChatIsAnInvalidRequest` (ein Chat mit beiden Feldern ist eine `InvalidRequestException` mit upstreams Text „Cannot use both json_schema and grammar“ — der Beleg, dass das Durchreichen beim Client als 400 ankommt). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is "object" but: was ""“ (`json_schema` fehlte im gemappten Körper); mit dem Fix grün (`OpenAiRequestMapperTest` 36/36). `ErrorHandlingTest#testJsonSchemaWithGrammarInAChatIsAnInvalidRequest` grün mit dem lokalen Winzmodell (CPU); in der Klasse scheitert unabhängig davon `#testHandleInfillMissingInputSuffix`, weil das Winzmodell keine FIM-Token hat. `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `6d78c47` +- **Commit:** java-llama.cpp `fea0a1d` ### F219 – java-llama.cpp: Chat-Mapper normalisiert `max_tokens` unter -1 und `n` unter 1 still auf die Vorgaben - **Zeit:** 2026-10-20 21:30 UTC @@ -1739,7 +1739,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#tokenCapBelowMinusOneReachesUpstreamsRefusal`, `#zeroOrNegativeChoicesAreRefusedLikeUpstream` (`n` 0 mit Meldung, `n` -1), als Schutz `#explicitTokenCapMinusOneLeavesTheServerDefault`; modellgestützt `ErrorHandlingTest#testNPredictMinusTwoInAChatIsAnInvalidRequest` (ein Chat mit `n_predict` -2 ist eine `InvalidRequestException`, die `n_predict` nennt — der Beleg, dass das Durchreichen beim Client als 400 ankommt). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: is <-2> but: was <-1>“ (`n_predict` fehlte) und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ (`n: 0`); mit dem Fix grün (`OpenAiRequestMapperTest` 39/39, `OpenAiCompatServerHttpTest` 68/68, `AnthropicApiSupportTest` 26/26, `ResponsesApiSupportTest` 54/54, `OllamaApiSupportTest` 42/42). `ErrorHandlingTest#testNPredictMinusTwoInAChatIsAnInvalidRequest` grün mit dem lokalen Winzmodell (CPU); `#testHandleInfillMissingInputSuffix` scheitert unabhängig davon am Winzmodell ohne FIM-Token. `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `1817210` +- **Commit:** java-llama.cpp `63f05e1` ### F220 – java-llama.cpp: Chat-Mapper verwirft `top_logprobs` ohne `logprobs` still und liest ein ganzzahliges `logprobs` als `n_probs` 20 - **Zeit:** 2026-10-20 21:40 UTC @@ -1751,7 +1751,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#topLogprobsWithoutLogprobsIsRefusedLikeUpstream` (ohne, mit `false`, mit `null` und neben einer Zahl), `#anIntegerLogprobsMapsToNProbsLikeUpstreamsSchemaAlias`, `#nProbsWinsOverAnIntegerLogprobsLikeUpstream`, `#aLogprobsThatIsNeitherBooleanNorNumberIsRefused` (mit `n_probs` daneben keine Ablehnung, wie upstream); als Schutz `#topLogprobsNullWithoutLogprobsIsTheDefault`, die drei F123-Tests unverändert grün. - **Rot-Grün-Nachweis:** Ohne den Fix vier rot: „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ (`top_logprobs: 3` allein bzw. `logprobs: "true"`), „Expected: is <2> but: was <20>“ (`logprobs: 2`), „Expected: is <5> but: was <20>“ (`n_probs: 5` neben `logprobs: 2`); mit dem Fix grün (`OpenAiRequestMapperTest` 44/44, `OpenAiCompatServerHttpTest` 68/68, `AnthropicApiSupportTest` 26/26, `ResponsesApiSupportTest` 54/54, `OllamaApiSupportTest` 42/42, `LlamaArchitectureTest` 13/13). `spotless:apply` grün; `spotbugs:check` meldete zuerst zweimal `WEM_WEAK_EXCEPTION_MESSAGING` für die festen Texte, mit der Unterdrückung grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ededfc5` +- **Commit:** java-llama.cpp `ca1baa9` ### F221 – java-llama.cpp: Anthropic meldet ein getroffenes Stoppwort als `stop_reason` `end_turn` statt `stop_sequence` - **Zeit:** 2026-10-20 22:30 UTC @@ -1763,7 +1763,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#aMatchedStopWordIsReportedAsStopReasonStopSequence`, `#theMatchedStopWordIsTheStopSequence` (Erwartung von `end_turn` auf `stop_sequence` umgestellt), `#onlyAStopThatAStopWordEndedIsStopSequence` (die drei unveränderten Zweige mit Wort und `stop` ohne Wort), als Schutz `#withoutAStopWordAStopIsStillEndTurn`; `AnthropicStreamTranslatorTest#aMatchedStopWordIsTheStopReasonStopSequenceOfTheMessageDelta`, `#theMatchedStopWordIsTheStopSequenceOfTheMessageDelta` (umgestellt). - **Rot-Grün-Nachweis:** Ohne den Fix vier rot: „Expected: is "stop_sequence" but: was "end_turn"“ (beide blockierenden Fälle) und „Expected: a string containing "\"stop_reason\":\"stop_sequence\""“ (beide gestreamten); mit dem Fix grün (`AnthropicApiSupportTest` 29/29, `AnthropicStreamTranslatorTest` 8/8, `OpenAiCompatServerHttpTest` 68/68). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `a1be394` +- **Commit:** java-llama.cpp `7e965d9` ### F222 – java-llama.cpp: Gestreamte Anthropic-Antwort öffnet bei Denkinhalt nach Text einen zweiten Thinking-Block, während der Textblock offen bleibt - **Zeit:** 2026-10-20 22:35 UTC @@ -1775,7 +1775,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicStreamTranslatorTest#reasoningAfterTextClosesTheTextBlockBeforeANewThinkingBlock` (zweiter Thinking-Start an Index 2, letztes Textdelta an Index 3, Stopp von 1 vor dem zweiten Thinking-Start, kein Delta an 1 nach dessen Stopp, `end()` schließt genau einen Block, Index 3); die übrigen Tests der Klasse unverändert grün. - **Rot-Grün-Nachweis:** Ohne den Fix rot: „Expected: a string containing "\"index\":3,\"delta\":{\"type\":\"text_delta\",\"text\":\"d\"}"“ (das Delta „d“ ging an Index 1); mit dem Fix grün (`AnthropicStreamTranslatorTest` 9/9, `AnthropicApiSupportTest` 29/29, `OpenAiCompatServerHttpTest` 68/68). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `e1de898` +- **Commit:** java-llama.cpp `cb123a9` ### F223 – java-llama.cpp: Anthropic `max_tokens`, das keine Zahl ist, wird still durch die Vorgabe 4096 ersetzt statt abgewiesen - **Zeit:** 2026-10-20 22:40 UTC @@ -1787,7 +1787,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#aMaxTokensThatIsNotANumberIsRefused` (`"100"` und `true`, Meldung nennt `max_tokens`), als Schutz `#requestWithNullMaxTokensIsCappedLikeAbsent` (vorher und nachher grün) und `#requestWithoutMaxTokensIsCappedLikeUpstream`; `OpenAiCompatServerHttpTest#aStringMaxTokensOnTheAnthropicRouteIsA400InvalidRequestError` (`stream` false und true: 400, `"type":"invalid_request_error"`, nichts generiert). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „"100" ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „Expected: is <400> but: was <200>“; nur mit der Übersetzungsseite des Fixes scheiterte der HTTP-Test mit „java.net.SocketException: Unexpected end of file from server“ (der Beleg, dass die Ablehnung ohne das verlegte `try` als abgebrochene Verbindung ankäme); mit dem ganzen Fix grün (`AnthropicApiSupportTest` 31/31, `AnthropicStreamTranslatorTest` 9/9, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `4849673` +- **Commit:** java-llama.cpp `82bef4a` ### F224 – java-llama.cpp: Responses `input_image` ohne URL und fremde Werkzeugergebnis-Teile werden still verworfen statt abgewiesen - **Zeit:** 2026-10-20 23:05 UTC @@ -1799,7 +1799,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#inputImageWithoutAUrlIsRejectedLikeUpstream`, `#toolOutputPartOfAnotherTypeIsRejectedLikeUpstream`, `#toolOutputPartWithoutATypeIsRejectedLikeUpstream`, `#toolOutputImageWithoutAUrlIsRejectedLikeUpstream`, `#toolOutputImageInObjectFormKeepsItsUrl`; die 400-Abbildung pinnt schon `OpenAiCompatServerHttpTest#responsesWithAnInputFileIsRejectedLikeUpstream`. - **Rot-Grün-Nachweis:** Ohne den Fix rot (5 Fehlschläge: viermal „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, einmal „Expected: is "image_url" but: was ""“), mit dem Fix grün (`ResponsesApiSupportTest` 59/59, `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `ffd3ae4` +- **Commit:** java-llama.cpp `e372019` ### F225 – java-llama.cpp: Responses `function_call` mit Objekt-`arguments` wird still zu `"{}"` - **Zeit:** 2026-10-20 23:07 UTC @@ -1811,19 +1811,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#functionCallWithArgumentsThatAreNotAStringIsRejectedLikeUpstream` (Objekt, Array, Zahl, Boolean; Meldung nennt `'arguments'` und die `call_id`); `#functionCallWithoutArgumentsBecomesAnEmptyArgumentsObject` um `"arguments":null` erweitert (vorher und nachher grün, pinnt, dass `null` weiter `"{}"` ist). - **Rot-Grün-Nachweis:** Ohne den Fix rot („{"city":"Paris"} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 60/60, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `fdc800e` +- **Commit:** java-llama.cpp `4913acf` ### F226 – java-llama.cpp: Responses-Route verwirft `logit_bias` und `n_probs` still, obwohl der Chat-Mapper sie liest - **Zeit:** 2026-10-20 23:12 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`CHAT_PASSTHROUGH_FIELDS` und seine Javadoc) - **Schwere:** niedrig (ein per `logit_bias` gesperrtes Token wurde auf `/v1/responses` trotzdem erzeugt, ohne Hinweis) -- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-3). Die Liste entstand mit F106 (`87635b2`) gegen den damaligen Mapper; F180 (`3d001d2`) brachte dem Mapper `logit_bias` und `n_probs` bei und vermerkte, das erreiche auch die Responses-Übersetzung – die Liste filtert aber, was kopiert wird, und wurde nicht nachgezogen. F183/F202 fügten `verbose` und `response_fields` hinzu, ebenfalls ohne die Liste. +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J5-3). Die Liste entstand mit F106 (`b370643`) gegen den damaligen Mapper; F180 (`cceaaa0`) brachte dem Mapper `logit_bias` und `n_probs` bei und vermerkte, das erreiche auch die Responses-Übersetzung – die Liste filtert aber, was kopiert wird, und wurde nicht nachgezogen. F183/F202 fügten `verbose` und `response_fields` hinzu, ebenfalls ohne die Liste. - **Beschreibung:** Upstreams `convert_responses_to_chatcmpl` (`server-chat.cpp:15`, b11639) beginnt mit `json chatcmpl_body = response_body;` und kopiert damit jedes Feld; ein `logit_bias` auf upstreams `/v1/responses` erreicht das Schema (`server-schema.cpp:382`) und wirkt. Hier kopierte die Übersetzung nur die Felder ihrer Liste, deren Javadoc behauptete, es seien die, die der Chat-Mapper liest; `logit_bias` und `n_probs` fehlten, die Anfrage lief ohne sie. Der parametrisierte Test zählte nur die Felder der Liste auf und konnte eine Lücke nicht sehen. Auch die CHANGELOG-Zeile zu F180 (alle Chat-Routen einschließlich Responses reichen `logit_bias`/`n_probs` weiter) stimmte damit nicht. - **Fix:** `logit_bias` und `n_probs` stehen in der Liste (`n_probs` aus Parität mit upstreams Annahme, sichtbar wird es nicht: die Responses-Ausgabe trägt hier wie upstream keine Wahrscheinlichkeiten). Die Javadoc sagt jetzt, was die Liste ist (die vom Mapper gelesenen Felder, deren Wirkung einen Responses-Client erreichen kann) und nennt die bewussten Auslassungen: die oben übersetzten Strukturfelder, `verbose`/`response_fields` (die Responses-Ausgabe trägt weder das Debug-Objekt noch eine Projektion; weitergereicht zöge `verbose` nur upstreams Debug-Objekt durch JNI, um es zu verwerfen, die Kosten, die F202 auf der Anthropic-Route entfernte) und chats `logprobs`/`top_logprobs` (die Responses-Form ist `top_logprobs` mit `include`, die Ausgabe trägt keine Wahrscheinlichkeiten). Der vorgeschlagene reflektive Wächter über die `request.path(…)`-Literale des Mappers wurde nicht gebaut (er müsste Quelltext lesen und eine große Ausnahmeliste pflegen); die Ausnahmeliste der Javadoc ist der Vertrag. - **Tests:** `ResponsesApiSupportTest#requestFieldsTheChatMapperReadsPassThroughLikeUpstream` um die Zeilen `logit_bias|{"42":-100}` und `n_probs|3` erweitert. - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is <{"42":-100}> but: was <>“ und „Expected: is <3> but: was <>“), mit dem Fix grün (`ResponsesApiSupportTest` 62/62, `OpenAiCompatServerHttpTest` 69/69, `OpenAiRequestMapperTest` 44/44). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `365fcdb` +- **Commit:** java-llama.cpp `88f823e` ### F227 – java-llama.cpp: `tool_choice`, das eine Funktion benennt, lässt bei mehreren Werkzeugen jedes zu - **Zeit:** 2026-10-20 23:21 UTC @@ -1835,7 +1835,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#toolChoiceNamingAFunctionForwardsOnlyThatTool`, `#toolChoiceNamingAnUnknownFunctionIsRejected`, `#toolChoiceOfTypeFunctionWithoutANameIsRejected` und dieselben drei in `OpenAiRequestMapperTest`; die F109-Tests `#toolChoiceNamingAFunctionRequiresAToolCall` (ein Werkzeug) beider Klassen bleiben grün. - **Rot-Grün-Nachweis:** Ohne den Fix rot (6 Fehlschläge: je Klasse „Expected: is <1> but: was <2>“ und zweimal „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 65/65, `OpenAiRequestMapperTest` 47/47, `OpenAiCompatServerHttpTest` 69/69, `AnthropicApiSupportTest` 31/31, `OllamaApiSupportTest` 42/42, `LlamaArchitectureTest` 13/13). `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit fester Meldung ohne Namen fiel bei SpotBugs als `WEM_WEAK_EXCEPTION_MESSAGING` durch; die Meldung nennt jetzt das `tool_choice`). - **Status:** gefixt -- **Commit:** java-llama.cpp `f888bde` +- **Commit:** java-llama.cpp `740938d` ### F228 – java-llama.cpp: Responses-Eingabe-Items unbekannten Typs werden zu leeren Nutzernachrichten statt abgewiesen - **Zeit:** 2026-10-20 23:26 UTC @@ -1847,7 +1847,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesApiSupportTest#inputItemOfAnUnknownTypeIsRejectedLikeUpstream` (Meldung nennt upstreams Text und `custom_tool_call`), als Schutz `#inputItemWithoutATypeStillMapsToAMessage` (vorher und nachher grün). - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit dem Fix grün (`ResponsesApiSupportTest` 67/67, `ResponsesStreamTranslatorTest` 5/5, `OpenAiCompatServerHttpTest` 69/69). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `647dd57` +- **Commit:** java-llama.cpp `beaffcc` ### F229 – java-llama.cpp: Abgeschnittene Responses-Antwort (`finish_reason` `length`) wird als `completed` gemeldet - **Zeit:** 2026-10-20 23:33 UTC @@ -1859,7 +1859,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ResponsesStreamTranslatorTest#maxTokensCutOffEndsWithResponseIncomplete` (Text, `length`, dann der reine Usage-Chunk; `response.incomplete`, kein `response.completed`, Status und Grund, Nachrichten-Item `incomplete`), `#aStoppedAnswerStaysCompleted`; `ResponsesApiSupportTest#responseCutOffAtTheTokenLimitIsIncomplete`, `#responseWrapsOutputMessageWithOutputTextAndUsage` prüft zusätzlich, dass eine `stop`-Antwort kein `incomplete_details` trägt. Die modellgestützten `OpenAiCompatServerIntegrationTest#responsesNonStreamingRoundTrip`/`#responsesStreamingRoundTrip` erwarteten mit `max_output_tokens` 16 `completed`; sie fragen jetzt eine Antwort ab, die in 4 Token nicht passt („Count from 1 to 100 …“), und erwarten `incomplete`/`response.incomplete` mit dem Grund – deterministisch statt davon abhängig, ob das Modell vor 16 Token aufhört. - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is "incomplete" but: was "completed"“ und „Expected: a string containing "event: response.incomplete" but: was "event: response.output_text.done …"“); umgekehrt scheiterten nach dem Fix die unveränderten Integrationstests mit dem lokalen Winzmodell („Expected: is "completed" but: was "incomplete"“, der Beweis, dass der Fix den echten Server erreicht); mit dem Fix und den angepassten Integrationstests grün (`ResponsesApiSupportTest` 68/68, `ResponsesStreamTranslatorTest` 7/7, `OpenAiCompatServerHttpTest` 69/69, `OpenAiCompatServerIntegrationTest` 13/13 mit dem Winzmodell, zweimal). `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** java-llama.cpp `69a0791` +- **Commit:** java-llama.cpp `ad8b0b1` ## Offene Verdachte ohne Fund-ID diff --git a/BUGHUNT-MEASUREMENTS.md b/BUGHUNT-MEASUREMENTS.md index fb60e8831..051f220b2 100644 --- a/BUGHUNT-MEASUREMENTS.md +++ b/BUGHUNT-MEASUREMENTS.md @@ -15,7 +15,7 @@ Auftrag. Hintergrund in `BUGHUNT-FOUND.md` (die Fund-IDs) und `CHANGELOG.md` ( - **Nur messen und berichten.** Keine Produktionsänderung, keine Fixes. Wegwerf-Proben (Testklassen, kleine `main`-Programme) nach dem Lauf entfernen; committet wird nur diese Datei mit den Ergebnissen. -- Arbeitsstand: Branch `ccr-90fc5f6a-vs4s0f` (mindestens `13ddf17`). Pin: llama.cpp b11639. +- Arbeitsstand: Branch `ccr-90fc5f6a-vs4s0f` (mindestens `573619a`). Pin: llama.cpp b11639. - Dateien aus `.github/shared-files.sha256` nicht verändern. - Jeder Punkt wird beantwortet, auch wenn er nicht ausführbar war (mit Grund). Zu jedem Ergebnis die Umgebung (CPU, Kerne, RAM, GPU, OS, JDK) und die wörtlichen Ausgaben, die das Ergebnis tragen. @@ -82,7 +82,7 @@ backend=cpu kind=library classifier=cpu-windows-x86-64 jllama.version=6.0.0-SNAPSHOT -jllama.commit=c56982b2823210075dd0bcecf0c042051b91d002 +jllama.commit=86d0f8a8746feb4accc998c67c80e44ffd63874f patches=0001,0002,0003,0006,0007,0008,0014,0015,0016,0017,0018,0019,0020,0021 patches.sha256=0176a9b12e147b97027b393ca107f559bc9c997f6076b5a94390957bf10106b8 compiler=MSVC 19.44.35229.0 @@ -90,7 +90,7 @@ toolkit=none signed=no ``` -Arbeitsstand also `c56982b2` auf `ccr-90fc5f6a-vs4s0f` (die verlangte Mindesthöhe `13ddf17` ist +Arbeitsstand also `86d0f8a8` auf `ccr-90fc5f6a-vs4s0f` (die verlangte Mindesthöhe `573619a` ist enthalten), Pin b11639, alle 14 Patches angewandt. **Modelle.** Die elf Zeilen aus `.github/models.csv` lagen bereits vollständig in `models/`; nichts From efe5b75f86104b92b6bba5c4d53e44ff7b703d7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:09:45 +0000 Subject: [PATCH 396/589] Pin the refusal text of a nameless function tool_choice The two tests for a tool_choice of type function without a name checked only that the message contains "tool_choice" and "name", which the unknown-tool refusal ("tool_choice names unknown tool 'g'") satisfies as well, so they could not tell the two refusals apart. Both now assert the text of the nameless case, in the chat mapper and in the Responses translation; both are green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/server/OpenAiRequestMapperTest.java | 3 +-- .../net/ladenthin/llama/server/ResponsesApiSupportTest.java | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 916bc04bf..73371ef5b 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -365,8 +365,7 @@ public void toolChoiceOfTypeFunctionWithoutANameIsRejected() { () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]," + "\"tool_choice\":{\"type\":\"function\"}}")); - assertThat(e.getMessage(), containsString("tool_choice")); - assertThat(e.getMessage(), containsString("name")); + assertThat(e.getMessage(), containsString("tool_choice of type 'function' needs the function's 'name'")); } @Test diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java index 446696c04..5c2edd982 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponsesApiSupportTest.java @@ -462,8 +462,7 @@ public void toolChoiceOfTypeFunctionWithoutANameIsRejected() throws IOException () -> ResponsesApiSupport.toOpenAiChatRequest(read("{\"model\":\"m\",\"input\":\"hi\"," + "\"tools\":[{\"type\":\"function\",\"name\":\"f\"}]," + "\"tool_choice\":{\"type\":\"function\"}}"))); - assertThat(e.getMessage(), containsString("tool_choice")); - assertThat(e.getMessage(), containsString("name")); + assertThat(e.getMessage(), containsString("tool_choice of type 'function' needs the function's 'name'")); } @Test From 5781ef5839cc099f51accf6ec8f7fa163c0775fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:15:42 +0000 Subject: [PATCH 397/589] Read a float top_n like upstream and refuse a negative one (F230) readTopN treated every value that was not a Jackson IntNode as absent, so "top_n": 2.0 kept every result where upstream's nlohmann get() truncates it to 2, and "top_n": -1.5 slipped past the negative check F177 added and reached the native call as "keep all". It now reads any JSON number within the int range and truncates it toward zero, as upstream's static_cast does; a negative value is refused in either spelling. A value that is no number (a string, or a boolean upstream would convert) and a number outside the int range keep all, which the Javadoc states and a test pins. readTopNConvertsAFloatLikeUpstream and negativeFloatTopNIsAnInvalidRequest were red without the change ("Expected: is <2> but: was <-1>", and "Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.") and are green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 ++++++++++ .../llama/server/OaiRerankSupport.java | 16 ++++++++--- .../llama/server/OaiRerankSupportTest.java | 27 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 37c7b73cd..c4351f7c2 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -152,6 +152,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F227](#f227) — niedrig — *gefixt* - [F228](#f228) — niedrig — *gefixt* - [F229](#f229) — niedrig — *gefixt* +- [F230](#f230) — niedrig — *gefixt* ## Funde @@ -1367,6 +1368,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** C++ ohne den Fix rot (`Which is: -1`), mit ihm grün (9/9 der Suite); Java ohne den Fix rot (3 von 16), mit ihm grün (16/16, mit `OpenAiCompatServerHttpTest` 71); SpotBugs grün. Ende zu Ende mit einem Reranker (CI: `OpenAiServerRerankIntegrationTest`) nicht lokal gelaufen. - **Status:** gefixt - **Commit:** java-llama.cpp `68f81b6` +- **Nachtrag (Branch-Review 2026-10-20):** Die Abweisung eines negativen `top_n` griff nur für die ganzzahlige Schreibweise; ein `-1.5` galt als „fehlt“. Seit F230 liest `readTopN` jede Zahl, die Abweisung deckt auch die Gleitkomma-Schreibweise ab (`OaiRerankSupportTest#negativeFloatTopNIsAnInvalidRequest`). ### F178 – java-llama.cpp: Ollama-Routen melden `done_reason` falsch und `/api/generate` keine Tokenzahlen @@ -1861,6 +1863,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ad8b0b1` +### F230 – java-llama.cpp: `/v1/rerank`: ein `top_n` als Gleitkommazahl gilt als „fehlt“, ein negatives (`-1.5`) umgeht die Abweisung +- **Zeit:** 2026-10-21 00:14 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` (`readTopN`) +- **Schwere:** niedrig (still abweichend von upstream; die Abweisung aus F177 griff für die Gleitkomma-Schreibweise nicht) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J6-1). Die Hälfte „Gleitkommazahl = fehlt“ ist älter als der Branch (`return topN.isInt() ? topN.asInt() : -1;`); F177 setzte die Prüfung auf negative Werte hinter dieselbe `isInt()`-Bedingung, daher erreichte ein negativer Gleitkommawert sie nie. +- **Beschreibung:** `readTopN` gab für jeden Nicht-`IntNode` `-1` (= alle) zurück. Jackson liest `2.0` als `DoubleNode`, also behielt `"top_n": 2.0` alle Ergebnisse und `"top_n": -1.5` wurde nicht abgewiesen. Upstream liest `json_value(body, "top_n", (int)documents.size())` (`server-context.cpp:5760`, b11639) über nlohmanns `get()`, das eine Gleitkommazahl per `static_cast` umwandelt: `2.0` und `2.7` behalten dort 2, `-1.0` lässt das `resize` in `format_response_rerank` scheitern. Ein Python-Client mit `top_n = len(docs) / 2` serialisiert `2.0`. +- **Fix:** `readTopN` liest jede JSON-Zahl im `int`-Bereich (`isNumber() && canConvertToInt()`, dann `asInt()`, das wie upstreams Cast zur Null hin abschneidet); ein negativer Wert ist danach in beiden Schreibweisen eine ungültige Anfrage (400). Zwei bewusste Abweichungen stehen in der Javadoc: ein Wert, der keine Zahl ist (Zeichenkette, Boolean – upstream wandelt `true` in 1), und eine Zahl außerhalb des `int`-Bereichs (upstream: der Cast läuft über) behalten alle Ergebnisse. +- **Tests:** `OaiRerankSupportTest#readTopNConvertsAFloatLikeUpstream` (`2.0` und `2.7` → 2), `#negativeFloatTopNIsAnInvalidRequest` (`-1.5`), `#readTopNKeepsAllForANonNumberOrAValueOutsideTheIntRange` (`"2"`, `true`, `4294967296` → -1, pinnt die dokumentierten Abweichungen). +- **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is <2> but: was <-1>“ und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit ihm grün (`OaiRerankSupportTest` 19/19, `OpenAiCompatServerHttpTest` 69/69); `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java index d51bebe10..f4cbd14cd 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java @@ -75,14 +75,22 @@ static String[] readDocuments(JsonNode request) { * Read the optional {@code top_n} cap. {@code 0} keeps no result, as upstream's {@code * format_response_rerank} does ({@code min(top_n, n)} entries). * + *

    A JSON number is read as upstream reads it ({@code get()}, a {@code static_cast}): a float + * is truncated toward zero, so {@code 2.0} and {@code 2.7} keep 2. Two deliberate differences: a + * value that is no number (a string, or a boolean, which upstream would convert to 1 or 0) keeps + * all, and so does a number outside the {@code int} range, which upstream's cast wraps (and the + * request then fails or keeps nothing in the server). + * * @param request the parsed rerank request - * @return the requested cap, or {@code -1} when absent or not an integer - * @throws IllegalArgumentException if {@code top_n} is negative (upstream resizes its result vector - * to {@code min(top_n, n)}, a huge size for a negative value, and fails inside the server) + * @return the requested cap, or {@code -1} when absent, not a number or outside the {@code int} + * range + * @throws IllegalArgumentException if {@code top_n} is negative, integer or float (upstream resizes + * its result vector to {@code min(top_n, n)}, a huge size for a negative value, and fails inside + * the server) */ static int readTopN(JsonNode request) { JsonNode topN = request.path("top_n"); - if (!topN.isInt()) { + if (!topN.isNumber() || !topN.canConvertToInt()) { return -1; } final int value = topN.asInt(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java index 34eb4aa37..be644e6ba 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java @@ -85,6 +85,33 @@ public void negativeTopNIsAnInvalidRequest() throws IOException { assertThat(e.getMessage(), containsString("top_n")); } + @Test + public void readTopNConvertsAFloatLikeUpstream() throws IOException { + // Upstream reads top_n with nlohmann's get(), which static_casts a JSON float: 2.0 and 2.7 + // keep 2 there. Ours treated any float as absent and kept every result. + assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":2.0}")), is(2)); + assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":2.7}")), is(2)); + } + + @Test + public void negativeFloatTopNIsAnInvalidRequest() throws IOException { + // The float spelling of a negative top_n reached the native call as "keep all" before. + JsonNode request = read("{\"top_n\":-1.5}"); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> OaiRerankSupport.readTopN(request)); + assertThat(e.getMessage(), containsString("top_n")); + } + + @Test + public void readTopNKeepsAllForANonNumberOrAValueOutsideTheIntRange() throws IOException { + // Deliberate: a string or boolean is no number (upstream: a type error, i.e. its default = all; + // it would convert true to 1), and a value outside the int range keeps all rather than wrapping + // as upstream's static_cast does. + assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":\"2\"}")), is(-1)); + assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":true}")), is(-1)); + assertThat(OaiRerankSupport.readTopN(read("{\"top_n\":4294967296}")), is(-1)); + } + @Test public void topNZeroReturnsNoResultsLikeUpstream() throws IOException { // format_response_rerank keeps min(top_n, n) entries, so 0 keeps none; ours kept all. From 56ba0d3b3a51c5baa2147f6a74eb18b757c842d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:15:42 +0000 Subject: [PATCH 398/589] Record the commit of F230 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c4351f7c2..b0b1c0b3a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1873,7 +1873,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OaiRerankSupportTest#readTopNConvertsAFloatLikeUpstream` (`2.0` und `2.7` → 2), `#negativeFloatTopNIsAnInvalidRequest` (`-1.5`), `#readTopNKeepsAllForANonNumberOrAValueOutsideTheIntRange` (`"2"`, `true`, `4294967296` → -1, pinnt die dokumentierten Abweichungen). - **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected: is <2> but: was <-1>“ und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit ihm grün (`OaiRerankSupportTest` 19/19, `OpenAiCompatServerHttpTest` 69/69); `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `5781ef5` ## Offene Verdachte ohne Fund-ID From 893ede36660ffe58c162f8886234c488dca7f47c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:19:35 +0000 Subject: [PATCH 399/589] Serve a rerank from texts when documents is null (F231) readDocuments chose "documents" whenever the key was present, and Jackson's has() is true for an explicit null as well, so a request with "documents": null next to "texts" was refused with a 400 although upstream's json_value treats a null as absent and serves it from "texts". hasNonNull() restores that fallback; the answer-shape switch in toResponse keeps has("texts"), because upstream's body.contains("texts") counts a null too. A documents value of another type is still refused rather than silently replaced. readDocumentsFallsBackToTextsWhenDocumentsIsNull was red without the change ("Unexpected exception thrown: IllegalArgumentException: 'documents' (or TEI's 'texts') must be a non-empty array") and is green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++++++++ .../ladenthin/llama/server/OaiRerankSupport.java | 8 +++++--- .../llama/server/OaiRerankSupportTest.java | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b0b1c0b3a..bce75ec03 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -153,6 +153,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F228](#f228) — niedrig — *gefixt* - [F229](#f229) — niedrig — *gefixt* - [F230](#f230) — niedrig — *gefixt* +- [F231](#f231) — niedrig — *gefixt* ## Funde @@ -1875,6 +1876,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `5781ef5` +### F231 – java-llama.cpp: `/v1/rerank`: ein explizites `"documents": null` neben `texts` wird abgewiesen statt aus `texts` bedient +- **Zeit:** 2026-10-21 00:19 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` (`readDocuments`) +- **Schwere:** niedrig (still abweichend von upstream; ein Client muss `null` neben `texts` senden) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J6-4). Seit F111 (`texts` von text-embeddings-inference akzeptiert); der Kommentar behauptete Parität mit upstream. +- **Beschreibung:** `readDocuments` wählte `request.has("documents") ? documents : texts`. Jacksons `has()` ist auch für einen expliziten `null`-Wert wahr, also nahm `{"documents":null,"texts":["a"]}` den `NullNode` und warf „'documents' (or TEI's 'texts') must be a non-empty array“ (400). Upstream liest `json_value(body, "documents", json_value(body, "texts", {}))` (`server-context.cpp:5753`, b11639), und `json_value` behandelt `null` wie „fehlt“ (`server-common.h:43-46`) – die Anfrage wird dort aus `texts` bedient. +- **Fix:** `hasNonNull("documents")`; Kommentar und Javadoc nennen den Rückfall bei `null`. Die Formatwahl in `toResponse` (`request.has("texts")`) bleibt, weil upstreams `body.contains("texts")` ein `null` ebenfalls mitzählt. Ein `documents` falschen Typs bleibt ein 400 (upstream fiele still auf `texts` zurück; das wäre ein stiller Rückfall). +- **Tests:** `OaiRerankSupportTest#readDocumentsFallsBackToTextsWhenDocumentsIsNull`, `#readDocumentsThrowsWhenDocumentsIsNullWithoutTexts`. +- **Rot-Grün-Nachweis:** Ohne den Fix rot („Unexpected exception thrown: java.lang.IllegalArgumentException: 'documents' (or TEI's 'texts') must be a non-empty array“), mit ihm grün (`OaiRerankSupportTest` 21/21, `OpenAiCompatServerHttpTest` 69/69); `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java index f4cbd14cd..5ad2cc01b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java @@ -50,11 +50,13 @@ static String readQuery(JsonNode request) { * * @param request the parsed rerank request * @return the documents, in request order - * @throws IllegalArgumentException if {@code documents} is absent, empty, or holds an unsupported entry + * @throws IllegalArgumentException if {@code documents} (or, when it is absent or {@code null}, TEI's + * {@code texts}) is absent, empty, or holds an unsupported entry */ static String[] readDocuments(JsonNode request) { - // Hugging Face text-embeddings-inference names them "texts"; upstream reads it when "documents" is absent. - JsonNode documents = request.has("documents") ? request.path("documents") : request.path("texts"); + // Hugging Face text-embeddings-inference names them "texts"; upstream reads it when "documents" is + // absent or null (its json_value treats an explicit null as absent). + JsonNode documents = request.hasNonNull("documents") ? request.path("documents") : request.path("texts"); if (!documents.isArray() || documents.size() == 0) { throw new IllegalArgumentException("'documents' (or TEI's 'texts') must be a non-empty array"); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java index be644e6ba..af5b2d3e8 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java @@ -171,6 +171,22 @@ public void readDocumentsAcceptsTheTeiTexts() throws IOException { assertThat(documents[1], is("b")); } + @Test + public void readDocumentsFallsBackToTextsWhenDocumentsIsNull() throws IOException { + // upstream's json_value treats an explicit null as absent and serves the request from "texts"; + // ours selected the null and refused the request. + JsonNode request = read("{\"query\":\"q\",\"documents\":null,\"texts\":[\"a\"]}"); + String[] documents = assertDoesNotThrow(() -> OaiRerankSupport.readDocuments(request)); + assertThat(documents.length, is(1)); + assertThat(documents[0], is("a")); + } + + @Test + public void readDocumentsThrowsWhenDocumentsIsNullWithoutTexts() throws IOException { + JsonNode request = read("{\"query\":\"q\",\"documents\":null}"); + assertThrows(IllegalArgumentException.class, () -> OaiRerankSupport.readDocuments(request)); + } + @Test public void teiRequestGetsTheTeiArrayWithScoreLikeUpstream() throws IOException { // upstream answers a request with "texts" as a bare array of {index, score}, best first. From d028ae02684d4838e1999ab393be7300381ea9cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:19:35 +0000 Subject: [PATCH 400/589] Record the commit of F231 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index bce75ec03..b9ba0dd81 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1886,7 +1886,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OaiRerankSupportTest#readDocumentsFallsBackToTextsWhenDocumentsIsNull`, `#readDocumentsThrowsWhenDocumentsIsNullWithoutTexts`. - **Rot-Grün-Nachweis:** Ohne den Fix rot („Unexpected exception thrown: java.lang.IllegalArgumentException: 'documents' (or TEI's 'texts') must be a non-empty array“), mit ihm grün (`OaiRerankSupportTest` 21/21, `OpenAiCompatServerHttpTest` 69/69); `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `893ede3` ## Offene Verdachte ohne Fund-ID From 5c5a892806128a540091332d2aecc9a8b0b37fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:35:31 +0000 Subject: [PATCH 401/589] Refuse a native rerank body that is not a JSON array (F232) toOaiResponse swallowed a parse failure of the native rerank body and answered 200 with empty results, and toTeiResponse copied that branch when it was added. Two more paths never reached the catch: Jackson reads an empty body as a MissingNode, and a non-array such as "{}" fell through the isArray() guard; both also became an empty answer, indistinguishable from "nothing matched". One helper now reads the body for both methods and throws an IOException quoting it whenever it is not a JSON array. IOException is the type OpenAiBackend.rerank declares for a server error, so the route answers 500 with a logged warning, and no unchecked conversion needs a SpotBugs suppression. The native producer always returns a JSON array, so this is a contract fix rather than a user-visible one. The two tests (each over "not json", "" and "{}") were red without the change ("Expected java.io.IOException to be thrown, but nothing was thrown."); a variant that threw only from the catch stayed red for the empty body. They are green with the change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++ .../llama/server/LlamaModelBackend.java | 2 +- .../llama/server/OaiRerankSupport.java | 87 ++++++++++++------- .../llama/server/OaiRerankSupportTest.java | 23 ++++- 4 files changed, 87 insertions(+), 38 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index b9ba0dd81..8d5f138f0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -154,6 +154,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F229](#f229) — niedrig — *gefixt* - [F230](#f230) — niedrig — *gefixt* - [F231](#f231) — niedrig — *gefixt* +- [F232](#f232) — niedrig — *gefixt* ## Funde @@ -1888,6 +1889,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `893ede3` +### F232 – java-llama.cpp: `/v1/rerank`: ein nativer Antwortkörper, der kein JSON-Array ist, wird still zu einer leeren Ergebnisliste (200) +- **Zeit:** 2026-10-21 00:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` (`toTeiResponse`, `toOaiResponse`, neu `readNativeArray`), `LlamaModelBackend.java` (`rerank`) +- **Schwere:** niedrig (in der Praxis nicht erreichbar: der native Körper ist immer `safe_json_to_str(rerank_results_to_json(...))`, ein Fehler im nativen Aufruf wird vorher zur `LlamaException`; ein Vertrags- und Konsistenzfix, kein für Nutzer sichtbarer Fehler) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J6-5). Der Rückfall in `toOaiResponse` (samt `toOaiResponseOnMalformedNativeBodyYieldsEmptyResults`, der ihn festschrieb) ist älter als der Branch; F111 hat ihn in das neue `toTeiResponse` kopiert, ohne Test. +- **Beschreibung:** Beide Methoden fingen die `IOException` des Parsers ab und antworteten mit leeren Ergebnissen, ohne Log – für den Client ununterscheidbar von „nichts passt“. Zwei weitere stille Wege gingen nie durch den `catch`: Jackson liest einen leeren Körper als `MissingNode`, und ein Nicht-Array (`{}`) fiel durch `if (arr.isArray())`; beide ergaben ebenso `[]`. Der LangChain4j-Adapter (`LangChain4jMapping.parseRerankScores`) wirft im selben Fall. +- **Fix:** Ein Helfer `readNativeArray` für beide Methoden: ein Parserfehler, ein leerer Körper und ein Nicht-Array werfen eine `IOException` mit einem Ausschnitt des Körpers (höchstens 80 Zeichen) und der Ursache. `IOException` statt der im Fund vorgeschlagenen `IllegalStateException`, weil `OpenAiBackend.rerank` genau diesen Typ für einen Serverfehler deklariert (`LlamaModelBackend.rerank` deklariert ihn jetzt) und SpotBugs die Umwandlung in eine ungeprüfte Ausnahme ohne Unterdrückung nicht hinnimmt (`EXS_EXCEPTION_SOFTENING_NO_CONSTRAINTS`). `completeNonStreaming` antwortet darauf mit 500 `server_error` und einer WARN-Zeile. `json.RerankResponseParser.parse(String)` (dokumentierter Rückfall der typisierten API, PIT-gesichert) bleibt unverändert. +- **Tests:** `OaiRerankSupportTest#toOaiResponseRefusesANativeBodyThatIsNotAJsonArray` (ersetzt `#toOaiResponseOnMalformedNativeBodyYieldsEmptyResults`), `#toTeiResponseRefusesANativeBodyThatIsNotAJsonArray`; je `"not json"`, `""` und `"{}"`, und die Meldung zitiert den Körper. +- **Rot-Grün-Nachweis:** Ohne den Fix rot („not json ==> Expected java.io.IOException to be thrown, but nothing was thrown.“, in beiden Tests); eine Zwischenfassung, die nur im `catch` warf, blieb für den leeren Körper rot („Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.“) – die zusätzlichen Fälle pinnen also den ganzen Fix. Mit dem Fix grün (`OaiRerankSupportTest` 22/22, `OpenAiCompatServerHttpTest` 69/69, `LlamaArchitectureTest` 13/13); `spotless:apply` und `spotbugs:check` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java index adaf1581e..37d8944c3 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java @@ -127,7 +127,7 @@ public String infill(JsonNode request) { } @Override - public String rerank(JsonNode request) { + public String rerank(JsonNode request) throws IOException { final String query = OaiRerankSupport.readQuery(request); final String[] documents = OaiRerankSupport.readDocuments(request); OaiRerankSupport.readTopN(request); // a negative top_n is refused before anything is evaluated diff --git a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java index 5ad2cc01b..8c60fddaf 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import org.jspecify.annotations.Nullable; /** * Pure request-parsing and response-shaping helpers for the {@code POST /v1/rerank} route. @@ -27,6 +28,9 @@ final class OaiRerankSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** How much of an unexpected native body a refusal quotes. */ + private static final int NATIVE_BODY_EXCERPT = 80; + private OaiRerankSupport() {} /** @@ -107,6 +111,37 @@ private static int limit(int topN, int size) { return topN < 0 ? size : Math.min(topN, size); } + /** + * Parse the native rerank body, which must be a JSON array. Anything else is a broken result, not + * an empty one: answering it with no results would read as "nothing matched". An empty body is + * checked as well, since Jackson reads it as a missing node rather than failing. The failure is an + * {@link IOException}, the type {@link OpenAiBackend#rerank} declares for a server error. + * + * @param nativeArrayJson the native {@code [{document,index,score}]} array as JSON + * @return the parsed array + * @throws IOException if the body is not a JSON array + */ + private static JsonNode readNativeArray(String nativeArrayJson) throws IOException { + final JsonNode arr; + try { + arr = OBJECT_MAPPER.readTree(nativeArrayJson); + } catch (IOException e) { + throw notANativeArray(nativeArrayJson, e); + } + if (!arr.isArray()) { + throw notANativeArray(nativeArrayJson, null); + } + return arr; + } + + private static IOException notANativeArray(String nativeArrayJson, @Nullable IOException cause) { + final String excerpt = nativeArrayJson.length() <= NATIVE_BODY_EXCERPT + ? nativeArrayJson + : nativeArrayJson.substring(0, NATIVE_BODY_EXCERPT) + "..."; + return new IOException( + "Failed to parse the native rerank response, expected a JSON array but got: '" + excerpt + "'", cause); + } + /** * Shape the native rerank array as the response the request's format expects. * @@ -114,8 +149,9 @@ private static int limit(int topN, int size) { * @param nativeArrayJson the native {@code [{document,index,score}]} array as JSON * @param model the model id to echo (omitted when empty) * @return the rerank response JSON + * @throws IOException if {@code nativeArrayJson} is not a JSON array */ - static String toResponse(JsonNode request, String nativeArrayJson, String model) { + static String toResponse(JsonNode request, String nativeArrayJson, String model) throws IOException { if (request.has("texts")) { return toTeiResponse( nativeArrayJson, @@ -135,25 +171,18 @@ static String toResponse(JsonNode request, String nativeArrayJson, String model) * keep all * @param returnText whether to include each document's text * @return the TEI rerank response JSON + * @throws IOException if {@code nativeArrayJson} is not a JSON array */ - static String toTeiResponse(String nativeArrayJson, int topN, boolean returnText) { + static String toTeiResponse(String nativeArrayJson, int topN, boolean returnText) throws IOException { final List entries = new ArrayList<>(); - try { - JsonNode arr = OBJECT_MAPPER.readTree(nativeArrayJson); - if (arr.isArray()) { - for (JsonNode entry : arr) { - ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.put("index", entry.path("index").asInt()); - result.put("score", entry.path("score").asDouble()); - if (returnText) { - result.put("text", entry.path("document").asText("")); - } - entries.add(result); - } + for (JsonNode entry : readNativeArray(nativeArrayJson)) { + ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("index", entry.path("index").asInt()); + result.put("score", entry.path("score").asDouble()); + if (returnText) { + result.put("text", entry.path("document").asText("")); } - } catch (IOException e) { - // The native call already succeeded; an unexpected body just yields an empty array. - entries.clear(); + entries.add(result); } entries.sort((a, b) -> Double.compare(b.path("score").asDouble(), a.path("score").asDouble())); @@ -174,25 +203,17 @@ static String toTeiResponse(String nativeArrayJson, int topN, boolean returnText * keep all * @return the rerank response JSON with sorted {@code results}, a {@code data} alias and the * {@code usage} of the evaluated tokens + * @throws IOException if {@code nativeArrayJson} is not a JSON array */ - static String toOaiResponse(String nativeArrayJson, String model, int topN) { + static String toOaiResponse(String nativeArrayJson, String model, int topN) throws IOException { final List entries = new ArrayList<>(); long promptTokens = 0; - try { - JsonNode arr = OBJECT_MAPPER.readTree(nativeArrayJson); - if (arr.isArray()) { - for (JsonNode entry : arr) { - ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.put("index", entry.path("index").asInt()); - result.put("relevance_score", entry.path("score").asDouble()); - entries.add(result); - promptTokens += entry.path("tokens_evaluated").asLong(0L); - } - } - } catch (IOException e) { - // The native call already succeeded; an unexpected body just yields empty results. - entries.clear(); - promptTokens = 0; + for (JsonNode entry : readNativeArray(nativeArrayJson)) { + ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("index", entry.path("index").asInt()); + result.put("relevance_score", entry.path("score").asDouble()); + entries.add(result); + promptTokens += entry.path("tokens_evaluated").asLong(0L); } entries.sort((a, b) -> Double.compare( b.path("relevance_score").asDouble(), a.path("relevance_score").asDouble())); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java index af5b2d3e8..096719b44 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OaiRerankSupportTest.java @@ -209,9 +209,24 @@ public void teiReturnTextAddsTheDocumentText() throws IOException { } @Test - public void toOaiResponseOnMalformedNativeBodyYieldsEmptyResults() throws IOException { - JsonNode out = read(OaiRerankSupport.toOaiResponse("not json", "m", -1)); - assertThat(out.path("results").size(), is(0)); - assertThat(out.path("data").size(), is(0)); + public void toOaiResponseRefusesANativeBodyThatIsNotAJsonArray() { + // An unparsable body used to become 200 with empty results, indistinguishable from "nothing + // matched". An empty body is no exception to Jackson (it reads a MissingNode) and an object is + // valid JSON, so both are checked as well as the parse failure. + for (String body : new String[] {"not json", "", "{}"}) { + IOException e = assertThrows(IOException.class, () -> OaiRerankSupport.toOaiResponse(body, "m", -1), body); + assertThat(e.getMessage(), containsString("native rerank response")); + assertThat(e.getMessage(), containsString("'" + body + "'")); + } + } + + @Test + public void toTeiResponseRefusesANativeBodyThatIsNotAJsonArray() { + for (String body : new String[] {"not json", "", "{}"}) { + IOException e = + assertThrows(IOException.class, () -> OaiRerankSupport.toTeiResponse(body, -1, false), body); + assertThat(e.getMessage(), containsString("native rerank response")); + assertThat(e.getMessage(), containsString("'" + body + "'")); + } } } From cef566e50983f7e2e2194b591f214bee7a409584 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:35:31 +0000 Subject: [PATCH 402/589] Record the commit of F232 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 8d5f138f0..9685ccdf3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1899,7 +1899,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OaiRerankSupportTest#toOaiResponseRefusesANativeBodyThatIsNotAJsonArray` (ersetzt `#toOaiResponseOnMalformedNativeBodyYieldsEmptyResults`), `#toTeiResponseRefusesANativeBodyThatIsNotAJsonArray`; je `"not json"`, `""` und `"{}"`, und die Meldung zitiert den Körper. - **Rot-Grün-Nachweis:** Ohne den Fix rot („not json ==> Expected java.io.IOException to be thrown, but nothing was thrown.“, in beiden Tests); eine Zwischenfassung, die nur im `catch` warf, blieb für den leeren Körper rot („Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.“) – die zusätzlichen Fälle pinnen also den ganzen Fix. Mit dem Fix grün (`OaiRerankSupportTest` 22/22, `OpenAiCompatServerHttpTest` 69/69, `LlamaArchitectureTest` 13/13); `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `5c5a892` ## Offene Verdachte ohne Fund-ID From 27cd696d51a2138df38c9e73d8810962e085b440 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:40:00 +0000 Subject: [PATCH 403/589] Render an IPv6 router host bracketed in RouterClient.toString F07 bracketed an IPv6 literal in the request URL, but toString() kept concatenating the raw host, so new RouterClient("::1", 8080) logged the invalid authority "RouterClient(http://::1:8080)" while it requested http://[::1]:8080, contrary to the class Javadoc, which promises that the client renders as its target URL. toString() now goes through the same urlHost() as the request. toStringBracketsAnIpv6HostAsTheRequestUrlDoes was red without the change ("Expected: is "RouterClient(http://[::1]:8080)" but: was "RouterClient(http://::1:8080)"") and is green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + .../java/net/ladenthin/llama/server/RouterClient.java | 2 +- .../java/net/ladenthin/llama/server/RouterClientTest.java | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9685ccdf3..0a35cf2c9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -201,6 +201,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `RouterClientTest#ipv6LiteralHostIsBracketedInTheRequestUrl` (vorher rot: `MalformedURLException`, danach andere `IOException`, weil auf Port 1 nichts lauscht). Der Sandbox fehlt IPv6, ein echter Server auf `::1` war hier nicht testbar. - **Status:** gefixt - **Commit:** java-llama.cpp `5b98dc7` +- **Nachtrag (Branch-Review 2026-10-20, Fund J7-3):** `toString()` zeigt den Host jetzt wie die Anfrage-URL (`urlHost`): `RouterClient("::1", 8080)` wird zu `RouterClient(http://[::1]:8080)` statt der ungültigen Autorität `http://::1:8080`; die Klassen-Javadoc („renders as its target URL“) stimmt damit. Test `RouterClientTest#toStringBracketsAnIpv6HostAsTheRequestUrlDoes` (auch `"[::1]"` bleibt einfach geklammert); ohne die Änderung rot („Expected: is "RouterClient(http://[::1]:8080)" but: was "RouterClient(http://::1:8080)"“), mit ihr grün (`RouterClientTest` 22/22). ### F09 – java-llama.cpp: Loader löscht fremde `llama*`/`ggml*`-Einträge im Temp-Verzeichnis, Verzeichnisse rekursiv diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index 92476b4f0..02a6154de 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -296,7 +296,7 @@ private static String urlHost(String host) { @Override public String toString() { - return "RouterClient(http://" + host + ":" + port + ")"; + return "RouterClient(http://" + urlHost(host) + ":" + port + ")"; } private static String readFully(@Nullable InputStream stream) throws IOException { diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java index 1bc06178f..f4eabfba0 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java @@ -338,4 +338,12 @@ public void toStringNeverLeaksTheApiKey() { new RouterClient("127.0.0.1", 8080, "super-secret").toString(), is("RouterClient(http://127.0.0.1:8080)")); } + + @Test + public void toStringBracketsAnIpv6HostAsTheRequestUrlDoes() { + // The rendered target URL used the raw host, so "::1" printed the invalid authority + // "http://::1:8080" while the request went to http://[::1]:8080. + assertThat(new RouterClient("::1", 8080).toString(), is("RouterClient(http://[::1]:8080)")); + assertThat(new RouterClient("[::1]", 8080).toString(), is("RouterClient(http://[::1]:8080)")); + } } From 0a49c54363ae7dbe2e8a23129e283f57c2c687b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:10:04 +0000 Subject: [PATCH 404/589] Refuse a tool schema that is not one JSON value in appendTool (F233) buildToolsJson sent a parameter schema that did not parse, carried trailing content, or was empty or blank as a JSON string. llama.cpp takes that string as the schema and refuses it while building the tool-call grammar ("schema must be an object"), which reached the caller as an InvalidRequestException naming neither the tool nor the Java cause; a template rendered without a grammar used it silently. JsonParameters already rejects the same input class at write time. appendTool now parses the schema with the strict reader and throws an IllegalArgumentException naming the tool, with an excerpt of at most 80 characters as JsonParameters quotes; buildToolsJson only embeds the parsed node, and both string fallbacks are gone. A tool without parameters declares {"type":"object","properties":{}}. The two tests that pinned the string fallback are replaced, and the positive test asserts the exact output instead of a substring. appendToolRejectsASchemaThatIsNotExactlyOneJsonValue was red without the change ("Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.") and is green with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 16 +++++ llama/spotbugs-exclude.xml | 14 +++++ .../llama/parameters/ChatRequest.java | 59 +++++++++++++++---- .../llama/parameters/ChatRequestTest.java | 43 ++++++++------ 4 files changed, 101 insertions(+), 31 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0a35cf2c9..539386a45 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -155,6 +155,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F230](#f230) — niedrig — *gefixt* - [F231](#f231) — niedrig — *gefixt* - [F232](#f232) — niedrig — *gefixt* +- [F233](#f233) — niedrig — *gefixt* ## Funde @@ -179,6 +180,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Das Schema jeder `ToolDefinition` wurde mit einfachem `readTree` gelesen; das liest den ersten JSON-Wert und ignoriert den Rest. `{"type":"object"} {"required":["x"]}` wurde so ohne Fehler als `{"type":"object"}` gesendet – ein anderes Schema als vom Aufrufer geschrieben. `JsonParameters` nutzt aus genau diesem Grund `FAIL_ON_TRAILING_TOKENS` (siehe CLAUDE.md), `ChatRequest` umging das. Jetzt gleicher strikter Reader → solche Strings nehmen den vorhandenen Fallback für ungültiges JSON. - **Weitere Beobachtung (nicht geändert):** Ungültiges JSON wird absichtlich als **String** unter `parameters` weitergereicht, `""` und Leerzeichen werden zu `"parameters":null`. Ob eine `IllegalArgumentException` (wie in `JsonParameters`) besser wäre, ist eine API-Entscheidung. - **Reproduktion:** `ChatRequestTest$…#buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent` (vorher rot, danach grün), plus Positivtest `buildToolsJsonEmbedsAValidSchemaAsAnObject`; `ChatRequestTest` 24/24 grün. `spotbugs:check` nicht gelaufen. +- **Nachtrag (Branch-Review 2026-10-20):** Die „API-Entscheidung“ ist mit F233 gefallen: `appendTool` weist ein Schema, das nicht genau ein JSON-Wert ist, mit `IllegalArgumentException` samt Werkzeugnamen ab; der String-Rückfall ist weg, der Test oben durch `appendToolRejectsASchemaThatIsNotExactlyOneJsonValue` ersetzt. - **Status:** gefixt - **Commit:** java-llama.cpp `d6a9be5` @@ -420,6 +422,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: a string containing "\"parameters\":\"\"" but: was "[{"type":"function","function":{"name":"f","description":"d","parameters":null}}]"“), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `ChatRequestTest` 25/25, `ToolCallingAgentTest` 6/6, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `8a13aa9` +- **Nachtrag (Branch-Review 2026-10-20):** Der String-Rückfall war keine Verbesserung: upstream übernimmt `""` als Schema und weist es beim Grammatikbau mit „schema must be an object“ ab, ohne den Werkzeugnamen. Ersetzt durch F233 (Abweisung in `appendTool`); der Test oben ist entfallen. ### F56 – java-llama.cpp: nicht darstellbarer Offline-Modellpfad ergibt `InvalidPathException` statt `ModelUnavailableException` @@ -1902,6 +1905,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `5c5a892` +### F233 – java-llama.cpp: ein leeres oder unparsbares Tool-Schema geht als String an llama.cpp statt in Java abgewiesen zu werden +- **Zeit:** 2026-10-21 01:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` (`appendTool`, `buildToolsJson`, neu `parseSchemaOrThrow`) +- **Schwere:** niedrig (die Anfrage scheiterte laut, aber spät und ohne Werkzeugnamen; nur ohne Grammatik still) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J8-2). Der String-Rückfall für unparsbaren Text ist älter als der Branch; F05 schickte ein Schema mit nachfolgendem Text in denselben Rückfall, F55 ein leeres oder blankes Schema (vorher `"parameters": null`, das upstream als „keine Parameter“ las). +- **Beschreibung:** `buildToolsJson` schrieb ein Schema, das kein genau ein JSON-Wert war, als JSON-String unter `parameters`. Upstream (b11639) übernimmt den String als Schema (`common_chat_tools_parse_oaicompat`, `common/chat.cpp:580`; `common_chat_tool_parameters` ersetzt nur `null` und ein leeres Objekt, Zeile 546-554), und jeder Parser, der eine Tool-Grammatik baut, scheitert in `build_node` mit „schema must be an object“ (`common/json-schema.cpp:228`); `parse_oai_chat_params` macht daraus eine `InvalidRequestException`, deren Text weder das Werkzeug noch die Java-Ursache nennt. Der `force_pure_content`-Pfad (`chat.cpp:1375-1388`) rendert `"parameters":""` ohne Fehler in den Prompt. `JsonParameters.parseOrThrow` weist dieselbe Eingabeklasse mit einer `IllegalArgumentException` ab, der Branch behandelte denselben Fehler also zweifach. +- **Fix:** `appendTool` liest das Schema mit dem strikten Reader (`FAIL_ON_TRAILING_TOKENS`) und wirft für einen Parserfehler und für `MissingNode` (leer, blank) `IllegalArgumentException("tool '': parameters must be exactly one well-formed JSON value, but was: ")`, Ausschnitt höchstens 80 Zeichen wie in `JsonParameters`, Ursache verkettet. `buildToolsJson` setzt nur noch den geparsten Knoten; beide String-Rückfälle sind weg. Ein Werkzeug ohne Parameter deklariert `{"type":"object","properties":{}}` (so wie `LangChain4jMapping.EMPTY_PARAMETERS_SCHEMA`), die Javadoc von `appendTool` sagt das. Nicht übernommen: `null` oder ein leeres Objekt für `""` einzusetzen (der zweite Vorschlag des Funds) wäre ein stiller Rückfall. SpotBugs meldet die Umwandlung der `IOException` (`EXS_EXCEPTION_SOFTENING_NO_CONSTRAINTS`); unterdrückt mit Begründung neben der gleichartigen von `JsonParameters.parseOrThrow`. +- **Warum `chat_template_kwargs` anders bleibt:** `ParameterJsonSerializer.buildRawValueObject` (F203) schreibt einen Wert, der kein JSON ist, weiter als String-Literal. Das ist dort dokumentiert und ein gültiger Wert: eine Template-Variable darf eine Zeichenkette sein (etwa `reasoning_effort`), upstream reicht sie an das Template durch. Ein Tool-Schema muss dagegen ein Objekt sein, und einen String weist upstream ab; derselbe Rückfall erzeugt hier also nie eine gültige Anfrage. +- **Tests:** `ChatRequestTest$JsonHelpers#appendToolRejectsASchemaThatIsNotExactlyOneJsonValue` (`""`, `" "`, `"not json{{{"`, `{"type":"object"} {"required":["x"]}`, prüft die ganze Meldung mit dem Werkzeugnamen), `#appendToolBoundsTheRejectedSchemaInTheMessage` (300 Zeichen → 80 plus `...`); `#buildToolsJsonEmbedsAValidSchemaAsAnObject` prüft jetzt die genaue Ausgabe statt eines `containsString`. Ersetzt `#buildToolsJsonKeepsAnEmptySchemaAsTheStringFallbackInsteadOfNull` (F55) und den schwachen `#buildToolsJsonDoesNotSilentlyTruncateASchemaWithTrailingContent` (F05, prüfte nur `not(containsString(...))`). +- **Rot-Grün-Nachweis:** Ohne den Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, in allen vier Fällen und im Längentest), mit ihm grün (`ChatRequestTest` 28/28, `ChatResponseTest` 8/8, `LlamaArchitectureTest` 13/13, `LangChain4jMappingTest` 23/23); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml index 5f6074e80..8995642e3 100644 --- a/llama/spotbugs-exclude.xml +++ b/llama/spotbugs-exclude.xml @@ -137,6 +137,20 @@ SPDX-License-Identifier: MIT + + + + + + + @@ -441,8 +442,9 @@ SPDX-License-Identifier: MIT diff --git a/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java b/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java index 3cd7b3da9..be21518be 100644 --- a/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java +++ b/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java @@ -42,9 +42,10 @@ private BatchDispatch() {} * Submits every request on the calling thread, then receives the results in input order. * *

    The native scheduler runs as many of the submitted tasks at once as it has slots. When a - * submission fails, the tasks already submitted are released before the failure propagates, so no - * slot keeps working for a result nobody will read. A failed receive does not stop the others from - * being received; the first failure propagates after all of them. + * submission fails, an {@link Error} included, the tasks already submitted are released before the + * failure propagates, so no slot keeps working for a result nobody will read. A failed receive, an + * {@link Error} included, does not stop the others from being received; the first failure + * propagates after all of them. * * @param requests the requests in order * @param submit submits one request and returns its task id @@ -61,31 +62,34 @@ static List submitAllThenReceive( for (R request : requests) { taskIds.add(submit.applyAsInt(request)); } - } catch (RuntimeException e) { + } catch (Throwable t) { + // An Error included (an OutOfMemoryError while a prompt is rendered): it is not handled, + // only the tasks already submitted are released before it propagates unchanged. The try + // block throws nothing checked, so the precise rethrow needs no throws clause. for (int taskId : taskIds) { try { release.accept(taskId); - } catch (RuntimeException suppressed) { - e.addSuppressed(suppressed); + } catch (Throwable suppressed) { + t.addSuppressed(suppressed); } } - throw e; + throw t; } final List out = new ArrayList(taskIds.size()); - RuntimeException failure = null; + Throwable failure = null; for (int taskId : taskIds) { try { out.add(receive.apply(taskId)); - } catch (RuntimeException e) { + } catch (Throwable e) { // Keep receiving the rest so no sibling task is left unread (a native reader leak) - // before propagating the first failure. + // before propagating the first failure, an Error included. if (failure == null) { failure = e; } } } if (failure != null) { - throw failure; + throw asRuntimeException(failure); } return out; } @@ -158,6 +162,11 @@ static List runEachOnItsOwnThread(Collection requests, int maxThrea } } + /** + * Returns an unchecked failure as is and throws an {@link Error} as is, so the caller can write + * {@code throw asRuntimeException(failure)}; anything else (a checked cause, which a + * {@link Function} or an {@link IntFunction} cannot throw) is wrapped. + */ private static RuntimeException asRuntimeException(Throwable cause) { if (cause instanceof RuntimeException) { return (RuntimeException) cause; diff --git a/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java b/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java index 6aacab9d9..c897133c7 100644 --- a/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java +++ b/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java @@ -89,6 +89,84 @@ public void submitAllThenReceive_failedSubmission_releasesTheTasksAlreadySubmitt assertThat(released, contains(10, 20)); } + @Test + public void submitAllThenReceive_failedSubmissionWithAnError_releasesTheTasksAlreadySubmitted() { + // Only a RuntimeException used to release them: an Error (an OutOfMemoryError while a prompt + // is rendered to JSON) left the tasks submitted before it running with nobody reading them. + List released = new ArrayList<>(); + AssertionError failure = new AssertionError("native failure"); + + AssertionError thrown = assertThrows( + AssertionError.class, + () -> BatchDispatch.submitAllThenReceive( + Arrays.asList(1, 2, 3), + request -> { + if (request == 3) { + throw failure; + } + return request * 10; + }, + taskId -> "", + released::add)); + + assertThat(thrown, is(failure)); + assertThat(released, contains(10, 20)); + } + + @Test + public void submitAllThenReceive_aReleaseThatThrowsAnError_doesNotStopTheOtherReleases() { + List released = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("third submission refused"); + AssertionError releaseFailure = new AssertionError("release failed"); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> BatchDispatch.submitAllThenReceive( + Arrays.asList(1, 2, 3), + request -> { + if (request == 3) { + throw failure; + } + return request * 10; + }, + taskId -> "", + taskId -> { + released.add(taskId); + if (taskId == 10) { + throw releaseFailure; + } + })); + + assertThat(thrown, is(failure)); + assertThat(released, contains(10, 20)); + assertThat(thrown.getSuppressed(), is(new Throwable[] {releaseFailure})); + } + + @Test + public void submitAllThenReceive_anErrorFromAReceive_stillReceivesTheOthersThenThrowsIt() { + // An Error from one receive (an OutOfMemoryError while a large result is parsed) used to skip + // every later receive, so their native readers were never drained. + List received = new ArrayList<>(); + AssertionError failure = new AssertionError("parse failed"); + + AssertionError thrown = assertThrows( + AssertionError.class, + () -> BatchDispatch.submitAllThenReceive( + Arrays.asList(1, 2, 3), + request -> request, + taskId -> { + received.add(taskId); + if (taskId == 1) { + throw failure; + } + return ""; + }, + taskId -> {})); + + assertThat(thrown, is(failure)); + assertThat(received, contains(1, 2, 3)); + } + @Test public void submitAllThenReceive_failedReceive_stillReceivesTheOthersThenThrowsTheFirstFailure() { List received = new ArrayList<>(); From 2e264276434debc94c1177d57f92865552e4560e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:28:28 +0000 Subject: [PATCH 410/589] Record the commit of F235 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d9fd5e586..188b0201d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1943,7 +1943,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `BatchDispatchTest#submitAllThenReceive_failedSubmissionWithAnError_releasesTheTasksAlreadySubmitted` (die dritte Einreichung wirft einen `AssertionError`; dieselbe Instanz kommt an, 10 und 20 sind freigegeben), `#submitAllThenReceive_aReleaseThatThrowsAnError_doesNotStopTheOtherReleases` (die Freigabe von 10 wirft einen `AssertionError`; 20 wird trotzdem freigegeben, der `Error` hängt unterdrückt an der Einreichfehlermeldung), `#submitAllThenReceive_anErrorFromAReceive_stillReceivesTheOthersThenThrowsIt` (der Empfang von 1 wirft einen `AssertionError`; 1, 2, 3 werden empfangen, dieselbe Instanz kommt an). - **Rot-Grün-Nachweis:** Ohne den Fix alle drei rot („Expected: iterable containing [<10>, <20>] but: no item was <10>“, „Unexpected exception type thrown, expected: but was: “, „Expected: iterable containing [<1>, <2>, <3>] but: no item was <2>“), mit ihm grün (`BatchDispatchTest` 14/14); `spotless:apply` und `spotbugs:check` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `edad4ca` ## Offene Verdachte ohne Fund-ID From 4b410d153c225d6c1c2bf01d95c2859d60bf0c1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:35:47 +0000 Subject: [PATCH 411/589] Attach later batch failures as suppressed instead of dropping them (F236) Both BatchDispatch loops kept the first failure only: when request 1 of a batch failed as invalid and request 3 on the context size, the caller saw the first and nothing else. The behaviour predates the branch (the old completeBatch had the same loop) and moved into BatchDispatch with F167. The release path of the same class already used addSuppressed, but without an identity check, so a release throwing the submission's own exception instance made Throwable.addSuppressed throw "Self-suppression not permitted", which escaped the loop and skipped the other releases. Later failures are now attached to the first in both loops (the unwrapped cause in runEachOnItsOwnThread), all three sites go through suppress(), which skips the same instance, and the Javadoc of both methods and of completeBatch/chatBatch says so. Without the change the suppressed assertions were red ("Expected: is <1> but: was <0>", "but: was []") and the release case threw an IllegalArgumentException; the two loop identity tests were shown red against an unguarded suppress(). All are green with the change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++ .../net/ladenthin/llama/BatchDispatch.java | 32 +++++-- .../java/net/ladenthin/llama/LlamaModel.java | 7 +- .../ladenthin/llama/BatchDispatchTest.java | 94 +++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 188b0201d..078f8f551 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -158,6 +158,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F233](#f233) — niedrig — *gefixt* - [F234](#f234) — niedrig — *gefixt* - [F235](#f235) — niedrig — *gefixt* +- [F236](#f236) — niedrig — *gefixt* ## Funde @@ -1945,6 +1946,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `edad4ca` +### F236 – java-llama.cpp: die Batch-Methoden verwerfen jeden Fehler nach dem ersten ohne Spur +- **Zeit:** 2026-10-21 01:35 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java` (`submitAllThenReceive`, `runEachOnItsOwnThread`, neu `suppress`), `LlamaModel.java` (Javadoc von `completeBatch`, `chatBatch`) +- **Schwere:** niedrig (der Aufrufer sah von mehreren fehlgeschlagenen Anfragen eines Batches nur die erste) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J9-3). Das Verhalten ist älter als der Branch: `LlamaModel.completeBatch` hielt schon vor F167 nur den ersten `join()`-Fehler fest, F167 hat das unverändert nach `BatchDispatch` übernommen. Der Test `submitAllThenReceive_failedReceive_stillReceivesTheOthersThenThrowsTheFirstFailure` ließ die zweite Anfrage `"second"` werfen und prüfte nie, ob davon etwas ankommt. +- **Beschreibung:** Die Empfangsschleife von `submitAllThenReceive` und die Warteschleife von `runEachOnItsOwnThread` schrieben `if (failure == null) failure = e;` und verwarfen jeden späteren Fehler; scheiterte in einem Batch Anfrage 1 als ungültig und Anfrage 3 an der Kontextgröße, sah der Aufrufer nur die erste. Der Freigabepfad derselben Klasse hängte spätere Fehler dagegen mit `addSuppressed` an, ohne Identitätsprüfung: wirft eine Freigabe dieselbe Instanz wie die Einreichung (eine geteilte oder zwischengespeicherte Ausnahme), lehnt `Throwable.addSuppressed` sie mit `IllegalArgumentException("Self-suppression not permitted")` ab; die entkam der Schleife, die übrigen Aufgaben wurden nicht freigegeben, und der Aufrufer sah die IAE statt des Fehlers. +- **Fix:** Spätere Fehler hängen als unterdrückt am ersten, in beiden Schleifen (bei `runEachOnItsOwnThread` die ausgepackte Ursache der `ExecutionException`). Alle drei Stellen gehen über `suppress(failure, later)`, das dieselbe Instanz überspringt (Identität, weil `addSuppressed` genau sie ablehnt; `@SuppressWarnings("ReferenceEquality")` mit Begründung). Die Javadoc beider Methoden und von `completeBatch`/`chatBatch` nennt das. +- **Tests:** `BatchDispatchTest#submitAllThenReceive_failedReceive_stillReceivesTheOthersThenThrowsTheFirstFailure` (erweitert: `"second"` hängt unterdrückt am ersten), `#runEachOnItsOwnThread_laterFailuresAreSuppressedOnTheFirst` (der Fehler von Anfrage 1 kommt an, der von Anfrage 2 hängt daran), `#submitAllThenReceive_aReleaseThatThrowsTheSubmissionFailure_isNotSuppressedOnItself` (beide Freigaben laufen, dieselbe Instanz ohne unterdrückte kommt an), `#submitAllThenReceive_theSameFailureForTwoReceives_isNotSuppressedOnItself` und `#runEachOnItsOwnThread_theSameFailureForTwoRequests_isNotSuppressedOnItself` (dieselbe Instanz zweimal: sie kommt ohne unterdrückte an, alle Aufgaben werden empfangen). +- **Rot-Grün-Nachweis:** Ohne den Fix drei rot („Expected: is <1> but: was <0>“ für die Länge von `getSuppressed()`, „Expected: is [] but: was []“, „Unexpected exception type thrown, expected: but was: “ für die Freigabe); die beiden Identitätstests der Schleifen sind gegen den alten Code grün (dort gab es kein `addSuppressed`) und wurden gegen ein `suppress` ohne Identitätsprüfung rot gezeigt (dieselbe IAE-Meldung). Mit dem Fix grün (`BatchDispatchTest` 18/18); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java b/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java index be21518be..53527b75c 100644 --- a/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java +++ b/llama/src/main/java/net/ladenthin/llama/BatchDispatch.java @@ -45,7 +45,7 @@ private BatchDispatch() {} * submission fails, an {@link Error} included, the tasks already submitted are released before the * failure propagates, so no slot keeps working for a result nobody will read. A failed receive, an * {@link Error} included, does not stop the others from being received; the first failure - * propagates after all of them. + * propagates after all of them, with the later failures attached to it as suppressed. * * @param requests the requests in order * @param submit submits one request and returns its task id @@ -70,7 +70,7 @@ static List submitAllThenReceive( try { release.accept(taskId); } catch (Throwable suppressed) { - t.addSuppressed(suppressed); + suppress(t, suppressed); } } throw t; @@ -82,9 +82,12 @@ static List submitAllThenReceive( out.add(receive.apply(taskId)); } catch (Throwable e) { // Keep receiving the rest so no sibling task is left unread (a native reader leak) - // before propagating the first failure, an Error included. + // before propagating the first failure, an Error included; the later ones are attached + // to it as suppressed. if (failure == null) { failure = e; + } else { + suppress(failure, e); } } } @@ -101,7 +104,8 @@ static List submitAllThenReceive( * submission cannot be separated from the wait. One thread per request up to {@code maxThreads}, * so that many reach the native scheduler at once and the rest start as threads free up; the * threads are daemons and end with the call. A failure, an {@link Error} included, propagates once - * every request has finished, the first one first. + * every request has finished: the first one in input order, with the later ones attached to it as + * suppressed. * * @param requests the requests in order * @param maxThreads the most requests run at once, at least 1 @@ -142,9 +146,11 @@ static List runEachOnItsOwnThread(Collection requests, int maxThrea } catch (InterruptedException e) { interrupted = true; } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; if (failure == null) { - Throwable cause = e.getCause(); - failure = cause != null ? cause : e; + failure = cause; + } else { + suppress(failure, cause); } break; } @@ -162,6 +168,20 @@ static List runEachOnItsOwnThread(Collection requests, int maxThrea } } + /** + * Attaches {@code later} to {@code failure} as suppressed. The same instance can fail two tasks (a + * shared or cached exception), and {@link Throwable#addSuppressed} refuses the exception itself + * with an {@link IllegalArgumentException}, which would escape the loop, leave the remaining tasks + * unread and hide the real failure; that case is skipped. Identity, not {@code equals}, because + * identity is what {@code addSuppressed} refuses. + */ + @SuppressWarnings("ReferenceEquality") + private static void suppress(Throwable failure, Throwable later) { + if (later != failure) { + failure.addSuppressed(later); + } + } + /** * Returns an unchecked failure as is and throws an {@link Error} as is, so the caller can write * {@code throw asRuntimeException(failure)}; anything else (a checked cause, which a diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java index ecd7139ff..7a455ddc8 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java @@ -187,7 +187,8 @@ public CompletionResult completeWithStats(InferenceParameters parameters) { * {@link net.ladenthin.llama.parameters.ModelParameters#setParallel(int)} gives it slots, * independent of the number of processors. With a default single-slot model the requests * still run, but sequentially. If a submission fails, the requests already submitted are - * released before the exception propagates. + * released before the exception propagates. If a request fails, the others are still received; + * the first failure propagates afterwards, with the later ones attached to it as suppressed. * * @param requests the inference parameter blocks (must be distinct instances) * @return the generated texts in input order @@ -220,7 +221,9 @@ public java.util.List completeBatchWithStats(java.util.Collect * in the same order. Each request runs on a thread of its own (the chat endpoint submits and * waits in one native call), up to 64 at once; the rest start as threads free up. Requires * {@link net.ladenthin.llama.parameters.ModelParameters#setParallel(int)} > 1 for - * actual parallelism; otherwise the calls run sequentially on the single slot. + * actual parallelism; otherwise the calls run sequentially on the single slot. If a request + * fails, the call still waits for the others; the first failure in input order propagates + * afterwards, with the later ones attached to it as suppressed. * * @param requests the typed chat requests (must be distinct instances) * @return parsed responses in input order diff --git a/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java b/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java index c897133c7..03a1a893b 100644 --- a/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java +++ b/llama/src/test/java/net/ladenthin/llama/BatchDispatchTest.java @@ -191,6 +191,61 @@ public void submitAllThenReceive_failedReceive_stillReceivesTheOthersThenThrowsT assertThat(thrown, is(first)); assertThat(received, contains(1, 2, 3)); + // the later failure used to be dropped without a trace + assertThat(thrown.getSuppressed().length, is(1)); + assertThat(thrown.getSuppressed()[0].getMessage(), is("second")); + } + + @Test + public void submitAllThenReceive_theSameFailureForTwoReceives_isNotSuppressedOnItself() { + // Throwable.addSuppressed refuses the exception itself: without the identity check the + // IllegalArgumentException would escape the loop and the third task would never be received. + List received = new ArrayList<>(); + IllegalStateException shared = new IllegalStateException("shared"); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> BatchDispatch.submitAllThenReceive( + Arrays.asList(1, 2, 3), + request -> request, + taskId -> { + received.add(taskId); + if (taskId < 3) { + throw shared; + } + return ""; + }, + taskId -> {})); + + assertThat(thrown, is(shared)); + assertThat(thrown.getSuppressed().length, is(0)); + assertThat(received, contains(1, 2, 3)); + } + + @Test + public void submitAllThenReceive_aReleaseThatThrowsTheSubmissionFailure_isNotSuppressedOnItself() { + List released = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("native context closed"); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> BatchDispatch.submitAllThenReceive( + Arrays.asList(1, 2, 3), + request -> { + if (request == 3) { + throw failure; + } + return request * 10; + }, + taskId -> "", + taskId -> { + released.add(taskId); + throw failure; + })); + + assertThat(thrown, is(failure)); + assertThat(thrown.getSuppressed().length, is(0)); + assertThat(released, contains(10, 20)); } @Test @@ -250,6 +305,45 @@ public void runEachOnItsOwnThread_failure_isThrownAfterTheOthersFinished() { assertThat(finished.size(), is(2)); } + @Test + public void runEachOnItsOwnThread_laterFailuresAreSuppressedOnTheFirst() { + // The futures are read in input order, so the first request's failure is the one thrown. + IllegalArgumentException first = new IllegalArgumentException("first"); + IllegalStateException second = new IllegalStateException("second"); + + IllegalArgumentException thrown = assertThrows( + IllegalArgumentException.class, + () -> BatchDispatch.runEachOnItsOwnThread(Arrays.asList(1, 2, 3), 3, request -> { + if (request == 1) { + throw first; + } + if (request == 2) { + throw second; + } + return request; + })); + + assertThat(thrown, is(first)); + assertThat(thrown.getSuppressed(), is(new Throwable[] {second})); + } + + @Test + public void runEachOnItsOwnThread_theSameFailureForTwoRequests_isNotSuppressedOnItself() { + IllegalStateException shared = new IllegalStateException("shared"); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> BatchDispatch.runEachOnItsOwnThread(Arrays.asList(1, 2, 3), 3, request -> { + if (request < 3) { + throw shared; + } + return request; + })); + + assertThat(thrown, is(shared)); + assertThat(thrown.getSuppressed().length, is(0)); + } + @Test public void runEachOnItsOwnThread_noRequests_returnsAnEmptyList() { assertThat( From 4e4b8ce564e60a1e18da26a6dc5d210138d12163 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:35:47 +0000 Subject: [PATCH 412/589] Record the commit of F236 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 078f8f551..95d661475 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1956,7 +1956,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `BatchDispatchTest#submitAllThenReceive_failedReceive_stillReceivesTheOthersThenThrowsTheFirstFailure` (erweitert: `"second"` hängt unterdrückt am ersten), `#runEachOnItsOwnThread_laterFailuresAreSuppressedOnTheFirst` (der Fehler von Anfrage 1 kommt an, der von Anfrage 2 hängt daran), `#submitAllThenReceive_aReleaseThatThrowsTheSubmissionFailure_isNotSuppressedOnItself` (beide Freigaben laufen, dieselbe Instanz ohne unterdrückte kommt an), `#submitAllThenReceive_theSameFailureForTwoReceives_isNotSuppressedOnItself` und `#runEachOnItsOwnThread_theSameFailureForTwoRequests_isNotSuppressedOnItself` (dieselbe Instanz zweimal: sie kommt ohne unterdrückte an, alle Aufgaben werden empfangen). - **Rot-Grün-Nachweis:** Ohne den Fix drei rot („Expected: is <1> but: was <0>“ für die Länge von `getSuppressed()`, „Expected: is [] but: was []“, „Unexpected exception type thrown, expected: but was: “ für die Freigabe); die beiden Identitätstests der Schleifen sind gegen den alten Code grün (dort gab es kein `addSuppressed`) und wurden gegen ein `suppress` ohne Identitätsprüfung rot gezeigt (dieselbe IAE-Meldung). Mit dem Fix grün (`BatchDispatchTest` 18/18); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `4b410d1` ## Offene Verdachte ohne Fund-ID From 2eb3fffac5c2764bd8de606420cb81d714f89e72 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:42:09 +0000 Subject: [PATCH 413/589] Keep the RPC slot while a stopped server thread survives (F237) stopAndJoin gave up after STOP_TIMEOUT_MILLIS without telling anyone: start then threw "could not listen ... (is the port already in use?)" and its finally freed the single-instance slot while the server thread was still alive. Patch 0015 clears the stop flag on bind, so such a thread could still bind and listen on the process-global ggml-rpc state while a second start was admitted onto the same globals, the orphan F30 set out to prevent, with no log line. close() freed the slot the same way after its join. The slot now has two holders once the thread runs: the thread itself, which lets go when serve() returns, and the start or the RpcServer whose close() lets go; the last one frees it. A start whose thread outlives the stop throws a LlamaException naming that and logs it; close() logs a warning. Two holders rather than the thread alone, because an open RpcServer whose close() stops the globals must not see a newer server. The stop timeout is a parameter of the package-private start overload. With only the parameter added the new tests were red ("but: was "RPC server could not listen on 127.0.0.1:50052 (is the port already in use?)"", "Expected java.lang.IllegalStateException to be thrown, but nothing was thrown."); they are green with the change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++ .../java/net/ladenthin/llama/RpcServer.java | 85 +++++++++++++++-- .../llama/RpcServerLifecycleTest.java | 93 ++++++++++++++++++- 3 files changed, 181 insertions(+), 10 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 95d661475..c73c94c25 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -159,6 +159,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F234](#f234) — niedrig — *gefixt* - [F235](#f235) — niedrig — *gefixt* - [F236](#f236) — niedrig — *gefixt* +- [F237](#f237) — niedrig — *gefixt* ## Funde @@ -1958,6 +1959,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4b410d1` +### F237 – java-llama.cpp: `RpcServer` gibt den Einzelinstanz-Platz nach 30 s frei, obwohl der Server-Thread noch lebt, und meldet „could not listen“ +- **Zeit:** 2026-10-21 01:42 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (`start`, `stopAndJoin`, `close`, neu `releaseSlotShare`) +- **Schwere:** niedrig (nur wenn der Server-Thread den Stopp mehr als 30 s überlebt, etwa eine Geräteinitialisierung vor dem Bind, die länger als Start- und Stopp-Frist zusammen dauert) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J9-4). F30 hat den Stopp eines fehlgeschlagenen Starts wiederholt, bis der Thread endet, das Warten aber auf `STOP_TIMEOUT_MILLIS` begrenzt und den Fall danach nicht behandelt; `close()` hatte dieselbe Form schon vor dem Branch (`joinQuietly`, dann `ACTIVE.set(false)`). +- **Beschreibung:** `stopAndJoin` lief, bis der Thread endete oder 30 s vergangen waren, und gab nichts zurück. `start` warf danach immer „RPC server could not listen on … (is the port already in use?)“, und sein `finally` gab den Platz frei, auch wenn der Thread noch lebte. Patch 0015 setzt das Stopp-Flag erst beim Bind zurück, ein solcher Thread kann also danach noch binden und lauschen, mit dem prozessweiten ggml-rpc-Zustand (`g_rpc_server_wake`, `g_rpc_server_client`), während ein zweiter `start` auf dieselben Globals zugelassen wurde: genau der verwaiste Server, den F30 ausschließen sollte, ohne Logzeile und mit einer Meldung, die das Gegenteil sagt. `close()` gab den Platz nach seinem 30-s-Join ebenso frei. +- **Fix:** Der Platz hat jetzt zwei Halter, sobald der Thread läuft: den Thread selbst (er lässt in einem `finally` nach `serve()` los) und den Start bzw. danach das `RpcServer`-Objekt (sein `close()` lässt los); erst der letzte gibt ihn frei (`releaseSlotShare`). `stopAndJoin` gibt zurück, ob der Thread geendet hat; wenn nicht, wirft `start` `LlamaException("RPC server on did not come up and its thread did not end within ms after the stop; RPC servers stay unavailable in this process until it ends")` und loggt denselben Text als Warnung. `close()` warnt in diesem Fall („RPC server thread on … is still alive … ms after the stop …“) und wirft nicht. Zwei Halter statt „der Thread gibt frei“, weil ein noch offenes `RpcServer`, dessen `close()` die Globals stoppt, sonst einen neueren Server beenden könnte, nachdem sein eigener Thread von selbst geendet hat. Die Stopp-Frist geht als zusätzlicher `long` durch die paketinterne `start`-Überladung und in `close()`, damit die Tests sie kurz wählen; die öffentlichen Fabriken nutzen weiter 30 s. +- **Tests:** `RpcServerLifecycleTest#aServerThatIgnoresTheStopIsReportedAndKeepsTheSlot` (ein Backend, dessen `serve` den Stopp ignoriert, Start-Frist 100 ms, Stopp-Frist 200 ms: die Meldung nennt den überlebenden Thread, ein zweiter Start wirft `IllegalStateException`, nach dem Ende des Threads ist der Platz frei), `#aCloseThatTheServerThreadOutlivesKeepsTheSlot` (dasselbe für `close()`), `#aServerThreadThatEndsOnItsOwnKeepsTheSlotUntilClose` (der Platz bleibt bis `close()` belegt, auch wenn der Thread schon geendet hat; gegen den alten Code grün, er pinnt die Zwei-Halter-Regel). +- **Rot-Grün-Nachweis:** Zuerst nur die Stopp-Frist als Parameter eingeführt (verhaltensneutral), dann rot: „Expected: a string containing "its thread did not end within 200 ms after the stop" but: was "RPC server could not listen on 127.0.0.1:50052 (is the port already in use?)"“ und „Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.“; mit dem Fix grün (`RpcServerLifecycleTest` 18/18, `RpcServerTest` 9/9 gegen die native Bibliothek, `RpcServerOptionsTest` 9/9); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/RpcServer.java b/llama/src/main/java/net/ladenthin/llama/RpcServer.java index 0585de5b3..87c03df94 100644 --- a/llama/src/main/java/net/ladenthin/llama/RpcServer.java +++ b/llama/src/main/java/net/ladenthin/llama/RpcServer.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import net.ladenthin.llama.exception.LlamaException; import net.ladenthin.llama.value.RpcEndpoint; @@ -71,6 +72,12 @@ public final class RpcServer implements AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(RpcServer.class); + /** + * The single-instance slot. It is held until the server thread has ended and the start + * failed or {@link #close()} ran: ggml-rpc keeps the server state in process globals, so a second + * server must not start while the thread of the first can still bind, and a still-open + * {@code RpcServer} whose {@code close()} would stop the globals must not see a newer server. + */ private static final AtomicBoolean ACTIVE = new AtomicBoolean(); /** @@ -102,13 +109,23 @@ interface Backend { private final Thread thread; private final Backend backend; private final List devices; + private final long stopTimeoutMillis; + private final AtomicInteger slotHolders; private final AtomicBoolean closed = new AtomicBoolean(); - private RpcServer(RpcEndpoint endpoint, Thread thread, Backend backend, List devices) { + private RpcServer( + RpcEndpoint endpoint, + Thread thread, + Backend backend, + List devices, + long stopTimeoutMillis, + AtomicInteger slotHolders) { this.endpoint = endpoint; this.thread = thread; this.backend = backend; this.devices = devices; + this.stopTimeoutMillis = stopTimeoutMillis; + this.slotHolders = slotHolders; } /** @@ -221,6 +238,21 @@ static RpcServer start( List devices, Backend backend, long startTimeoutMillis) { + return start(endpoint, threads, cacheDir, devices, backend, startTimeoutMillis, STOP_TIMEOUT_MILLIS); + } + + /** + * The lifecycle on an explicit backend, start timeout and stop timeout; the stop timeout bounds + * the wait of a failed start and of {@link #close()} for the server thread. + */ + static RpcServer start( + RpcEndpoint endpoint, + int threads, + @Nullable Path cacheDir, + List devices, + Backend backend, + long startTimeoutMillis, + long stopTimeoutMillis) { if (threads < 1) { throw new IllegalArgumentException("threads must be at least 1, was " + threads); } @@ -229,6 +261,10 @@ static RpcServer start( throw new IllegalStateException("cannot start an RPC server on " + endpoint + ": an RpcServer is already running in this process; close it first"); } + // two holders of the slot once the thread runs: the thread itself and this start (later the + // RpcServer, whose close() releases that share); the last one to let go frees the slot + AtomicInteger slotHolders = new AtomicInteger(2); + boolean threadStarted = false; boolean started = false; try { // resolved before the thread starts, so an unknown name fails here and not in the thread @@ -241,27 +277,45 @@ static RpcServer start( backend.serve(endpoint.getHost(), endpoint.getPort(), threads, cache, deviceList); } catch (Throwable t) { failure.set(t); + } finally { + releaseSlotShare(slotHolders); } }, "jllama-rpc-server-" + endpoint.getPort()); thread.setDaemon(true); thread.start(); + threadStarted = true; if (!awaitListening(thread, backend, startTimeoutMillis)) { - stopAndJoin(thread, backend, STOP_TIMEOUT_MILLIS); + if (!stopAndJoin(thread, backend, stopTimeoutMillis)) { + String message = "RPC server on " + endpoint + " did not come up and its thread did not end" + + " within " + stopTimeoutMillis + " ms after the stop; RPC servers stay unavailable" + + " in this process until it ends"; + LOGGER.warn(message); + throw new LlamaException(message); + } Throwable cause = failure.get(); throw new LlamaException("RPC server could not listen on " + endpoint + (cause == null ? " (is the port already in use?)" : ": " + cause.getMessage())); } - RpcServer server = new RpcServer(endpoint, thread, backend, served); + RpcServer server = new RpcServer(endpoint, thread, backend, served, stopTimeoutMillis, slotHolders); started = true; return server; } finally { - if (!started) { + if (!threadStarted) { ACTIVE.set(false); + } else if (!started) { + releaseSlotShare(slotHolders); } } } + /** Lets go of one share of the single-instance slot; the last share frees it. */ + private static void releaseSlotShare(AtomicInteger slotHolders) { + if (slotHolders.decrementAndGet() == 0) { + ACTIVE.set(false); + } + } + /** * A short description for logs. * @@ -310,6 +364,8 @@ public void awaitTermination() throws InterruptedException { /** * Stops the server: the client being served is disconnected, the port is released. Idempotent. + * Waits up to 30 seconds for the server thread; should it still be alive then, a warning is + * logged and no other server can start in this process until that thread has ended. */ @Override public void close() { @@ -318,9 +374,16 @@ public void close() { } try { backend.stop(); - joinQuietly(thread, STOP_TIMEOUT_MILLIS); + joinQuietly(thread, stopTimeoutMillis); + if (thread.isAlive()) { + LOGGER.warn( + "RPC server thread on {} is still alive {} ms after the stop; RPC servers stay" + + " unavailable in this process until it ends", + endpoint, + stopTimeoutMillis); + } } finally { - ACTIVE.set(false); + releaseSlotShare(slotHolders); } } @@ -520,10 +583,13 @@ private static boolean awaitListening(Thread thread, Backend backend, long timeo * Stops a server thread that did not come up and waits for it to end. The stop is repeated while * the thread lives: the native server clears its stop flag when it binds the socket, so a stop that * arrives before the bind (a slow device initialization, an interrupted start) is lost, and the - * server would go on listening after the start has failed and released the single-instance slot. - * An interrupt of the calling thread does not cut the wait short; it is restored afterwards. + * server would go on listening after the start has failed. An interrupt of the calling thread does + * not cut the wait short; it is restored afterwards. + * + * @return whether the thread has ended; when it has not, it keeps the single-instance slot until it + * does, and the caller reports that */ - private static void stopAndJoin(Thread thread, Backend backend, long timeoutMillis) { + private static boolean stopAndJoin(Thread thread, Backend backend, long timeoutMillis) { boolean interrupted = Thread.interrupted(); long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); try { @@ -540,6 +606,7 @@ private static void stopAndJoin(Thread thread, Backend backend, long timeoutMill Thread.currentThread().interrupt(); } } + return !thread.isAlive(); } private static void joinQuietly(Thread thread, long millis) { diff --git a/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java b/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java index 371f85a38..252e2b1ab 100644 --- a/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java +++ b/llama/src/test/java/net/ladenthin/llama/RpcServerLifecycleTest.java @@ -34,7 +34,8 @@ @ClaudeGenerated( purpose = "Pin RpcServer's Java-side lifecycle without native code: listening, bind failure, " + "a failing serve, the start timeout, close/idempotence, the single-instance slot being " - + "released on every failure, the cache directory and the served-device list.") + + "released on every failure and kept while a server thread survives the stop, the cache " + + "directory and the served-device list.") public class RpcServerLifecycleTest { private static final RpcEndpoint ENDPOINT = RpcEndpoint.of("127.0.0.1", 50052); @@ -232,6 +233,96 @@ public void stop() { assertSlotIsFree(); } + /** + * serve() ignores stop() until {@link #end} is released: a server thread the stop cannot reach, + * e.g. one stuck in a device initialization before the bind. + */ + static class StopIgnoringBackend extends WorkingBackend { + final CountDownLatch end = new CountDownLatch(1); + final CountDownLatch entered = new CountDownLatch(1); + volatile @Nullable Thread serving; + private final boolean listens; + + StopIgnoringBackend(boolean listens) { + this.listens = listens; + } + + @Override + public void serve(String host, int port, int threads, @Nullable String cacheDir, String devices) { + serving = Thread.currentThread(); + listening = listens; + entered.countDown(); + try { + end.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + listening = false; + } + + /** Lets serve() return and waits until the server thread has ended. */ + void endAndJoin() throws InterruptedException { + end.countDown(); + assertThat("serve() was entered", entered.await(10, TimeUnit.SECONDS), is(true)); + Thread thread = serving; + assertThat(thread != null, is(true)); + thread.join(TimeUnit.SECONDS.toMillis(10)); + assertThat("the server thread ended", thread.isAlive(), is(false)); + } + } + + @Test + public void aServerThatIgnoresTheStopIsReportedAndKeepsTheSlot() throws Exception { + // The failed start used to report "could not listen ... (is the port already in use?)" and + // free the single-instance slot while the native server thread, which owns process-global + // ggml-rpc state, was still alive and could still bind. + StopIgnoringBackend stuck = new StopIgnoringBackend(false); + LlamaException e = assertThrows( + LlamaException.class, () -> RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, stuck, 100, 200)); + assertThat(e.getMessage(), containsString("its thread did not end within 200 ms after the stop")); + assertThat(e.getMessage(), containsString(ENDPOINT.toString())); + + IllegalStateException second = assertThrows( + IllegalStateException.class, + () -> RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, new WorkingBackend(), 5_000)); + assertThat(second.getMessage(), containsString("already running")); + + stuck.endAndJoin(); + assertSlotIsFree(); + } + + @Test + public void aCloseThatTheServerThreadOutlivesKeepsTheSlot() throws Exception { + StopIgnoringBackend stuck = new StopIgnoringBackend(true); + RpcServer server = RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, stuck, 5_000, 200); + server.close(); + assertThat(server.isRunning(), is(false)); + + IllegalStateException second = assertThrows( + IllegalStateException.class, + () -> RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, new WorkingBackend(), 5_000)); + assertThat(second.getMessage(), containsString("already running")); + + stuck.endAndJoin(); + assertSlotIsFree(); + } + + @Test + public void aServerThreadThatEndsOnItsOwnKeepsTheSlotUntilClose() throws Exception { + // the open RpcServer's close() stops the process-global native server, so a newer server + // must not start before it has run, even when the old thread is already gone + StopIgnoringBackend ending = new StopIgnoringBackend(true); + RpcServer server = RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, ending, 5_000, 200); + ending.endAndJoin(); + + assertThrows( + IllegalStateException.class, + () -> RpcServer.start(ENDPOINT, 1, null, NO_DEVICES, new WorkingBackend(), 5_000)); + + server.close(); + assertSlotIsFree(); + } + @Test public void theCacheDirectoryIsCreatedAndPassedOn() throws Exception { Path cache = tempDir.resolve("a").resolve("b"); From ccf30c02afd17e2e4eff0ee49264a61d52498c2e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:42:09 +0000 Subject: [PATCH 414/589] Record the commit of F237 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c73c94c25..140164620 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1969,7 +1969,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `RpcServerLifecycleTest#aServerThatIgnoresTheStopIsReportedAndKeepsTheSlot` (ein Backend, dessen `serve` den Stopp ignoriert, Start-Frist 100 ms, Stopp-Frist 200 ms: die Meldung nennt den überlebenden Thread, ein zweiter Start wirft `IllegalStateException`, nach dem Ende des Threads ist der Platz frei), `#aCloseThatTheServerThreadOutlivesKeepsTheSlot` (dasselbe für `close()`), `#aServerThreadThatEndsOnItsOwnKeepsTheSlotUntilClose` (der Platz bleibt bis `close()` belegt, auch wenn der Thread schon geendet hat; gegen den alten Code grün, er pinnt die Zwei-Halter-Regel). - **Rot-Grün-Nachweis:** Zuerst nur die Stopp-Frist als Parameter eingeführt (verhaltensneutral), dann rot: „Expected: a string containing "its thread did not end within 200 ms after the stop" but: was "RPC server could not listen on 127.0.0.1:50052 (is the port already in use?)"“ und „Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.“; mit dem Fix grün (`RpcServerLifecycleTest` 18/18, `RpcServerTest` 9/9 gegen die native Bibliothek, `RpcServerOptionsTest` 9/9); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `2eb3fff` ## Offene Verdachte ohne Fund-ID From 8809fe4a2a9ee0e7692b612b324d117816be7744 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:52:03 +0000 Subject: [PATCH 415/589] Leave unencodable offline model paths to the native loader (F238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OfflineModelGuard turned every InvalidPathException into "the model file does not exist". On Linux, UnixPath encodes the name with sun.jnu.encoding, which follows the locale, so in a JVM started without LANG (ANSI_X3.4-1968) every non-ASCII name throws, although the file exists and the native loader, which receives the UTF-8 bytes, opens it. With --offline, the typical air-gapped deployment, an existing /models/größe.gguf was reported missing. The rule came with F56, which had only the NUL character in mind. The decision is now the pure countsAsMissing(model, charset, parse): a NUL is missing, otherwise Files.exists decides, and a name the parser rejects counts as missing only when the charset can encode it (the file system refused it); one the JVM cannot spell goes to the native loader. The charset is sun.jnu.encoding, resolved like the JDK resolves it, and UTF-8 on Windows, whose WindowsPath never consults it. With the function extracted under the old semantics the new test was red ("Expected: is but: was "); it is green with the change. Checked by hand under LC_ALL=C with an existing größe.gguf. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++ .../llama/loader/OfflineModelGuard.java | 84 ++++++++++++-- .../llama/loader/OfflineModelGuardTest.java | 103 ++++++++++++++++++ 3 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 140164620..33cd12333 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -160,6 +160,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F235](#f235) — niedrig — *gefixt* - [F236](#f236) — niedrig — *gefixt* - [F237](#f237) — niedrig — *gefixt* +- [F238](#f238) — niedrig — *gefixt* ## Funde @@ -1971,6 +1972,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `2eb3fff` +### F238 – java-llama.cpp: `--offline` meldet eine vorhandene Modelldatei mit Nicht-ASCII-Namen als fehlend, wenn die JVM mit der POSIX-Locale läuft +- **Zeit:** 2026-10-21 01:52 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` (`localFileExists`, neu `countsAsMissing`, `fileNameEncoding`, `jnuEncoding`) +- **Schwere:** niedrig (braucht zugleich eine JVM ohne `LANG`/mit POSIX-Locale, einen Nicht-ASCII-Modellpfad und `--offline`; das ist allerdings genau die abgeschottete Container-Umgebung, für die `--offline` da ist) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J10-1). Eingeführt mit F56: dessen Fix machte jede `InvalidPathException` zu „fehlt“ und hatte nur das NUL-Zeichen im Blick. Vor dem Branch kam eine rohe `InvalidPathException` an, unhandlich, aber wahr. +- **Beschreibung:** `localFileExists` schrieb `try { return Files.exists(Paths.get(model)); } catch (InvalidPathException e) { return false; }`, und die Javadoc begründete das mit „a path the file system cannot represent“. Unter Linux kodiert `UnixPath` den Namen aber mit `sun.jnu.encoding`, das der Locale folgt; mit `LC_ALL=C` ist das `ANSI_X3.4-1968`, und `Paths.get("…/größe.gguf")` wirft „Malformed input or input contains unmappable characters“, obwohl die Datei als UTF-8-Name auf der Platte liegt. Die native Seite hat diese Grenze nicht (`jllama.cpp` gibt das argv als `String.getBytes("UTF-8")` weiter, upstream öffnet mit `std::filesystem::u8path`), ohne `--offline` lädt dasselbe Modell also. Mit `--offline` warf der Wächter selbstsicher `ModelUnavailableException` „the configured model file does not exist“ für eine vorhandene Datei. +- **Fix:** Die Entscheidung liegt in der paketinternen, reinen Funktion `countsAsMissing(model, charset, parse)`, in dieser Reihenfolge: ein NUL-Zeichen ist „fehlt“ (F56 bleibt; nativer Code schnitte den Pfad dort ab, auch wenn der Name zugleich nicht kodierbar ist); sonst entscheidet `Files.exists(parse(model))`; lehnt `parse` den Namen mit `InvalidPathException` ab, ist er nur dann „fehlt“, wenn der Zeichensatz ihn kodieren kann (die JVM konnte ihn schreiben, das Dateisystem hat ihn abgewiesen); kann er es nicht, geht der Pfad an den nativen Lader, der eine fehlende Datei selbst meldet. Der Zeichensatz ist `sun.jnu.encoding`, aufgelöst wie im JDK (`Charset.defaultCharset()`, wenn die Eigenschaft fehlt oder keinen unterstützten Zeichensatz nennt; nicht hart „UTF-8“), und unter Windows UTF-8, weil `WindowsPath` den UTF-16-String parst und `sun.jnu.encoding` nie liest, dort also jede Ablehnung die des Dateisystems ist (Präzisierung des Code-Skeptikers). Die Javadoc von `check` nennt den Fall. +- **Tests:** `OfflineModelGuardTest` (neu, im `loader`-Paket, weil die Funktion paketintern ist): `countsAsMissing_aNameTheJvmEncodingCannotRepresent_isLeftToTheNativeLoader` (US-ASCII und ein Parser, der wie `UnixPath.encode` wirft: nicht „fehlt“), `…_aNameTheFileSystemRejects_isMissing`, `…_aNulCharacter_isMissing`, `…_aNulCharacterWinsOverAnUnencodableName`, `…_anExistingAndAMissingFile`, `fileNameEncoding_isTheJnuEncodingExceptOnWindows`, `jnuEncoding_isTheCharsetOfTheProperty`, `jnuEncoding_anAbsentOrUnknownNameFallsBackToTheDefaultCharset`; der F56-Test `LlamaModelOfflineTest#check_offlineAndUnrepresentablePath_throwsModelUnavailable` bleibt grün. Zusätzlich von Hand geprüft mit `LC_ALL=C` (`sun.jnu.encoding=ANSI_X3.4-1968`) und einer vorhandenen Datei `größe.gguf`: der alte Wächter warf `ModelUnavailableException`, der neue lässt durch; ein fehlender Nicht-ASCII-Name geht jetzt ebenfalls an den nativen Lader. +- **Rot-Grün-Nachweis:** Zuerst die Funktion mit der alten Semantik herausgezogen (jede `InvalidPathException` heißt „fehlt“, Zeichensatz unbeachtet), dann rot: `countsAsMissing_aNameTheJvmEncodingCannotRepresent_isLeftToTheNativeLoader` „Expected: is but: was “; mit dem Fix grün (`OfflineModelGuardTest` 8/8, `LlamaModelOfflineTest` 9/9, `LlamaArchitectureTest` 13/13); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java b/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java index 658e69a0f..8c94266a3 100644 --- a/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java +++ b/llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java @@ -4,12 +4,18 @@ package net.ladenthin.llama.loader; +import java.io.File; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; +import java.nio.file.Path; import java.nio.file.Paths; +import java.util.function.Function; import net.ladenthin.llama.args.ModelFlag; import net.ladenthin.llama.exception.ModelUnavailableException; import net.ladenthin.llama.parameters.ModelParameters; +import org.jspecify.annotations.Nullable; /** * Pure-Java guard that fails fast with a typed @@ -49,7 +55,9 @@ private OfflineModelGuard() { /** * Throws {@link net.ladenthin.llama.exception.ModelUnavailableException} when {@code parameters} * has {@link net.ladenthin.llama.args.ModelFlag#OFFLINE} set and its configured local - * {@code --model} path points at a file that does not exist; otherwise returns normally. + * {@code --model} path points at a file that does not exist; otherwise returns normally. A path + * the JVM's file-name encoding cannot represent (a non-ASCII name in a JVM started with the POSIX + * locale) is not judged here but left to the native loader, which opens it with its UTF-8 bytes. * * @param parameters the parameters about to be passed to the native loader * @throws net.ladenthin.llama.exception.ModelUnavailableException when offline and the @@ -68,18 +76,80 @@ public static void check(ModelParameters parameters) { } /** - * Whether {@code model} names an existing file. A path the file system cannot represent (a NUL - * character, or a character Windows forbids) cannot name a file, so it counts as missing instead - * of escaping as an {@link InvalidPathException}. + * Whether {@code model} names an existing file, as far as the JVM can tell. * * @param model the configured local model path - * @return {@code true} if the file exists + * @return {@code true} if the file exists or the JVM cannot decide (see {@link #countsAsMissing}) */ private static boolean localFileExists(String model) { + return !countsAsMissing( + model, + fileNameEncoding(File.separatorChar == '\\', System.getProperty("sun.jnu.encoding")), + Paths::get); + } + + /** + * The charset {@code Paths.get} encodes a file name with. On Windows none: {@code WindowsPath} + * parses the UTF-16 string and never consults {@code sun.jnu.encoding}, so every rejection there + * is the file system's, which UTF-8 (able to encode any well-formed string) expresses. Elsewhere + * {@code UnixPath} encodes with {@code sun.jnu.encoding}. + * + * @param windows whether this is Windows + * @param jnu the value of {@code sun.jnu.encoding}, or {@code null} + * @return the charset to test a rejected name against + */ + static Charset fileNameEncoding(boolean windows, @Nullable String jnu) { + return windows ? StandardCharsets.UTF_8 : jnuEncoding(jnu); + } + + /** + * The charset the JVM encodes file names with, resolved like the JDK does: the + * {@code sun.jnu.encoding} property (set from the locale at startup, on Java 8 as well), and the + * default charset when it is absent or names no supported charset. + * + * @param name the value of {@code sun.jnu.encoding}, or {@code null} + * @return the file-name charset + */ + static Charset jnuEncoding(@Nullable String name) { + if (name != null) { + try { + return Charset.forName(name); + } catch (IllegalArgumentException e) { + // IllegalCharsetNameException and UnsupportedCharsetException: the JDK's fallback below + } + } + return Charset.defaultCharset(); + } + + /** + * Whether the guard reports {@code model} as missing. + * + *

      + *
    1. A NUL character: missing. No file name can contain one, and native code would cut the path + * at it, so it is never handed on.
    2. + *
    3. Otherwise the file system decides through {@code parse} ({@code Paths.get}).
    4. + *
    5. When {@code parse} rejects the name with an {@link InvalidPathException}, it is missing only + * if {@code jnuEncoding} can encode it: then the JVM could spell the name and the file system + * refused it (a character Windows forbids). If the JVM's file-name encoding cannot represent + * it, the rejection says nothing about the file: on Linux {@code Paths.get} throws for every + * non-ASCII name in a JVM started with the POSIX locale (no {@code LANG}), while the native + * loader receives the UTF-8 bytes and opens the file. Such a path is left to the native + * loader, which reports a missing file itself.
    6. + *
    + * + * @param model the configured local model path + * @param jnuEncoding the charset the JVM encodes file names with + * @param parse turns the name into a path, {@code Paths::get} outside tests + * @return {@code true} if the offline guard should report the model as missing + */ + static boolean countsAsMissing(String model, Charset jnuEncoding, Function parse) { + if (model.indexOf('\0') >= 0) { + return true; + } try { - return Files.exists(Paths.get(model)); + return !Files.exists(parse.apply(model)); } catch (InvalidPathException e) { - return false; + return jnuEncoding.newEncoder().canEncode(model); } } } diff --git a/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java b/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java new file mode 100644 index 000000000..94ca3be3f --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.loader; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * {@link OfflineModelGuard#countsAsMissing}: which configured model paths the offline guard reports + * as missing. The path parser is injected, because on Linux {@code Paths.get} throws for a name the + * JVM's {@code sun.jnu.encoding} cannot encode (a POSIX-locale JVM and a non-ASCII name) although the + * file exists and the native loader, which receives the UTF-8 bytes, opens it. + */ +public class OfflineModelGuardTest { + + /** What {@code UnixPath.encode} throws when {@code sun.jnu.encoding} cannot encode the name. */ + private static Path unencodable(String model) { + throw new InvalidPathException(model, "Malformed input or input contains unmappable characters"); + } + + @Test + public void countsAsMissing_aNameTheJvmEncodingCannotRepresent_isLeftToTheNativeLoader() { + // An existing /models/größe.gguf in a container without LANG used to be reported as + // "the configured model file does not exist", although it loads without --offline. + assertThat( + OfflineModelGuard.countsAsMissing( + "models/fehlt-😀.gguf", StandardCharsets.US_ASCII, OfflineModelGuardTest::unencodable), + is(false)); + } + + @Test + public void countsAsMissing_aNameTheFileSystemRejects_isMissing() { + // the JVM could spell it, so the rejection is the file system's (a character Windows forbids) + assertThat( + OfflineModelGuard.countsAsMissing( + "models/bad?name.gguf", StandardCharsets.UTF_8, OfflineModelGuardTest::unencodable), + is(true)); + } + + @Test + public void countsAsMissing_aNulCharacter_isMissing() { + assertThat( + OfflineModelGuard.countsAsMissing("models/bad\u0000name.gguf", StandardCharsets.UTF_8, Paths::get), + is(true)); + } + + @Test + public void countsAsMissing_aNulCharacterWinsOverAnUnencodableName() { + // native code would cut the path at the NUL, so it is never handed on + assertThat( + OfflineModelGuard.countsAsMissing( + "models/bad\u0000😀.gguf", StandardCharsets.US_ASCII, OfflineModelGuardTest::unencodable), + is(true)); + } + + @Test + public void countsAsMissing_anExistingAndAMissingFile(@TempDir Path dir) throws IOException { + Path model = Files.createFile(dir.resolve("model.gguf")); + + assertThat(OfflineModelGuard.countsAsMissing(model.toString(), StandardCharsets.UTF_8, Paths::get), is(false)); + assertThat( + OfflineModelGuard.countsAsMissing( + dir.resolve("absent.gguf").toString(), StandardCharsets.UTF_8, Paths::get), + is(true)); + } + + @Test + public void fileNameEncoding_isTheJnuEncodingExceptOnWindows() { + // WindowsPath never consults sun.jnu.encoding, so a name it rejects is the file system's verdict + assertThat(OfflineModelGuard.fileNameEncoding(false, "ANSI_X3.4-1968"), is(StandardCharsets.US_ASCII)); + assertThat(OfflineModelGuard.fileNameEncoding(true, "Cp1252"), is(StandardCharsets.UTF_8)); + assertThat( + OfflineModelGuard.countsAsMissing( + "models/bad?\uD83D\uDE00.gguf", + OfflineModelGuard.fileNameEncoding(true, "Cp1252"), + OfflineModelGuardTest::unencodable), + is(true)); + } + + @Test + public void jnuEncoding_isTheCharsetOfTheProperty() { + assertThat(OfflineModelGuard.jnuEncoding("UTF-8"), is(StandardCharsets.UTF_8)); + assertThat(OfflineModelGuard.jnuEncoding("ANSI_X3.4-1968"), is(StandardCharsets.US_ASCII)); + } + + @Test + public void jnuEncoding_anAbsentOrUnknownNameFallsBackToTheDefaultCharset() { + assertThat(OfflineModelGuard.jnuEncoding(null), is(java.nio.charset.Charset.defaultCharset())); + assertThat(OfflineModelGuard.jnuEncoding("no-such-charset"), is(java.nio.charset.Charset.defaultCharset())); + assertThat(OfflineModelGuard.jnuEncoding("bad name!"), is(java.nio.charset.Charset.defaultCharset())); + } +} From b0bbfb3662040c8be009a0cfea8fda9086bb90fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:52:03 +0000 Subject: [PATCH 416/589] Record the commit of F238 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 33cd12333..d6fda5bbf 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1982,7 +1982,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OfflineModelGuardTest` (neu, im `loader`-Paket, weil die Funktion paketintern ist): `countsAsMissing_aNameTheJvmEncodingCannotRepresent_isLeftToTheNativeLoader` (US-ASCII und ein Parser, der wie `UnixPath.encode` wirft: nicht „fehlt“), `…_aNameTheFileSystemRejects_isMissing`, `…_aNulCharacter_isMissing`, `…_aNulCharacterWinsOverAnUnencodableName`, `…_anExistingAndAMissingFile`, `fileNameEncoding_isTheJnuEncodingExceptOnWindows`, `jnuEncoding_isTheCharsetOfTheProperty`, `jnuEncoding_anAbsentOrUnknownNameFallsBackToTheDefaultCharset`; der F56-Test `LlamaModelOfflineTest#check_offlineAndUnrepresentablePath_throwsModelUnavailable` bleibt grün. Zusätzlich von Hand geprüft mit `LC_ALL=C` (`sun.jnu.encoding=ANSI_X3.4-1968`) und einer vorhandenen Datei `größe.gguf`: der alte Wächter warf `ModelUnavailableException`, der neue lässt durch; ein fehlender Nicht-ASCII-Name geht jetzt ebenfalls an den nativen Lader. - **Rot-Grün-Nachweis:** Zuerst die Funktion mit der alten Semantik herausgezogen (jede `InvalidPathException` heißt „fehlt“, Zeichensatz unbeachtet), dann rot: `countsAsMissing_aNameTheJvmEncodingCannotRepresent_isLeftToTheNativeLoader` „Expected: is but: was “; mit dem Fix grün (`OfflineModelGuardTest` 8/8, `LlamaModelOfflineTest` 9/9, `LlamaArchitectureTest` 13/13); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `8809fe4` ## Offene Verdachte ohne Fund-ID From 4205d20d24b93a6b3bd62888b8d2b8e92f458e97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:56:24 +0000 Subject: [PATCH 417/589] Sweep stale .tmp leftovers of the old flat layout again (F239) Up to 5.1.0 the loader wrote every flat file through Files.createTempFile(dir, name + ".", ".tmp") and moved it into place, so a JVM that died mid-copy left jllama.dll..tmp or ggml-metal.metal..tmp at the temp root (verified at v5.1.0). The old prefix filter removed them; the library-suffix rule of F09 kept them for good, the case F09's ledger entry left open. The Javadoc of cleanup() and isFlatLayoutLibraryName also promised jllama*/llama*/ggml* files and .so/.dylib libraries, although libjllama.so/libjllama.dylib never carried a cleanup prefix and no version removed them. isStaleExtraction now strips a trailing ..tmp from a regular file; when a library name of the old layout remains, the file goes once it is STALE_EXTRACTION_AGE old, because a younger one may belong to a pre-6.0.0 JVM copying right now. The plain library names still go at once. The Javadoc names the verified files. The new LlamaLoaderTest case was red without the change ("expected: but was: " for jllama.dll.4711.tmp) and is green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +++++ .../ladenthin/llama/loader/LlamaLoader.java | 55 +++++++++++++++---- .../llama/loader/LlamaLoaderTest.java | 33 ++++++++++- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d6fda5bbf..2611389e4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -161,6 +161,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F236](#f236) — niedrig — *gefixt* - [F237](#f237) — niedrig — *gefixt* - [F238](#f238) — niedrig — *gefixt* +- [F239](#f239) — niedrig — *gefixt* ## Funde @@ -218,6 +219,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** `cleanup()` lief bei jedem Start, der die Natives-Jars erreicht, über `java.io.tmpdir` (bzw. `net.ladenthin.llama.tmpdir`) und löschte **sofort und rekursiv** jeden Eintrag, dessen Name mit `jllama`, `llama` oder `ggml` beginnt und kein `jllama-backend-*`-Verzeichnis ist. Gedacht war das für die einzelnen Bibliotheksdateien des flachen Layouts vor 6.0.0 (so steht es in der Javadoc). Getroffen wurde aber auch z. B. ein `/tmp/llama.cpp`-Checkout, ein `/tmp/ggml-models/`-Ordner oder ein heruntergeladenes `/tmp/llama-2-7b.Q4_K_M.gguf`. Fix: Aus dem alten Layout werden nur noch **reguläre Dateien mit Bibliotheksendung** gelöscht (`.so`, `.dll`, `.dylib`, `.jnilib`, `.metal`). - **Reproduktion:** `LlamaLoaderTest#staleExtractionLeavesForeignEntriesWithAMatchingPrefixAlone` (vorher rot, danach grün); `LlamaLoaderTest` 63/63, inkl. der bestehenden Fälle `jllama-3f2a.so` / `ggml-metal.metal` (werden weiter gelöscht). - **Bitte prüfen:** Die genauen Dateinamen des alten Layouts konnte ich nicht verifizieren, weil der Clone flach ist und keine Historie vor 6.0.0 enthält. Falls es damals auch Endungen wie `.tmp` oder `.lck` gab, bleiben die jetzt liegen. Das ist harmlos, nur Restmüll. +- **Nachtrag (Branch-Review 2026-10-20):** Am Tag `v5.1.0` geprüft: das alte Layout entpackte `System.mapLibraryName("jllama")` flach (`libjllama.so`, `libjllama.dylib`, `jllama.dll`), unter macOS dazu `ggml-metal.metal`, jede Kopie über `Files.createTempFile(dir, name + ".", ".tmp")`. `libjllama.so`/`libjllama.dylib` trugen nie ein Bereinigungspräfix und wurden von keiner Version entfernt; was dieser Fix entfernt, sind `jllama.dll` (und ältere `llama*`/`ggml*`-DLLs) und `ggml-metal.metal`. Die `..tmp`-Reste einer abgebrochenen Kopie blieben seit diesem Fix liegen; seit F239 gehen sie wieder, sobald sie `STALE_EXTRACTION_AGE` alt sind. Das „Bitte prüfen“ ist damit erledigt. - **Status:** gefixt - **Commit:** java-llama.cpp `61ea8f5` @@ -1984,6 +1986,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `8809fe4` +### F239 – java-llama.cpp: der Loader lässt die `.tmp`-Reste unterbrochener Kopien des alten Layouts für immer liegen +- **Zeit:** 2026-10-21 01:56 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` (`isStaleExtraction`, neu `isOlderThanTheStaleAge`, `FLAT_LAYOUT_TEMP_COPY`; Javadoc von `cleanup`, `isFlatLayoutLibraryName`, `FLAT_LAYOUT_SUFFIXES`) +- **Schwere:** niedrig (Restmüll im Temp-Verzeichnis, nur nach einer abgebrochenen Kopie einer Version vor 6.0.0) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J10-2). Folge von F09, dessen „Bitte prüfen“ genau diesen Fall offen ließ; die Namen sind jetzt am Tag `v5.1.0` verifiziert. +- **Beschreibung:** Bis 5.1.0 entpackte `LlamaLoader` `System.mapLibraryName("jllama")` flach ins Temp-Verzeichnis (`libjllama.so`, `libjllama.dylib`, `jllama.dll`), unter macOS dazu `ggml-metal.metal`, jede Kopie über `Files.createTempFile(dir, name + ".", ".tmp")` (die Zufallsmitte ist eine Dezimalzahl) und ein Verschieben an den Zielnamen. Eine JVM, die mitten in der Kopie starb, hinterließ `jllama.dll..tmp` bzw. `ggml-metal.metal..tmp`. Der alte Präfixfilter räumte die weg, die Bibliotheksendungs-Regel von F09 behielt sie (`.tmp` ist keine Bibliotheksendung). Außerdem stimmten die Javadocs nicht: `cleanup()` versprach die flachen Dateien `jllama*`, `llama*`, `ggml*` und `isFlatLayoutLibraryName` „a native library (.so, .dll, .dylib, .jnilib)“, obwohl `libjllama.so`/`libjllama.dylib` nie ein Bereinigungspräfix trugen und von keiner Version entfernt wurden. +- **Fix:** `isStaleExtraction` erkennt im Zweig für reguläre Dateien die Endung `\.\d+\.tmp$` (genau die Form von `createTempFile`); bleibt davor ein Bibliotheksname des alten Layouts, wird die Datei entfernt, sobald sie `STALE_EXTRACTION_AGE` (10 min) alt ist, denn eine jüngere kann einer JVM vor 6.0.0 gehören, die gerade kopiert (6.x schreibt nie in die Wurzel des Temp-Verzeichnisses). Die Altersprüfung teilt sich `isOlderThanTheStaleAge` mit dem Verzeichniszweig. Die reinen Bibliotheksnamen gehen weiter sofort. Die drei Javadocs nennen die verifizierten Namen. `testShouldCleanPathGgmlPrefix` prüft jetzt einen echten Namen des alten Layouts (`ggml-metal.metal.4711.tmp`) und sagt, dass es nur das Präfix-Prädikat prüft. +- **Tests:** `LlamaLoaderTest#staleExtractionRemovesAnInterruptedCopyOfTheOldLayoutOnceItIsOld` (alt: `jllama.dll.4711.tmp` und `ggml-metal.metal..tmp` gehen; nicht: dieselbe Form ohne Alter, `somefile.so.4711.tmp` ohne Präfix, `jllama.gguf.4711.tmp` ohne Bibliotheksnamen, `jllama.dll.tmp` ohne Ziffern, ein Verzeichnis `jllama.dll.4713.tmp`). +- **Rot-Grün-Nachweis:** Ohne den Fix rot („expected: but was: “ für `jllama.dll.4711.tmp`), mit ihm grün (`LlamaLoaderTest` 64/64); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. Die CHANGELOG-Zeile zu F09 („removes regular files with a library suffix … nothing else“) ist damit unvollständig; sie bleibt für den CHANGELOG-Durchgang. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java b/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java index 35d2521fc..988c7701e 100644 --- a/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java +++ b/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java @@ -33,6 +33,8 @@ import java.util.Map; import java.util.Set; import java.util.jar.JarFile; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.ZipEntry; import lombok.ToString; @@ -259,10 +261,23 @@ public class LlamaLoader { */ static final Duration STALE_EXTRACTION_AGE = Duration.ofMinutes(10); - /** File-name endings of what the flat layout before 6.0.0 extracted (see {@link #isFlatLayoutLibraryName}). */ + /** + * File-name endings of what the flat layout before 6.0.0 extracted (see + * {@link #isFlatLayoutLibraryName}). Of those names only {@code jllama.dll}, the older Windows + * {@code llama*.dll}/{@code ggml*.dll} and {@code ggml-metal.metal} ever carried a cleanup prefix: + * the Linux and macOS library was {@code libjllama.so}/{@code libjllama.dylib} + * ({@code System.mapLibraryName}), which no cleanup removed, before 6.0.0 or after. + */ private static final List FLAT_LAYOUT_SUFFIXES = Collections.unmodifiableList(Arrays.asList(".so", ".dll", ".dylib", ".jnilib", ".metal")); + /** + * The tail an interrupted copy of the flat layout left: up to 5.1.0 every file was written through + * {@code Files.createTempFile(dir, name + ".", ".tmp")}, whose random middle part is a decimal + * number, and moved into place; a JVM that died mid-copy left {@code ..tmp}. + */ + private static final Pattern FLAT_LAYOUT_TEMP_COPY = Pattern.compile("\\.\\d+\\.tmp$"); + /** Shader source a non-embedding Metal build ships next to its library. */ private static final String METAL_SOURCE_FILE = "ggml-metal.metal"; @@ -315,9 +330,13 @@ private static void load() { * start of the same build finds its files in place (every one is still compared with the jar byte * for byte before it is loaded, see {@link #extractFile}) and skips the copy -- 18 files, ~1 s with * an on-access scanner on Windows, on every JVM start before. What goes: the flat files of the - * layout before 6.0.0 ({@code jllama*}, {@code llama*}, {@code ggml*} directly in the temp dir; on - * Windows a loaded DLL was never removed at VM exit, bug #80) and the extraction directories of - * other builds once they are {@link #STALE_EXTRACTION_AGE} old -- a younger one may belong to a + * layout before 6.0.0 that carry a cleanup prefix, directly in the temp dir -- {@code jllama.dll} + * (and the older {@code llama*.dll}/{@code ggml*.dll}; on Windows a loaded DLL was never removed + * at VM exit, bug #80) and {@code ggml-metal.metal} at once, the {@code ..tmp} an + * interrupted copy of one of them left once it is {@link #STALE_EXTRACTION_AGE} old; the Linux and + * macOS {@code libjllama.so}/{@code libjllama.dylib} never carried a prefix and stay, as they + * always did -- and the extraction directories of other builds (the 5.x {@code jllama-backend-*} + * ones included) once they are {@link #STALE_EXTRACTION_AGE} old -- a younger one may belong to a * JVM that is still extracting. Every delete is best-effort: a file a running JVM has loaded is * locked on Windows and stays, which is the right outcome. * @@ -334,10 +353,11 @@ private static void cleanup(Set keep) { /** * Whether {@link #cleanup(Set)} removes {@code path}: a {@link #shouldCleanPath named} regular file - * with a {@link #isFlatLayoutLibraryName library name} (the pre-6.0.0 flat layout), or an extraction - * directory of a build not in {@code keep} whose last modification is more than - * {@link #STALE_EXTRACTION_AGE} before {@code now}. A directory whose modification time cannot be - * read is left alone. + * with a {@link #isFlatLayoutLibraryName library name} (the pre-6.0.0 flat layout); such a name + * followed by {@code ..tmp} (an interrupted copy of that layout) once it is + * {@link #STALE_EXTRACTION_AGE} old, since a younger one may belong to a pre-6.0.0 JVM that is + * copying right now; or an extraction directory of a build not in {@code keep} that old. An entry + * whose modification time cannot be read is left alone. * * @param path an entry of the temp directory * @param keep the extraction directory names this classpath's backends use @@ -354,11 +374,24 @@ static boolean isStaleExtraction(Path path, Set keep, Instant now) { // The old flat layout extracted single library files. The temp directory is shared with // everything else on the machine, so only a regular file named like a native library goes: // a llama.cpp checkout or a downloaded model carries the same prefix and is not ours. - return Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && isFlatLayoutLibraryName(name); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + Matcher tempCopy = FLAT_LAYOUT_TEMP_COPY.matcher(name); + if (tempCopy.find()) { + return isFlatLayoutLibraryName(name.substring(0, tempCopy.start())) + && isOlderThanTheStaleAge(path, now); + } + return isFlatLayoutLibraryName(name); } if (keep.contains(name)) { return false; } + return isOlderThanTheStaleAge(path, now); + } + + /** Whether {@code path} was last modified more than {@link #STALE_EXTRACTION_AGE} before {@code now}. */ + private static boolean isOlderThanTheStaleAge(Path path, Instant now) { try { return Files.getLastModifiedTime(path) .toInstant() @@ -371,7 +404,9 @@ static boolean isStaleExtraction(Path path, Set keep, Instant now) { /** * Whether a temp-directory file name is one the layout before 6.0.0 extracted: a native library - * ({@code .so}, {@code .dll}, {@code .dylib}, {@code .jnilib}) or the Metal shader source. + * ({@code .so}, {@code .dll}, {@code .dylib}, {@code .jnilib}) or the Metal shader source. With the + * cleanup prefix that leaves {@code jllama.dll}, the older Windows {@code llama*.dll} and + * {@code ggml*.dll}, and {@code ggml-metal.metal}; see {@link #FLAT_LAYOUT_SUFFIXES}. * * @param name the file name, already known to carry a {@link #shouldCleanPath cleanup prefix} * @return whether the file is an extracted library of the old layout diff --git a/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java b/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java index 89db1fd4c..3887ba652 100644 --- a/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java +++ b/llama/src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java @@ -121,7 +121,8 @@ public void testShouldCleanPathGgmlMetalFile() { @Test public void testShouldCleanPathGgmlPrefix() { - assertTrue(LlamaLoader.shouldCleanPath(Paths.get("/tmp/ggml.tmp"))); + // the prefix predicate only; what cleanup() removes is isStaleExtraction's decision + assertTrue(LlamaLoader.shouldCleanPath(Paths.get("/tmp/ggml-metal.metal.4711.tmp"))); } // ------------------------------------------------------------------------- @@ -691,6 +692,36 @@ public void staleExtractionRemovesTheFlatFilesOfTheOldLayoutAtOnce(@TempDir Path assertFalse(LlamaLoader.isStaleExtraction(unrelated, Collections.emptySet(), now)); } + @Test + public void staleExtractionRemovesAnInterruptedCopyOfTheOldLayoutOnceItIsOld(@TempDir Path dir) throws IOException { + // Up to 5.1.0 every extraction went through Files.createTempFile(tmp, name + ".", ".tmp"), so a + // copy cut short left ..tmp at the temp root. The old prefix filter removed it; + // the library-suffix rule of F09 kept it for good. A young one may belong to a pre-6.0.0 JVM + // that is copying right now (6.x never writes at the temp root), so it gets the same age as a + // directory of another build. + Instant now = Instant.now(); + FileTime old = FileTime.from(now.minus(LlamaLoader.STALE_EXTRACTION_AGE).minusSeconds(1)); + Path dll = Files.write(dir.resolve("jllama.dll.4711.tmp"), new byte[] {1}); + Path metal = Files.write(dir.resolve("ggml-metal.metal.8133917650000000000.tmp"), new byte[] {1}); + Path young = Files.write(dir.resolve("jllama.dll.4712.tmp"), new byte[] {1}); + Path foreign = Files.write(dir.resolve("somefile.so.4711.tmp"), new byte[] {1}); + Path model = Files.write(dir.resolve("jllama.gguf.4711.tmp"), new byte[] {1}); + Path noDigits = Files.write(dir.resolve("jllama.dll.tmp"), new byte[] {1}); + Path directory = Files.createDirectory(dir.resolve("jllama.dll.4713.tmp")); + for (Path path : new Path[] {dll, metal, foreign, model, noDigits, directory}) { + Files.setLastModifiedTime(path, old); + } + Set keep = Collections.emptySet(); + + assertTrue(LlamaLoader.isStaleExtraction(dll, keep, now)); + assertTrue(LlamaLoader.isStaleExtraction(metal, keep, now)); + assertFalse(LlamaLoader.isStaleExtraction(young, keep, now), "may still be copying"); + assertFalse(LlamaLoader.isStaleExtraction(foreign, keep, now), "no cleanup prefix"); + assertFalse(LlamaLoader.isStaleExtraction(model, keep, now), "not a library name"); + assertFalse(LlamaLoader.isStaleExtraction(noDigits, keep, now), "not the createTempFile shape"); + assertFalse(LlamaLoader.isStaleExtraction(directory, keep, now), "not a regular file"); + } + @Test public void staleExtractionLeavesForeignEntriesWithAMatchingPrefixAlone(@TempDir Path dir) throws IOException { // The temp directory is shared with everything else on the machine. A llama.cpp checkout or a From ebd7c0f4554b52f9ba35f0eba188c7bbf2e83e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:56:24 +0000 Subject: [PATCH 418/589] Record the commit of F239 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 2611389e4..ed3918067 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1996,7 +1996,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `LlamaLoaderTest#staleExtractionRemovesAnInterruptedCopyOfTheOldLayoutOnceItIsOld` (alt: `jllama.dll.4711.tmp` und `ggml-metal.metal..tmp` gehen; nicht: dieselbe Form ohne Alter, `somefile.so.4711.tmp` ohne Präfix, `jllama.gguf.4711.tmp` ohne Bibliotheksnamen, `jllama.dll.tmp` ohne Ziffern, ein Verzeichnis `jllama.dll.4713.tmp`). - **Rot-Grün-Nachweis:** Ohne den Fix rot („expected: but was: “ für `jllama.dll.4711.tmp`), mit ihm grün (`LlamaLoaderTest` 64/64); `spotless:apply`, `spotbugs:check` und `javadoc:javadoc` grün. Die CHANGELOG-Zeile zu F09 („removes regular files with a library suffix … nothing else“) ist damit unvollständig; sie bleibt für den CHANGELOG-Durchgang. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `4205d20` ## Offene Verdachte ohne Fund-ID From 0bf1e38d44e264685e79bd584107dfe5bcb9c30b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:56:43 +0000 Subject: [PATCH 419/589] Note F235 to F238 under the bug-hunt entries they follow up F30, F56 and F167 now point at the findings that tightened them: the RPC slot kept for a surviving server thread (F237), the offline guard's narrower InvalidPathException rule (F238), and the batch dispatcher's Error handling and suppressed failures (F235, F236). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ed3918067..8f27a9407 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -346,6 +346,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `RpcServerLifecycleTest#aStopThatArrivesBeforeTheBindDoesNotLeaveTheServerRunning` (Fake-Backend, das wie der native Server das Stopp-Flag beim Binden zurücksetzt; ohne Fix rot nach 31 s, mit Fix 15/15 grün) - **Status:** gefixt - **Commit:** e824ae5 +- **Nachtrag (Branch-Review 2026-10-20):** Die Wiederholung des Stopps war auf `STOP_TIMEOUT_MILLIS` begrenzt; überlebte der Thread sie, gab `start` den Einzelinstanz-Platz trotzdem frei und meldete „could not listen“. Seit F237 behält ein überlebender Thread den Platz, bis er endet, und `start` meldet ihn (`RpcServerLifecycleTest#aServerThatIgnoresTheStopIsReportedAndKeepsTheSlot`). ### F36 – java-llama.cpp: `LlamaIterator` wird nach `cancel()` aus einem zweiten Thread durch ein spät eintreffendes Ergebnis wiederbelebt @@ -442,6 +443,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** ohne Fix rot („expected: but was: “, Nul character not allowed), mit Fix grün; Gegenprobe per `git stash` rot, nach `stash pop` grün. `LlamaModelOfflineTest` 9/9, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `f3b8aed` +- **Nachtrag (Branch-Review 2026-10-20):** Jede `InvalidPathException` als „fehlt“ zu werten war zu weit: unter Linux wirft `Paths.get` auch für einen Nicht-ASCII-Namen in einer JVM mit POSIX-Locale, obwohl die Datei existiert. Seit F238 gilt das nur noch für das NUL-Zeichen und für Namen, die die JVM kodieren kann (`OfflineModelGuardTest`); der Test oben bleibt grün. ### F57 – java-llama.cpp: `ServerMetrics.getCumulativeUsage()` liefert ein `Usage` gegen dessen eigenen Vertrag @@ -1289,6 +1291,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** mit dem alten Mechanismus (`supplyAsync` je Anfrage, keine Freigabe) rot (4 von 8; die übrigen 4 prüfen unverändertes Verhalten), mit dem Fix grün. Ein modellgestützter Lauf (J5-Probe) steht aus: erwartet `maxProcessingSlots=8` auch auf 4 Kernen für `completeBatch`. - **Status:** gefixt - **Commit:** 8130463 +- **Nachtrag (Branch-Review 2026-10-20):** `submitAllThenReceive` fing nur `RuntimeException`; ein `Error` übersprang Freigabe und restliche Empfänge (behoben als F235). Spätere Fehler eines Batches gingen ohne Spur verloren (behoben als F236, gilt auch für `runEachOnItsOwnThread`). ### F168 – java-llama.cpp: `JllamaNative.takeStrings` gibt ein leeres Nicht-NULL-Array nicht frei From eafc272662e3c05079e5cdf5d8ad66c266114563 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:17:01 +0000 Subject: [PATCH 420/589] Import Charset in OfflineModelGuardTest instead of naming it in full The fallback test spelled java.nio.charset.Charset out three times while the class already imports its sibling StandardCharsets. No behaviour change; OfflineModelGuardTest stays green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/loader/OfflineModelGuardTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java b/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java index 94ca3be3f..b5982a899 100644 --- a/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java +++ b/llama/src/test/java/net/ladenthin/llama/loader/OfflineModelGuardTest.java @@ -8,6 +8,7 @@ import static org.hamcrest.Matchers.is; import java.io.IOException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; @@ -96,8 +97,8 @@ public void jnuEncoding_isTheCharsetOfTheProperty() { @Test public void jnuEncoding_anAbsentOrUnknownNameFallsBackToTheDefaultCharset() { - assertThat(OfflineModelGuard.jnuEncoding(null), is(java.nio.charset.Charset.defaultCharset())); - assertThat(OfflineModelGuard.jnuEncoding("no-such-charset"), is(java.nio.charset.Charset.defaultCharset())); - assertThat(OfflineModelGuard.jnuEncoding("bad name!"), is(java.nio.charset.Charset.defaultCharset())); + assertThat(OfflineModelGuard.jnuEncoding(null), is(Charset.defaultCharset())); + assertThat(OfflineModelGuard.jnuEncoding("no-such-charset"), is(Charset.defaultCharset())); + assertThat(OfflineModelGuard.jnuEncoding("bad name!"), is(Charset.defaultCharset())); } } From 166a2673b33629406fe8844dd3642007b83cb608 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:22:34 +0000 Subject: [PATCH 421/589] Start every slot with the server's LoRA adapters in patch 0021 (F240) slot.lora started empty, so on a server started with --lora the else branch patch 0021 added took the first task of every slot for an adapter change: it logged "0 loras -> 1 loras" and cleared the slot's prompt. On an empty slot that cost nothing, but a never-used slot can already hold a prompt computed under the server's adapters -- restored with SLOT_RESTORE, or loaded from the host-memory prompt cache by get_available_slot() -- and that state was thrown away and the whole prompt processed again, where the plain else branch before the patch kept it. A second hunk sets slot.lora = params_base.lora_adapters in init()'s slot loop, so the field means "the adapters the slot's state belongs to" from the start. The header describes the hunk and has a drop-check per hunk; docs/patches.md is regenerated, and the full patch set applies to the pristine b11639 source. LoraCacheFreshSlot.AStateRestoredIntoANeverUsedSlotIsKept saves slot 0 after a request, restores it into the never-used slot 1 and sends the same request there: red without the init() hunk ("Expected: (prompt_n) <= (2), actual: 77 vs 2"), green with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++ CLAUDE.md | 6 +- docs/patches.md | 16 +++- ...ache-when-the-server-adapters-change.patch | 26 +++++- llama/src/test/cpp/test_lora_cache.cpp | 79 +++++++++++++++++++ 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 8f27a9407..d3415b6b3 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -162,6 +162,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F237](#f237) — niedrig — *gefixt* - [F238](#f238) — niedrig — *gefixt* - [F239](#f239) — niedrig — *gefixt* +- [F240](#f240) — niedrig — *gefixt* ## Funde @@ -2001,6 +2002,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4205d20` +### F240 – java-llama.cpp: Patch 0021 hält einen nie benutzten Slot für einen Adapterwechsel und wirft dessen geladenen Prompt weg +- **Zeit:** 2026-10-21 02:22 UTC +- **Datei:** `llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch` (zweiter Hunk in `init()` von `tools/server/server-context.cpp`, Kopftext), `llama/src/test/cpp/test_lora_cache.cpp`, `docs/patches.md`, `CLAUDE.md` +- **Schwere:** niedrig (konservativ: kein falsches Ergebnis, nur ein verlorener Cache-Treffer und eine irreführende Trace-Zeile) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J12-2). Folge von F67 (Patch 0021), dessen `else`-Zweig den Vergleich `params_base.lora_adapters` gegen `slot.lora` einführte. +- **Beschreibung:** `server_slot::lora` beginnt leer, und `init()` setzt es nie. Auf einem mit `--lora` gestarteten Server vergleicht der `else`-Zweig von Patch 0021 deshalb bei der ersten Aufgabe jedes Slots `[]` mit `[Adapter]`: `lora_should_clear_cache([], next)` ist für eine gewöhnliche LoRA wahr, der Slot protokolliert „clearing cache for lora change. 0 loras -> 1 loras“ und ruft `slot.prompt.clear()` auf. Auf einem leeren Slot kostet das nichts; hält der nie benutzte Slot aber schon einen Prompt -- per `SLOT_RESTORE` wiederhergestellt oder von `get_available_slot()` aus dem RAM-Prompt-Cache geladen, beides unter den Adaptern des Servers berechnet --, geht dieser Zustand verloren und der ganze Prompt wird neu verarbeitet. Vor dem Patch behielt der bloße `else`-Zweig ihn; der Satz des Patch-Kopfs „Equal sets keep the cache exactly as before“ galt für die erste Aufgabe eines Slots also nicht. Upstreams Zweig für Anfragen mit eigenen Adaptern hat dieselbe Asymmetrie, wenn eine Anfrage die Adapter des Servers nennt. +- **Fix:** Ein zweiter Hunk in Patch 0021 setzt in der Slot-Schleife von `init()` neben `slot.id = i;` `slot.lora = params_base.lora_adapters;`, sodass `slot.lora` von Anfang an „die Adapter, zu denen der Zustand des Slots gehört“ bedeutet. Ein `SET_LORA` vor der ersten Aufgabe eines Slots ist weiter ein Wechsel und leert ihn (auf einem leeren Slot folgenlos); `slot.lora` wird sonst nur von `common_set_adapter_lora` nach dem Start und von `can_batch_with` gelesen, die Initialisierung ist dort neutral. Der Patch-Kopf beschreibt den Hunk und hat je Hunk einen Drop-Check; `docs/patches.md` neu erzeugt, die CLAUDE.md-Zeilen von 0021 und `test_lora_cache.cpp` nachgezogen (646 Tests). Der ganze Patch-Satz ist auf die unveränderte Quelle b11639 (`a0e6dc738`) angewandt und ergibt für `server-context.cpp` genau den Diff des Build-Baums; `check-patches.py`: 14 Patches, 97 Hunks, 0 Probleme. +- **Tests:** `LoraCacheFreshSlot.AStateRestoredIntoANeverUsedSlotIsKept` (neue Fixture: zufälliges Modell und zufällige LoRA, `-np 2`, `--cache-ram 0`, `--slot-save-path`; Anfrage auf Slot 0, Slot 0 speichern, in den nie benutzten Slot 1 wiederherstellen, dieselbe Anfrage auf Slot 1: `timings.prompt_n` ≤ 2 und dieselbe Antwort wie auf Slot 0). Der vom Reviewer vorgeschlagene Test (`-np 2`, LRU, zweimal derselbe Prompt) wäre leer gewesen, weil der erste Zustand dabei nie in den RAM-Cache gelangt; deshalb der Weg über Speichern/Wiederherstellen, den der Praxis-Skeptiker beschrieben hat. Alle 646 C++-Tests grün. +- **Rot-Grün-Nachweis:** Ohne den `init()`-Hunk rot („Expected: (prompt_n) <= (2), actual: 77 vs 2“, `cache_n` 0: der wiederhergestellte Prompt war geleert), mit ihm grün (`LoraCache*` 5/5). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CLAUDE.md b/CLAUDE.md index 7ec32da0c..a07336862 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1453,7 +1453,7 @@ Current patches: | `0018-ggml-amx-free-the-aligned-buffer.patch` | **The AMX weight buffer is freed with `free()` but allocated with `_aligned_malloc()` on Windows -- `STATUS_HEAP_CORRUPTION` (0xC0000374) the moment a model is unloaded.** `ggml_backend_amx_buffer_type_alloc_buffer()` (`ggml/src/ggml-cpu/amx/amx.cpp`) allocates with `ggml_aligned_malloc()`, which is `_aligned_malloc()` under `_MSC_VER` (plain clang on a `*-windows-msvc` target defines it too); `ggml_backend_amx_buffer_free_buffer()` called `free()` on it. Fine on Linux (`posix_memalign` + `free`), fatal on Windows, and only there. The patch frees with `ggml_aligned_free(buffer->context, buffer->size)`, exactly what `ggml_backend_cpu_buffer_free_buffer()` does; on Linux that is `free()`, so nothing changes there. **Reached by** the `sapphirerapids` CPU module (the only variant built with `AMX_TILE AMX_INT8`, which `ggml_backend_load_best` picks on Sapphire/Emerald/Granite Rapids; `ggml_amx_init()` is `return true` on `_WIN32`) and a model whose weights have AMX kernels (Q4_0, Q4_1, Q8_0, Q4_K, Q5_K, Q6_K, IQ4_XS -- i.e. every CI model). **Found by the second full run of the modular natives** (38069595101): `Java Tests Windows 2025 x86_64 (clang)` landed on an `Intel(R) Xeon(R) 6973P-C` runner (Granite Rapids), loaded `ggml-cpu-sapphirerapids.dll`, ran `GrammarExample`'s two generations fine and died with exit code -1073740940 at `LlamaModel.close()`; the first run (38065017595) had the same job green on an AMD EPYC 7763 (no AVX-512, `haswell`). **The Windows runners are a CPU lottery**, so which CPU module a Windows job exercises differs per run; before the modular natives the Windows jar was a static Haswell-level build and this code never ran. **Of the six callers of `ggml_aligned_malloc` in the tree this was the only mismatched pair** (`ggml-backend.cpp`, `ggml-cpu.c` twice, `ggml-openvino.cpp`, `ggml-zdnn.cpp` and `ggml.c` all pair correctly), and `ggml-backend.cpp:2464` is the line the fix copies. **Reproduced deliberately rather than waiting for the lottery again**: on a rented Granite Rapids machine, one CPU module staged at a time with the same model and the DLLs CI itself built, `sapphirerapids` crashed **6 of 6** runs -- including at **one** thread, which rules out a data race -- while `cooperlake`, `icelake` and `haswell` were clean 6 of 6, and on Linux with the same CPU all six exit 0 with byte-identical output. The crash lands *after* the inference prints its timings, in cleanup: the signature of a heap corrupted earlier and detected at `free`. With the patch applied the same matrix on the same machine is **clean in all twelve runs**. **The guard is a configure-time check, not a `jllama_test` case**: the AMX code exists only in that one variant of a `GGML_CPU_ALL_VARIANTS` build and executes only on AMX hardware, which the static test build, the local agent's Zen 3 and the cloud sandboxes lack -- so **`llama/cmake/check-aligned-free-pairing.cmake`** fails the configure on **every** platform when a ggml source calls `ggml_aligned_malloc` and never `ggml_aligned_free` (verified red against the unpatched source, naming `amx.cpp`, and green with this patch). A run-time test cannot replace it: without an AMX CPU there is nothing to observe, which is exactly how this shipped. What exercises it: the Windows x86-64 Java test job and the `windows-x86-64` smoke row whenever GitHub hands them an Intel AMX runner; the patch itself is held by `check-patches.py`, applies at b11639, and the patched file compiles with the variant's flags (GCC 13, `-mamx-tile -mamx-int8 -mavx512vnni ...`). Upstream master still carried the `free()` on 2026-10-10; **not yet filed upstream** (nothing before the release). **Drop-check on every bump, note the inversion:** `grep -n "free(buffer->context)" ggml/src/ggml-cpu/amx/amx.cpp` at the new tag -- a **hit** means the bare `free()` is still there and the patch is still needed; **gone** means upstream fixed it, drop the patch. Measured at b11639 (`a0e6dc738`): one hit, line 46. | | `0019-ggml-cpu-select-the-neon-fp32-to-fp16-conversion.patch` | **Every ARM build converts fp32 to fp16 in software, ours and upstream's.** The `__ARM_NEON` branch of `simd-mappings.h` defines both `GGML_CPU_COMPUTE_*` macros and selects only `FP16_TO_FP32`, so `GGML_CPU_FP32_TO_FP16` falls through to `GGML_COMPUTE_FP32_TO_FP16` -- ggml-base's deliberately portable software bit manipulation -- while `neon_compute_fp32_to_fp16`, one `fcvt`, stays defined and referenced by nothing. The `GGML_CPU_*` pair exists to be the *accelerated* variant; on ARM it was defined to be the unaccelerated one. `__POWER9_VECTOR__` and `__riscv_zfhmin` select both, and the comment on that fallback has always promised NEON does too. **Not a regression** -- absent in all 25 most recent commits touching the file, back to 2025-04-10. **Why only ARM feels it:** `ggml_cpu_fp32_to_fp16`, the `from_float` of `GGML_TYPE_F16`, has vector paths for F16C, RISC-V and s390x (the last from #30157) and **none** for NEON, so there the whole function is the scalar loop through the macro. **In the shipped artifacts:** no `fcvt` of any form in that function in any of this project's 17 ARM libraries (`cpu-linux-aarch64` 8 variants, `cpu-android-aarch64` 7, `cpu-windows-aarch64`, `metal-macos-aarch64`) nor in upstream's own `bin-ubuntu-arm64` / `bin-macos-arm64`; a plain `gcc` with no flags defines `__ARM_NEON` and `__ARM_FP16_FORMAT_IEEE` on aarch64, so nothing can miss it. **Measured 2.45x-13.3x** on five ARM core types, three operating systems and nine compiler builds, with the opposite direction in the same run as a control (within 3.2 % everywhere); **bit-exact over all 2^32 float inputs**, differing only in NaN payload, where x86 F16C gives the same numbers -- so it makes ARM agree with what x86 already ships. **Not an inference speed-up:** `perf` puts 83.9 % of an F16 `pp512` run in tinyBLAS's gemm, which converts in its own microkernel, and 0.03 % here. The patch also replaces the stale comment above the fallback with an `#error` pair that refuses a branch defining a COMPUTE macro without selecting it -- its position chosen by a mutation measurement, not by taste (4 of 4 correctness cases caught before the fallback against 3 of 4 directly after the chain). **The `FP16_TO_FP32` half carries `&& !defined(__F16C__)`, and that carve-out is load-bearing since the b11639 bump:** upstream #30343 (`685f70894` = b11635, merged 2026-10-14) puts that one direction back on the lookup table deliberately -- clang lowers the F16C intrinsic into the live accumulator register of the quantized dot products and serialises the loop, 45-65 % slower Q8_0/Q4_0 `MUL_MAT_ID` -- so without it the guard fires on **11 of the 14 x86-64 CPU variant modules**. Up to b11632 #30343 was absent, the `__F16C__` block still selected both directions and the carve-out was inert; from b11639 on the pin carries #30343, so x86 FP16->FP32 is on the lookup table exactly as upstream ships it, and the carve-out is what keeps the guard quiet there (`gcc -E` of the patched b11639 header silent for `-march=x86-64`, `-msse4.2`, `-march=haswell`, `-march=sapphirerapids`, `-march=znver4` and `-mavx -mf16c`). A **first** version of this patch also moved #30270's two F16C selections into the `__F16C__` branch; that was dropped when #30343 turned the block into a documented exception with an issue link, which is also why this diff now applies **both** at the pin and on upstream master (verified with `git apply --check` against each, and `clang -E` silent in all five configurations of the patched pinned source). Full record: [`docs/benchmarks/arm-fp16-conversion.md`](docs/benchmarks/arm-fp16-conversion.md). **Submitted upstream as [ggml-org/llama.cpp#30505](https://github.com/ggml-org/llama.cpp/pull/30505)** (open, rebased onto master 2026-10-14 at the maintainer's request), byte-identical to this diff -- the diff body of the patch file and `git diff` of the PR commit hash the same. **Drop-check on every bump, and its output must be read rather than tested:** `git show :ggml/src/ggml-cpu/simd-mappings.h \| sed -n '/^#if defined(__ARM_NEON)/,/^#elif/p' \| grep -n GGML_CPU_FP32_TO_FP16` prints **nothing** while the patch is needed and one line once upstream merged it -- then **drop this patch rather than refreshing it**. **Do not reduce the looser `git grep -n "GGML_CPU_FP32_TO_FP16(x) GGML_CPU_COMPUTE_FP32_TO_FP16"` over that file to a true/false:** it returns **three** hits at every tag so far (`__POWER9_VECTOR__`, `__riscv_zfhmin` and the out-of-chain `__F16C__` block, the first two with the NEON line's own four-space indent), so a `grep -q` reports "upstream fixed it" at every tag and deletes a fix that is still needed -- measured at b11639 (`a0e6dc738`): lines 71, 114 and 150, none in the `__ARM_NEON` branch, while `git apply --check` of the patch is clean. | | `0020-ggml-alloc-validate-leafs-before-reusing-an-allocation.patch` | **An `--embedding` model that is asked to generate crashes the process in `ggml_gallocr_alloc_graph`** (SIGSEGV on Linux, 0xC0000005 with both Windows toolchains), since upstream #30445 (`cb55ffe6e`, "graph: skip the LM head for pooled embeddings", b11574..b11612). `ggml_gallocr_alloc_graph()` assigns every tensor by **position** from the graph it was reserved with, and `ggml_gallocr_needs_realloc()` validates every node and every node's sources but of the **leafs only the count**. Since #30445 the LM head is expanded by `build_logits()` *after* `build_pooling()`, so an embeddings context reserves a graph that expands `t_embd` first and `t_logits` second, while a generation step on it (the server calls `llama_set_embeddings(ctx, false)` for a slot that samples) expands only `t_logits`, whose first source is `output.weight`: same node and leaf counts, every leaf after it shifted by one. An input leaf then lands on the slot of a weight (`buffer_id -1`) and, with the asserts compiled out, `ggml_vbuffer_tensor_alloc()` indexes `galloc->buffers[-1]`; a leaf landing on another input's slot is not size-checked either. The patch validates the leafs exactly as the nodes are validated, so an invalid leaf makes the allocator reserve again (one buffer) or return false, on which `ggml_backend_sched` reserves the graph itself. **Found by** Publish run 38648257387 (2026-10-14, the first full run since b11558): `ErrorHandlingTest` loads its model with `enableEmbedding()` and posts an infill request to it -- its first test in JUnit's order -- and the fork died in all three Java test jobs (`ggml_gallocr_alloc_graph <- ggml_backend_sched_alloc_graph <- llama_context::process_ubatch <- decode <- server_context_impl::update_slots`). **Reproduced locally without a downloaded model** (a random 2-layer llama GGUF with FIM tokens, context 128): infill crashes with `--embedding` and works without; a debug print showed leaf 31 `out_ids` arriving at a slot with no allocation; reverting `cb55ffe6e` makes it pass, and so does this patch -- with greedy sampling the `--embedding` model then returns the same tokens as the model without, run after run. Upstream master (`a0e6dc738`) still lacks the check; **not yet filed upstream**. **Runnable guard: `src/test/cpp/test_gallocr_leafs.cpp`** (two graphs with the same counts and the leafs swapped). **Drop-check on every bump:** read `ggml_gallocr_needs_realloc()` in `ggml/src/ggml-alloc.c` at the new tag -- once it walks `graph->leafs` itself, drop this patch rather than refreshing it. | -| `0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch` | **A slot's prompt cache survived a change of the LoRA adapters it was computed with.** `launch_slot_with_task()` (`tools/server/server-context.cpp`) clears the cache when a request names its own adapters and they differ from the slot's (`lora_should_clear_cache`, which keeps it only for aLoRA), but a request that names none takes `params_base.lora_adapters` in the `else` branch without that check. So the cache survived `SET_LORA` (`LlamaModel.setLoraAdapter(s)`, upstream's `POST /lora-adapters`) and the return to the server's adapters after a request with its own, and the slot answered like neither adapter set, silently. **Measured** (J9 of the bug-hunt brief, b11639, upstream's own LoRA test pair `stories15M_MOE-F16.gguf` + `moe_shakespeare15M.gguf`, greedy, one slot): after `setLoraAdapter(0, 0f)` the warm slot answered ". He saw a picture of a bird ..." where an emptied slot with the same scale answers " was a little girl ..."; only `eraseSlot(0)` restored it (F67). The patch applies the per-request rule in the `else` branch too; equal sets keep the cache as before. Upstream has the same defect on its HTTP route, so the patch is upstream-shaped; **not yet filed upstream**. Not covered, and said in `setLoraAdapters`' Javadoc: the host-memory prompt cache (`--cache-ram`, on by default) stores slot states without their adapters. **Runnable guard: `src/test/cpp/test_lora_cache.cpp`** (a random llama model and a random LoRA adapter on K, V and the FFN output, written by the test; four cases). **Drop-check on every bump:** read the `else` branch of the "process per-request lora adapters" block in `launch_slot_with_task()` at the new tag -- once it compares `params_base.lora_adapters` with `slot.lora` and clears the prompt (or `SET_LORA` clears the slots), drop this patch rather than refreshing it. | +| `0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch` | **A slot's prompt cache survived a change of the LoRA adapters it was computed with.** `launch_slot_with_task()` (`tools/server/server-context.cpp`) clears the cache when a request names its own adapters and they differ from the slot's (`lora_should_clear_cache`, which keeps it only for aLoRA), but a request that names none takes `params_base.lora_adapters` in the `else` branch without that check. So the cache survived `SET_LORA` (`LlamaModel.setLoraAdapter(s)`, upstream's `POST /lora-adapters`) and the return to the server's adapters after a request with its own, and the slot answered like neither adapter set, silently. **Measured** (J9 of the bug-hunt brief, b11639, upstream's own LoRA test pair `stories15M_MOE-F16.gguf` + `moe_shakespeare15M.gguf`, greedy, one slot): after `setLoraAdapter(0, 0f)` the warm slot answered ". He saw a picture of a bird ..." where an emptied slot with the same scale answers " was a little girl ..."; only `eraseSlot(0)` restored it (F67). The patch applies the per-request rule in the `else` branch too; equal sets keep the cache as before. **A second hunk starts every slot with the server's adapters in `init()`** (`slot.lora = params_base.lora_adapters;`): `slot.lora` used to start empty, so a never-used slot of a server started with `--lora` read as "computed under no adapters", and its first task logged `0 loras -> 1 loras` and cleared a prompt it already held -- a state restored into it (`SLOT_RESTORE`) or loaded from the host-memory prompt cache by `get_available_slot()`, both computed under the server's adapters (F240). Upstream has the same defect on its HTTP route, so the patch is upstream-shaped; **not yet filed upstream**. Not covered, and said in `setLoraAdapters`' Javadoc: the host-memory prompt cache (`--cache-ram`, on by default) stores slot states without their adapters. **Runnable guard: `src/test/cpp/test_lora_cache.cpp`** (a random llama model and a random LoRA adapter on K, V and the FFN output, written by the test; five cases). **Drop-check on every bump, one per hunk:** read the `else` branch of the "process per-request lora adapters" block in `launch_slot_with_task()` at the new tag -- once it compares `params_base.lora_adapters` with `slot.lora` and clears the prompt (or `SET_LORA` clears the slots), drop that hunk rather than refreshing it; and read the slot loop of `init()` -- once it sets `slot.lora` from `params_base.lora_adapters`, drop that hunk. The patch goes when both have. | **Dropped patches** (`0004`, `0005`, `0009`, `0010`, `0011`, `0012`, `0013` -- upstream fixed each defect itself) are recorded in [`docs/history/dropped-llama-patches.md`](docs/history/dropped-llama-patches.md): @@ -2621,9 +2621,9 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_c_api.cpp` | 31 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | -| `src/test/cpp/test_lora_cache.cpp` | 4 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. | +| `src/test/cpp/test_lora_cache.cpp` | 5 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. A second fixture (`LoraCacheFreshSlot`: two slots, `--slot-save-path`) saves slot 0 after a request and restores it into the never-used slot 1, whose request must take all of the prompt but at most two tokens from it and answer like slot 0 -- red before the `init()` hunk (`Expected: (prompt_n) <= (2), actual: 77 vs 2`), green with it (F240). | -**Current total: 645 tests (all passing).** +**Current total: 646 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/docs/patches.md b/docs/patches.md index 4014573ac..9fa5f9587 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -35,7 +35,7 @@ match a binary to this page. | 0018 | [`0018-ggml-amx-free-the-aligned-buffer.patch`](#0018-ggml-amx-free-the-aligned-bufferpatch) | ggml-cpu(amx): free the AMX weight buffer with ggml_aligned_free(), not free() | 1 | 1 | | 0019 | [`0019-ggml-cpu-select-the-neon-fp32-to-fp16-conversion.patch`](#0019-ggml-cpu-select-the-neon-fp32-to-fp16-conversionpatch) | ggml-cpu : select the NEON fp32->fp16 conversion | 1 | 2 | | 0020 | [`0020-ggml-alloc-validate-leafs-before-reusing-an-allocation.patch`](#0020-ggml-alloc-validate-leafs-before-reusing-an-allocationpatch) | ggml-alloc : validate the leafs before reusing a graph's allocation | 1 | 1 | -| 0021 | [`0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch`](#0021-server-clear-the-slot-cache-when-the-server-adapters-changepatch) | server : clear a slot's cache when the server's LoRA adapters change | 1 | 1 | +| 0021 | [`0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch`](#0021-server-clear-the-slot-cache-when-the-server-adapters-changepatch) | server : clear a slot's cache when the server's LoRA adapters change | 1 | 2 | ## 0001-win32-arg-parse-embed-guard.patch @@ -638,16 +638,26 @@ The patch applies the per-request rule in the else branch too: when params_base. differ from the slot's, lora_should_clear_cache() decides, the same trace lines are logged, and the slot takes the server's adapters. Equal sets keep the cache exactly as before. +For that to hold on a slot's first task, init() starts every slot with the server's adapters. +slot.lora used to start empty, so a never-used slot of a server started with --lora read as +"computed under no adapters": its first task logged "0 loras -> 1 loras" and cleared a prompt +the slot already held -- a state restored into it (SLOT_RESTORE) or loaded from the host-memory +prompt cache by get_available_slot(), both computed under the server's adapters. The same holds +for upstream's per-request branch when a request names the server's own adapters. A SET_LORA +before a slot's first task is still a change and clears, which costs nothing on an empty slot. + Not covered: the host-memory prompt cache (--cache-ram) stores slot states without the adapters they were computed with, so a state saved under other adapters can still be loaded into a slot; that holds for per-request adapters as much as for these. The guard is src/test/cpp/test_lora_cache.cpp: a random llama model and a random LoRA adapter on K, V and the FFN output, greedy, one slot; after each kind of change the warm slot must answer -like an emptied one. +like an emptied one. A second fixture with two slots restores slot 0's saved state into the +never-used slot 1, which must reuse it. Drop-check on every bump: read the else branch of the "process per-request lora adapters" block of launch_slot_with_task() in tools/server/server-context.cpp at the new tag -- once it compares params_base.lora_adapters with slot.lora and clears the prompt (or SET_LORA clears the slots), -drop this patch rather than refreshing it. +drop that hunk rather than refreshing it; and read the slot loop of init() -- once it sets +slot.lora from params_base.lora_adapters, drop that hunk. The patch goes when both have. ``` diff --git a/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch b/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch index e2cf77742..d161bcee6 100644 --- a/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch +++ b/llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch @@ -23,24 +23,42 @@ The patch applies the per-request rule in the else branch too: when params_base. differ from the slot's, lora_should_clear_cache() decides, the same trace lines are logged, and the slot takes the server's adapters. Equal sets keep the cache exactly as before. +For that to hold on a slot's first task, init() starts every slot with the server's adapters. +slot.lora used to start empty, so a never-used slot of a server started with --lora read as +"computed under no adapters": its first task logged "0 loras -> 1 loras" and cleared a prompt +the slot already held -- a state restored into it (SLOT_RESTORE) or loaded from the host-memory +prompt cache by get_available_slot(), both computed under the server's adapters. The same holds +for upstream's per-request branch when a request names the server's own adapters. A SET_LORA +before a slot's first task is still a change and clears, which costs nothing on an empty slot. + Not covered: the host-memory prompt cache (--cache-ram) stores slot states without the adapters they were computed with, so a state saved under other adapters can still be loaded into a slot; that holds for per-request adapters as much as for these. The guard is src/test/cpp/test_lora_cache.cpp: a random llama model and a random LoRA adapter on K, V and the FFN output, greedy, one slot; after each kind of change the warm slot must answer -like an emptied one. +like an emptied one. A second fixture with two slots restores slot 0's saved state into the +never-used slot 1, which must reuse it. Drop-check on every bump: read the else branch of the "process per-request lora adapters" block of launch_slot_with_task() in tools/server/server-context.cpp at the new tag -- once it compares params_base.lora_adapters with slot.lora and clears the prompt (or SET_LORA clears the slots), -drop this patch rather than refreshing it. +drop that hunk rather than refreshing it; and read the slot loop of init() -- once it sets +slot.lora from params_base.lora_adapters, drop that hunk. The patch goes when both have. diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp -index 22a7805aa..e33a01cbd 100644 +index 22a7805aa..8293b7bfd 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp -@@ -1882,7 +1882,15 @@ +@@ -1447,6 +1447,7 @@ + server_slot & slot = slots[i]; + + slot.id = i; ++ slot.lora = params_base.lora_adapters; + slot.ctx_tgt = ctx_tgt; + slot.ctx_dft = ctx_dft; + slot.mem.init(ctx_tgt, ctx_dft); +@@ -1882,7 +1883,15 @@ } slot.lora = task_loras; } diff --git a/llama/src/test/cpp/test_lora_cache.cpp b/llama/src/test/cpp/test_lora_cache.cpp index e7d599d1b..99c7a53b7 100644 --- a/llama/src/test/cpp/test_lora_cache.cpp +++ b/llama/src/test/cpp/test_lora_cache.cpp @@ -183,3 +183,82 @@ TEST_F(LoraCache, TheServerAdaptersAfterARequestWithItsOwnAreNotAnsweredFromItsC EXPECT_EQ(after_change, fresh_answer()); } + +namespace { + +// Two slots and a slot-save directory, for the one case the shared fixture above cannot reach: a +// slot that has never run a task. Its adapters must already be the server's, so a state restored +// into it is kept like on any other slot (patches/0021, init() hunk). +class LoraCacheFreshSlot : public ::testing::Test { + protected: + static void SetUpTestSuite() { + model_path_ = write_tiny_llama_gguf("jllama-lora-cache-fresh-model"); + adapter_path_ = write_tiny_lora_gguf(); + save_dir_ = (std::filesystem::temp_directory_path() / + ("jllama-lora-cache-slots-" + std::to_string(std::random_device{}()))) + .string(); + ASSERT_FALSE(model_path_.empty()); + ASSERT_FALSE(adapter_path_.empty()); + ASSERT_TRUE(std::filesystem::create_directories(save_dir_)); + const std::vector args = { + "jllama", "-m", model_path_, "--lora", adapter_path_, "-c", "256", "-np", + "2", "-ngl", "0", "-t", "2", "--cache-ram", "0", "--slot-save-path", + save_dir_, "--no-warmup"}; + std::vector argv; + for (const auto &a : args) { + argv.push_back(a.c_str()); + } + ASSERT_EQ(jllama_model_load(static_cast(argv.size()), argv.data(), nullptr, nullptr, &model_), + JLLAMA_STATUS_OK) + << jllama_last_error(); + } + static void TearDownTestSuite() { + jllama_model_free(model_); + model_ = nullptr; + std::error_code ignored; + std::filesystem::remove(model_path_, ignored); + std::filesystem::remove(adapter_path_, ignored); + std::filesystem::remove_all(save_dir_, ignored); + } + + static json complete_on(int slot) { + const json request = {{"prompt", PROMPT}, {"n_predict", 8}, {"temperature", 0}, + {"top_k", 1}, {"cache_prompt", true}, {"id_slot", slot}}; + Owned response; + EXPECT_EQ(jllama_completions(model_, request.dump().c_str(), &response.s), JLLAMA_STATUS_OK) + << jllama_last_error(); + return json::parse(response.str()); + } + static void slot_action(jllama_slot_op op, int slot, const char *filename) { + Owned response; + ASSERT_EQ(jllama_slot_action(model_, op, slot, filename, &response.s), JLLAMA_STATUS_OK) << jllama_last_error(); + } + + static std::string model_path_; + static std::string adapter_path_; + static std::string save_dir_; + static jllama_model *model_; +}; +std::string LoraCacheFreshSlot::model_path_; +std::string LoraCacheFreshSlot::adapter_path_; +std::string LoraCacheFreshSlot::save_dir_; +jllama_model *LoraCacheFreshSlot::model_ = nullptr; + +} // namespace + +TEST_F(LoraCacheFreshSlot, AStateRestoredIntoANeverUsedSlotIsKept) { + ASSERT_NE(model_, nullptr); + const json first = complete_on(0); + slot_action(JLLAMA_SLOT_SAVE, 0, "slot0.bin"); + slot_action(JLLAMA_SLOT_RESTORE, 1, "slot0.bin"); // slot 1 has never run a task + + const json restored = complete_on(1); + + // Computed under the same adapters as the restored state, so all of the prompt but the token + // the slot always re-evaluates comes from it; a slot that took this for an adapter change + // cleared it and processed the whole prompt again. + const int prompt_n = restored.at("timings").at("prompt_n").get(); + const int evaluated = restored.at("tokens_evaluated").get(); + EXPECT_LE(prompt_n, 2) << "of " << evaluated << " prompt tokens: " << restored.dump(); + EXPECT_EQ(restored.at("content").get(), first.at("content").get()); +} From 91eafcc6406435c6f947fe5428fb1f612c5d1276 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:22:35 +0000 Subject: [PATCH 422/589] Record the commit of F240 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index d3415b6b3..ec9a02b11 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2012,7 +2012,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `LoraCacheFreshSlot.AStateRestoredIntoANeverUsedSlotIsKept` (neue Fixture: zufälliges Modell und zufällige LoRA, `-np 2`, `--cache-ram 0`, `--slot-save-path`; Anfrage auf Slot 0, Slot 0 speichern, in den nie benutzten Slot 1 wiederherstellen, dieselbe Anfrage auf Slot 1: `timings.prompt_n` ≤ 2 und dieselbe Antwort wie auf Slot 0). Der vom Reviewer vorgeschlagene Test (`-np 2`, LRU, zweimal derselbe Prompt) wäre leer gewesen, weil der erste Zustand dabei nie in den RAM-Cache gelangt; deshalb der Weg über Speichern/Wiederherstellen, den der Praxis-Skeptiker beschrieben hat. Alle 646 C++-Tests grün. - **Rot-Grün-Nachweis:** Ohne den `init()`-Hunk rot („Expected: (prompt_n) <= (2), actual: 77 vs 2“, `cache_n` 0: der wiederhergestellte Prompt war geleert), mit ihm grün (`LoraCache*` 5/5). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `166a267` ## Offene Verdachte ohne Fund-ID From 1047673e9a292cac664de43588631fdbf49cac7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:35:48 +0000 Subject: [PATCH 423/589] Keep an RPC stop issued before the bind in the C API (F241) Patch 0015 clears ggml's stop flag when the server binds its socket, so a stop that lands before the bind (device initialization, transport start) is lost and the server goes on listening. F30 repeated the stop in RpcServer.stopAndJoin, the JNI path alone; jllama_rpc_server_stop() and FfmRpcServer.stop() stayed a single pass-through, so a C or FFM caller that stopped a server that had not come up yet left it running. jllama_rpc_server_serve() now marks itself in progress, and a stop in that time is kept: serve does not start ggml's server once it is set, and a stop from another thread repeats ggml's stop every 50 ms until serve has returned, failing after 30 s. A stop on the serving thread itself returns at once. Without a serve in progress the stop stays a no-op and never reaches the next serve, which is why ggml clears its flag at all; a stop before the serving thread has entered serve is indistinguishable from that and is documented in jllama_api.h and FfmRpcServer. A test-only seam, jllama::rpc::set_serve_hook_for_testing(), runs on the serving thread between the device selection and ggml's call. CApiRpc.AStopBeforeTheBindFromTheServingThreadEndsTheServe and ...FromAnotherThreadReturnsOnlyOnceTheServeHasEnded were red with the seam but without the fix ("Value of: run.returned_within(...) Actual: false Expected: true", "the stop returned while serve had not even started the server") and are green with it; a stop while nothing serves does not stop the next serve, in C++ and over FFM. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++ CLAUDE.md | 9 +- .../net/ladenthin/llama/ffm/FfmRpcServer.java | 14 +- .../ladenthin/llama/ffm/FfmRpcServerTest.java | 62 ++++++++ llama/src/main/cpp/jllama_api.cpp | 104 +++++++++++- llama/src/main/cpp/jllama_api.h | 13 +- llama/src/main/cpp/rpc_support.hpp | 7 + .../java/net/ladenthin/llama/RpcServer.java | 8 +- llama/src/test/cpp/test_c_api.cpp | 149 ++++++++++++++++++ 9 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmRpcServerTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ec9a02b11..ab29365af 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -163,6 +163,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F238](#f238) — niedrig — *gefixt* - [F239](#f239) — niedrig — *gefixt* - [F240](#f240) — niedrig — *gefixt* +- [F241](#f241) — niedrig bis mittel — *gefixt* ## Funde @@ -2014,6 +2015,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `166a267` +### F241 – java-llama.cpp: `jllama_rpc_server_stop`/`FfmRpcServer.stop` verlieren einen Stopp vor dem Bind; der Server lauscht weiter +- **Zeit:** 2026-10-21 02:35 UTC +- **Datei:** `llama/src/main/cpp/jllama_api.cpp` (`jllama_rpc_server_serve`, `jllama_rpc_server_stop`, neu `rpc_serving_scope`), `llama/src/main/cpp/jllama_api.h`, `llama/src/main/cpp/rpc_support.hpp` (Test-Seam), `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` (Javadoc), `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (Kommentar von `stopAndJoin`), `CLAUDE.md` +- **Schwere:** niedrig bis mittel (verwaister Listener, auf einem Netzwerk-Host ein offener Port; nur über die C-API und FFM, der JNI-`RpcServer` hatte die Wiederholung von F30) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund XJ-1). F30 hat den verlorenen Stopp vor dem Bind nur in `RpcServer.stopAndJoin` (Java) behoben; die C-API und `FfmRpcServer` rufen dieselbe native Funktion einmal auf, und `FfmRpcServer.serve` empfahl das Abfragen von `isListening()` „as RpcServer does“. +- **Beschreibung:** Patch 0015 setzt in `ggml_backend_rpc_start_server` nach dem Binden `g_rpc_server_stop = false`. Ein `ggml_backend_rpc_stop_server()`, das davor ankommt (Geräteinitialisierung, Transport-Start), setzt das Flag, findet noch keine Weck-Adresse und wird beim Bind gelöscht: der Server lauscht weiter, bis ihn jemand erneut stoppt. `jllama_rpc_server_stop()` war ein einmaliger Durchreicher („no-op when none runs“), `FfmRpcServer.stop()` ebenso; ein FFM- oder C-Aufrufer, der nach einem Timeout einmal stoppt, glaubte den Server beendet. Das Zurücksetzen beim Bind selbst ist tragend: ohne es würde ein liegengebliebener Stopp (ohne laufenden Server spezifiziert als No-op) den nächsten Serve sofort beenden. +- **Fix:** Die C-API führt den Zustand selbst (Variante (b) des Praxis-Skeptikers, Patch 0015 bleibt unverändert): `jllama_rpc_server_serve` markiert sich für seine ganze Dauer als laufend (`rpc_serving_scope`, Zähler unter Mutex, dazu ein `thread_local` für den Serving-Thread); ein Stopp während eines laufenden Serve wird als Anforderung gehalten. Serve startet den ggml-Server nicht mehr, wenn die Anforderung vor dem Aufruf vorliegt, und gibt OK zurück; ein Stopp von einem anderen Thread wiederholt `ggml_backend_rpc_stop_server()` alle 50 ms, bis Serve zurückgekehrt ist, und gibt erst dann OK zurück, nach 30 s `JLLAMA_STATUS_ERROR` („the RPC server did not stop within 30 s of the stop request“). Ein Stopp auf dem Serving-Thread selbst (aus einem Callback vor dem Start) kehrt sofort zurück. Ohne laufenden Serve bleibt der Stopp ein No-op, und die Anforderung endet mit dem letzten Serve, erreicht also nie den nächsten. Ein Stopp, bevor der Serving-Thread `jllama_rpc_server_serve` überhaupt betreten hat, ist von „keiner läuft“ nicht zu unterscheiden und bleibt verloren; das steht jetzt in `jllama_api.h` und im Javadoc von `FfmRpcServer.stop`/`serve` (wiederholen, bis Serve zurückgekehrt ist, wie `RpcServer`). Test-Seam: `jllama::rpc::set_serve_hook_for_testing()` (in `rpc_support.hpp` deklariert, nicht exportiert), aufgerufen nach der Geräteauswahl und vor dem ggml-Aufruf. Folge für den JNI-Pfad: `RpcServer.close()` und `stopAndJoin` warten jetzt schon im nativen Stopp; im pathologischen Fall, dass Serve 30 s nach dem Stopp nicht zurückkehrt, wirft der native Stopp eine `LlamaException`, statt dass `close()` nur warnt. +- **Tests:** `CApiRpc.AStopBeforeTheBindFromTheServingThreadEndsTheServe` (Stopp aus dem Seam auf dem Serving-Thread), `CApiRpc.AStopBeforeTheBindFromAnotherThreadReturnsOnlyOnceTheServeHasEnded` (Serve im Seam festgehalten, Stopp von einem anderen Thread darf nicht vor dem Serve zurückkehren, danach endet der Serve), `CApiRpc.AStopWhileNoServeRunsDoesNotStopTheNextOne` (Schutz gegen einen klebenden Stopp), `FfmRpcServerTest.aStopWhileNoServeRunsDoesNotStopTheNextServeAndAStopEndsIt` (FFM über Loopback: ein vorheriger Stopp ohne Server hält den nächsten Serve nicht ab, ein Stopp beendet ihn; der Fall vor dem Bind braucht den Seam und ist in C++ gepinnt). Daneben grün: alle 649 C++-Tests, `RpcServerTest`, `RpcServerLifecycleTest`, `RpcServerOptionsTest`, `RpcIntegrationTest` (Winzmodell), `llama-ffm verify` 36/36. +- **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne den Fix rot: „Value of: run.returned_within(std::chrono::seconds(10)) Actual: false Expected: true — serve went on after a stop issued before the bind“ und „Value of: jllama_rpc_server_listening() Actual: true Expected: false“ (erster Test); „Value of: stop_changed.wait_for(lock, std::chrono::milliseconds(300), …) Actual: true Expected: false — the stop returned while serve had not even started the server“ (zweiter Test). Mit dem Fix grün (`CApiRpc*`/`Rpc*` 32/32, dreimal). Der dritte und der FFM-Test sind Regressionsschutz und vorher wie nachher grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CLAUDE.md b/CLAUDE.md index a07336862..7ae409e41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1642,7 +1642,10 @@ without `libjllama` and its whole Java-side lifecycle is tested with a fake back (`RpcServerLifecycleTest`) — the analysis build (SonarCloud, no native library) sees it covered, and a new lifecycle branch cannot hide behind "needs natives". The in-JVM `rpc-server`: ggml's blocking `ggml_backend_rpc_start_server()` runs on a daemon Java thread, `close()` calls -`ggml_backend_rpc_stop_server()` (patch `0015`), and `start` waits for +`ggml_backend_rpc_stop_server()` (patch `0015`) through `jllama_rpc_server_stop()` -- which keeps a +stop issued during the serve call but before ggml binds the socket (ggml clears its own flag there) +and returns once the serve has, so the FFM and C callers get what `RpcServer`'s Java retry gave the +JNI path alone (F241) -- and `start` waits for `ggml_backend_rpc_server_listening()` so a bind failure is a `LlamaException` rather than a silently dead thread. It serves every accelerator, else the CPU — **never an RPC device** (`jllama::rpc::server_devices()`), which in a JVM that is also a client would forward to itself. @@ -2618,12 +2621,12 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_model_flags.cpp` | 4 | **The contract between the Java CLI-flag registries and llama.cpp's server argument parser.** CMake reads `ModelFlag.java` + `ModelOption.java` (`cmake/extract-java-wire-names.cmake` → a generated header of `{name, contract}` pairs), and this file asserts every `SERVER_PARSER` name is in `common_params_parser_init(params, LLAMA_EXAMPLE_SERVER).options`. It exists because **no Java test can catch this class**: `ModelFlagTest`/`ModelParametersExtendedTest` pin the *string mapping* (`hasKey("--mlock")`), never that llama.cpp still accepts the string, so they stay green forever while the flag is dead — and `common_params_parse` treats an unregistered option as a hard error, so the affected builder method makes the model **unloadable**, not merely ineffective. **A grep over `arg.cpp` is not a substitute**: `--grp-attn-n`/`-w` are present there at every pinned tag but `set_examples()`-scoped to `LLAMA_EXAMPLE_COMPLETION`/`PASSKEY`, so the server parser rejects them exactly like a deleted flag — only the real option table sees that. `--vocab-only` is the one exemption, and it declares itself `CliContract.PROJECT_PSEUDO` on its own constant rather than appearing in a list inside this file; the test asserts such a name is **still unknown** to the parser (an exemption upstream later registers would be hiding a real check) and that the exempt set is non-empty. | | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). | | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. | -| `src/test/cpp/test_c_api.cpp` | 31 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. | +| `src/test/cpp/test_c_api.cpp` | 34 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. Three `CApiRpc` cases pin `jllama_rpc_server_stop()` around the bind: a stop issued before ggml binds the socket -- from the serving thread through the test seam `jllama::rpc::set_serve_hook_for_testing()` (`rpc_support.hpp`, not exported), or from another thread while the serve is held in that seam, where the stop must not return before the serve has -- ends the serve instead of being lost (patches/0015 clears ggml's stop flag at the bind; red before F241), and a stop while nothing serves does not stop the next serve. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 5 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`tokens_cached > 0`). **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. A second fixture (`LoraCacheFreshSlot`: two slots, `--slot-save-path`) saves slot 0 after a request and restores it into the never-used slot 1, whose request must take all of the prompt but at most two tokens from it and answer like slot 0 -- red before the `init()` hunk (`Expected: (prompt_n) <= (2), actual: 77 vs 2`), green with it (F240). | -**Current total: 646 tests (all passing).** +**Current total: 649 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java index 7b5e31fe8..3e17643a3 100644 --- a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java +++ b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java @@ -27,7 +27,9 @@ private FfmRpcServer() {} /** * Serves until {@link #stop()}: blocks the calling thread for the life of the server, * or returns at once when the socket cannot be bound (check {@link #isListening()} from another - * thread to tell the two apart, as {@code RpcServer} does). + * thread to tell the two apart, as {@code RpcServer} does). A caller that gives up waiting for the + * server to listen stops it the way {@link #stop()} describes: repeatedly until this call has + * returned, since a stop before the serving thread has entered it is lost. * * @param host the IPv4 literal to bind * @param port the port @@ -45,7 +47,15 @@ public static void serve(String host, int port, int threads, String cacheDir, St } } - /** Stops the running server; a no-op when none runs. */ + /** + * Stops the server and returns once {@link #serve} has returned, also for a stop that arrives before + * the socket is bound. A no-op when no {@link #serve} call is in progress, and it does not carry over + * to the next one: a stop issued before the serving thread has entered {@link #serve} is lost, so + * stopping a server that may not have come up yet means repeating this until {@link #serve} has + * returned. + * + * @throws LlamaException when {@link #serve} has not returned 30 seconds after the request + */ public static void stop() { JllamaNative.call(JllamaNative.RPC_SERVER_STOP); } diff --git a/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmRpcServerTest.java b/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmRpcServerTest.java new file mode 100644 index 000000000..2b614e07b --- /dev/null +++ b/llama-ffm/src/test/java/net/ladenthin/llama/ffm/FfmRpcServerTest.java @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT +package net.ladenthin.llama.ffm; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * {@link FfmRpcServer} over loopback, serving the CPU: no model. The stop-before-bind case itself + * needs a seam inside the serve call and is pinned in {@code test_c_api.cpp}; this pins the FFM half of + * the contract around it. + */ +class FfmRpcServerTest { + + /** Upper bound for the waits below; reaching it is a failure, not a slow machine. */ + private static final long AWAIT_MILLIS = 10_000; + + /** Ports are tried from a range, so parallel jobs on one runner do not collide. */ + private static final int ATTEMPTS = 50; + + @Test + void aStopWhileNoServeRunsDoesNotStopTheNextServeAndAStopEndsIt() throws Exception { + FfmRpcServer.stop(); // nothing runs: a no-op that must not carry over + + boolean served = false; + for (int attempt = 0; attempt < ATTEMPTS && !served; attempt++) { + int port = 42_000 + (int) ((System.nanoTime() / 1000 + attempt * 97L) % 20_000); + AtomicReference failure = new AtomicReference<>(); + Thread serve = new Thread( + () -> { + try { + FfmRpcServer.serve("127.0.0.1", port, 2, null, "CPU"); + } catch (Throwable t) { + failure.set(t); + } + }, + "ffm-rpc-serve-" + port); + serve.setDaemon(true); + serve.start(); + long deadline = System.nanoTime() + AWAIT_MILLIS * 1_000_000L; + while (!FfmRpcServer.isListening() && serve.isAlive() && System.nanoTime() < deadline) { + serve.join(10); + } + if (FfmRpcServer.isListening()) { + served = true; + FfmRpcServer.stop(); + serve.join(AWAIT_MILLIS); + assertThat("serve returned after the stop", serve.isAlive(), is(false)); + assertThat(failure.get(), is(nullValue())); + } else { + serve.join(AWAIT_MILLIS); // the port was taken: serve returned at once + } + } + assertThat("listened on a free port of the range", served, is(true)); + assertThat(FfmRpcServer.isListening(), is(false)); + } +} diff --git a/llama/src/main/cpp/jllama_api.cpp b/llama/src/main/cpp/jllama_api.cpp index a18153e7c..61379f37f 100644 --- a/llama/src/main/cpp/jllama_api.cpp +++ b/llama/src/main/cpp/jllama_api.cpp @@ -23,8 +23,12 @@ #endif #include +#include +#include +#include #include #include +#include #include #include @@ -97,8 +101,68 @@ void log_adapter(enum ggml_log_level level, const char *text, void * /*user_data } } +// jllama::rpc::set_serve_hook_for_testing(). +std::atomic g_rpc_serve_hook{nullptr}; + +// The serves in progress, for jllama_rpc_server_stop(). ggml clears its stop flag when it binds the +// socket (patches/0015), so a stop of ggml's alone is lost when it lands before the bind (device +// initialization, transport start) and the server goes on listening. A stop while a serve is in +// progress is therefore kept here: serve does not start ggml's server once it is set, and the +// stopping thread repeats ggml's stop until serve has returned. A stop while none is in progress +// stays a no-op, and the request ends with the last serve, so it never reaches the next one -- the +// reason ggml clears its flag in the first place. +struct rpc_serve_state { + std::mutex mutex; + std::condition_variable ended; + int serving = 0; + bool stop_requested = false; +}; + +rpc_serve_state &rpc_serve() { + static rpc_serve_state state; + return state; +} + +// Whether this thread is inside jllama_rpc_server_serve(): a stop from there cannot wait for it. +thread_local bool t_in_rpc_serve = false; + +constexpr auto RPC_STOP_RETRY = std::chrono::milliseconds(50); +constexpr auto RPC_STOP_TIMEOUT = std::chrono::seconds(30); + +// Marks one jllama_rpc_server_serve() call in progress, for its whole duration. +class rpc_serving_scope { + public: + rpc_serving_scope() { + rpc_serve_state &state = rpc_serve(); + std::lock_guard lock(state.mutex); + if (state.serving++ == 0) { + state.stop_requested = false; + } + t_in_rpc_serve = true; + } + ~rpc_serving_scope() { + t_in_rpc_serve = false; + rpc_serve_state &state = rpc_serve(); + std::lock_guard lock(state.mutex); + if (--state.serving == 0) { + state.stop_requested = false; + } + state.ended.notify_all(); + } + rpc_serving_scope(const rpc_serving_scope &) = delete; + rpc_serving_scope &operator=(const rpc_serving_scope &) = delete; + + static bool stop_requested() { + rpc_serve_state &state = rpc_serve(); + std::lock_guard lock(state.mutex); + return state.stop_requested; + } +}; + } // namespace +void jllama::rpc::set_serve_hook_for_testing(void (*hook)()) { g_rpc_serve_hook.store(hook); } + extern "C" { const char *jllama_api_version(void) { return JLLAMA_VERSION_STRING; } @@ -553,12 +617,20 @@ jllama_status jllama_rpc_server_serve(const char *host, int port, int threads, c if (host == nullptr || devices == nullptr) { return fail(JLLAMA_STATUS_INVALID_ARGUMENT, "host and devices must not be null"); } + rpc_serving_scope serving; try { const std::string endpoint = std::string(host) + ":" + std::to_string(port); auto selected = jllama::rpc::server_devices(jllama::rpc::split_endpoints(devices)); if (selected.empty()) { throw std::runtime_error("no device to serve over RPC"); } + if (auto *hook = g_rpc_serve_hook.load()) { + hook(); + } + if (rpc_serving_scope::stop_requested()) { + t_last_error.clear(); + return JLLAMA_STATUS_OK; // stopped before the server started + } // Blocks until jllama_rpc_server_stop(), or returns at once when the socket cannot be bound. jllama::rpc::rpc_proc("ggml_backend_rpc_start_server")( endpoint.c_str(), cache_dir != nullptr && cache_dir[0] != '\0' ? cache_dir : nullptr, @@ -574,9 +646,35 @@ jllama_status jllama_rpc_server_serve(const char *host, int port, int threads, c jllama_status jllama_rpc_server_stop(void) { try { - jllama::rpc::rpc_proc("ggml_backend_rpc_stop_server")(); - t_last_error.clear(); - return JLLAMA_STATUS_OK; + auto *stop_server = + jllama::rpc::rpc_proc("ggml_backend_rpc_stop_server"); + rpc_serve_state &state = rpc_serve(); + std::unique_lock lock(state.mutex); + if (state.serving > 0) { + state.stop_requested = true; + } + if (state.serving == 0 || t_in_rpc_serve) { + // Nothing to wait for: none runs, or this is the serving thread itself, before ggml's + // server started (serve sees the request and does not start it). + lock.unlock(); + stop_server(); + t_last_error.clear(); + return JLLAMA_STATUS_OK; + } + const auto deadline = std::chrono::steady_clock::now() + RPC_STOP_TIMEOUT; + for (;;) { + lock.unlock(); + stop_server(); // lost while the socket is not bound yet, hence the repetition + lock.lock(); + const auto next = std::min(std::chrono::steady_clock::now() + RPC_STOP_RETRY, deadline); + if (state.ended.wait_until(lock, next, [&state]() { return state.serving == 0; })) { + t_last_error.clear(); + return JLLAMA_STATUS_OK; + } + if (std::chrono::steady_clock::now() >= deadline) { + return fail(JLLAMA_STATUS_ERROR, "the RPC server did not stop within 30 s of the stop request"); + } + } } catch (const std::exception &e) { return fail(JLLAMA_STATUS_ERROR, e.what()); } diff --git a/llama/src/main/cpp/jllama_api.h b/llama/src/main/cpp/jllama_api.h index 0eff008b2..c0c51145e 100644 --- a/llama/src/main/cpp/jllama_api.h +++ b/llama/src/main/cpp/jllama_api.h @@ -365,7 +365,18 @@ JLLAMA_API void jllama_strings_free(char **strings, size_t count); JLLAMA_API jllama_status jllama_rpc_server_serve(const char *host, int port, int threads, const char *cache_dir, const char *devices); -/** Stops the running RPC server (no-op when none runs). */ +/** + * Stops the RPC server and returns once jllama_rpc_server_serve() has returned -- also for a stop + * that arrives before the socket is bound (device initialization), which ggml alone would lose. + * JLLAMA_STATUS_ERROR when serve has not returned 30 s after the request. Called on the serving + * thread itself (from a callback that runs there before the server starts), it returns at once and + * serve returns without starting the server. + * + * A no-op when no serve call is in progress, and it does not carry over to the next one: a stop + * issued before the serving thread has entered jllama_rpc_server_serve() is lost, so a caller that + * stops a server that may not have come up yet repeats the stop until serve has returned, the way + * the Java RpcServer does. + */ JLLAMA_API jllama_status jllama_rpc_server_stop(void); /** True while the RPC server listens. */ diff --git a/llama/src/main/cpp/rpc_support.hpp b/llama/src/main/cpp/rpc_support.hpp index 6f1ab3077..da55c136c 100644 --- a/llama/src/main/cpp/rpc_support.hpp +++ b/llama/src/main/cpp/rpc_support.hpp @@ -378,4 +378,11 @@ inline std::optional exclude_stale_devices(std::vector #include #include +#include #include #include #include @@ -677,6 +679,153 @@ TEST(CApiRpc, TheRpcServerServesTheCpuOverLoopbackAndStops) { EXPECT_EQ(jllama_rpc_server_serve("127.0.0.1", 1, 2, nullptr, "no-such-device"), JLLAMA_STATUS_ERROR); } +namespace { + +// A port of the range the serve test above uses (a fixed port would collide with parallel jobs). +int rpc_test_port(int attempt) { + return 42000 + static_cast( + (std::chrono::steady_clock::now().time_since_epoch().count() / 1000 + attempt * 97) % 20000); +} + +// jllama_rpc_server_serve() on a thread of its own, with a way to wait for it to return. +class rpc_serve_run { + public: + explicit rpc_serve_run(int port) + : thread_([this, port]() { + const jllama_status status = jllama_rpc_server_serve("127.0.0.1", port, 2, nullptr, "CPU"); + std::lock_guard lock(mutex_); + status_ = status; + done_ = true; + ended_.notify_all(); + }) {} + rpc_serve_run(const rpc_serve_run &) = delete; + rpc_serve_run &operator=(const rpc_serve_run &) = delete; + // A serve that is still running (the test failed) is stopped once it listens, so the thread ends. + ~rpc_serve_run() { + while (!returned_within(std::chrono::milliseconds(100))) { + jllama_rpc_server_stop(); + } + thread_.join(); + } + + bool returned_within(std::chrono::milliseconds timeout) { + std::unique_lock lock(mutex_); + return ended_.wait_for(lock, timeout, [this]() { return done_; }); + } + jllama_status status() { + std::lock_guard lock(mutex_); + return status_; + } + + private: + std::mutex mutex_; + std::condition_variable ended_; + bool done_ = false; + jllama_status status_ = JLLAMA_STATUS_ERROR; + std::thread thread_; // last: it reads the members above +}; + +std::atomic g_hook_stop_status{JLLAMA_STATUS_ERROR}; + +// Holds the serving thread in the seam until released, so a stop from another thread lands while +// serve runs and before ggml binds. +struct held_serve { + std::mutex mutex; + std::condition_variable changed; + bool entered = false; + bool released = false; +}; +held_serve g_held; + +// Resets the seam when a test ends, whatever its outcome. +struct serve_hook_scope { + explicit serve_hook_scope(void (*hook)()) { jllama::rpc::set_serve_hook_for_testing(hook); } + ~serve_hook_scope() { jllama::rpc::set_serve_hook_for_testing(nullptr); } + serve_hook_scope(const serve_hook_scope &) = delete; + serve_hook_scope &operator=(const serve_hook_scope &) = delete; +}; + +} // namespace + +// ggml clears its stop flag when it binds the socket (patches/0015), so a stop issued before the +// bind used to be lost and the server went on listening. A stop from the serving thread itself +// (the seam stands for anything that runs there before the bind) must end the serve. +TEST(CApiRpc, AStopBeforeTheBindFromTheServingThreadEndsTheServe) { + serve_hook_scope hook([]() { g_hook_stop_status.store(jllama_rpc_server_stop()); }); + rpc_serve_run run(rpc_test_port(0)); + + EXPECT_TRUE(run.returned_within(std::chrono::seconds(10))) << "serve went on after a stop issued before the bind"; + EXPECT_EQ(g_hook_stop_status.load(), JLLAMA_STATUS_OK) << jllama_last_error(); + EXPECT_EQ(run.status(), JLLAMA_STATUS_OK); + EXPECT_FALSE(jllama_rpc_server_listening()); +} + +// The same from another thread: the stop returns only once serve has returned, also when it was +// issued before the bind. +TEST(CApiRpc, AStopBeforeTheBindFromAnotherThreadReturnsOnlyOnceTheServeHasEnded) { + g_held.entered = false; + g_held.released = false; + serve_hook_scope hook([]() { + std::unique_lock lock(g_held.mutex); + g_held.entered = true; + g_held.changed.notify_all(); + g_held.changed.wait(lock, []() { return g_held.released; }); + }); + rpc_serve_run run(rpc_test_port(1)); + { + std::unique_lock lock(g_held.mutex); + ASSERT_TRUE(g_held.changed.wait_for(lock, std::chrono::seconds(10), []() { return g_held.entered; })); + } + + std::mutex stop_mutex; + std::condition_variable stop_changed; + bool stop_returned = false; + jllama_status stop_status = JLLAMA_STATUS_ERROR; + std::thread stopper([&]() { + const jllama_status status = jllama_rpc_server_stop(); + std::lock_guard lock(stop_mutex); + stop_status = status; + stop_returned = true; + stop_changed.notify_all(); + }); + { + std::unique_lock lock(stop_mutex); + EXPECT_FALSE(stop_changed.wait_for(lock, std::chrono::milliseconds(300), [&]() { return stop_returned; })) + << "the stop returned while serve had not even started the server"; + } + { + std::lock_guard lock(g_held.mutex); + g_held.released = true; + g_held.changed.notify_all(); + } + + EXPECT_TRUE(run.returned_within(std::chrono::seconds(10))) << "serve went on after a stop issued before the bind"; + stopper.join(); + EXPECT_EQ(stop_status, JLLAMA_STATUS_OK); + EXPECT_EQ(run.status(), JLLAMA_STATUS_OK); + EXPECT_FALSE(jllama_rpc_server_listening()); +} + +// A stop while nothing serves stays a no-op: it must not end the next serve as well. +TEST(CApiRpc, AStopWhileNoServeRunsDoesNotStopTheNextOne) { + ASSERT_EQ(jllama_rpc_server_stop(), JLLAMA_STATUS_OK); + bool served = false; + for (int attempt = 0; attempt < 50 && !served; ++attempt) { + rpc_serve_run run(rpc_test_port(attempt + 2)); + for (int i = 0; + i < 500 && !jllama_rpc_server_listening() && !run.returned_within(std::chrono::milliseconds(10)); ++i) { + } + if (jllama_rpc_server_listening()) { + served = true; + EXPECT_EQ(jllama_rpc_server_stop(), JLLAMA_STATUS_OK); + EXPECT_TRUE(run.returned_within(std::chrono::seconds(10))); + EXPECT_EQ(run.status(), JLLAMA_STATUS_OK) << jllama_last_error(); + } + } + EXPECT_TRUE(served); + EXPECT_FALSE(jllama_rpc_server_listening()); +} + TEST(CApiTts, TextToSpeechRefusesMissingModelsAndNullHandles) { jllama_tts_model *tts = nullptr; EXPECT_EQ(jllama_tts_load("/nonexistent/backbone.gguf", "/nonexistent/mmproj.gguf", 0, 1, &tts), From 184e4205b223529d4e91e3a5b54d96f6563d8576 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:35:49 +0000 Subject: [PATCH 424/589] Record the commit of F241 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index ab29365af..dce213971 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2025,7 +2025,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `CApiRpc.AStopBeforeTheBindFromTheServingThreadEndsTheServe` (Stopp aus dem Seam auf dem Serving-Thread), `CApiRpc.AStopBeforeTheBindFromAnotherThreadReturnsOnlyOnceTheServeHasEnded` (Serve im Seam festgehalten, Stopp von einem anderen Thread darf nicht vor dem Serve zurückkehren, danach endet der Serve), `CApiRpc.AStopWhileNoServeRunsDoesNotStopTheNextOne` (Schutz gegen einen klebenden Stopp), `FfmRpcServerTest.aStopWhileNoServeRunsDoesNotStopTheNextServeAndAStopEndsIt` (FFM über Loopback: ein vorheriger Stopp ohne Server hält den nächsten Serve nicht ab, ein Stopp beendet ihn; der Fall vor dem Bind braucht den Seam und ist in C++ gepinnt). Daneben grün: alle 649 C++-Tests, `RpcServerTest`, `RpcServerLifecycleTest`, `RpcServerOptionsTest`, `RpcIntegrationTest` (Winzmodell), `llama-ffm verify` 36/36. - **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne den Fix rot: „Value of: run.returned_within(std::chrono::seconds(10)) Actual: false Expected: true — serve went on after a stop issued before the bind“ und „Value of: jllama_rpc_server_listening() Actual: true Expected: false“ (erster Test); „Value of: stop_changed.wait_for(lock, std::chrono::milliseconds(300), …) Actual: true Expected: false — the stop returned while serve had not even started the server“ (zweiter Test). Mit dem Fix grün (`CApiRpc*`/`Rpc*` 32/32, dreimal). Der dritte und der FFM-Test sind Regressionsschutz und vorher wie nachher grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `1047673` ## Offene Verdachte ohne Fund-ID From 6c3e264473a50907bcc824dfaff29966fd7dcb8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:44:08 +0000 Subject: [PATCH 425/589] Describe the throwing native stop in RpcServer.close's Javadoc Since F241 the native stop waits for the native server to end and fails after 30 s, so RpcServer.close() throws a LlamaException in that case instead of only logging the warning its Javadoc promised. The Javadoc now names both cases and declares the exception; the ledger entry of F241 carries a note. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + llama/src/main/java/net/ladenthin/llama/RpcServer.java | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index dce213971..0cd1229ea 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2024,6 +2024,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Die C-API führt den Zustand selbst (Variante (b) des Praxis-Skeptikers, Patch 0015 bleibt unverändert): `jllama_rpc_server_serve` markiert sich für seine ganze Dauer als laufend (`rpc_serving_scope`, Zähler unter Mutex, dazu ein `thread_local` für den Serving-Thread); ein Stopp während eines laufenden Serve wird als Anforderung gehalten. Serve startet den ggml-Server nicht mehr, wenn die Anforderung vor dem Aufruf vorliegt, und gibt OK zurück; ein Stopp von einem anderen Thread wiederholt `ggml_backend_rpc_stop_server()` alle 50 ms, bis Serve zurückgekehrt ist, und gibt erst dann OK zurück, nach 30 s `JLLAMA_STATUS_ERROR` („the RPC server did not stop within 30 s of the stop request“). Ein Stopp auf dem Serving-Thread selbst (aus einem Callback vor dem Start) kehrt sofort zurück. Ohne laufenden Serve bleibt der Stopp ein No-op, und die Anforderung endet mit dem letzten Serve, erreicht also nie den nächsten. Ein Stopp, bevor der Serving-Thread `jllama_rpc_server_serve` überhaupt betreten hat, ist von „keiner läuft“ nicht zu unterscheiden und bleibt verloren; das steht jetzt in `jllama_api.h` und im Javadoc von `FfmRpcServer.stop`/`serve` (wiederholen, bis Serve zurückgekehrt ist, wie `RpcServer`). Test-Seam: `jllama::rpc::set_serve_hook_for_testing()` (in `rpc_support.hpp` deklariert, nicht exportiert), aufgerufen nach der Geräteauswahl und vor dem ggml-Aufruf. Folge für den JNI-Pfad: `RpcServer.close()` und `stopAndJoin` warten jetzt schon im nativen Stopp; im pathologischen Fall, dass Serve 30 s nach dem Stopp nicht zurückkehrt, wirft der native Stopp eine `LlamaException`, statt dass `close()` nur warnt. - **Tests:** `CApiRpc.AStopBeforeTheBindFromTheServingThreadEndsTheServe` (Stopp aus dem Seam auf dem Serving-Thread), `CApiRpc.AStopBeforeTheBindFromAnotherThreadReturnsOnlyOnceTheServeHasEnded` (Serve im Seam festgehalten, Stopp von einem anderen Thread darf nicht vor dem Serve zurückkehren, danach endet der Serve), `CApiRpc.AStopWhileNoServeRunsDoesNotStopTheNextOne` (Schutz gegen einen klebenden Stopp), `FfmRpcServerTest.aStopWhileNoServeRunsDoesNotStopTheNextServeAndAStopEndsIt` (FFM über Loopback: ein vorheriger Stopp ohne Server hält den nächsten Serve nicht ab, ein Stopp beendet ihn; der Fall vor dem Bind braucht den Seam und ist in C++ gepinnt). Daneben grün: alle 649 C++-Tests, `RpcServerTest`, `RpcServerLifecycleTest`, `RpcServerOptionsTest`, `RpcIntegrationTest` (Winzmodell), `llama-ffm verify` 36/36. - **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne den Fix rot: „Value of: run.returned_within(std::chrono::seconds(10)) Actual: false Expected: true — serve went on after a stop issued before the bind“ und „Value of: jllama_rpc_server_listening() Actual: true Expected: false“ (erster Test); „Value of: stop_changed.wait_for(lock, std::chrono::milliseconds(300), …) Actual: true Expected: false — the stop returned while serve had not even started the server“ (zweiter Test). Mit dem Fix grün (`CApiRpc*`/`Rpc*` 32/32, dreimal). Der dritte und der FFM-Test sind Regressionsschutz und vorher wie nachher grün. +- **Nachtrag (Branch-Review 2026-10-20):** Bei der Prüfung nachgezogen: das Javadoc von `RpcServer.close()` sagte weiter, nach 30 s werde nur gewarnt; seit diesem Fix wirft der native Stopp in dem Fall eine `LlamaException` aus `close()`. Das Javadoc beschreibt jetzt beide Fälle und hat ein `@throws`. - **Status:** gefixt - **Commit:** java-llama.cpp `1047673` diff --git a/llama/src/main/java/net/ladenthin/llama/RpcServer.java b/llama/src/main/java/net/ladenthin/llama/RpcServer.java index c1af47879..98bb23f16 100644 --- a/llama/src/main/java/net/ladenthin/llama/RpcServer.java +++ b/llama/src/main/java/net/ladenthin/llama/RpcServer.java @@ -364,8 +364,11 @@ public void awaitTermination() throws InterruptedException { /** * Stops the server: the client being served is disconnected, the port is released. Idempotent. - * Waits up to 30 seconds for the server thread; should it still be alive then, a warning is - * logged and no other server can start in this process until that thread has ended. + * The native stop waits up to 30 seconds for the native server to end and throws when it has not; + * otherwise this waits up to 30 seconds for the server thread and logs a warning should it still be + * alive then. Either way no other server can start in this process until that thread has ended. + * + * @throws LlamaException when the native server has not ended 30 seconds after the stop */ @Override public void close() { From b90de3ce364e2f6502461ebafe5938c632a99252 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:47:43 +0000 Subject: [PATCH 426/589] Complete a streamed tool call when the next one starts (F242) StreamingChunkAssembler sent onCompleteToolCall only from complete(), so with parallel calls a handler saw partial(0), partial(1), complete(0), complete(1): a handler that runs a tool as soon as it is complete waited for the end of the stream. langchain4j describes the event as the end of a single call, and its own OpenAI binding completes a call as soon as a fragment with another index arrives. llama.cpp's compute_diffs only ever extends the last tool call of a message, so a call is final once one with a higher index starts. A fragment with a higher index now completes every lower open call that has a name, in index order; a call without a name stays open for complete(), so no CompleteToolCall with a blank name goes out. A fragment that changes a call already reported complete is a native contract violation and throws IllegalStateException, which reaches onError like an unparseable chunk, instead of making the final request differ from the CompleteToolCall the handler holds. complete() reports only the calls still open and builds the requests from all of them as before. assemblesParallelToolCallsByIndex and the new completesEachToolCallAsSoonAsTheNextOneStarts were red ("Expected: iterable containing ["partial:0", "complete:0", "partial:1", "complete:1"] but: item 1: was "partial:1"") and the rejection test threw nothing; all twelve tests of the class are green with the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++ .../langchain4j/StreamingChunkAssembler.java | 81 ++++++++++++++---- .../StreamingChunkAssemblerTest.java | 82 +++++++++++++++++++ 3 files changed, 160 insertions(+), 16 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0cd1229ea..21381f92e 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -164,6 +164,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F239](#f239) — niedrig — *gefixt* - [F240](#f240) — niedrig — *gefixt* - [F241](#f241) — niedrig bis mittel — *gefixt* +- [F242](#f242) — niedrig — *gefixt* ## Funde @@ -2028,6 +2029,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `1047673` +### F242 – java-llama.cpp: `llama-langchain4j` meldet `onCompleteToolCall` eines parallelen Werkzeugaufrufs erst am Stream-Ende, nach den Teilstücken des nächsten +- **Zeit:** 2026-10-21 02:47 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`acceptToolCallFragment`, neu `completeCallsBelow`, `complete`) +- **Schwere:** niedrig (Reihenfolge der Ereignisse; die Methoden sind in langchain4j `@Experimental`, kein falsches Endergebnis) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J14-5). Vorbestehend seit dem gestreamten Werkzeugaufruf in `llama-langchain4j` (5.0.6); F128 hat am selben Zustandsautomaten nur die leeren Teilstücke abgefangen. +- **Beschreibung:** `onCompleteToolCall` wurde nur in `complete()` gesendet, in einer Schleife über alle Aufrufe. Bei zwei parallelen Aufrufen sah ein Handler deshalb `partial(0)…, partial(1)…, complete(0), complete(1)`. langchain4j 1.22.0 beschreibt `onCompleteToolCall` als „Invoked when the model has finished streaming a single tool call“, und seine eigene OpenAI-Anbindung (`ChatCompletionEventDispatcher`) schließt einen Aufruf ab, sobald ein Fragment mit einem anderen Index eintrifft, also vor dem ersten Teilstück des nächsten. Ein Handler, der ein Werkzeug ausführt, sobald es vollständig ist, wartete so bis zum Ende des Streams. Upstream macht den frühen Abschluss sicher: `common_chat_msg_diff::compute_diffs` (b11639, `common/chat.cpp`) erweitert nur den letzten Aufruf der vorigen Nachricht und hängt neue Indizes aufsteigend an; ein Aufruf N bekommt nach dem Start von N+1 nichts mehr. Der vorhandene Test `assemblesParallelToolCallsByIndex` prüfte nur die Anzahl, und `RecordingHandler` hielt je Ereignistyp eine eigene Liste, sah die Reihenfolge also nicht. +- **Fix:** Fassung des Praxis-Skeptikers. Je Aufruf ein Merker `completed`, im Assembler `highestIndex`. Trifft ein Fragment mit einem **höheren** Index ein (ein niedrigerer stößt nichts an), werden vorher alle offenen niedrigeren Aufrufe, die schon einen Namen haben, in Index-Reihenfolge abgeschlossen (`onCompleteToolCall`); ein Aufruf ohne Namen bleibt offen und wird von `complete()` gemeldet, damit nie ein `CompleteToolCall` mit leerem Namen ausgeht (der Fall von `filter_tool_calls`/F128, in dem der Name erst später kommt). Ein Fragment, das einen schon abgeschlossenen Aufruf ändert (nicht leere Argumente, ein anderer Name oder eine andere Id), ist eine Verletzung des nativen Vertrags und wirft `IllegalStateException` mit dem Index, die wie ein unparsbarer Chunk über `onError` ankommt; still zusammengeführt würde die endgültige `ToolExecutionRequest` von dem `CompleteToolCall` abweichen, den der Handler schon hat. `complete()` meldet nur die noch offenen Aufrufe, baut die `toolExecutionRequests` aber weiter aus allen in Index-Reihenfolge. Klassen-Javadoc nachgezogen. +- **Tests:** `StreamingChunkAssemblerTest`: `RecordingHandler` führt zusätzlich eine geordnete Liste `toolCallEvents`; `assemblesParallelToolCallsByIndex` prüft jetzt die Reihenfolge (beide Aufrufe in einem Chunk); neu `completesEachToolCallAsSoonAsTheNextOneStarts` (drei Chunks, Ereignisse genau `partial:0, complete:0, partial:1, complete:1`, die `CompleteToolCall`-Nutzlasten gleich den endgültigen Requests), `defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts` (Aufruf 0 ohne Namen beim Start von 1: `partial:1, complete:0, complete:1`, Name aus dem späten Fragment; Regressionsschutz, vorher wie nachher grün), `argumentsForAToolCallThatIsAlreadyCompleteFailTheStream`. Modul `llama-langchain4j` 58/0/0/7 grün. +- **Rot-Grün-Nachweis:** Gegen den unveränderten Code rot: `assemblesParallelToolCallsByIndex` und `completesEachToolCallAsSoonAsTheNextOneStarts` mit „Expected: iterable containing ["partial:0", "complete:0", "partial:1", "complete:1"] but: item 1: was "partial:1"“, `argumentsForAToolCallThatIsAlreadyCompleteFailTheStream` mit „Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.“; mit dem Fix alle 12 Tests der Klasse grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java index dd4d519cb..d1c2c7d1e 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java @@ -33,7 +33,9 @@ *
  • {@code delta.tool_calls[*]} fragments → accumulated per OpenAI {@code index} (id and * name arrive on the first fragment, arguments split across fragments), each forwarded as * {@link StreamingChatResponseHandler#onPartialToolCall(PartialToolCall)} and completed as - * {@link StreamingChatResponseHandler#onCompleteToolCall(CompleteToolCall)} + * {@link StreamingChatResponseHandler#onCompleteToolCall(CompleteToolCall)} as soon as a + * call with a higher index starts (llama.cpp only ever extends the last call of a message), + * the last one, and any call that had no name yet at that point, by {@link #complete()} *
  • {@code finish_reason} / trailing {@code usage} chunk → finish reason and * {@link TokenUsage} on the final response *
@@ -51,12 +53,23 @@ private static final class PartialTool { String id = ""; String name = ""; final StringBuilder arguments = new StringBuilder(); + /** Whether {@code onCompleteToolCall} was already sent for this call. */ + boolean completed; + + ToolExecutionRequest toRequest() { + return ToolExecutionRequest.builder() + .id(id) + .name(name) + .arguments(arguments.toString()) + .build(); + } } private final StreamingChatResponseHandler handler; private final StringBuilder text = new StringBuilder(); private final StringBuilder thinking = new StringBuilder(); private final Map toolsByIndex = new TreeMap<>(); + private int highestIndex = -1; private String finishReason = ""; private TokenUsage tokenUsage; @@ -114,20 +127,38 @@ void accept(String chunkJson) { private void acceptToolCallFragment(JsonNode fragment) { int index = fragment.path("index").asInt(0); + JsonNode function = fragment.path("function"); + String idFragment = fragment.path("id").isTextual() ? fragment.path("id").asText() : ""; + String nameFragment = function.path("name").isTextual() ? function.path("name").asText() : ""; + String argumentsFragment = + function.path("arguments").isTextual() ? function.path("arguments").asText() : ""; PartialTool tool = toolsByIndex.get(index); + if (tool != null && tool.completed) { + // The handler already holds this call's CompleteToolCall; merging a change would make the + // final request differ from it. + if (!argumentsFragment.isEmpty() + || (!nameFragment.isEmpty() && !nameFragment.equals(tool.name)) + || (!idFragment.isEmpty() && !idFragment.equals(tool.id))) { + throw new IllegalStateException("A delta changes tool call " + index + + ", which was already reported complete when tool call " + highestIndex + + " started: " + fragment); + } + return; + } + if (index > highestIndex) { + completeCallsBelow(index); + highestIndex = index; + } if (tool == null) { tool = new PartialTool(); toolsByIndex.put(index, tool); } - if (fragment.path("id").isTextual() && !fragment.path("id").asText().isEmpty()) { - tool.id = fragment.path("id").asText(); + if (!idFragment.isEmpty()) { + tool.id = idFragment; } - JsonNode function = fragment.path("function"); - if (function.path("name").isTextual() && !function.path("name").asText().isEmpty()) { - tool.name = function.path("name").asText(); + if (!nameFragment.isEmpty()) { + tool.name = nameFragment; } - String argumentsFragment = - function.path("arguments").isTextual() ? function.path("arguments").asText() : ""; tool.arguments.append(argumentsFragment); // PartialToolCall refuses empty arguments and a blank name, and llama.cpp sends both: the // first delta of a call usually carries id and name only, and its incremental parser may @@ -144,8 +175,27 @@ private void acceptToolCallFragment(JsonNode fragment) { } /** - * Finalize the stream: emit {@code onCompleteToolCall} for every accumulated call and - * build the final {@link ChatResponse} (text and/or tool execution requests, thinking, + * Completes every call below {@code index} that is still open and has a name, in index order: + * llama.cpp's {@code compute_diffs} only ever extends the last tool call of a message, so a call + * is final once one with a higher index starts. A call without a name stays open for + * {@link #complete()}, so that no {@link CompleteToolCall} with a blank name is sent. + */ + private void completeCallsBelow(int index) { + for (Map.Entry entry : toolsByIndex.entrySet()) { + if (entry.getKey() >= index) { + break; + } + PartialTool tool = entry.getValue(); + if (!tool.completed && !tool.name.isEmpty()) { + tool.completed = true; + handler.onCompleteToolCall(new CompleteToolCall(entry.getKey(), tool.toRequest())); + } + } + } + + /** + * Finalize the stream: emit {@code onCompleteToolCall} for every accumulated call not yet + * reported complete, in index order, and build the final {@link ChatResponse} (text and/or tool execution requests, thinking, * finish reason, token usage when the stream carried a usage chunk). * * @return the assembled final response for {@code onCompleteResponse} @@ -162,12 +212,11 @@ ChatResponse complete() { List requests = new ArrayList<>(toolsByIndex.size()); for (Map.Entry entry : toolsByIndex.entrySet()) { PartialTool tool = entry.getValue(); - ToolExecutionRequest request = ToolExecutionRequest.builder() - .id(tool.id) - .name(tool.name) - .arguments(tool.arguments.toString()) - .build(); - handler.onCompleteToolCall(new CompleteToolCall(entry.getKey(), request)); + ToolExecutionRequest request = tool.toRequest(); + if (!tool.completed) { + tool.completed = true; + handler.onCompleteToolCall(new CompleteToolCall(entry.getKey(), request)); + } requests.add(request); } message.toolExecutionRequests(requests); diff --git a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java index c0c006071..f8ed89dcd 100644 --- a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java +++ b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java @@ -6,6 +6,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; @@ -35,6 +36,8 @@ private static final class RecordingHandler implements StreamingChatResponseHand final List thinking = new ArrayList<>(); final List partialToolCalls = new ArrayList<>(); final List completeToolCalls = new ArrayList<>(); + /** Every tool-call event in arrival order, e.g. {@code "partial:0"}, {@code "complete:0"}. */ + final List toolCallEvents = new ArrayList<>(); @Override public void onPartialResponse(String partialResponse) { @@ -49,11 +52,13 @@ public void onPartialThinking(PartialThinking partialThinking) { @Override public void onPartialToolCall(PartialToolCall partialToolCall) { partialToolCalls.add(partialToolCall); + toolCallEvents.add("partial:" + partialToolCall.index()); } @Override public void onCompleteToolCall(CompleteToolCall completeToolCall) { completeToolCalls.add(completeToolCall); + toolCallEvents.add("complete:" + completeToolCall.index()); } @Override @@ -172,10 +177,87 @@ void assemblesParallelToolCallsByIndex() { ChatResponse response = assembler.complete(); assertThat(handler.completeToolCalls.size(), is(2)); + // Call 0 is complete once the fragment of call 1 arrives, even inside one chunk. + assertThat(handler.toolCallEvents, contains("partial:0", "complete:0", "partial:1", "complete:1")); assertThat(response.aiMessage().toolExecutionRequests().get(0).name(), is("first")); assertThat(response.aiMessage().toolExecutionRequests().get(1).name(), is("second")); } + /** + * llama.cpp only ever extends the last tool call of a message ({@code compute_diffs} in + * {@code common/chat.cpp}), so call 0 is final once call 1 starts. langchain4j's handler contract + * and its own OpenAI binding complete a call at that point, before the next call's first partial, + * so that a handler can run the tool at once. + */ + @Test + void completesEachToolCallAsSoonAsTheNextOneStarts() { + RecordingHandler handler = new RecordingHandler(); + StreamingChunkAssembler assembler = new StreamingChunkAssembler(handler); + + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"id\":\"a\",\"function\":{\"name\":\"first\",\"arguments\":\"{\\\"x\\\":1}\"}}]}," + + "\"finish_reason\":null}]}"); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1," + + "\"id\":\"b\",\"function\":{\"name\":\"second\",\"arguments\":\"{\\\"y\\\":2}\"}}]}," + + "\"finish_reason\":null}]}"); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}"); + ChatResponse response = assembler.complete(); + + assertThat(handler.toolCallEvents, contains("partial:0", "complete:0", "partial:1", "complete:1")); + assertThat(handler.completeToolCalls.get(0).toolExecutionRequest(), + is(response.aiMessage().toolExecutionRequests().get(0))); + assertThat(handler.completeToolCalls.get(1).toolExecutionRequest(), + is(response.aiMessage().toolExecutionRequests().get(1))); + assertThat(response.aiMessage().toolExecutionRequests().get(0).arguments(), is("{\"x\":1}")); + assertThat(response.aiMessage().toolExecutionRequests().get(1).arguments(), is("{\"y\":2}")); + } + + /** + * A call that has no name yet when the next one starts is not completed early: a + * {@link CompleteToolCall} with a blank name would reach the handler. It is completed by + * {@code complete()}, with the name that arrived in the meantime. + */ + @Test + void defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts() { + RecordingHandler handler = new RecordingHandler(); + StreamingChunkAssembler assembler = new StreamingChunkAssembler(handler); + + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"function\":{\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}"); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1," + + "\"id\":\"b\",\"function\":{\"name\":\"second\",\"arguments\":\"{}\"}}]}," + + "\"finish_reason\":null}]}"); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"id\":\"a\",\"function\":{\"name\":\"first\"}}]},\"finish_reason\":\"tool_calls\"}]}"); + ChatResponse response = assembler.complete(); + + assertThat(handler.toolCallEvents, contains("partial:1", "complete:0", "complete:1")); + assertThat(handler.completeToolCalls.get(0).toolExecutionRequest().name(), is("first")); + assertThat(response.aiMessage().toolExecutionRequests().get(0).name(), is("first")); + assertThat(response.aiMessage().toolExecutionRequests().get(1).name(), is("second")); + } + + /** + * Arguments for a call that was already reported complete would make the final request differ + * from the {@link CompleteToolCall} the handler got; that is a native contract violation and + * fails the stream instead of being merged silently. + */ + @Test + void argumentsForAToolCallThatIsAlreadyCompleteFailTheStream() { + RecordingHandler handler = new RecordingHandler(); + StreamingChunkAssembler assembler = new StreamingChunkAssembler(handler); + assembler.accept("{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[" + + "{\"index\":0,\"id\":\"a\",\"function\":{\"name\":\"first\",\"arguments\":\"{\"}}," + + "{\"index\":1,\"id\":\"b\",\"function\":{\"name\":\"second\",\"arguments\":\"{}\"}}" + + "]},\"finish_reason\":null}]}"); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> assembler.accept( + "{\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"function\":{\"arguments\":\"}\"}}]},\"finish_reason\":null}]}")); + + assertThat(e.getMessage(), containsString("tool call 0")); + } + @Test void forwardsThinkingDeltasAndKeepsThinkingOnFinalMessage() { RecordingHandler handler = new RecordingHandler(); From b2752fe1b2e3c53b366a38cd5a5060619c0bcca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:47:44 +0000 Subject: [PATCH 427/589] Record the commit of F242 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 21381f92e..5ba3f6d01 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2039,7 +2039,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `StreamingChunkAssemblerTest`: `RecordingHandler` führt zusätzlich eine geordnete Liste `toolCallEvents`; `assemblesParallelToolCallsByIndex` prüft jetzt die Reihenfolge (beide Aufrufe in einem Chunk); neu `completesEachToolCallAsSoonAsTheNextOneStarts` (drei Chunks, Ereignisse genau `partial:0, complete:0, partial:1, complete:1`, die `CompleteToolCall`-Nutzlasten gleich den endgültigen Requests), `defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts` (Aufruf 0 ohne Namen beim Start von 1: `partial:1, complete:0, complete:1`, Name aus dem späten Fragment; Regressionsschutz, vorher wie nachher grün), `argumentsForAToolCallThatIsAlreadyCompleteFailTheStream`. Modul `llama-langchain4j` 58/0/0/7 grün. - **Rot-Grün-Nachweis:** Gegen den unveränderten Code rot: `assemblesParallelToolCallsByIndex` und `completesEachToolCallAsSoonAsTheNextOneStarts` mit „Expected: iterable containing ["partial:0", "complete:0", "partial:1", "complete:1"] but: item 1: was "partial:1"“, `argumentsForAToolCallThatIsAlreadyCompleteFailTheStream` mit „Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.“; mit dem Fix alle 12 Tests der Klasse grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `b90de3c` ## Offene Verdachte ohne Fund-ID From f10aa6aed04dfcb69814719b8349b8c6666ddfd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:50:26 +0000 Subject: [PATCH 428/589] Serve the agent's in-process model under its model id (F243) Since every native OpenAI answer carries upstream's model name, an embedding caller that loads its own LlamaModel has to set the alias, or llama.cpp names each answer after the model's path. The agent's ModelEndpoint did not: /v1/models listed the model id while every chat answer and stream chunk named the absolute .gguf path, the mismatch the CLI fix removed for OpenAiServerCli. modelParameters now sets the alias to the model id. Because llama.cpp splits --alias at commas and strips each part, AgentOptions refuses a --model-id with a comma, an empty one and one with surrounding whitespace when --model is given, the rule of OpenAiServerCli's --model-id; against --base-url the id names another server's model and is sent unchanged. The Javadoc of ModelParameters.setAlias now says what llama.cpp answers with when it is unset: the repository, or the model path as given, not the file name. LocalAgentTest.inProcessModelAnswersUnderTheModelIdItIsListedWith was red ("Expected: a collection containing "--alias"") and the rejection test threw nothing; both are green with the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++++++++++ .../llama/atmosphere/AgentOptions.java | 22 +++++++++++++++++ .../llama/atmosphere/ModelEndpoint.java | 3 +++ .../llama/atmosphere/AgentOptionsTest.java | 24 +++++++++++++++++++ .../llama/atmosphere/LocalAgentTest.java | 17 +++++++++++++ .../llama/parameters/ModelParameters.java | 5 ++-- 6 files changed, 82 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5ba3f6d01..17e816f27 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -165,6 +165,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F240](#f240) — niedrig — *gefixt* - [F241](#f241) — niedrig bis mittel — *gefixt* - [F242](#f242) — niedrig — *gefixt* +- [F243](#f243) — niedrig — *gefixt* ## Funde @@ -2041,6 +2042,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b90de3c` +### F243 – java-llama.cpp: der Atmosphere-Agent nennt in jeder Antwort seines In-Process-Servers den absoluten Modellpfad statt der Modell-Id +- **Zeit:** 2026-10-21 02:50 UTC +- **Datei:** `llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java` (`modelParameters`), `llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java` (`parse`, neu `requireAliasSafeModelId`), `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Javadoc von `setAlias`) +- **Schwere:** niedrig (Loopback-Server; ein falscher `model`-Wert und ein lokaler Pfad in den Antworten, kein falsches Ergebnis) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund XJ-2). Folge von F185 (jede native OpenAI-Antwort trägt upstreams Modellnamen, vorher ""); `15f857a` hat dieselbe Abweichung für `OpenAiServerCli` behoben, indem die CLI immer `--alias` setzt, und F201 die Modell-Id dort auf Komma/Randleerzeichen geprüft. Der CHANGELOG sagt, ein Aufrufer mit eigenem `LlamaModel` müsse `setAlias` selbst setzen; der Agent ist ein solcher Aufrufer und tat es nicht. +- **Beschreibung:** `ModelEndpoint.modelParameters` lud das In-Process-Modell ohne `setAlias`, `open()` bot es mit `OpenAiServerConfig.modelId(options.getModelId())` an (Vorgabe `local-model`). Upstream b11639 (`server-context.cpp`, Modellname) nimmt ohne Alias `params_base.model.get_name()`, und das ist für eine lokale Datei der Pfad (`common.h`, `common_params_model::get_name`). `OpenAiCompatServer` reicht die native Antwort unverändert weiter. Also listete `/v1/models` `local-model`, während jede `/v1/chat/completions`-Antwort und jedes Stück des Streams z. B. `/home/user/models/qwen.gguf` nannte. `AgentOptions` nahm `--model-id` ungeprüft; da `--alias` an Kommas trennt und jedes Stück trimmt, hätte ein bloßes `setAlias` für `a,b` oder ` x` dieselbe Abweichung behalten. +- **Fix:** `modelParameters` setzt `setAlias(options.getModelId())`, die Regel von `OpenAiServerCli.toModelParameters`. `AgentOptions.parse` lehnt mit `--model` eine `--model-id` mit Komma, eine leere und eine mit Randleerzeichen ab, wie `OpenAiServerCli.modelIdValue`; mit `--base-url` bleibt die Id ungeprüft, weil sie dort ein Modell eines fremden Servers benennt und kein Alias wird (Abweichung vom Brief, der die Prüfung allgemein verlangt). Das Javadoc von `ModelParameters.setAlias` sagte, ohne Alias nehme llama.cpp den eigenen Namen oder den Dateinamen; es nennt jetzt das Repository bzw. den Pfad wie an `--model` übergeben und dass jedes Alias getrimmt wird. +- **Tests:** `LocalAgentTest.inProcessModelAnswersUnderTheModelIdItIsListedWith` (`--alias` = `AgentOptions.DEFAULT_MODEL_ID` ohne, `x` mit `--model-id x`), `AgentOptionsTest.modelIdThatLlamaCppsAliasWouldSplitOrTrimIsRejectedForAnInProcessModel` (`a,b`, ` x`, `x `, `""`), `AgentOptionsTest.modelIdForARemoteEndpointIsSentUnchanged` (`a,b` mit `--base-url` bleibt). `LocalAgentTest` 28/28, `AgentOptionsTest` 17/17 grün; Kern-Javadoc ohne Fehler. +- **Rot-Grün-Nachweis:** Gegen den unveränderten Code rot: „Expected: a collection containing "--alias" but: mismatches were: [was "", was "--fit", was "off", was "--model", was "m.gguf", …]“ und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix beide grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java index 50301cc33..3e42e8b7e 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java @@ -173,6 +173,9 @@ public static AgentOptions parse(String[] args) { if (frontEnds > 1) { throw new IllegalArgumentException("--web, --acp and --prompt exclude each other"); } + if (b.modelPath != null) { + requireAliasSafeModelId(b.modelId); + } } return new AgentOptions(b); } @@ -216,6 +219,25 @@ public AgentOptions withWorkspace(Path newWorkspace) { return new AgentOptions(b); } + /** + * The in-process model is served under the model id as its {@code --alias}, which llama.cpp splits at + * commas and strips; an id it would change would make {@code /v1/models} and the answers name the model + * differently. The same rule as {@code OpenAiServerCli}'s {@code --model-id}. Against {@code --base-url} + * the id names a model of another server and is sent unchanged. + */ + private static void requireAliasSafeModelId(String modelId) { + if (modelId.indexOf(',') >= 0) { + throw new IllegalArgumentException( + "--model-id must not contain a comma with --model (llama.cpp reads it as a list of aliases), got: " + + modelId); + } + if (modelId.isEmpty() || !modelId.equals(modelId.trim())) { + throw new IllegalArgumentException( + "--model-id must be non-empty without leading or trailing whitespace with --model, got: '" + modelId + + "'"); + } + } + private static String value(String[] args, int index, String flag) { if (index >= args.length) { throw new IllegalArgumentException("Missing value for " + flag); diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java index 2692c15cc..1d8797560 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java @@ -85,6 +85,9 @@ static ModelParameters modelParameters(AgentOptions options) { .setCtxSize(options.getCtxSize()) .setGpuLayers(options.getGpuLayers()) .setFit(false) + // Without an alias llama.cpp names every answer after the model's path; the alias makes the + // answers carry the id /v1/models lists. + .setAlias(options.getModelId()) // Jinja rendering is what lets the native parser apply the model's tool-call template. .enableJinja(); // llama.cpp logs to stderr, which shares the console with the streamed answer on stdout; the diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java index 6d9a37da5..aa348b5ed 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java @@ -262,4 +262,28 @@ void frontEndsExcludeEachOtherAndABadTokenOrPortIsRefused() { IllegalArgumentException.class, () -> AgentOptions.parse(new String[] {"--base-url", "u", "--web-port", "70000"})); } + + @Test + void modelIdThatLlamaCppsAliasWouldSplitOrTrimIsRejectedForAnInProcessModel() { + // The in-process model is served under the id as its --alias, which llama.cpp splits at commas + // and strips; /v1/models and the answers would name it differently again. + for (String id : new String[] {"a,b", " x", "x ", ""}) { + assertThat( + id, + assertThrows( + IllegalArgumentException.class, + () -> AgentOptions.parse(new String[] {"--model-id", id, "--model", "m.gguf"})) + .getMessage(), + containsString("--model-id")); + } + } + + @Test + void modelIdForARemoteEndpointIsSentUnchanged() { + // Against --base-url the id names a model of someone else's server; nothing here splits it. + assertThat( + AgentOptions.parse(new String[] {"--base-url", "http://x/v1", "--model-id", "a,b"}) + .getModelId(), + is("a,b")); + } } diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java index 2cce11f07..487281dda 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/LocalAgentTest.java @@ -415,6 +415,23 @@ void inProcessModelIsLoadedWithAQuietLogThresholdByDefault() { assertThat(args, not(hasItem("--verbose"))); } + @Test + void inProcessModelAnswersUnderTheModelIdItIsListedWith() { + // Without an alias llama.cpp names every answer and stream chunk after the model's path, while + // /v1/models lists the model id; the alias makes the two agree. + List byDefault = + List.of(ModelEndpoint.modelParameters(AgentOptions.parse(new String[] {"--model", "m.gguf"})) + .toArray()); + List explicit = List.of( + ModelEndpoint.modelParameters(AgentOptions.parse(new String[] {"--model", "m.gguf", "--model-id", "x"})) + .toArray()); + + assertThat(byDefault, hasItem("--alias")); + assertThat(byDefault.get(byDefault.indexOf("--alias") + 1), is(AgentOptions.DEFAULT_MODEL_ID)); + assertThat(explicit, hasItem("--alias")); + assertThat(explicit.get(explicit.indexOf("--alias") + 1), is("x")); + } + @Test void verboseReplacesTheThresholdWithLlamaCppsOwnVerboseFlag() { List args = List.of(ModelEndpoint.modelParameters( diff --git a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java index 0631ce0d5..5f8b05499 100644 --- a/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java +++ b/llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java @@ -1556,8 +1556,9 @@ public ModelParameters setMmprojAuto(boolean enabled) { /** * Set the name the model answers to ({@code --alias}): the {@code model} field of every OpenAI * answer and chunk, and the id the embedded server lists under {@code /v1/models}. Unset, llama.cpp - * uses the model's own name or file name. Upstream accepts several aliases separated by commas and - * answers with the first. + * answers with the Hugging Face or Docker repository the model was loaded from, and for a local file + * with the model path as given to {@code --model} (not just the file name). Upstream accepts several + * aliases separated by commas, strips each, and answers with the first. * * @param alias the model name, or several separated by commas * @return this builder From 55d207fe12d571a6b9ee673396a3ad27ed988b65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:50:26 +0000 Subject: [PATCH 429/589] Record the commit of F243 in the bug-hunt ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 17e816f27..e894695b5 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2052,7 +2052,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `LocalAgentTest.inProcessModelAnswersUnderTheModelIdItIsListedWith` (`--alias` = `AgentOptions.DEFAULT_MODEL_ID` ohne, `x` mit `--model-id x`), `AgentOptionsTest.modelIdThatLlamaCppsAliasWouldSplitOrTrimIsRejectedForAnInProcessModel` (`a,b`, ` x`, `x `, `""`), `AgentOptionsTest.modelIdForARemoteEndpointIsSentUnchanged` (`a,b` mit `--base-url` bleibt). `LocalAgentTest` 28/28, `AgentOptionsTest` 17/17 grün; Kern-Javadoc ohne Fehler. - **Rot-Grün-Nachweis:** Gegen den unveränderten Code rot: „Expected: a collection containing "--alias" but: mismatches were: [was "", was "--fit", was "off", was "--model", was "m.gguf", …]“ und „Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix beide grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `f10aa6a` ## Offene Verdachte ohne Fund-ID From 4dde45be4be86f8868b9a0905b03f36769a24210 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:55:06 +0000 Subject: [PATCH 430/589] Name the highest index in the rejected tool-call delta message The IllegalStateException for a delta that changes a tool call already reported complete said the call was completed "when tool call N started", with N the highest index seen; once more calls had followed, that named the wrong call. It now says what the number is. The Javadoc of StreamingChunkAssembler.complete() is wrapped like the rest of the class, and AgentOptions.parse's @throws names the --model-id check that applies with --model. Documentation and message text only; the tests of both classes stay green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .../net/ladenthin/llama/atmosphere/AgentOptions.java | 6 ++++-- .../llama/langchain4j/StreamingChunkAssembler.java | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java index 3e42e8b7e..fe04de975 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java @@ -125,8 +125,10 @@ private AgentOptions(Builder b) { * * @param args the raw arguments * @return the parsed options - * @throws IllegalArgumentException on an unknown flag, a missing value, or when neither/both of - * {@code --base-url} and {@code --model} are given + * @throws IllegalArgumentException on an unknown flag, a missing value, when neither/both of + * {@code --base-url} and {@code --model} are given, or when {@code --model} is given with a + * {@code --model-id} that llama.cpp's {@code --alias} would split or strip (a comma, empty, + * leading or trailing whitespace) */ public static AgentOptions parse(String[] args) { Builder b = new Builder(); diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java index d1c2c7d1e..978d24d15 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java @@ -140,8 +140,8 @@ private void acceptToolCallFragment(JsonNode fragment) { || (!nameFragment.isEmpty() && !nameFragment.equals(tool.name)) || (!idFragment.isEmpty() && !idFragment.equals(tool.id))) { throw new IllegalStateException("A delta changes tool call " + index - + ", which was already reported complete when tool call " + highestIndex - + " started: " + fragment); + + ", which was already reported complete (the highest tool call index seen is " + + highestIndex + "): " + fragment); } return; } @@ -195,8 +195,9 @@ private void completeCallsBelow(int index) { /** * Finalize the stream: emit {@code onCompleteToolCall} for every accumulated call not yet - * reported complete, in index order, and build the final {@link ChatResponse} (text and/or tool execution requests, thinking, - * finish reason, token usage when the stream carried a usage chunk). + * reported complete, in index order, and build the final {@link ChatResponse} (text and/or + * tool execution requests, thinking, finish reason, token usage when the stream carried a + * usage chunk). * * @return the assembled final response for {@code onCompleteResponse} */ From 7f7ef60c55a76ca67d0db917bfdced3f4d2b22c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:02:49 +0000 Subject: [PATCH 431/589] Release the heartbeat test backends on an observed heartbeat aRejectionAfterAHeartbeatIsAnEvent and streamingEmitsHeartbeatsDuringAGap asserted a heartbeat that the 50 ms timer had to write while the backend slept for 300 ms; a stalled scheduler thread on a loaded runner turned the first one into a 400 and the second into a stream without a heartbeat. Both backends now wait on a latch that the test counts down only after it has read ": ping" from the streamed body, line by line, so the assertions follow a heartbeat that arrived. The heartbeat test also asserts the rejection reason, the gap test the chunk after the heartbeat instead of any colon. With a heartbeat that writes nothing, both tests are red deterministically ("Expected: is <200> but: was <500>" once the backend gives up after 30 s); unchanged, 69/69 in the class are green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + .../server/OpenAiCompatServerHttpTest.java | 137 ++++++++++++++---- 2 files changed, 107 insertions(+), 31 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e894695b5..c0abd2e4d 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1453,6 +1453,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `ResponseStream` beginnt die Antwort erst mit dem ersten Schreiben: Status `200`, Header, dann der mit `startWith` gesetzte Vorspann (die Eröffnungsereignisse von Anthropic und Responses), dann der Text. Ein Heartbeat ist ebenfalls ein Schreiben, denn er hält einen Client über einen langen Prompt hinweg; danach bleibt ein Fehler ein Ereignis. Vorher beantwortet `answerIfNotStarted` die Anfrage mit `400`/`500` und dem Fehlerformat der Route (OpenAI, Ollama `{"error"}`, Anthropic `invalid_request_error`/`api_error`), wie ohne `stream`. Nach einer solchen Antwort sind weitere Schreibversuche wirkungslos (ein Heartbeat zwischen Antwort und Abbruch schreibt nichts). - **Tests:** `OpenAiCompatServerHttpTest#aRejectionBeforeTheFirstChunkIsA400OnEveryStreamingRoute`, `#aFailureBeforeTheFirstChunkIsA500OnEveryStreamingRoute`, `#aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent`, `#aRejectionAfterAHeartbeatIsAnEvent`; die vier bestehenden Ereignis-Tests (Responses `response.failed`, Anthropic-Fehlertypen) laufen jetzt mit einem Backend, das nach einem Chunk scheitert (`ScriptedStreamBackend`). Im Agenten `AtmosphereWireContractTest#engineFailureBeforeTheFirstChunkFailsTheTurn` (neu) und `#midStreamEngineFailureCompletesSilentlyRatherThanErroring` (jetzt wirklich nach einem Chunk). - **Rot-Grün-Nachweis:** vor dem Umbau rot (die beiden Vor-dem-ersten-Chunk-Tests), danach 63/63 in `OpenAiCompatServerHttpTest` und alle Server-Testklassen grün; der Agent gegen den installierten neuen Kern: vorher rot (`API returned 500: model exploded` statt `null`), nach der Aufteilung `verify` grün (278 Tests, 63 übersprungen, modell- oder bildschirmgebunden); SpotBugs grün. +- **Nachtrag (Branch-Review 2026-10-20):** `#aRejectionAfterAHeartbeatIsAnEvent` hing an der Uhr: das Backend schlief 300 ms und warf, das 200 kam nur, wenn der 50-ms-Heartbeat in dieser Zeit lief (Fund J1-5). Das Backend wartet jetzt auf einen Latch, den der Test erst zählt, wenn er `: ping` aus dem zeilenweise gelesenen Stream gelesen hat; danach `startsWith(": ping")`, `invalid_request_error` und der Ablehnungsgrund im Text. Dasselbe für das ältere `#streamingEmitsHeartbeatsDuringAGap` (`SlowFakeBackend`, gleiche 50-gegen-300-ms-Form, vorher nur `containsString(":")`). Gegenprobe: mit einem Heartbeat, der nicht schreibt, sind beide deterministisch rot (`Expected: is <200> but: was <500>`, das Backend gibt nach 30 s auf); unverändert 69/69 grün. - **Status:** gefixt - **Commit:** java-llama.cpp `9587087` diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index dfcd7de23..bc02391e0 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -10,11 +10,17 @@ import static org.hamcrest.Matchers.startsWith; import com.fasterxml.jackson.databind.JsonNode; +import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.Socket; +import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import net.ladenthin.llama.exception.InvalidRequestException; import net.ladenthin.llama.exception.LlamaException; import org.junit.jupiter.api.Test; @@ -76,6 +82,8 @@ public void streamingCompletionsReturnsTextCompletionChunksThenDone() throws IOE } } + // The backend emits only after the client has read a heartbeat, so the assertion follows an + // observed ": ping" instead of a timer that had to fire inside a fixed sleep. @Test public void streamingEmitsHeartbeatsDuringAGap() throws IOException { OpenAiServerConfig cfg = OpenAiServerConfig.builder() @@ -83,11 +91,13 @@ public void streamingEmitsHeartbeatsDuringAGap() throws IOException { .port(0) .heartbeatMillis(50L) .build(); - try (OpenAiCompatServer server = new OpenAiCompatServer(new SlowFakeBackend(), cfg).start()) { + CountDownLatch release = new CountDownLatch(1); + try (OpenAiCompatServer server = new OpenAiCompatServer(new SlowFakeBackend(release), cfg).start()) { String body = "{\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; - Response response = post(server.getPort(), "/v1/chat/completions", body, ""); + Response response = postReleasingAfterAHeartbeat(server.getPort(), "/v1/chat/completions", body, release); assertThat(response.code, is(200)); - assertThat(response.body, containsString(":")); // SSE comment heartbeat + assertThat(response.body, startsWith(": ping")); + assertThat(response.body, containsString("\"content\":\"done\"")); assertThat(response.body, containsString("data: [DONE]")); } } @@ -729,7 +739,7 @@ public void aBackendIOExceptionBeforeTheFirstChunkIsA500OnEveryStreamingRoute() @Test public void aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent() throws IOException { ScriptedStreamBackend backend = - new ScriptedStreamBackend(1, 0L, new InvalidRequestException(RejectingBackend.REASON)); + new ScriptedStreamBackend(1, new InvalidRequestException(RejectingBackend.REASON)); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; Response response = post(server.getPort(), "/v1/chat/completions", body, ""); @@ -742,6 +752,8 @@ public void aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent() throws IOExcep } // A heartbeat starts the response too: it is what keeps a client waiting through a long prompt. + // The backend fails only after the client has read a heartbeat, so the stream has provably + // started when the rejection arrives. @Test public void aRejectionAfterAHeartbeatIsAnEvent() throws IOException { OpenAiServerConfig cfg = OpenAiServerConfig.builder() @@ -749,15 +761,60 @@ public void aRejectionAfterAHeartbeatIsAnEvent() throws IOException { .port(0) .heartbeatMillis(50L) .build(); + CountDownLatch release = new CountDownLatch(1); ScriptedStreamBackend backend = - new ScriptedStreamBackend(0, 300L, new InvalidRequestException(RejectingBackend.REASON)); + new ScriptedStreamBackend(0, release, new InvalidRequestException(RejectingBackend.REASON)); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, cfg).start()) { String body = "{\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; - Response response = post(server.getPort(), "/v1/chat/completions", body, ""); + Response response = postReleasingAfterAHeartbeat(server.getPort(), "/v1/chat/completions", body, release); assertThat(response.code, is(200)); - assertThat(response.body, containsString(": ping")); + assertThat(response.body, startsWith(": ping")); assertThat(response.body, containsString("\"type\":\"invalid_request_error\"")); + assertThat(response.body, containsString(RejectingBackend.REASON_FRAGMENT)); + } + } + + /** + * POSTs {@code body} and reads the response line by line; {@code release} is counted down once a + * heartbeat line has arrived, and the rest of the body is read to its end. + * + * @param port the server port + * @param path the request path + * @param body the JSON request body + * @param release the latch the backend waits on + * @return the status and the whole body, lines joined by {@code \n} + * @throws IOException on transport failure + * @throws AssertionError if a successful stream ends before a heartbeat arrived + */ + private static Response postReleasingAfterAHeartbeat(int port, String path, String body, CountDownLatch release) + throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path).openConnection(); + conn.setReadTimeout((int) TimeUnit.SECONDS.toMillis(BACKEND_RELEASE_TIMEOUT_SECONDS * 2)); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); } + // blocks until the first write of the server, which is the heartbeat: nothing else is written + // before the backend is released + int code = conn.getResponseCode(); + StringBuilder text = new StringBuilder(); + // an error status means the backend already gave up waiting: hand it to the caller's assertions + InputStream stream = code < 400 ? conn.getInputStream() : conn.getErrorStream(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + text.append(line).append('\n'); + if (release.getCount() > 0 && line.startsWith(": ping")) { + release.countDown(); + } + } + } + if (code < 400 && release.getCount() > 0) { + throw new AssertionError("the stream ended without a heartbeat: " + text); + } + return new Response(code, text.toString(), null, conn.getContentType()); } // A Responses client dispatches on the data's "type" and reads a stream failure from a @@ -767,7 +824,7 @@ public void aRejectionAfterAHeartbeatIsAnEvent() throws IOException { @Test public void rejectedStreamedResponseEndsWithAResponseFailedEvent() throws IOException { ScriptedStreamBackend backend = - new ScriptedStreamBackend(1, 0L, new InvalidRequestException(RejectingBackend.REASON)); + new ScriptedStreamBackend(1, new InvalidRequestException(RejectingBackend.REASON)); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}"; String events = post(server.getPort(), "/v1/responses", body, "").body; @@ -782,8 +839,7 @@ public void rejectedStreamedResponseEndsWithAResponseFailedEvent() throws IOExce @Test public void failedStreamedResponseEndsWithAResponseFailedEvent() throws IOException { - ScriptedStreamBackend backend = - new ScriptedStreamBackend(1, 0L, new LlamaException(StreamFailingBackend.REASON)); + ScriptedStreamBackend backend = new ScriptedStreamBackend(1, new LlamaException(StreamFailingBackend.REASON)); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"input\":\"hi\"}"; String events = post(server.getPort(), "/v1/responses", body, "").body; @@ -875,8 +931,7 @@ public void aFailedAnthropicMessageIsAnApiError() throws IOException { @Test public void aFailedStreamedAnthropicMessageIsAnApiError() throws IOException { - ScriptedStreamBackend backend = - new ScriptedStreamBackend(1, 0L, new LlamaException(StreamFailingBackend.REASON)); + ScriptedStreamBackend backend = new ScriptedStreamBackend(1, new LlamaException(StreamFailingBackend.REASON)); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; @@ -891,7 +946,7 @@ public void aFailedStreamedAnthropicMessageIsAnApiError() throws IOException { @Test public void aRejectedStreamedAnthropicMessageStaysAnInvalidRequestError() throws IOException { ScriptedStreamBackend backend = - new ScriptedStreamBackend(1, 0L, new InvalidRequestException(RejectingBackend.REASON)); + new ScriptedStreamBackend(1, new InvalidRequestException(RejectingBackend.REASON)); try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"test-model\",\"stream\":true,\"max_tokens\":8," + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; @@ -1259,15 +1314,40 @@ public void streamCompletions(JsonNode request, ChunkSink sink) throws IOExcepti } } - /** Backend whose streams emit {@code chunks} chunks, wait {@code delayMillis}, then fail with {@code failure}. */ + /** How long a test backend waits for the client to release it before it gives up. */ + static final long BACKEND_RELEASE_TIMEOUT_SECONDS = 30L; + + /** + * Waits until the test releases the backend; a release that never comes fails the request + * instead of hanging the fork. + */ + static void awaitRelease(CountDownLatch release) { + try { + if (!release.await(BACKEND_RELEASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("the client never read a heartbeat, so the backend was not released"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for the client", e); + } + } + + /** + * Backend whose streams emit {@code chunks} chunks, wait until {@code release} is counted down, + * then fail with {@code failure}. + */ static final class ScriptedStreamBackend extends FakeBackend { private final int chunks; - private final long delayMillis; + private final CountDownLatch release; private final RuntimeException failure; - ScriptedStreamBackend(int chunks, long delayMillis, RuntimeException failure) { + ScriptedStreamBackend(int chunks, RuntimeException failure) { + this(chunks, new CountDownLatch(0), failure); + } + + ScriptedStreamBackend(int chunks, CountDownLatch release, RuntimeException failure) { this.chunks = chunks; - this.delayMillis = delayMillis; + this.release = release; this.failure = failure; } @@ -1290,18 +1370,18 @@ public void streamCompletions(JsonNode request, ChunkSink sink) throws IOExcepti } private void pause() { - if (delayMillis > 0) { - try { - Thread.sleep(delayMillis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } + awaitRelease(release); } } - /** Backend that stalls before emitting, so the server's heartbeat fires during the gap. */ + /** Backend that stalls until {@code release} is counted down, so the server's heartbeat fires during the gap. */ static final class SlowFakeBackend implements OpenAiBackend { + private final CountDownLatch release; + + SlowFakeBackend(CountDownLatch release) { + this.release = release; + } + @Override public String complete(JsonNode request) { return "{\"object\":\"chat.completion\",\"choices\":[]}"; @@ -1309,12 +1389,7 @@ public String complete(JsonNode request) { @Override public void stream(JsonNode request, ChunkSink sink) throws IOException { - try { - Thread.sleep(300L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } + awaitRelease(release); sink.accept("{\"object\":\"chat.completion.chunk\"," + "\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}"); } From 1543695fe7954070f2087c41049c9541ef300e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:08:58 +0000 Subject: [PATCH 432/589] Pin the fields /api/generate hands to the chat template path toOpenAiChatRequestFromGenerate copies model, options, format and think from an /api/generate request into the synthetic /api/chat request, but no test read any of them: dropping a field from GENERATE_CHAT_FIELDS kept the suite green while a generate call silently lost its sampling options, its output format or its thinking switch. Two unit tests now assert the translated request: model, temperature, max_tokens, stop, a json_object response format, reasoning_effort "none" and the system/user order, and a schema format with no system message. Removing any one of the four fields turns them red ("Expected: is <0.2> but: was <0.0>" for options); unchanged, 44/44 in OllamaApiSupportTest are green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + .../llama/server/OllamaApiSupportTest.java | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c0abd2e4d..0087fd270 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1413,6 +1413,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Entscheidung des Nutzers: Ollamas Semantik. Mit `suffix` wie bisher Infill, mit `raw: true` wie bisher die rohe Completion (`system` ignoriert — **korrigiert in F215:** Ollama ignoriert es nicht, sondern lehnt `raw` mit `system`, `template` oder `context` mit 400 ab; so jetzt auch hier), sonst der Chat-Weg: aus der Anfrage wird eine `/api/chat`-Anfrage (`system` + Nutzer-`prompt` mit `images`, dazu `options`, `format`, `think`) und über die vorhandene Übersetzung geschickt; die Antwort trägt `response`, `thinking` (aus `reasoning_content`), `done_reason` und die Zählungen. README und `package-info` beschreiben die drei Wege; der modellgestützte `OpenAiServerCompletionIntegrationTest` bekam eine `raw`-Variante. - **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithoutRawAppliesTheChatTemplateLikeOllama` (Aufzeichnungs-Backend: Chat-Weg, `system`, Bild, `thinking`, Zählung), `#ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawRoundTrip` (Modell, CI). - **Rot-Grün-Nachweis:** ohne den Fix rot (`was "completions"`), mit ihm grün (96 Tests); der F178-Test gab nun explizit `raw: true` an, weil er die Completion-Abbildung prüft; SpotBugs grün. Die modellgestützte Variante läuft nur in CI bzw. beim lokalen Agenten. +- **Nachtrag (Branch-Review 2026-10-20):** Die Weitergabe von `model`, `options`, `format` und `think` in die synthetische Chat-Anfrage hatte keinen Test; jedes der vier Felder ließ sich aus `GENERATE_CHAT_FIELDS` streichen, ohne dass etwas rot wurde (Fund J3-5). `OllamaApiSupportTest#generateWithoutRawCarriesOptionsFormatAndThinkIntoTheChatRequest` pinnt `model`, `temperature` 0.2, `max_tokens` 16, `stop`, `response_format` `json_object`, `reasoning_effort` `none` und die Reihenfolge System/User; `#generateWithASchemaFormatAndNoSystemIsOneUserMessageWithAJsonSchema` den Schema-Zweig von `format` und die eine User-Nachricht ohne `system`. Gegenprobe je gestrichenem Feld rot: `model` (`Expected: is "m" but: was ""`), `options` (`Expected: is <0.2> but: was <0.0>`), `format` (`Expected: is "json_object" but: was ""`, dazu der Schema-Test), `think` (`Expected: is "none" but: was ""`); unverändert 44/44 in der Klasse grün. - **Status:** gefixt - **Commit:** java-llama.cpp `f12d970` diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index aec04167f..42a6412fe 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -565,4 +565,42 @@ public void thinkTrueSwitchesThinkingOnExplicitly() throws IOException { assertThat(openAi.toString(), kwargs.path("enable_thinking").asBoolean(), is(true)); } } + + /** + * {@code /api/generate} without {@code raw} goes through the chat template as a synthetic + * {@code /api/chat} request; the request's {@code model}, {@code options}, {@code format} and + * {@code think} must reach that request, or a generate call silently loses its sampling, its + * output format and its thinking switch. + */ + @Test + public void generateWithoutRawCarriesOptionsFormatAndThinkIntoTheChatRequest() throws IOException { + JsonNode openAi = OllamaApiSupport.toOpenAiChatRequestFromGenerate(read("{\"model\":\"m\",\"prompt\":\"q\"," + + "\"system\":\"s\",\"format\":\"json\",\"think\":false," + + "\"options\":{\"temperature\":0.2,\"num_predict\":16,\"stop\":[\"END\"]}}")); + assertThat(openAi.path("model").asText(), is("m")); + assertThat(openAi.path("temperature").asDouble(), is(0.2)); + assertThat(openAi.path("max_tokens").asInt(), is(16)); + assertThat(openAi.path("stop").get(0).asText(), is("END")); + assertThat(openAi.path("response_format").path("type").asText(), is("json_object")); + assertThat(openAi.path("reasoning_effort").asText(), is("none")); + JsonNode messages = openAi.path("messages"); + assertThat(messages.size(), is(2)); + assertThat(messages.get(0).path("role").asText(), is("system")); + assertThat(messages.get(0).path("content").asText(), is("s")); + assertThat(messages.get(1).path("role").asText(), is("user")); + assertThat(messages.get(1).path("content").asText(), is("q")); + } + + @Test + public void generateWithASchemaFormatAndNoSystemIsOneUserMessageWithAJsonSchema() throws IOException { + String schema = "{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"integer\"}}}"; + JsonNode openAi = OllamaApiSupport.toOpenAiChatRequestFromGenerate( + read("{\"model\":\"m\",\"prompt\":\"q\",\"format\":" + schema + "}")); + assertThat(openAi.path("response_format").path("type").asText(), is("json_schema")); + assertThat(openAi.path("response_format").path("json_schema").path("schema"), is(read(schema))); + JsonNode messages = openAi.path("messages"); + assertThat(messages.size(), is(1)); + assertThat(messages.get(0).path("role").asText(), is("user")); + assertThat(messages.get(0).path("content").asText(), is("q")); + } } From 3d20bddf84882cc8ca423496b79f1d75715b9c25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:14:31 +0000 Subject: [PATCH 433/589] Pin the MIME detection of Ollama images toImageDataUrl labels a bare base64 image by its magic bytes (JPEG, GIF, WebP, else PNG) and passes an existing data URL through, but the only test sent a PNG: swapping the JPEG and GIF literals or dropping the data: check kept the suite green. Two unit tests now assert each label and the passthrough. The passthrough is the case a client would notice, since llama.cpp rejects a doubled data: prefix while it never reads the subtype. Swapped literals, a missing WebP branch and a missing passthrough each turn them red (e.g. "but: was \"data:image/png;base64,data:image/jpeg;base64,/9j/4AAQ\""); unchanged, 46/46 in OllamaApiSupportTest are green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + .../llama/server/OllamaApiSupportTest.java | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0087fd270..cddb48096 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -235,6 +235,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Ollama-Clients schicken Bilder als `"images": ["", …]` neben `"content"`. `toOpenAiMessage` kopierte die Nachricht unverändert; der OpenAI-Parser von llama.cpp liest nur `image_url`-Content-Parts, das Feld `images` wurde also ignoriert. Das Vision-Modell antwortete ohne Bild, kein Fehler. Gleichzeitig meldet `/api/show` die Fähigkeit `vision`, wenn `--mmproj` gesetzt ist. Fix: `images` wird entfernt und als `image_url`-Parts (Data-URL, MIME-Typ aus den Magic-Bytes: JPEG/GIF/WebP, sonst PNG – llama.cpp dekodiert nach Inhalt) hinter dem Text angehängt. - **Reproduktion:** `OllamaApiSupportTest#toOpenAiChatRequestTurnsOllamaImagesIntoImageUrlParts` (vorher rot, danach grün); `OllamaApiSupportTest` 20/20, `OpenAiCompatServerHttpTest` 43/43. Ende-zu-Ende mit echtem Vision-Modell nicht geprüft (kein Modell im Sandbox). - **Nebenbefund (nicht geändert):** Die gestreamte Abschlusszeile (`toOllamaDoneLine`) setzt `done_reason` immer auf `"stop"`, auch bei Abbruch durch `length`; die nicht-gestreamte Antwort übernimmt `finish_reason` korrekt. +- **Nachtrag (Branch-Review 2026-10-20):** Die MIME-Erkennung aus den Magic-Bytes und der Durchlass einer fertigen `data:`-URL hatten keinen Test; der PNG-Test oben nimmt nur den `else`-Zweig (Fund J3-6). `OllamaApiSupportTest#ollamaImageMimeTypeFollowsTheMagicBytes` pinnt `/9j/` → `image/jpeg`, `R0lGOD` → `image/gif`, `UklGR` → `image/webp` und den PNG-Rückfall; `#ollamaImageThatIsAlreadyADataUrlIsPassedThrough` den Durchlass — der einzige Zweig mit sichtbarer Wirkung, denn ein doppeltes `data:` lehnt llama.cpp ab, während es den Untertyp nie liest. Gegenprobe rot: vertauschte JPEG/GIF-Literale (`Expected: is "data:image/jpeg;base64,/9j/4AAQ" but: was "data:image/gif;base64,/9j/4AAQ"`), fehlender WebP-Zweig (`… but: was "data:image/png;base64,UklGRh4A"`), fehlender Durchlass (`… but: was "data:image/png;base64,data:image/jpeg;base64,/9j/4AAQ"`); unverändert 46/46 in der Klasse grün. - **Status:** gefixt - **Commit:** java-llama.cpp `ef7295c` diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 42a6412fe..0fddb95ae 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -146,6 +146,45 @@ public void toOpenAiChatRequestTurnsOllamaImagesIntoImageUrlParts() throws IOExc assertThat(message.has("images"), is(false)); } + /** + * The MIME type of a bare base64 image follows its magic bytes. llama.cpp decodes the payload by + * content, so a wrong subtype would still work; these cases keep the labels honest. + */ + @Test + public void ollamaImageMimeTypeFollowsTheMagicBytes() throws IOException { + String[][] cases = { + {"/9j/4AAQ", "image/jpeg"}, + {"R0lGODlh", "image/gif"}, + {"UklGRh4A", "image/webp"}, + {"AAAA", "image/png"} + }; + for (String[] imageAndMimeType : cases) { + assertThat( + imageAndMimeType[0], + imageUrlOf(imageAndMimeType[0]), + is("data:" + imageAndMimeType[1] + ";base64," + imageAndMimeType[0])); + } + } + + /** + * An image that already is a data URL passes through unchanged: a second {@code data:} prefix makes + * llama.cpp reject the whole request ("Invalid uri-encoded base64 value"), so this is the case a + * client would notice. + */ + @Test + public void ollamaImageThatIsAlreadyADataUrlIsPassedThrough() throws IOException { + String dataUrl = "data:image/jpeg;base64,/9j/4AAQ"; + assertThat(imageUrlOf(dataUrl), is(dataUrl)); + } + + private static String imageUrlOf(String image) throws IOException { + JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest(read( + "{\"messages\":[{\"role\":\"user\",\"content\":\"what is this?\",\"images\":[\"" + image + "\"]}]}")); + JsonNode part = openAi.path("messages").get(0).path("content").get(1); + assertThat(part.path("type").asText(), is("image_url")); + return part.path("image_url").path("url").asText(); + } + @Test public void toOpenAiChatRequestMapsFormatJsonToResponseFormat() throws IOException { JsonNode openAi = OllamaApiSupport.toOpenAiChatRequest( From 9980ce0890d16d7f7d30e303628d9d960da53766 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:40:20 +0000 Subject: [PATCH 434/589] Test the client-disconnect path of the streamed completions The streaming backend relays a sink IOException (a client that went away) out of the model's Consumer-based API as an UncheckedIOException; the model call cancels its native task in a finally block, and the backend unwraps the original IOException so that the server logs a disconnect at DEBUG instead of a failed completion at WARN with an error event to the gone client. Nothing tested that chain since the streamed /v1/completions moved onto the native stream (F186). The relay that stream and streamCompletions each carried is now one package-private helper, LlamaModelBackend.relay, unit-tested with lambdas for the model: the sink's IOException comes back as the same instance, a foreign UncheckedIOException and any other RuntimeException pass unchanged. Two model-backed tests in OpenAiServerCompletionIntegrationTest pin the native cancel and the server's disconnect arm: a sink that throws on the first chunk, and a raw socket closed after the first data line (LogCaptor: one DEBUG disconnect line, no WARN). Both count decodes against a fixed budget that fits one slot, since waiting for an idle slot alone passes without the cancel too. The SpotBugs LEST suppression now names relay. Red/green with the random test model: without the finally cancel in streamCompletionsOai both model-backed tests fail ("decodes of the cancelled stream: 100"), without the unwrap the unit test ("expected: but was: ") and the socket test ("Expected: is <1> but: was <0>") fail; green otherwise. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + llama/spotbugs-exclude.xml | 12 +- .../llama/server/LlamaModelBackend.java | 51 ++++---- .../llama/server/LlamaModelBackendTest.java | 84 ++++++++++++ ...OpenAiServerCompletionIntegrationTest.java | 120 ++++++++++++++++++ 5 files changed, 237 insertions(+), 31 deletions(-) create mode 100644 llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index cddb48096..b78ff9d1b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1508,6 +1508,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** Neuer nativer Einstieg, der den Körper wie das blockierende `completions_oai` mit `oaicompat_completion_params_parse` liest und als `TASK_RESPONSE_TYPE_OAI_CMPL` streamt; der Empfang ist der vorhandene Hüllen-Empfang des Chat-Streams. `LlamaModelBackend.streamCompletions` reicht den Körper (mit `stream: true`) wörtlich weiter. `toCompletionParameters` samt seiner Ablehnungslisten und `OpenAiSseFormatter.completionChunk` hatten danach keinen Aufrufer mehr und sind entfernt; die Mapper-Tests, die gemeinsame Felder prüften, laufen jetzt über den Chat-Weg. Mehrere Prompts lehnt der native Weg seit F184 ab, gestreamt wie blockierend. - **Tests:** Java `OpenAiServerCompletionIntegrationTest#aStreamedCompletionIsUpstreamsStream` (modellgestützt: Token-Prompt, `logprobs`, eine Id, `usage` mit `prompt_tokens` 2, `finish_reason`, `[DONE]`); C++ `CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` (Zufallsmodell, dazu die Ablehnungen ohne Prompt, mit zwei Prompts und mit Null-Argument); FFM `FfmLlamaModelIntegrationTest#oaiCompletionStreamEndsWithUsage`; `OpenAiRequestMapperTest` umgestellt (33). - **Rot-Grün-Nachweis:** Der Java-Integrationstest mit einem Zufallsmodell vor dem Umbau rot (`400` für den Token-Prompt), danach grün; C++ 643/643; `llama-ffm verify` grün (17 + 4 + 9) gegen die neu gebaute Bibliothek; `OpenAiCompatServerHttpTest` 63, `NativeLibraryLoadSmokeTest` 6 grün. +- **Nachtrag (Branch-Review 2026-10-20):** Der Abbruchweg hatte keinen Test: die IOException der Sink kreuzt `streamCompletionsOai` als `UncheckedIOException`, dessen `finally` den nativen Task abbricht, und `LlamaModelBackend` packt sie wieder aus, damit der Server den Disconnect-Zweig (DEBUG) nimmt und nicht den Fehlerzweig (WARN plus Fehler-Event an den gegangenen Client) (Fund J2-7). Das doppelte Weiterreichen aus `stream` und `streamCompletions` ist jetzt der paketprivate Helfer `LlamaModelBackend.relay`, modellfrei getestet in `LlamaModelBackendTest` (die IOException der Sink kommt als dieselbe Instanz zurück und beendet den Modellaufruf, eine fremde `UncheckedIOException` und jede andere RuntimeException laufen unverändert durch). Modellgestützt in `OpenAiServerCompletionIntegrationTest`: `#aSinkThatFailsMidStreamCancelsTheNativeTask` (eine Sink, die beim ersten Chunk wirft; dieselbe Exception-Instanz, dann Warten bis kein Slot mehr arbeitet, 30 s) und `#aClientThatDisconnectsMidStreamIsADisconnectAndFreesTheSlot` (roher Socket, nach der ersten `data:`-Zeile geschlossen; LogCaptor: genau ein DEBUG „client disconnected during stream“, kein WARN „streaming completion failed“). Gezählt wird über `getDecodeTotal()` bei festem Budget (100 Token, `ignore_eos`): das Warten auf einen freien Slot allein ist auch ohne den Abbruch grün, der Slot erzeugt dann nur erst das ganze Budget. Das Budget muss in den Kontext eines Slots passen; mit 200 Token hielt der Slot des Winzmodells (128) von selbst an, und ein fehlender Abbruch war nicht zu unterscheiden. Gegenprobe mit dem Winzmodell: ohne den `finally`-Abbruch in `streamCompletionsOai` sind beide rot (`decodes of the cancelled stream: 100` / `decodes of the disconnected stream: 100`, `Expected: is but: was `); ohne das Auspacken in `relay` sind `LlamaModelBackendTest#anIOExceptionOfTheSinkComesBackAsTheSameInstance` (`expected: but was: `) und der Socket-Test rot (`[] Expected: is <1> but: was <0>`); unverändert grün in vier Wiederholungen. Die LEST-Unterdrückung in `spotbugs-exclude.xml` nennt jetzt `relay`. - **Status:** gefixt - **Commit:** java-llama.cpp `4fb82c1` diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml index 466c29fc3..54c87b583 100644 --- a/llama/spotbugs-exclude.xml +++ b/llama/spotbugs-exclude.xml @@ -545,9 +545,10 @@ SPDX-License-Identifier: MIT From 1d0f436925b74a5b57c3943b6277a7d68f654373 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:41:17 +0000 Subject: [PATCH 469/589] List all four rerank entry fields in the C header jllama_api.h still said each entry of jllama_rerank's result carries its index and score. Since F177 rerank_results_to_json writes document, index, score and tokens_evaluated, and LlamaModel.handleRerank's Javadoc was updated then; the shipped header a C or FFM consumer reads was not. It now names all four, with index as the position in documents and tokens_evaluated 0 when upstream reports none. Comment only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + llama/src/main/cpp/jllama_api.h | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index f9459a42b..8cd9d6564 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1415,6 +1415,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Commit:** java-llama.cpp `68f81b6` - **Nachtrag (Branch-Review 2026-10-20):** Die Abweisung eines negativen `top_n` griff nur für die ganzzahlige Schreibweise; ein `-1.5` galt als „fehlt“. Seit F230 liest `readTopN` jede Zahl, die Abweisung deckt auch die Gleitkomma-Schreibweise ab (`OaiRerankSupportTest#negativeFloatTopNIsAnInvalidRequest`). - **Nachtrag (Branch-Review 2026-10-20, Fund J6-3):** Ende zu Ende nachgeholt, über die echte Route und `LlamaModelBackend.rerank`: `OpenAiServerRerankIntegrationTest#negativeTopNIsRejectedBeforeAnythingIsEvaluated` (400, `invalid_request_error`, Meldung nennt `top_n`, und `getMetricsTyped().getCumulativeProcessedPromptTokens()` bleibt gleich – der Status allein kann die Reihenfolge nicht zeigen, weil `toResponse` `top_n` nach dem nativen Aufruf noch einmal liest) und `#topNZeroAnswersNoResultsButUsageCountsTheEvaluatedTokens` (keine `results`/`data`, `usage.prompt_tokens > 0` und gleich `total_tokens` – der Beweis, dass das echte `server_task_result_rerank` `tokens_evaluated` in die Summe trägt; der C++-Test nutzt ein unechtes Ergebnis). Lokal gelaufen mit dem zufälligen 2-Layer-Llama als Reranker (`-Dnet.ladenthin.llama.rerank.model=`, Rank-Pooling über den letzten Zustand), 4/4, nichts übersprungen; CI nimmt den Jina-Reranker. Gegenprobe rot: ohne den frühen `readTopN`-Aufruf im Backend („Expected: is <419L> but: was <493L>“ beim Zähler, Status weiter 400), mit dem Stand vor F177 in Java („Expected: is <400> but: was <200>“, „Expected: is <0> but: was <2>“), ohne `usage` („Expected: a value greater than <0> but: <-1> was less than <0>“). +- **Nachtrag (Branch-Review 2026-10-20, Fund J11-3):** Der Kommentar von `jllama_rerank` im ausgelieferten C-Header `jllama_api.h` nannte je Eintrag nur `index` und `score`; er nennt jetzt alle vier Felder, die `rerank_results_to_json` schreibt (`document`, `index` als Position in `documents`, `score`, `tokens_evaluated` mit 0, wenn upstream nichts meldet), wie die Javadoc von `LlamaModel#handleRerank`. Nur Doku. ### F178 – java-llama.cpp: Ollama-Routen melden `done_reason` falsch und `/api/generate` keine Tokenzahlen diff --git a/llama/src/main/cpp/jllama_api.h b/llama/src/main/cpp/jllama_api.h index c0c51145e..7d954b4e3 100644 --- a/llama/src/main/cpp/jllama_api.h +++ b/llama/src/main/cpp/jllama_api.h @@ -242,7 +242,9 @@ JLLAMA_API jllama_status jllama_embeddings(jllama_model *model, const char *requ /** * Reranks `n_documents` documents against `query` (a model loaded with `--reranking`); the result - * array, each entry with its `index` and `score`. + * array, one entry per document with `document` (its text), `index` (its position in `documents`), + * `score` and `tokens_evaluated` (the tokens the model evaluated for it, 0 if not reported; an + * OpenAI-shaped `usage` sums them). */ JLLAMA_API jllama_status jllama_rerank(jllama_model *model, const char *query, int n_documents, const char *const *documents, char **response_json); From 9fabf3e2ea652eb4c7d721cfa9604de8449a6345 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:42:21 +0000 Subject: [PATCH 470/589] Describe the current llama-ffm test set in its README and CLAUDE.md The module README still said "7 model-free tests", the count before this branch added the takeStrings cases, FfmNativeServerTest and FfmTextToSpeechTest, and claimed every native-backed test skips itself without a native build. CLAUDE.md's Tests bullet named only JllamaNativeTest and the integration test. The README now names no number (a count is what went stale) and says which classes run without the library, which skip and that FfmRpcServerTest needs one; CLAUDE.md lists the lifecycle tests, the takeStrings cases, the RPC test, the OAI completion stream case and the two callback-error cases. Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + CLAUDE.md | 20 ++++++++++++++------ llama-ffm/README.md | 9 ++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 8cd9d6564..9fc881acb 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1624,6 +1624,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** Mit dem Seam, aber ohne Lock: `closeWaitsForASynthesisInsideTheNativeCall` rot (`close()` kehrte zurück, während die Synthese noch lief); mit dem Lock grün. `llama-ffm` `verify` 35/35 mit dem Zufallsmodell. - **Status:** gefixt - **Commit:** java-llama.cpp `c07aad0` +- **Nachtrag (Branch-Review 2026-10-20, Fund J13-1):** `llama-ffm/README.md` nannte noch „7 model-free tests“ (Stand vor dem Branch) und behauptete, die nativen Tests übersprängen sich ohne Bibliothek; der CLAUDE.md-Punkt „Tests“ des Moduls kannte nur `JllamaNativeTest` und `FfmLlamaModelIntegrationTest`. Das README nennt jetzt keine Zahl mehr und sagt, welche Klassen ohne Bibliothek laufen (`FfmNativeServerTest`, `FfmTextToSpeechTest`, über Fake-`Backend`s, F59/F198), welche sich überspringen (`JllamaNativeTest`, der modellgestützte Test) und dass `FfmRpcServerTest` eine braucht (er überspringt sich nicht); CLAUDE.md zählt die beiden Lebenszyklus-Klassen, die drei `takeStrings`-Fälle, `FfmRpcServerTest`, `oaiCompletionStreamEndsWithUsage` und die beiden Callback-Fehlerfälle auf. Nur Doku. ### F199 – java-llama.cpp: Ein Heartbeat vor dem Eröffnungsevent verschluckt `message_start` (Anthropic) bzw. `response.created` (Responses) diff --git a/CLAUDE.md b/CLAUDE.md index 431e3ae01..30e0b9019 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3102,12 +3102,20 @@ the "Java 22+ over the C API" item of the delivery roadmap (TODO.md item 10), de are major 66 and are not part of the smoke sets. - **Tests**: `JllamaNativeTest` (model-free: `apiVersion` equals the pom version surefire passes, `buildInfo` starts with `LlamaCppVersion`, a grammar conversion, a missing model's message, the log - callback reached from the worker and flushed on `null`, RPC device names) and - `FfmLlamaModelIntegrationTest` (the draft model, or `-Dnet.ladenthin.llama.ffm.model`: progress - callback, meta, chat completion, streaming to `stop` and an early close, raw completion and its - stream, tokenize/detokenize round trip, template, metrics, LoRA and configure, slot erase and the - refused save, the embedding refusal, invalid requests, the attached server answering `/health` and - the model outliving it, a closed model refusing calls). Locally the C++ test fixture's twin, a + callback reached from the worker and flushed on `null`, an `Error` thrown by the log callback losing + that line and nothing else, RPC device names, and three `takeStrings` cases: a populated array read + in order and freed with its count, an empty non-null array still freed, a null array not freed), + `FfmNativeServerTest` and `FfmTextToSpeechTest` (no native library, fake `Backend`s: `close()` waits + for an `isRunning()` / `synthesize` call inside the native call -- the lifecycle lock, a read/write + lock for TTS --, a call after `close()` does not touch the handle, a second `close()` has no + effect), `FfmRpcServerTest` (loopback, CPU: a stop while nothing serves does not stop the next serve, + a stop ends it) and `FfmLlamaModelIntegrationTest` (the draft model, or + `-Dnet.ladenthin.llama.ffm.model`: progress callback, meta, chat completion, streaming to `stop` and + an early close, raw completion and its stream, the OAI completion stream ending with `usage`, + tokenize/detokenize round trip, template, metrics, LoRA and configure, slot erase and the refused + save, the embedding refusal, invalid requests, the attached server answering `/health` and the model + outliving it, an `Error` thrown by the progress callback aborting the load instead of the JVM, a + closed model refusing calls). Locally the C++ test fixture's twin, a tiny random llama GGUF written with `gguf-py`, serves as the model where no download is possible. - **`versions.POMS`** names `llama-ffm/pom.xml` in all four repositories (a shared file). diff --git a/llama-ffm/README.md b/llama-ffm/README.md index 7d3101e48..d545365ed 100644 --- a/llama-ffm/README.md +++ b/llama-ffm/README.md @@ -85,10 +85,13 @@ builds like every other module. ```bash mvn -pl llama -am -DskipTests install # the core first -mvn -f llama-ffm/pom.xml verify # 7 model-free tests + the model-backed ones +mvn -f llama-ffm/pom.xml verify # the model-free tests + the model-backed ones # the model-backed test takes the draft model of .github/models.csv, or: mvn -f llama-ffm/pom.xml verify -Dnet.ladenthin.llama.ffm.model=/path/to/model.gguf ``` -The native-backed tests skip themselves without a native build (`llama/src/main/natives`, or -`-Dnet.ladenthin.llama.lib.path=`). +`FfmNativeServerTest` and `FfmTextToSpeechTest` run over fake `Backend` implementations and need +no native library. `JllamaNativeTest` and the model-backed `FfmLlamaModelIntegrationTest` skip +themselves without a native build (`llama/src/main/natives`, or +`-Dnet.ladenthin.llama.lib.path=`); `FfmRpcServerTest` serves the CPU over +loopback and needs one as well. From a520c36f27e97113bfb0a1d48dd74a5e33335d17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:43:31 +0000 Subject: [PATCH 471/589] Say which tool-call fragments reach onPartialToolCall StreamingChunkAssembler's class Javadoc said every tool_calls fragment is forwarded as onPartialToolCall and that id and name arrive on the first fragment. Since 4d599fa a fragment is forwarded only if it carries argument text and its call already has a name, because PartialToolCall refuses empty arguments and a blank name; an id/name-only header or arguments that arrive before the name only feed the assembled call, and llama.cpp's parser may report a call before its name. The Javadoc and the module README now say so. Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + llama-langchain4j/README.md | 5 +++-- .../llama/langchain4j/StreamingChunkAssembler.java | 14 +++++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9fc881acb..25f05b8cb 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1203,6 +1203,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag (Branch-Review 2026-10-20):** Die beiden Tests oben prüften nur, dass nichts wirft, und die fertige Anfrage; welche Fragmente als `PartialToolCall` gemeldet werden und mit welcher Id, war ungeprüft (Fund J14-2). `#toolCallDeltaWithoutArguments_doesNotFailTheStream` pinnt jetzt genau ein gemeldetes Teilstück (Index 0, Id `call_1`, Name `get_weather`, Argumente `{}`), einen `CompleteToolCall` und langchain4js Vertrag über den neuen Helfer `assertPartialsMakeUpTheCompleteCall` (die Argumentstücke ergeben aneinandergehängt die Argumente des fertigen Aufrufs, jedes trägt dessen Id und Namen). `#toolCallDeltaBeforeItsName_doesNotFailTheStream` prüft nur, dass mindestens ein Teilstück gemeldet wird (`is(not(empty()))`, damit die Schleife nicht leer durchläuft; bei der Prüfung ergänzt, rot gegen eine Mutante, die nach einem zurückgehaltenen Fragment nichts mehr meldet: `Expected: is not an empty collection`) und dass jedes gemeldete Teilstück die endgültige Id und den Namen trägt: der Vertrag ist dort heute rot (`Expected: is "{\"city\":1}" but: was "ty\":1}"`, das Präfix vor dem Namen wird nie gemeldet, Fund J14-1) und kommt mit dessen Fix; das heutige einzelne Teilstück wird bewusst nicht festgeschrieben. Gegenprobe rot: die Id aus dem aktuellen Fragment statt aus dem gesammelten Aufruf (`Expected: is "call_1" but: was ""`), das Kopf-Fragment mit einem Platzhalter-Argument `" "` gemeldet (`Expected: is <1> but: was <2>`); unverändert 12/12 grün. - **Status:** gefixt - **Commit:** java-llama.cpp `f7f3f0f` +- **Nachtrag (Branch-Review 2026-10-20, Fund J14-4):** Die Klassen-Javadoc von `StreamingChunkAssembler` sagte noch, jedes `tool_calls`-Fragment gehe als `onPartialToolCall` hinaus, und dass id und Name am ersten Fragment kämen. Seit dem Fix (`4d599fa`) wird ein Fragment nur weitergereicht, wenn es Argumenttext trägt und sein Aufruf schon einen Namen hat; andere (ein Kopf nur mit id/Name, Argumente vor dem Namen) speisen nur den zusammengesetzten Aufruf. Javadoc und der Satz im `llama-langchain4j/README.md` sagen das jetzt, „id und Name“ kommen „usually“ am ersten Fragment. Nur Doku. ### F133 – java-llama.cpp: `setPoolingType(UNSPECIFIED)` lässt ein vorher gesetztes `--pooling` stehen diff --git a/llama-langchain4j/README.md b/llama-langchain4j/README.md index 6b9f55d5e..91c953b78 100644 --- a/llama-langchain4j/README.md +++ b/llama-langchain4j/README.md @@ -128,8 +128,9 @@ jina-reranker models the core test jobs already download) — the `presencePenalty`, `stopSequences`. - **Streaming tool calls + thinking events.** `JllamaStreamingChatModel` streams over the - native OpenAI chunk path: `delta.tool_calls` fragments surface as `onPartialToolCall` / - `onCompleteToolCall` events and land on the final response as + native OpenAI chunk path: a `delta.tool_calls` fragment carrying arguments for an already named + call surfaces as `onPartialToolCall`, every assembled call as `onCompleteToolCall`, and the calls + land on the final response as `AiMessage.toolExecutionRequests()` (finish reason `TOOL_EXECUTION`); reasoning deltas surface as `onPartialThinking` and as `AiMessage.thinking()`; both the blocking and the streamed final response carry the model's real finish reason and token usage. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java index 978d24d15..42fa7fd4c 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java @@ -31,11 +31,15 @@ *
  • {@code delta.reasoning_content} → * {@link StreamingChatResponseHandler#onPartialThinking(PartialThinking)} *
  • {@code delta.tool_calls[*]} fragments → accumulated per OpenAI {@code index} (id and - * name arrive on the first fragment, arguments split across fragments), each forwarded as - * {@link StreamingChatResponseHandler#onPartialToolCall(PartialToolCall)} and completed as - * {@link StreamingChatResponseHandler#onCompleteToolCall(CompleteToolCall)} as soon as a - * call with a higher index starts (llama.cpp only ever extends the last call of a message), - * the last one, and any call that had no name yet at that point, by {@link #complete()} + * name usually arrive on the first fragment, arguments split across fragments). A fragment is + * forwarded as {@link StreamingChatResponseHandler#onPartialToolCall(PartialToolCall)} only if + * it carries argument text and its call already has a name ({@code PartialToolCall} refuses + * empty arguments and a blank name); other fragments, such as an id/name-only header or + * arguments that arrive before the name, only feed the assembled call. Every call is + * completed as {@link StreamingChatResponseHandler#onCompleteToolCall(CompleteToolCall)} + * as soon as a call with a higher index starts (llama.cpp only ever extends the last call of + * a message), the last one, and any call that had no name yet at that point, by + * {@link #complete()} *
  • {@code finish_reason} / trailing {@code usage} chunk → finish reason and * {@link TokenUsage} on the final response * From 707da0e6f48fde321c3b9be2287457b274b97120 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:47:21 +0000 Subject: [PATCH 472/589] Document the one-prompt, one-completion rule on every entry point F184 and F197 refuse several prompts in one request and "n" > 1 with InvalidRequestException, unlike upstream's server, which answers both. Only the C header of jllama_completions/jllama_completions_oai said so, and streamCompletionsOai named the prompts but not "n". The @throws of handleCompletions, handleCompletionsOai and streamCompletionsOai now name both, handleChatCompletions and handleInfill only "n" (a chat request renders one prompt from its messages, infill builds its own); the eight request methods of FfmLlamaModel carry the same split, and the chat, infill and streaming functions of jllama_api.h say it too. Phrased as an unconditional refusal, not tied to the slot count. Documentation only; javadoc builds clean for the core and llama-ffm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + .../ladenthin/llama/ffm/FfmLlamaModel.java | 20 +++++++++++++++++++ llama/src/main/cpp/jllama_api.h | 14 ++++++++----- .../java/net/ladenthin/llama/LlamaModel.java | 17 +++++++++++----- 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 25f05b8cb..5e46feb4a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1612,6 +1612,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** Der C-API-Test gegen den alten Code rot (alle vier Wege `OK` statt `INVALID_REQUEST`), mit dem Fix grün; `ctest` grün. Die beiden Java-Tests lokal mit dem Zufallsmodell grün (die zwei Infill-Tests der Klasse scheitern dort erwartungsgemäß, das Zufallsmodell hat keine FIM-Tokens); in CI mit dem echten Modell. - **Status:** gefixt - **Commit:** java-llama.cpp `7a51bbc` +- **Nachtrag (Branch-Review 2026-10-20, Fund XJ-5):** Die Ablehnung mehrerer Prompts (F184) und von `"n"` > 1 stand im C-Header nur bei `jllama_completions`/`jllama_completions_oai` und in der Javadoc von `streamCompletionsOai` nur zur Hälfte. Jetzt nennen sie die `@throws InvalidRequestException` von `LlamaModel#handleCompletions`, `#handleCompletionsOai` und `#streamCompletionsOai` (beides), `#handleChatCompletions` und `#handleInfill` (nur `"n"`: eine Chat-Anfrage rendert genau einen Prompt aus `messages`, Infill baut ihn selbst), alle acht Anfrage-Methoden von `FfmLlamaModel` mit derselben Aufteilung, und im C-Header die Chat-, Infill- und Stream-Funktionen. Formuliert als unbedingte Ablehnung (ein Prompt und eine Completion je Anfrage), nicht über die Slot-Zahl. Nur Doku. ### F198 – java-llama.cpp: `FfmTextToSpeech.close()` gibt die Engine frei, während `synthesize()` auf einem anderen Thread in ihr läuft (FFM-Hälfte von F16) diff --git a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java index 4b9904f81..5b065ae92 100644 --- a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java +++ b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java @@ -146,6 +146,8 @@ public String meta() { * * @param requestJson the OpenAI chat body ({@code {"messages": [...], ...}}) * @return the {@code chat.completion} object + * @throws InvalidRequestException if the server refuses the body, including {@code "n"} greater than 1 + * (one completion per request; send separate requests) */ public String chatCompletions(String requestJson) { return stringOut(JllamaNative.CHAT_COMPLETIONS, requireNonNull(requestJson, "requestJson")); @@ -157,6 +159,8 @@ public String chatCompletions(String requestJson) { * * @param requestJson the OpenAI chat body * @return the stream; close it to release the task early + * @throws InvalidRequestException if the server refuses the body, including {@code "n"} greater than 1 + * (one completion per request; send separate requests) */ public FfmTaskStream chatCompletionsStream(String requestJson) { int task = taskOut(JllamaNative.CHAT_COMPLETIONS_STREAM_REQUEST, requireNonNull(requestJson, "requestJson")); @@ -169,6 +173,8 @@ public FfmTaskStream chatCompletionsStream(String requestJson) { * * @param requestJson the OpenAI chat body * @return the stream of raw result objects + * @throws InvalidRequestException if the server refuses the body, including {@code "n"} greater than 1 + * (one completion per request; send separate requests) */ public FfmTaskStream chatCompletionsRaw(String requestJson) { int task = taskOut(JllamaNative.CHAT_COMPLETIONS_REQUEST, requireNonNull(requestJson, "requestJson")); @@ -180,6 +186,9 @@ public FfmTaskStream chatCompletionsRaw(String requestJson) { * * @param requestJson the request object ({@code {"prompt": ..., ...}}) * @return the result object + * @throws InvalidRequestException if the server refuses the body, including several prompts in one + * request or {@code "n"} greater than 1 (one prompt and one completion per request; send separate + * requests) */ public String completions(String requestJson) { return stringOut(JllamaNative.COMPLETIONS, requireNonNull(requestJson, "requestJson")); @@ -190,6 +199,9 @@ public String completions(String requestJson) { * * @param requestJson the request object * @return the {@code text_completion} object + * @throws InvalidRequestException if the server refuses the body, including several prompts in one + * request or {@code "n"} greater than 1 (one prompt and one completion per request; send separate + * requests) */ public String completionsOai(String requestJson) { return stringOut(JllamaNative.COMPLETIONS_OAI, requireNonNull(requestJson, "requestJson")); @@ -202,6 +214,9 @@ public String completionsOai(String requestJson) { * * @param requestJson the request object, parsed as {@link #completionsOai(String)} parses it * @return the stream; close it to release the task early + * @throws InvalidRequestException if the server refuses the body, including several prompts in one + * request or {@code "n"} greater than 1 (one prompt and one completion per request; send separate + * requests) */ public FfmTaskStream completionsOaiStream(String requestJson) { int task = taskOut(JllamaNative.COMPLETIONS_OAI_STREAM_REQUEST, requireNonNull(requestJson, "requestJson")); @@ -213,6 +228,8 @@ public FfmTaskStream completionsOaiStream(String requestJson) { * * @param requestJson the request object ({@code input_prefix}, {@code input_suffix}, ...) * @return the result object + * @throws InvalidRequestException if the server refuses the body, including {@code "n"} greater than 1 + * (one completion per request) */ public String infill(String requestJson) { return stringOut(JllamaNative.INFILL, requireNonNull(requestJson, "requestJson")); @@ -223,6 +240,9 @@ public String infill(String requestJson) { * * @param requestJson the request object * @return the stream + * @throws InvalidRequestException if the server refuses the body, including several prompts in one + * request or {@code "n"} greater than 1 (one prompt and one completion per request; send separate + * requests) */ public FfmTaskStream completionsStream(String requestJson) { int task = taskOut(JllamaNative.COMPLETIONS_STREAM_REQUEST, requireNonNull(requestJson, "requestJson")); diff --git a/llama/src/main/cpp/jllama_api.h b/llama/src/main/cpp/jllama_api.h index 7d954b4e3..16e425667 100644 --- a/llama/src/main/cpp/jllama_api.h +++ b/llama/src/main/cpp/jllama_api.h @@ -123,7 +123,8 @@ JLLAMA_API void jllama_model_free(jllama_model *model); /** * An OpenAI chat completion, blocking: `request_json` is the request body * (`{"messages":[...], "max_tokens": ..., ...}`), *response_json the chat.completion object - * (jllama_string_free). JLLAMA_STATUS_INVALID_REQUEST for a body the server refuses. + * (jllama_string_free). JLLAMA_STATUS_INVALID_REQUEST for a body the server refuses, including + * "n" > 1: a request takes one completion (send separate requests). */ JLLAMA_API jllama_status jllama_chat_completions(jllama_model *model, const char *request_json, char **response_json); @@ -131,7 +132,8 @@ JLLAMA_API jllama_status jllama_chat_completions(jllama_model *model, const char * Starts a streaming OpenAI chat completion (`"stream": true` in the body makes the server emit * chat.completion.chunk deltas) and returns its task id in *task_id. Receive with * jllama_chat_completions_stream_receive until it reports stop; a task not received to its end is - * released with jllama_task_release. + * released with jllama_task_release. "n" > 1 is JLLAMA_STATUS_INVALID_REQUEST, as for + * jllama_chat_completions. */ JLLAMA_API jllama_status jllama_chat_completions_stream_request(jllama_model *model, const char *request_json, int *task_id); @@ -192,27 +194,29 @@ JLLAMA_API jllama_status jllama_completions_oai(jllama_model *model, const char * Starts a streaming OpenAI /v1/completions request (the body as for jllama_completions_oai) and * returns its task id in *task_id. Receive with jllama_chat_completions_stream_receive, whose * envelopes then carry text_completion chunks, the last with usage; release an unfinished task with - * jllama_task_release. + * jllama_task_release. One prompt and one choice, as for jllama_completions. */ JLLAMA_API jllama_status jllama_completions_oai_stream_request(jllama_model *model, const char *request_json, int *task_id); /** * A fill-in-the-middle request (`input_prefix`, `input_suffix`, optional `input_extra` and - * `prompt`), blocking. JLLAMA_STATUS_ERROR when the model has no FIM tokens. + * `prompt`), blocking. JLLAMA_STATUS_ERROR when the model has no FIM tokens; "n" > 1 is + * JLLAMA_STATUS_INVALID_REQUEST, as for jllama_completions. */ JLLAMA_API jllama_status jllama_infill(jllama_model *model, const char *request_json, char **response_json); /** * Starts a raw streaming completion (`"stream": true`; an infill one when the body carries * `input_prefix`/`input_suffix`) and returns its task id; receive with - * jllama_completions_stream_receive. + * jllama_completions_stream_receive. One prompt and one completion, as for jllama_completions. */ JLLAMA_API jllama_status jllama_completions_stream_request(jllama_model *model, const char *request_json, int *task_id); /** * Starts a streaming chat completion whose results are llama.cpp's raw result objects (the chat * template applied, no OpenAI chunk formatting); receive with jllama_completions_stream_receive. + * "n" > 1 is JLLAMA_STATUS_INVALID_REQUEST, as for jllama_chat_completions. */ JLLAMA_API jllama_status jllama_chat_completions_request(jllama_model *model, const char *request_json, int *task_id); diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java index 7a455ddc8..ef98aab77 100644 --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java @@ -800,7 +800,8 @@ public void streamChatCompletion(InferenceParameters parameters, Consumer chunkSink) { @@ -826,7 +827,9 @@ public void streamCompletionsOai(String requestJson, Consumer chunkSink) * @param paramsJson JSON string with at least a "prompt" field * @return JSON response from the server * @throws net.ladenthin.llama.exception.InvalidRequestException if the request is rejected: not valid JSON, no - * {@code "prompt"}, a parameter outside its limits, a prompt over the context size + * {@code "prompt"}, a parameter outside its limits, a prompt over the context size, several prompts in + * one request (an array of strings or of token arrays) or {@code "n"} greater than 1 -- one prompt and + * one completion per request, unlike upstream's server; send separate requests * @throws net.ladenthin.llama.exception.LlamaException if inference fails */ public native String handleCompletions(String paramsJson); @@ -838,7 +841,9 @@ public void streamCompletionsOai(String requestJson, Consumer chunkSink) * @param paramsJson JSON string with OAI-compatible completion parameters * @return JSON response in OAI format * @throws net.ladenthin.llama.exception.InvalidRequestException if the request is rejected: not valid JSON, no - * {@code "prompt"}, a parameter outside its limits, a prompt over the context size + * {@code "prompt"}, a parameter outside its limits, a prompt over the context size, several prompts in + * one request (an array of strings or of token arrays) or {@code "n"} greater than 1 -- one prompt and + * one completion per request, unlike upstream's server; send separate requests * @throws net.ladenthin.llama.exception.LlamaException if inference fails */ public native String handleCompletionsOai(String paramsJson); @@ -850,7 +855,8 @@ public void streamCompletionsOai(String requestJson, Consumer chunkSink) * @param paramsJson JSON string with infill parameters * @return JSON response from the server * @throws net.ladenthin.llama.exception.InvalidRequestException if the request is rejected: not valid JSON, a missing - * {@code "input_prefix"} or {@code "input_suffix"}, a parameter outside its limits + * {@code "input_prefix"} or {@code "input_suffix"}, a parameter outside its limits, {@code "n"} greater + * than 1 (one completion per request) * @throws net.ladenthin.llama.exception.LlamaException if the model has no fill-in-the-middle tokens or inference fails */ public native String handleInfill(String paramsJson); @@ -1180,7 +1186,8 @@ public void setLoraAdapter(int adapterId, float scale) { * @return JSON response in OAI chat-completion format * @throws net.ladenthin.llama.exception.InvalidRequestException if the request is rejected: not valid JSON, no * {@code "messages"}, a message the chat template cannot take, a parameter outside its limits, a - * prompt over the context size + * prompt over the context size, {@code "n"} greater than 1 (one completion per request, unlike + * upstream's server; send separate requests) * @throws net.ladenthin.llama.exception.LlamaException if the model was loaded in embedding mode or inference fails */ public native String handleChatCompletions(String params); From 026bfed1a5bfc7335fe1e0ca6297b392cc5880b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 07:55:41 +0000 Subject: [PATCH 473/589] Name the rebased commits in three ledger notes The J9-5/J11-2 and J14-4 notes, and the F181 description they corrected, cite ca6ccdf and 4d599fa. Those are the hashes from before the branch was rebased onto main; neither commit is on the branch any more. They are 4867189 (escapeNonAscii) and f7f3f0f (the fragment filter of StreamingChunkAssembler) now. Ledger only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 5e46feb4a..e197665a8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1203,7 +1203,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag (Branch-Review 2026-10-20):** Die beiden Tests oben prüften nur, dass nichts wirft, und die fertige Anfrage; welche Fragmente als `PartialToolCall` gemeldet werden und mit welcher Id, war ungeprüft (Fund J14-2). `#toolCallDeltaWithoutArguments_doesNotFailTheStream` pinnt jetzt genau ein gemeldetes Teilstück (Index 0, Id `call_1`, Name `get_weather`, Argumente `{}`), einen `CompleteToolCall` und langchain4js Vertrag über den neuen Helfer `assertPartialsMakeUpTheCompleteCall` (die Argumentstücke ergeben aneinandergehängt die Argumente des fertigen Aufrufs, jedes trägt dessen Id und Namen). `#toolCallDeltaBeforeItsName_doesNotFailTheStream` prüft nur, dass mindestens ein Teilstück gemeldet wird (`is(not(empty()))`, damit die Schleife nicht leer durchläuft; bei der Prüfung ergänzt, rot gegen eine Mutante, die nach einem zurückgehaltenen Fragment nichts mehr meldet: `Expected: is not an empty collection`) und dass jedes gemeldete Teilstück die endgültige Id und den Namen trägt: der Vertrag ist dort heute rot (`Expected: is "{\"city\":1}" but: was "ty\":1}"`, das Präfix vor dem Namen wird nie gemeldet, Fund J14-1) und kommt mit dessen Fix; das heutige einzelne Teilstück wird bewusst nicht festgeschrieben. Gegenprobe rot: die Id aus dem aktuellen Fragment statt aus dem gesammelten Aufruf (`Expected: is "call_1" but: was ""`), das Kopf-Fragment mit einem Platzhalter-Argument `" "` gemeldet (`Expected: is <1> but: was <2>`); unverändert 12/12 grün. - **Status:** gefixt - **Commit:** java-llama.cpp `f7f3f0f` -- **Nachtrag (Branch-Review 2026-10-20, Fund J14-4):** Die Klassen-Javadoc von `StreamingChunkAssembler` sagte noch, jedes `tool_calls`-Fragment gehe als `onPartialToolCall` hinaus, und dass id und Name am ersten Fragment kämen. Seit dem Fix (`4d599fa`) wird ein Fragment nur weitergereicht, wenn es Argumenttext trägt und sein Aufruf schon einen Namen hat; andere (ein Kopf nur mit id/Name, Argumente vor dem Namen) speisen nur den zusammengesetzten Aufruf. Javadoc und der Satz im `llama-langchain4j/README.md` sagen das jetzt, „id und Name“ kommen „usually“ am ersten Fragment. Nur Doku. +- **Nachtrag (Branch-Review 2026-10-20, Fund J14-4):** Die Klassen-Javadoc von `StreamingChunkAssembler` sagte noch, jedes `tool_calls`-Fragment gehe als `onPartialToolCall` hinaus, und dass id und Name am ersten Fragment kämen. Seit dem Fix (`f7f3f0f`) wird ein Fragment nur weitergereicht, wenn es Argumenttext trägt und sein Aufruf schon einen Namen hat; andere (ein Kopf nur mit id/Name, Argumente vor dem Namen) speisen nur den zusammengesetzten Aufruf. Javadoc und der Satz im `llama-langchain4j/README.md` sagen das jetzt, „id und Name“ kommen „usually“ am ersten Fragment. Nur Doku. ### F133 – java-llama.cpp: `setPoolingType(UNSPECIFIED)` lässt ein vorher gesetztes `--pooling` stehen @@ -1465,14 +1465,14 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Datei:** `llama/src/main/cpp/jni_utf8.hpp` (neu), `jllama.cpp` (`throw_api_status`, Modell-Laden), `jni_helpers.hpp` (`report_cxx_exception_impl`), `native_server.cpp`, `rpc_bridge.cpp`, `train_engine.cpp` - **Schwere:** niedrig bis mittel (still verfälschte Meldung auf HotSpot, Prozessabbruch unter Android-CheckJNI) - **Historie:** Offener Verdacht vom 2026-10-18 (Musterdurchsicht nach F71, TODO (6)). Gemessen auf HotSpot 21 mit einer eigenen JNI-Sonde: `GetStringUTFChars` liefert für U+1F600 `ED A0 BD ED B8 80` (CESU-8), `NewStringUTF` macht aus `F0 9F 98 80` ein falsches Zeichen und verschluckt Folgebytes. -- **Beschreibung:** `ThrowNew` und `NewStringUTF` lesen Modified UTF-8. Die Meldungen aus llama.cpp zitieren Pfade und Anfragetext in Standard-UTF-8. Die Trainer-Meldung für `missing-😀.gguf` kam als `missing-`, U+00F0 U+009F U+0098 U+0080 und `.g` an (der Rest des Pfads fehlte). Die Eingabeseite des Trainers (`GetStringUTFChars`) ist dagegen nicht erreichbar: `TrainingParameters.escapeNonAscii` (`ca6ccdf`) schreibt jedes Nicht-ASCII-Zeichen als `\u`-Escape (Jacksons `ESCAPE_NON_ASCII` ist standardmäßig aus), die Konfiguration ist also dort ASCII, wo sich die Kodierungen unterscheiden (gemessen: `"Hallo \\ud83d\\ude00 Welt"`). Die Fehlermeldungen von Anfragen an das Modell zitieren in den gemessenen Fällen keinen Anfragetext (Probe mit einem Zufallsmodell). +- **Beschreibung:** `ThrowNew` und `NewStringUTF` lesen Modified UTF-8. Die Meldungen aus llama.cpp zitieren Pfade und Anfragetext in Standard-UTF-8. Die Trainer-Meldung für `missing-😀.gguf` kam als `missing-`, U+00F0 U+009F U+0098 U+0080 und `.g` an (der Rest des Pfads fehlte). Die Eingabeseite des Trainers (`GetStringUTFChars`) ist dagegen nicht erreichbar: `TrainingParameters.escapeNonAscii` (`4867189`) schreibt jedes Nicht-ASCII-Zeichen als `\u`-Escape (Jacksons `ESCAPE_NON_ASCII` ist standardmäßig aus), die Konfiguration ist also dort ASCII, wo sich die Kodierungen unterscheiden (gemessen: `"Hallo \\ud83d\\ude00 Welt"`). Die Fehlermeldungen von Anfragen an das Modell zitieren in den gemessenen Fällen keinen Anfragetext (Probe mit einem Zufallsmodell). - **Fix:** Neuer Header `jni_utf8.hpp` (nur `jni.h`, keine gecachten IDs, deshalb auch in `train_engine.cpp` nutzbar): `throw_utf8_impl` und `new_string_utf8_impl`. ASCII bleibt bei `ThrowNew`/`NewStringUTF`, alles andere geht als Bytes durch `String(byte[], "UTF-8")`, und Javas Dekoder ersetzt ungültige Folgen durch U+FFFD. Umgestellt sind alle Stellen mit fremdem Text: `throw_api_status`, das Modell-Laden, `report_cxx_exception_impl` (die JNI-Ausnahmegrenze), `NativeServer`, `RpcServer` und der Rückgabetext von `LlamaTrainer`. Stellen mit literalem ASCII-Text bleiben, wie sie sind. Die Eingabeseite des Trainers bekommt einen Kommentar, warum sie sicher ist. - **Tests:** C++ `JniUtf8.*` (6 in `test_jni_helpers.cpp`: ASCII bleibt bei `ThrowNew`, 4-Byte-Zeichen und ungültiges Byte erreichen den Konstruktor als Originalbytes, dasselbe für `new_string_utf8_impl` und für `jni_guard_impl`); Java `LlamaTrainerTest#aFourByteCharacterInAPathComesBackIntactInTheErrorMessage` (modellfrei, braucht nur `libjllama`; überspringt, wo die Dateinamen-Kodierung den Pfad nicht darstellen kann) und `#aFourByteCharacterInTheTrainingTextReachesTheTrainer` (hält die Maskierung durch `escapeNonAscii` fest, von der die Eingabeseite abhängt). - **Rot-Grün-Nachweis:** C++: mit durchreichenden Platzhaltern 4 von 6 rot, mit dem Fix 6 von 6 grün, die ganze Suite 640/640. Java gegen die alte Bibliothek rot (`but: was "…/missing-ð.g"`), gegen die neu gebaute grün; `LlamaLoggerTest`, `NativeLibraryLoadSmokeTest`, `RpcServerTest` grün. - **Nachtrag (Branch-Review 2026-10-20):** Der Satz „Die Fehlermeldungen von Anfragen an das Modell zitieren in den gemessenen Fällen keinen Anfragetext“ gilt für b11639 nicht: upstream zitiert ihn z. B. in `server-common.cpp` (`response_format type must be one of … but got: `), `common/chat.cpp` (`Missing 'role' in message: `) und im Schema-Konverter (`cannot resolve $ref `), alle drei über `throw_api_status`. Diesen Weg hatte kein Java-Test; ein Rückbau auf `ThrowNew` blieb grün (Fund J11-1). Neu, modellfrei: `NativeLibraryLoadSmokeTest#anErrorMessageKeepsAFourByteCharacterOfTheRequest` (`LlamaModel.jsonSchemaToGrammar("{\"$ref\":\"#/$defs/😀\"}")`, die `LlamaException` muss `#/$defs/😀` enthalten). Gegenprobe mit `env->ThrowNew` in `throw_api_status`: rot (`got: JSON schema conversion failed: JSON schema error at #: cannot resolve $ref #/$defs/ð\x9f\x98\x80, $defs not fo`, der Rest der Meldung fehlt); mit `throw_utf8_impl` 7/7 in der Klasse grün. - **Status:** gefixt - **Commit:** java-llama.cpp `50b45f8` -- **Nachtrag (Branch-Review 2026-10-20, Funde J9-5/J11-2):** Beschreibung und Tests schrieben die Maskierung der Eingabeseite Jackson zu; sie kommt von `TrainingParameters.escapeNonAscii` (`ca6ccdf`), die gemessene Ausgabe mit kleingeschriebenem Hex (`\ud83d`) ist deren `%04x`. Jackson maskiert nur U+0000 (als Steuerzeichen) selbst. Beide Zeilen oben sind korrigiert, ebenso der Kommentar in `train_engine.cpp` und die Javadoc von `LlamaTrainerTest#aFourByteCharacterInTheTrainingTextReachesTheTrainer`, die jetzt `TrainingParametersTest#nonAsciiTextIsEscapedSoTheJsonCrossesJniIntact` als Unit-Wächter nennt. Nur Doku/Kommentar. +- **Nachtrag (Branch-Review 2026-10-20, Funde J9-5/J11-2):** Beschreibung und Tests schrieben die Maskierung der Eingabeseite Jackson zu; sie kommt von `TrainingParameters.escapeNonAscii` (`4867189`), die gemessene Ausgabe mit kleingeschriebenem Hex (`\ud83d`) ist deren `%04x`. Jackson maskiert nur U+0000 (als Steuerzeichen) selbst. Beide Zeilen oben sind korrigiert, ebenso der Kommentar in `train_engine.cpp` und die Javadoc von `LlamaTrainerTest#aFourByteCharacterInTheTrainingTextReachesTheTrainer`, die jetzt `TrainingParametersTest#nonAsciiTextIsEscapedSoTheJsonCrossesJniIntact` als Unit-Wächter nennt. Nur Doku/Kommentar. ### F182 – java-llama.cpp: Gestreamte Anfragen antworten mit 200, bevor das Backend läuft From cbf841f38214caf519fbef3f8f4b5a7bf30bd2b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:02:07 +0000 Subject: [PATCH 474/589] Merge the checked-files ledger to one line per path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUGHUNT-CHECKED.md says a file counts as checked only while its blob hash at HEAD equals the recorded one, and that a changed file's line is replaced. Neither held: each review appended a line (274 lines for 124 paths), and 43 paths had no line matching their current blob. The two OaiRerankSupport notes still said "top_n <= 0 keeps all", which has been wrong since top_n 0 keeps nothing and a negative top_n is refused. The file now has one line per path under "Geprüft" (81 paths whose blob is unchanged, every review of that blob merged into the line), an "Abgelaufen" section for the 43 changed files, naming the blob at HEAD and the fixes that changed them since, and a section for the pattern and interface reviews that never named a single blob. No line was re-dated to a blob nobody read. The header carries a shell loop that prints every line under "Geprüft" whose blob has gone stale; it prints nothing at HEAD. Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 412 ++++++++++++++++----------------------------- 1 file changed, 145 insertions(+), 267 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index e453c6979..5ceba4e34 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -7,293 +7,171 @@ SPDX-License-Identifier: MIT # Bug-Hunt: geprüfte Stellen — java-llama.cpp -Jede Zeile ist eine gründlich gelesene Produktionsklasse: `Pfad` · Blob-Hash der Datei beim Prüfen · Datum · -Ergebnis — Notiz. Eine Klasse gilt als erledigt, solange `git rev-parse --short HEAD:` noch denselben -Blob-Hash liefert; hat sich die Datei seitdem geändert, wird sie erneut geprüft und die Zeile ersetzt. -Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code korrekt), `unsicher` (offen, siehe -[`BUGHUNT-FOUND.md`](BUGHUNT-FOUND.md)), `Fund Fnn` (Fehler gefunden und dort beschrieben). +Jede Zeile unter „Geprüft“ steht für eine gründlich gelesene Produktionsdatei, **genau eine Zeile je Pfad**: +`Pfad` · Blob-Hash der Datei beim Prüfen · Datum der letzten Lesung · Ergebnis — Notizen. Eine Datei gilt als +geprüft, solange `git rev-parse --short HEAD:` denselben Blob-Hash liefert. Ändert sich die Datei, wandert +ihre Zeile nach „Abgelaufen – erneut zu prüfen“, mit dem Blob am HEAD und den Fixes, die sie seitdem geändert +haben; zurück unter „Geprüft“ kommt sie erst mit einer neuen vollständigen Lesung und dem dann gelesenen Blob. +Eine Zeile wird nie umdatiert oder auf einen neuen Blob gesetzt, ohne die Datei erneut gelesen zu haben (ein +geprüfter Fix-Diff ist keine Lesung der Klasse). Ergebnisse: `kein Fund`, `widerlegt` (Verdacht geprüft, Code +korrekt), `unsicher` (offen, siehe [`BUGHUNT-FOUND.md`](BUGHUNT-FOUND.md)), `Fund Fnn` (Fehler gefunden und dort +beschrieben). Mehrere Durchsichten derselben Datei am selben Blob stehen in einer Zeile, getrennt durch `‖`. + +Bis 2026-10-20 wurde je Durchsicht eine Zeile angehängt (274 Zeilen für 124 Pfade, von denen am 2026-10-21 +43 abgelaufen waren); am 2026-10-21 wurde die Datei auf eine Zeile je Pfad zusammengeführt, ohne eine Datei neu +zu lesen. Prüfung, ob eine Zeile unter „Geprüft“ abgelaufen ist (gibt nichts aus, solange alle stimmen): + +```bash +sed -n '/^## Geprüft/,/^## Abgelaufen/p' BUGHUNT-CHECKED.md | sed -n 's/^- `\([^`]*\)` · `\([0-9a-f]*\)`.*/\1 \2/p' | + while read -r p h; do c=$(git rev-parse HEAD:"$p"); case "$c" in "$h"*) ;; *) echo "abgelaufen: $p ($h, HEAD ${c:0:7})";; esac; done +``` ## Geprüft -- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingField.java` · `8a305f9` · 2026-10-18 · kein Fund — 15 Schlüssel gegen `TrainingParameters.toJson` (jeder genau einmal geschrieben) und gegen `jllama_train::config_keys()` (durch `test_wire_contracts.cpp` gehalten); `validate()` prüft Korpus, Epochen und `valSplit`. -- `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-17 · kein Fund — `json/RouterModelsResponseParser`, `value/RouterModel`: Feldnamen gegen die Testfixtures des Routers (b11538) konsistent, Null/fehlende Felder abgefangen. -- `llama/src/main/java/net/ladenthin/llama/value/ChatChoice.java` · `ad1c75f` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). -- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` · `31d8fb7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). -- `llama/src/main/java/net/ladenthin/llama/Diagnose.java` · `1a229ec` · 2026-10-17 · kein Fund — java-llama.cpp `Diagnose`, `loader/DiagnosticReport`: Backend-Filter-Auswertung (`namedBy`) deckt sich mit `LlamaLoader.selectModules` (inkl. Leer-/Whitespace-Fall); Versionsvergleich per `startsWith` würde nur bei einer 6-stelligen Build-Nummer mit gleichem Präfix irren. -- `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` · `a15ed7b` · 2026-10-17 · kein Fund — java-llama.cpp `Diagnose`, `loader/DiagnosticReport`: Backend-Filter-Auswertung (`namedBy`) deckt sich mit `LlamaLoader.selectModules` (inkl. Leer-/Whitespace-Fall); Versionsvergleich per `startsWith` würde nur bei einer 6-stelligen Build-Nummer mit gleichem Präfix irren. -- `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` · `35d2521` · 2026-10-17 · Fund F09 — java-llama.cpp: Loader löscht fremde `llama*`/`ggml*`-Einträge im Temp-Verzeichnis, Verzeichnisse rekursiv -- `llama/src/main/java/net/ladenthin/llama/value/CompletionResult.java` · `934a56d` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… -- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` · `707296e` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` · `dd4d519` · 2026-10-18 · Fund F128 — Werkzeugaufruf-Fragmente gegen `PartialToolCall`-Vorbedingungen (langchain4j 1.22.0, `javap`) und upstreams Diff-Writer gehalten; `readTree("")` liefert `MissingNode`, alle Pfade bleiben leer. -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — erneut gründlich: `doChat` reicht jede Exception aus dem Stream an `onError`, `complete()` erst danach; Fehler im Assembler → F128. -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` · `62e0ac1` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` · `37473c2` · 2026-10-18 · kein Fund — erneut gründlich: Scores per `index` der nativen Antwort in Eingabereihenfolge (`parseRerankScores`, fehlende Indizes 0.0, Rückfall auf Array-Reihenfolge), doppelte Dokumente bleiben getrennt; eine leere Liste postet keine Aufgabe (`jllama_engine.cpp` `rerank`). -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java` · `9a4b965` · 2026-10-18 · kein Fund — erneut gründlich: ein `embed` je Segment in Eingabereihenfolge; leere Segmente lässt langchain4j nicht zu, `dimension()` per Standard über ein Probe-Embedding. -- `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` · `b69653a` · 2026-10-17 · kein Fund — java-llama.cpp `ToolCallingAgent`: Rundenzählung = max. Anzahl Chat-Aufrufe, deckt sich mit Javadoc („loops until … the round cap is reached“); Handler-Exceptions/unbekannte Tools werden als `{"error":…}` zurückgegeben. -- `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` · `eda2bb6` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… -- `llama/src/main/java/net/ladenthin/llama/value/GgufMetadata.java` · `6261a64` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… -- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b578b98` · 2026-10-17 · kein Fund — java-llama.cpp `server/OaiRerankSupport`: Sortierung absteigend, `top_n<=0` = alle, fehlender `index` wird von der nativen Seite (`rerank_results_to_json`) bereits abgewiesen. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `6bdd3ff` · 2026-10-17 · Fund F25 — java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung -- `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` · `6f2a458` · 2026-10-17 · kein Fund — java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. -- `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` · `e167fa7` · 2026-10-17 · kein Fund — java-llama.cpp `value/ContentPart`: Validierung ok. Randnotiz: Lombok-`@ToString` gibt die komplette Base64-Nutzlast (`imageUrl`/`audioData`, schnell mehrere MB) aus – teuer, falls ein `ChatMessage`/`ChatRequest` geloggt wird. -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `c1840dc` · 2026-10-17 · Fund F37 — F37 parseArgumentsToObject: nur Objekt/Array durchreichen, sonst {} -- `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-18 · kein Fund — sechs Werte gegen `include/llama.h` (-1..4) und die CLI-Strings von `--pooling`; `UNSPECIFIED` als Wächter ohne CLI-String, beim einzigen Verbraucher `setPoolingType` fand sich F133. -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) -- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-18 · Verdacht notiert — öffentliche Methoden der Reihe nach gelesen (die Zeile vom 2026-10-17 trug versehentlich die Notiz von `EmbeddingResponseParser`): Batch-Methoden laufen auf dem gemeinsamen `ForkJoinPool` und übergeben nicht „sofort“, `rerank(query, docs)` fasst gleiche Dokumente zusammen; `complete`/`completeWithStats`/`completeAsync`/`chat*`/`rerank(boolean, …)` sonst ohne Befund -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `ac50ae4` · 2026-10-17 · Fund F17 — java-llama.cpp: Responses-API (`POST /v1/responses`) verwirft `input_image`-Teile → Vision-Modell sieht kein Bild -- `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` -- `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… -- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `8447e35` · 2026-10-17 · Fund F01 — F01 Validierung in `toJson()`: Pfade, genau ein Korpus, epochs >= 1, valSplit in [0, 1) -- `llama/src/main/java/net/ladenthin/llama/value/LoraAdapter.java` · `0351765` · 2026-10-17 · kein Fund — java-llama.cpp `value/LoraAdapter` (+ `json/LoraAdapterResponseParser`): Schlüssel `id/path/scale/task_name/prompt_prefix` gegen `server-task.cpp` b11639 identisch; das Javadoc „nicht gelistete Adapter werden auf 0 gesetzt“ stimmt mit `construct_lora_list` überein. -- `llama/src/main/java/net/ladenthin/llama/value/TokenLogprob.java` · `645a0f9` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… -- `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` · `138b590` · 2026-10-17 · kein Fund — java-llama.cpp `loader/OSInfo`: Abbildung von `os.name`/`os.arch` auf die Natives-Verzeichnisse für alle ausgelieferten Plattformen (Linux/Windows x86_64 + aarch64, Mac aarch64, s390x, Android) korrekt. Randnotizen (niedrig): `isMusl()` fällt nur bei einer Ausnahme von `Files.list` auf `/etc/os-r… -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `5bddfea` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiRequestMapper`: Pflichtfeld-Prüfung, `max_completion_tokens` vor `max_tokens`, `stop` als String/Array, `stream_options`/`response_format` werden durchgereicht. Randnotizen (niedrig, Funktionslücken, kein Fehler): `logprobs`/`top_logprobs`/`logit_bias` werden nicht we… -- `llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java` · `dbb8b3b` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/RequestField`: alle `SCHEMA`-Schlüssel gegen `server-schema.cpp` b11639 (64 Felder) abgeglichen – vorhanden, `grammar` als Alias von `json_schema` (`add_alias`). Wird zusätzlich vom C++-Test `test_wire_contracts.cpp` erzwungen. -- `llama/src/main/java/net/ladenthin/llama/server/ServerLauncher.java` · `d8433a0` · 2026-10-17 · kein Fund — java-llama.cpp `server/ServerLauncher`: entfernt jedes Vorkommen des Markers und dispatcht korrekt; trivial. -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` · `41c9e8b` · 2026-10-17 · kein Fund — java-llama.cpp `llama-ffm/…/FfmLlamaModel`: `handle()` prüft `closed` und ruft danach ohne Sperre nativ auf; ein paralleles `close()` kann den `jllama_context` dazwischen freigeben (`jllama_model_free` wartet nur auf Aufrufe, die die native Benutzerreferenz schon halten) → Use-after-free. Das ist… -- `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` · `a6b12c5` · 2026-10-17 · kein Fund — java-llama.cpp `value/RpcEndpoint`: Parser (genau ein ':', Port 1..65535, max. 5 Ziffern vor `parseInt` → kein Überlauf), Host-Zeichensatz und Länge, `parseList` mit leeren Teilen korrekt; equals/hashCode über host+port. -- `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` · `e9aa330` · 2026-10-17 · kein Fund — java-llama.cpp `value/ModelMeta`: alle Getter-Schlüssel gegen den Erzeuger in `jllama_engine.cpp` abgeglichen (`vocab_type`, `n_ctx_train`, `n_params`, `size`, `modalities.*`, `input/output_modalities`, `special_tokens.bos/eos/eot`, `metadata`, `chat_template`, …) – identisch; Default `-1` für fe… -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` · `c9677a3` · 2026-10-18 · Fund F24, F125 — F24 Eingabebudget bei kleinem `-c`; F125 Port-Bereich; Probe der Werte: Ganzzahlen getrimmt, Cache-Typen ohne Groß/Klein, `--chat-template-kwargs` lehnt Nicht-Objekte und leere Eingabe ab, `--rpc` über `RpcEndpoint.parseList`, Hilfe-Erkennung auch als Wert eines Flags (kosmetisch). -- `llama/src/main/java/net/ladenthin/llama/args/ModelFlag.java` · `0807fe4` · 2026-10-17 · übersprungen — java-llama.cpp `args/ModelFlag`: reine Wire-Name-Registry, gegen den echten llama.cpp-Parser durch `test_model_flags.cpp` erzwungen – kein eigenständig prüfbarer Code. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` · `11dd00a` · 2026-10-18 · kein Fund, kein Fund (nur gelesen, kein JDK 22 hier) — (2026-10-17, kein Fund) Durchsicht nach dem F36-Muster: `finished` wird nie wieder auf `false` gesetzt, `close()` gibt die Task genau einmal frei, auch nach dem natürlichen Stopp; kein Versprechen für Nutzung aus mehreren Threads (Felder nicht `volatile`, im Vertrag nicht verlangt). Grep nach `= !…stop` in allen Java-Zielen: kein weiterer Treffer. ‖ (2026-10-18, kein Fund (nur gelesen, kein JDK 22 hier)) Durchsicht „`FfmTaskStream.java` + `FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM)“: Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. - `llama-ffm/src/main/java/net/ladenthin/llama/ffm/Jllama.java` · `1c19474` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` · `6399310` · 2026-10-17 · kein Fund — java-llama.cpp `llama-langchain4j/…/JsonSchemaElementSerializer`: alle 11 `JsonSchemaElement`-Typen abgedeckt, `$ref` → `#/$defs/` passend zu den unter `$defs` geschriebenen Definitionen, Enum als String mit `enum`-Liste wie bei langchain4j. Randnotiz (niedrig): `JsonRawSchema` wird mit `re… -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` · `7f9c370` · 2026-10-17 · Fund F25 — java-llama.cpp: `OpenAiCompatServer` mit `--api-key` lehnt jeden Anthropic-Client ab (`x-api-key`) und `bearer` in Kleinschreibung -- `llama/src/main/java/net/ladenthin/llama/Session.java` · `7979c5c` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `51665a1` · 2026-10-17 · kein Fund — java-llama.cpp `server/ResponsesStreamTranslator`: Event-Folge (`created` → `output_item.added` → `content_part.added` → `output_text.delta`… → `output_text.done` → `content_part.done` → `output_item.done` → Function-Calls → `completed`) mit monotonen `sequence_number`s und konsistenten `output_i… -- `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` · `90d097c` · 2026-10-17 · Fund F13 — java-llama.cpp: `setRopeScaling(UNSPECIFIED | LONGROPE | MAX_VALUE)` macht das Modell unladbar -- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` · `386d73f` · 2026-10-17 · Fund F26 — java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert -- `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` · `b0428f5` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/CliParameters`: argv-Aufbau (Option + Wert, Flag ohne Wert, `argv[0] = ""`), Floats als `String.valueOf` (`1.0E-5`, von `std::stof` gelesen) korrekt. Randnotiz (niedrig, Sicherheit): `ModelParameters.toString()` gibt den Wert von `--hf-token` (`setHfToken`) im Klartext … -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `1a04e66` · 2026-10-17 · kein Fund — java-llama.cpp `server/OpenAiSseFormatter`: SSE-Framing, Fehler-JSON, `ensureUsageCachedTokens` (Schnellpfad per Substring kann nicht von einem im Text maskierten `\"usage\":null` getäuscht werden, unveränderte Ausgabe wenn `cached_tokens` schon da ist) und `/props`/`/v1/models`-Payloads korrekt. -- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `1c4c9f7` · 2026-10-17 · Fund F38 — F38 parseOrThrow weist MissingNode ab; Ein-Wert-Invariante und Excerpt-Grenze geprüft -- `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `4c59b6c` · 2026-10-17 · Fund F39 — F39 parseToObject: nur Objekt/Array durchreichen, sonst {}; readTree-Durchsicht der übrigen Aufrufer siehe Notiz in BUGHUNT-FOUND -- `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-17 · Fund F30 — java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · Fund F168 — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … **Nachtrag (Branch-Review 2026-10-20):** als F168 behoben (`takeStrings` gibt das Array auch bei 0 Einträgen frei). -- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `3f14aec` · 2026-10-17 · Fund F06 — java-llama.cpp: `RouterClient.awaitModelLoaded(id, Long.MAX_VALUE)` bricht sofort mit Timeout ab -- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-17 · Fund F16 — F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen -- `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36 — F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `7f002e9` · 2026-10-17 · kein Fund — Stop-Grund-Abbildung (`length`→`max_tokens`, `tool_calls`→`tool_use`, sonst `end_turn`) passt zu den gesendeten `tool_use`-Blöcken (llama.cpp meldet `tool_calls` genau dann, wenn der Stream Tool-Aufrufe enthält); Blockindizes Text vor Tools, Usage ohne Cache-Treffer wie bei Anthropic. Funktionslücke, kein Fehler: `reasoning_content` wird nicht in Thinking-Blöcke übersetzt. -- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `3d47cac` · 2026-10-17 · kein Fund — Abbruch bei Sink-Fehler: `stream` reicht die IOException durch, `LlamaModel.streamChatCompletion` gibt die Task im `finally` frei; `streamCompletions` schließt den Iterator per try-with-resources (cancel); finish_reason `length`/`stop` korrekt. Funktionslücke: gestreamte `/v1/completions` beachten `stream_options.include_usage` nicht (kein Usage-Chunk). -- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-17 · kein Fund — Gruppierung nach `index` (TreeMap, sortierte Ausgabe), Argument-Deltas werden verkettet, `id`/`name` aus dem ersten Delta; ein Aufruf ohne Argument-Deltas ergibt `arguments: ""`, was erst der Ollama-Konsument falsch abbildete (F37). -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` · `11dd00a` · 2026-10-17 · kein Fund — Durchsicht nach dem F36-Muster: `finished` wird nie wieder auf `false` gesetzt, `close()` gibt die Task genau einmal frei, auch nach dem natürlichen Stopp; kein Versprechen für Nutzung aus mehreren Threads (Felder nicht `volatile`, im Vertrag nicht verlangt). Grep nach `= !…stop` in allen Java-Zielen: kein weiterer Treffer. -- `llama/src/main/java/net/ladenthin/llama/loader/ProcessRunner.java` · `372d6f4` · 2026-10-17 · kein Fund — Liest stdout erst nach `waitFor`, stderr gar nicht: ein Kind mit mehr als einem Pipe-Puffer (~64 KiB) Ausgabe bliebe bis zum Timeout hängen. Nicht erreichbar: einzige Aufrufer sind `OSInfo.isAndroidTermux` (`uname -o`) und `getHardwareName` (`uname -m`), je eine Zeile; der Pfad mit viel Ausgabe (`readelf`) läuft über `runDiscardingOutput`, das vor `waitFor` leert. Timeout → IOException, `destroyForcibly` im finally, Abschluss-Newline bei `startsWith`/`contains` harmlos. Musterdurchsicht nach F41 (abgeschnittene Exception-Meldungen) über alle Ziele: kein weiterer Treffer. -- `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` · `6a637ed` · 2026-10-17 · kein Fund — `readTree("")` liefert `MissingNode`, dessen `isArray()` falsch ist → leere Liste wie dokumentiert (F37-Muster hier unschädlich). `toRequestJson` weist NaN/±∞ ab (würde nicht als JSON-Zahl serialisieren); unbekannte Ids ignoriert die native Seite tatsächlich (`parse_lora_request` füllt eine `std::map`, angewandt wird nur über geladene Adapter), passend zur Javadoc von `LlamaModel.setLoraAdapters`. `null`-Schlüssel/-Werte → NPE, für `Map` Aufruferfehler. -- `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` · `8327d64` · 2026-10-17 · kein Fund — Zahlen mit `Locale.ROOT` formatiert (kein Dezimalkomma); Leerlauf-Prüfung `promptN == 0 && predictedN == 0` passt zu `Timings.fromJson`, das bei fehlendem/`null`-Knoten und fehlenden Feldern 0 liefert (upstream lässt `timings` bei `prompt_n < 0` ganz weg). Draft-Segment nur bei `draftN > 0`; Aufrufer `CompletionResponseParser`/`ChatResponseParser` übergeben nie `null`. `isInfoEnabled` vor der String-Bildung. -- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` · `0ff46cf` · 2026-10-17 · kein Fund — `withCancellationToken`: Abbruch des Aufrufers lässt `await()` mit `CancellationException` enden, das Token wird vor dem Weiterwerfen abgebrochen, und `coroutineScope` wartet, bis der blockierende Block zurückkehrt – keine weiterlaufende Generierung nach der Rückkehr. Abbruch vor dem Start des `async`: Block läuft gar nicht. Fehler im Block brechen den Scope ab und kommen über `await()` beim Aufrufer an. `chatSuspend`/`chatCompleteTextSuspend`/`embedSuspend` dokumentieren ehrlich, dass sie nicht token-abbrechbar sind. Musterdurchsicht nach F45 (inklusive/exklusive Grenzen, Meldung vs. Prüfung) über alle drei Ziele: kein weiterer Treffer. -- `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` · `2591bb0` · 2026-10-17 · kein Fund — Leere/unparsbare Eingabe: `readTree("")` → `MissingNode`, `isArray()` falsch → leere, veränderbare Liste (F37-Muster hier unschädlich). Fehlende Felder fallen auf `""`/`0.0`; die native Seite (`rerank_results_to_json`) liefert immer `document`, `index` und `score` und wirft bei fehlendem oder ungültigem Index. `LlamaModel.rerank(reRank=true)` sortiert absteigend nach Score; ohne Sortierung verspricht die Javadoc keine Reihenfolge. -- `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` · `2448442` · 2026-10-17 · Fund F53 — F53 leerer Umschlag gilt als Streamende. `data` als Array → ein Aufruf pro Element in Reihenfolge, als Objekt → einer, sonst keiner; `stop` fehlt → `false`. -- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `408b4ae` · 2026-10-17 · Fund F54 — F54 leerer Wert bleibt String statt `null`. Frühere Prüfung: java-llama.cpp: `withTokenIdBias`/`withTokenBias` mit `Float.NEGATIVE_INFINITY` sperrt das Token nicht – wird still ignoriert -- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `7ac6e49` · 2026-10-17 · Fund F55 — F55 leeres Schema bleibt String statt `null`. Übrige `readTree`-Stellen der Durchsicht nach F53 siehe die F54-Notiz im Fundbuch. Frühere Prüfung: java-llama.cpp: `ChatRequest.buildToolsJson()` kürzt ein Tool-Schema still auf den ersten JSON-Wert -- `llama/src/main/java/net/ladenthin/llama/loader/NativeLibraryPermissionSetter.java` · `e2c6e1b` · 2026-10-17 · kein Fund — Setzt Lesen/Schreiben/Ausführen jeweils nur für den Besitzer (`setReadable(true)`/`setExecutable(true)` sind die Besitzer-Varianten, `setWritable(true, true)` explizit) – für eine extrahierte native Bibliothek die vorsichtigere Wahl. Ein Fehlschlag wird mit allen drei Ergebnissen gemeldet und als `false` zurückgegeben, nicht verschluckt; die Senke ist im Konstruktor auf `null` geprüft. -- `llama/src/main/java/net/ladenthin/llama/loader/LlamaSystemProperties.java` · `ce16589` · 2026-10-17 · kein Fund — Reine Zugriffsschicht auf `System.getProperty`, `@Nullable` korrekt. Die fünf Namen (`lib.path`, `tmpdir`, `osinfo.architecture`, `test.ngl`, `backend`) stimmen mit der Systemeigenschaften-Tabelle der README überein. -- `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` · `9d751fc` · 2026-10-17 · kein Fund (Verhalten) — Native Rückgabe `null`/leer = Erfolg, sonst `LlamaException` mit der Meldung; Konfiguration wird vor dem nativen Aufruf über `toJson()` geprüft (F01). Javadoc nennt die `IllegalArgumentException` dieser Prüfung nicht – als Doku-Lücke beim F01-Eintrag notiert. -- `llama/src/main/java/net/ladenthin/llama/RpcServerNative.java` · `ef8f53a` · 2026-10-17 · kein Fund — Reine JNI-Delegation hinter der `RpcServer.Backend`-Naht; Signaturen der vier `native`-Methoden und `@Nullable` (Cache-Verzeichnis, Geräteliste bei unbekanntem Namen) passen zum Interface. `LlamaLoader.initialize()` im statischen Block: das in CLAUDE.md beschriebene Wiedereintreten beim Laden vor `LlamaModel` ist in `LlamaLoader.runOnceOnThisThread` behoben. -- `llama/src/main/java/net/ladenthin/llama/callback/CancellationToken.java` · `70365a6` · 2026-10-17 · kein Fund (Verhalten) — `volatile`-Flag, `cancel()` idempotent. `LlamaModel.complete(params, token)` setzt den Token bewusst erst im `finally` zurück, nicht beim Eintritt, sodass ein vorab gesetztes `cancel()` wirkt. Doku-Abweichung: Javadoc von `reset()` sagt „Package-private.“, die Methode ist `public` – nur beschrieben (kein Test, Regel Schritt 4). -- `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` · `658e69a` · 2026-10-17 · Fund F56 — nicht darstellbarer Pfad ergab `InvalidPathException`. Durchsicht der übrigen `Paths.get`-Stellen: `RpcServer.Options.parse` (`--cache`) wirft dieselbe Ausnahme, die dort als `IllegalArgumentException` der Argumentprüfung passt; `OpenAiServerCli.getModelId` nur bei unsinnigem Modellpfad, den der native Lader ohnehin ablehnt – kein Fund. -- `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` · `2c10c02` · 2026-10-17 · kein Fund — Jeder Getter liest über `path()` mit Standardwert, also kein NPE bei fehlenden Schlüsseln; `getDraftAcceptanceRate` und die beiden Durchsatzberechnungen sind gegen Division durch 0 geschützt; Argumentreihenfolge an den `Timings`-Konstruktor (cacheN, promptN, promptMs, promptPerSec, predictedN, predictedMs, predictedPerSec, draftN, draftNAccepted) stimmt; Listen werden unveränderlich zurückgegeben. Schlüsselnamen passen zu `server_metrics_to_json` laut CLAUDE.md. -- `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-17 · kein Fund — `Status.fromValue` deckt alle sechs Namen von upstreams `server_model_status_to_string` (b11639, `server-models.h`) ab, `unknown`/`null`/Fremdes → `UNKNOWN`. Gegen `get_router_models` (b11639) abgeglichen: `id`, `status.value`/`failed`/`exit_code` (nur bei Fehlschlag gesendet, Standardwerte im Parser passen), `architecture.input_/output_modalities`, `context_length` (nur wenn > 0). Modalitätslisten werden kopiert und unveränderlich gemacht; `equals`/`hashCode` über alle Felder wie dokumentiert. -- `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` · `453a951` · 2026-10-17 · kein Fund — `parts` null/leer → IAE wie dokumentiert, Kopie unveränderlich; `toolCalls` null → leere Liste. Javadoc-Versprechen „Parts haben beim Serialisieren Vorrang“ in der einzigen Serialisierung (`ParameterJsonSerializer.buildMessages(List)`, genutzt von `ChatRequest.buildMessagesJson` und `InferenceParameters.withMessages`) erfüllt, `tool_call_id`/`tool_calls` werden mitgeschrieben. `Session`/`ChatTranscript` bauen nur Textnachrichten. Ein `null`-Element in `parts` ergäbe eine NPE statt IAE – Javadoc schweigt dazu, kein Fund. -- `llama/src/main/java/net/ladenthin/llama/value/Timings.java` · `e187e37` · 2026-10-17 · kein Fund — Schlüssel von `fromJson` gegen upstreams `server_slot_stats::to_json()` (b11639, `server-common.cpp`) abgeglichen: `cache_n`, `prompt_n/_ms/_per_second`, `predicted_n/_ms/_per_second` stimmen; `draft_n`/`draft_n_accepted` sendet upstream nur bei Draft-Tokens > 0, der Standardwert 0 passt. `null`/Missing/JSON-null → Nullobjekt. `*_per_token_ms` wird nicht gelesen (kein Getter versprochen). -- `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` · `cf96a79` · 2026-10-17 · kein Fund — Zweiphasen-Commit wie dokumentiert: `messagesWithPendingUserTurn` kopiert, mutiert nicht; `removePendingUserTurn` entfernt nur eine abschließende `user`-Runde. Leere/`null`-Systemnachricht wird in `snapshot()` und in `ParameterJsonSerializer.buildMessages(String, List)` gleich behandelt (weggelassen). Nicht synchronisiert, wie die Javadoc sagt; `Session` hält das Lock (bereits geprüft). `turnsSnapshot`/`resetTurns` kopieren, `Pair` ist unveränderlich. -- `llama/src/main/java/net/ladenthin/llama/LlamaQuantizer.java` · `8fca432` · 2026-10-17 · kein Fund — Null-Prüfung aller drei Referenzen vor dem nativen Aufruf; die Kurzform reicht `threads=0` (`<= 0` = alle Hardware-Threads laut Javadoc und `llama_model_quantize_params.nthread`) und `allowRequantize=false` durch. `LlamaLoader.initialize()` im statischen Block wie bei den übrigen JNI-Klassen. -- `llama/src/main/java/net/ladenthin/llama/args/QuantizationType.java` · `d5ccc9d` · 2026-10-17 · kein Fund — Alle 36 `ftype`-Werte gegen `enum llama_ftype` in `include/llama.h` (b11639) abgeglichen; Namen und Zahlen stimmen. Die upstream entfernten 4/5/6 und 33–35 (`Q4_0_4_4` …) fehlen hier zu Recht, ebenso `GUESSED` (1024, kein Ziel). -- `llama/src/main/java/net/ladenthin/llama/value/Usage.java` · `ce0b9a7` · 2026-10-17 · Fund F57 (beim Aufrufer `ServerMetrics`) — Klasse selbst korrekt: `getProcessedPromptTokens` auf ≥ 0 begrenzt, `getTotalTokens` = prompt + completion wie bei OAI. -- `llama/src/main/java/net/ladenthin/llama/value/ChatResponse.java` · `7b68d5f` · 2026-10-17 · kein Fund — `choices` null → leere Liste; `getFirstMessage`/`getFirstContent` behandeln leere Antworten. -- `llama/src/main/java/net/ladenthin/llama/value/LlamaOutput.java` · `e928537` · 2026-10-17 · kein Fund — reiner Träger, `toString` gibt den Text wie dokumentiert. -- `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` · `cb51433` · 2026-10-17 · kein Fund — Namen gegen `stop_type_to_str` (b11639, `server-task.cpp`) abgeglichen: `eos`/`word`/`limit`, `none` und Unbekanntes → `NONE`. -- `llama/src/main/java/net/ladenthin/llama/value/ToolCall.java` · `acce5a3` · 2026-10-17 · kein Fund — unveränderlicher Träger (id, name, Argumente als JSON-Text); `equals`/`hashCode` über alle drei Felder. -- `llama/src/main/java/net/ladenthin/llama/value/ToolDefinition.java` · `1b8732b` · 2026-10-17 · kein Fund — unveränderlicher Träger; das Schema bleibt Text, die Prüfung leerer/ungültiger Schemata liegt in `ChatRequest.buildToolsJson` (F55). -- `llama/src/main/java/net/ladenthin/llama/value/SessionCheckpoint.java` · `15a5811` · 2026-10-17 · kein Fund — Runden werden kopiert und unveränderlich gespeichert, `Pair` ist unveränderlich; `toString` nennt Pfad und Rundenzahl. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/SlotOp.java` · `f6fdfd1` · 2026-10-17 · kein Fund — Codes 1/2/3 stimmen mit `jllama_slot_op` in `jllama_api.h` überein. +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-18 · kein Fund, kein Fund (Doku-Abweichung notiert) — (2026-10-17, kein Fund) `closeableIterableFlow` öffnet die Quelle je Sammlung und schließt sie über `use` bei Ende, Fehler und Abbruch (ein `emit` nach Abbruch wirft `CancellationException`, `use` schließt). Abbruch wirkt erst nach Rückkehr des blockierenden `next()`, wie die KDoc mit dem Hinweis auf `flowOn(Dispatchers.IO)` sagt. ‖ (2026-10-17, kein Fund (Doku-Abweichung notiert)) Durchsicht „`LlamaSuspend.kt` + `LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe)“: Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). ‖ (2026-10-18, kein Fund) `use` schließt die Quelle bei Ende, Fehler und Abbruch (`take(n)`); Blockieren des Sammel-Dispatchers ist dokumentiert +- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` · `0ff46cf` · 2026-10-18 · kein Fund, kein Fund (Doku-Abweichung notiert) — (2026-10-17, kein Fund) `withCancellationToken`: Abbruch des Aufrufers lässt `await()` mit `CancellationException` enden, das Token wird vor dem Weiterwerfen abgebrochen, und `coroutineScope` wartet, bis der blockierende Block zurückkehrt – keine weiterlaufende Generierung nach der Rückkehr. Abbruch vor dem Start des `async`: Block läuft gar nicht. Fehler im Block brechen den Scope ab und kommen über `await()` beim Aufrufer an. `chatSuspend`/`chatCompleteTextSuspend`/`embedSuspend` dokumentieren ehrlich, dass sie nicht token-abbrechbar sind. Musterdurchsicht nach F45 (inklusive/exklusive Grenzen, Meldung vs. Prüfung) über alle drei Ziele: kein weiterer Treffer. ‖ (2026-10-17, kein Fund (Doku-Abweichung notiert)) Durchsicht „`LlamaSuspend.kt` + `LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe)“: Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). ‖ (2026-10-18, kein Fund) Durchsicht „`LlamaSuspend.kt` (↔ `LlamaModel.complete(params, token)`)“: Abbruch der Koroutine setzt das Token, `coroutineScope` wartet auf das Kind, `complete` gibt die Aufgabe an der nächsten Token-Grenze frei; vorab abgebrochenes Token gibt sofort frei; Javadoc „chat ohne Token-Überladung“ stimmt +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` · `62e0ac1` · 2026-10-17 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `llama-langchain4j` (`StreamingChunkAssembler`, `LangChain4jMapping`, `Jllama*Model`). **Lücke (niedrig, nicht geändert):** Der nicht-streamende `JllamaChatModel` übernimmt `reasoning_content` nicht nach `AiMessage.thinking()`, der streamende tut es – dasselbe Modell liefert also j… ‖ (2026-10-17, kein Fund) Durchsicht „`LangChain4jMapping.java` + `JllamaChatModel.java` + `JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`)“: `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaEmbeddingModel.java` · `9a4b965` · 2026-10-18 · kein Fund — erneut gründlich: ein `embed` je Segment in Eingabereihenfolge; leere Segmente lässt langchain4j nicht zu, `dimension()` per Standard über ein Probe-Embedding. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` · `37473c2` · 2026-10-19 · kein Fund — (2026-10-18, kein Fund) erneut gründlich: Scores per `index` der nativen Antwort in Eingabereihenfolge (`parseRerankScores`, fehlende Indizes 0.0, Rückfall auf Array-Reihenfolge), doppelte Dokumente bleiben getrennt; eine leere Liste postet keine Aufgabe (`jllama_engine.cpp` `rerank`). ‖ (2026-10-18, kein Fund) Durchsicht „`JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` ↔ `LlamaModel.handleRerank`“: Schnittstelle (2.3): `rerank_results_to_json` liefert je Ergebnis `index` und lehnt fehlende/ungültige ab, die Rückfall-Zuordnung nach Arrayposition greift also nie; Scores werden nach `index` einsortiert, doppelte Dokumente (anders als in `LlamaModel.rerank(String, String...)`, das nach Text in eine Map schreibt) gehen hier nicht verloren. `JllamaEmbeddingModel` (`9a4b965`): ein `embed` je Segment, Reihenfolge erhalten. ‖ (2026-10-19, kein Fund) Durchsicht „`JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores`“: Werte werden über `index` (Eingabeposition) zurückgeordnet, ohne `index` über die Reihenfolge; außerhalb liegende Indizes verworfen, doppelte Dokumente bleiben getrennt. Nicht verfolgt: `scoreAll` mit leerer Liste reicht ein leeres Dokument-Array an `handleRerank` weiter (native Seite, ohne Bibliothek nicht messbar). +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) Durchsicht „`LangChain4jMapping.java` + `JllamaChatModel.java` + `JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`)“: `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). ‖ (2026-10-18, kein Fund) erneut gründlich: `doChat` reicht jede Exception aus dem Stream an `onError`, `complete()` erst danach; Fehler im Assembler → F128. ‖ (2026-10-18, kein Fund) erneut gründlich: `doChat` reicht jede Exception aus dem Stream an `onError`, `complete()` erst danach; Fehler im Assembler → F128. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` · `6399310` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `llama-langchain4j/…/JsonSchemaElementSerializer`: alle 11 `JsonSchemaElement`-Typen abgedeckt, `$ref` → `#/$defs/` passend zu den unter `$defs` geschriebenen Definitionen, Enum als String mit `enum`-Liste wie bei langchain4j. Randnotiz (niedrig): `JsonRawSchema` wird mit `re… ‖ (2026-10-18, kein Fund) Durchsicht „`JsonSchemaElementSerializer.java` (Typ-Weiche gegen langchain4j-core 1.22.0)“: alle elf Elementtypen haben einen Zweig, ein unbekannter wirft (kein stilles Verwerfen); `$defs`/`#/$defs/` wie langchain4js interne Konvention, `required`/`additionalProperties` nur wenn gesetzt. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `e054789` · 2026-10-18 · Fund F94, kein Fund, kein Fund (Anmerkung) — (2026-10-18, Fund F94) Textteile ohne Trenner verbunden; Assistent mit `thinking()` im Verlauf wird ohne Denkinhalt gesendet (wie LangChain4js OpenAI-Adapter ohne `sendThinking`, kein Fund) ‖ (2026-10-18, kein Fund) Durchsicht „`LangChain4jMapping.java` (`toFinishReason`) + `StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream)“: `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` ‖ (2026-10-18, kein Fund) Durchsicht „`LangChain4jMapping.java` (`toJllamaRequest`, `applySampling`, `applyResponseFormat`, `toToolChoiceString`)“: `temperature`/`topP`/`topK`/`maxOutputTokens` (→ `n_predict`)/`frequencyPenalty`/`presencePenalty`/`stopSequences` übernommen, leere Stoppliste ausgelassen; JSON mit Schema → `json_schema`, ohne → `json_object`, Text-Format unverändert; `ToolChoice` `REQUIRED`/`NONE`/`AUTO` als Zeichenkette; blockierender und gestreamter Pfad nutzen dieselbe Abbildung ‖ (2026-10-18, kein Fund (Anmerkung)) Durchsicht „`LangChain4jMapping.java` (`toJllamaMessage`, Nachrichtentypen)“: System, User (ein Text, mehrere Texte mit `\n` seit F94, gemischte Teile als Teilliste), Assistent mit/ohne Werkzeugaufrufen (fehlende Id → `""`, fehlende Argumente → `{}`), Werkzeugergebnis mit `tool_call_id`; der Werkzeugname des Ergebnisses fällt weg (`ChatMessage.toolResult` kennt kein `name`), die Id genügt aber zur Zuordnung – anders als bei F116, wo Ollama keine Id schickt; `AiMessage.thinking()` wird nicht zurückgeschickt, wie langchain4js eigener OpenAI-Adapter es ohne `sendThinking` hält ‖ Frühere Durchsichten anderer Blobs: kein Fund (`550a6c2`, 2026-10-17); Fund F62 (nur beschrieben) (`550a6c2`, 2026-10-17); kein Fund (`550a6c2`, 2026-10-17); kein Fund (`550a6c2`, 2026-10-18); Fund F92 (`195bf32`, 2026-10-18); Fund F93 (`832ca3e`, 2026-10-18); kein Fund (`ohne Hash`, 2026-10-18); kein Fund (`ohne Hash`, 2026-10-19) +- `llama/src/main/java/net/ladenthin/llama/Diagnose.java` · `1a229ec` · 2026-10-17 · kein Fund — java-llama.cpp `Diagnose`, `loader/DiagnosticReport`: Backend-Filter-Auswertung (`namedBy`) deckt sich mit `LlamaLoader.selectModules` (inkl. Leer-/Whitespace-Fall); Versionsvergleich per `startsWith` würde nur bei einer 6-stelligen Build-Nummer mit gleichem Präfix irren. +- `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` · `3b4fafc` · 2026-10-18 · Fund F72 — Durchsicht „`GgufInspector.java` ↔ `ggml/src/gguf.cpp` (Schnittstelle GGUF-Metadatenleser ↔ upstream-Leser, b11639)“: Typ-Ids, Kopf (Magie, Version mit Byte-Order-Erkennung, Tensor- und KV-Anzahl) und Grenzen vor Allokation wie zuvor geprüft; Abweichung: Arrays von Arrays, die upstream ablehnt, wurden rekursiv gelesen (F72). ‖ Frühere Durchsichten anderer Blobs: kein Fund (`eda2bb6`, 2026-10-17) - `llama/src/main/java/net/ladenthin/llama/LlamaIterable.java` · `c7f2128` · 2026-10-17 · kein Fund — `close()` delegiert an `LlamaIterator.close()` (bricht nur ab, solange `hasNext`; mehrfach sicher, F36). `iterator()` liefert stets denselben Iterator, also ist das Iterable einmalig; die Javadoc verspricht nichts anderes. -- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-17 · kein Fund — `closeableIterableFlow` öffnet die Quelle je Sammlung und schließt sie über `use` bei Ende, Fehler und Abbruch (ein `emit` nach Abbruch wirft `CancellationException`, `use` schließt). Abbruch wirkt erst nach Rückkehr des blockierenden `next()`, wie die KDoc mit dem Hinweis auf `flowOn(Dispatchers.IO)` sagt. -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` · `21145a1` · 2026-10-17 · Fund F59 — Race `isRunning`/`close`. Sonst stimmen die Bindungen mit `jllama_api.h` überein: `start(argc, argv, out)`, `start_attached(model, argc, argv, out)`, `stop`, `is_running` mit `JAVA_BOOLEAN`, `set_worker_command` mit `""` für „löschen“ wie dokumentiert; `argv[0]` = Programmname. -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` · `7b5e31f` · 2026-10-17 · kein Fund — Signaturen passen zu `jllama_rpc_server_serve/stop/listening/devices`; `cacheDir` darf null sein (C-Vertrag), `devices` null → `""`. Das bekannte `takeStrings`-Leck bei Anzahl 0 steht als offener Verdacht im Fundbuch. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java` · `3353536` · 2026-10-17 · kein Fund — Naht ohne Logik; der Standard von `streamCompletions` wirft `UnsupportedOperationException`, die `OpenAiCompatServer.streamCompletions` als Server-Fehler-Ereignis im SSE-Strom meldet (Kopfzeilen sind dann schon gesendet), statt die Verbindung abzubrechen. Der einzige Produktions-Backend `LlamaModelBackend` überschreibt sie. -- `llama/src/main/java/net/ladenthin/llama/args/ModelOption.java` · `729cbc6` · 2026-10-17 · kein Fund — 110 Konstanten, 110 verschiedene Namen, keiner doppelt mit `ModelFlag`; alle 110 Namen kommen in `common/arg.cpp` von b11639 vor (die Server-Parser-Zuordnung prüft `test_model_flags.cpp`). Randnotiz: die Javadoc von `ModelFlag.getCliFlag()` nennt `--flash-attn` als Beispiel, das ist seit der Umstellung ein `ModelOption` – Stil, kein Fund. -- `llama/src/main/java/net/ladenthin/llama/args/Sampler.java` · `3d1661d` · 2026-10-17 · kein Fund — Alle neun Namen in `common_sampler_types_from_names` (b11639, `sampling.cpp`); upstream kennt zusätzlich `top_n_sigma` und `adaptive_p`, die hier fehlen – Vollständigkeit, kein Fund. `setSamplers` verbindet mit `;` wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` · `f7963f2` · 2026-10-17 · Fund F36, kein Fund (Designabweichung notiert) — (2026-10-17, Fund F36) F36 hasNext nur true→false; cancel/close/Stopp-Freigabe über die Naht TaskSource geprüft ‖ (2026-10-17, kein Fund (Designabweichung notiert)) Durchsicht „`server/OpenAiCompatServer.java` + `server/LlamaModelBackend.java` + `LlamaIterator.java` ↔ `jllama_api.h` (Schnittstelle Streaming-Abbruch und Fehler)“: Client-Abbruch: `IOException` der Senke wird über `UncheckedIOException` aus `streamChatCompletion` getragen, dessen `finally` `cancelCompletion` = `jllama_task_release` ruft (laut Header „cancels or releases“, unbekannte Id ignoriert); nach `stop` gibt der Empfang die Aufgabe selbst frei, `LlamaIterator.next` ruft dann `releaseTask`, `close()` danach ist ein No-op. `streamCompletions` bricht per `break` ab, `LlamaIterable.close()` gibt frei. Fehler nach den Headern als SSE-Ereignis (siehe offene Verdachte). +- `llama/src/main/java/net/ladenthin/llama/LlamaQuantizer.java` · `8fca432` · 2026-10-17 · kein Fund — Null-Prüfung aller drei Referenzen vor dem nativen Aufruf; die Kurzform reicht `threads=0` (`<= 0` = alle Hardware-Threads laut Javadoc und `llama_model_quantize_params.nthread`) und `allowRequantize=false` durch. `LlamaLoader.initialize()` im statischen Block wie bei den übrigen JNI-Klassen. +- `llama/src/main/java/net/ladenthin/llama/RpcServerNative.java` · `ef8f53a` · 2026-10-17 · kein Fund — Reine JNI-Delegation hinter der `RpcServer.Backend`-Naht; Signaturen der vier `native`-Methoden und `@Nullable` (Cache-Verzeichnis, Geräteliste bei unbekanntem Namen) passen zum Interface. `LlamaLoader.initialize()` im statischen Block: das in CLAUDE.md beschriebene Wiedereintreten beim Laden vor `LlamaModel` ist in `LlamaLoader.runOnceOnThisThread` behoben. +- `llama/src/main/java/net/ladenthin/llama/Session.java` · `61d2505` · 2026-10-18 · Fund F137 — Durchsicht „`Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`)“: negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. ‖ Frühere Durchsichten anderer Blobs: kein Fund (`7979c5c`, 2026-10-17) +- `llama/src/main/java/net/ladenthin/llama/SessionState.java` · `86ffcc3` · 2026-10-17 · kein Fund — java-llama.cpp `Session` (+ `SessionState`): Zweiphasen-Commit unter einem Lock, ein fehlschlagender Modellaufruf hinterlässt kein halbes Turn-Paar, `slotId` wird nach dem Customizer erzwungen, Fork verlangt einen anderen Slot, alle Slot-Operationen sind während eines Streams gesperrt. +- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` · `7b6b88d` · 2026-10-18 · Fund F16, kein Fund — (2026-10-17, Fund F16) F16 Lese-/Schreib-Sperre um synthesize/close, Naht TextToSpeech.Backend; Bibliothek wird beim ersten Laden statt im static-Block geladen ‖ (2026-10-18, kein Fund) Durchsicht „`TextToSpeech.java` ↔ `llama/src/main/cpp/jllama_api.h` (`jllama_tts_*`) / `tts_engine.cpp` (Schnittstelle Lebenszyklus und Nebenläufigkeit)“: Der C-Vertrag erlaubt „one synthesis at a time per handle“, Java nimmt für `synthesize` nur die Lese-Sperre; das ist sicher, weil `tts_engine.cpp` jede Synthese unter dem `synthesize_mutex` der Engine ausführt. `close()` wartet über die Schreib-Sperre auf laufende Synthesen, gibt einmal frei und setzt den Handle auf 0, danach meldet `synthesize` `IllegalStateException`. `seed` (Java `int`) wird zu `unsigned int`; `gpuLayers` 0 im Kurzkonstruktor heißt CPU, −1 (auto) ist explizit übergebbar. +- `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` · `b69653a` · 2026-10-17 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `ToolCallingAgent`: Rundenzählung = max. Anzahl Chat-Aufrufe, deckt sich mit Javadoc („loops until … the round cap is reached“); Handler-Exceptions/unbekannte Tools werden als `{"error":…}` zurückgegeben. ‖ (2026-10-17, kein Fund) Durchsicht „`ToolCallingAgent.java` + `callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug)“: `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. - `llama/src/main/java/net/ladenthin/llama/args/CacheType.java` · `e38c636` · 2026-10-17 · kein Fund — Genau die neun Typen von `kv_cache_types` in `common/arg.cpp` (b11639), gleiche Namen. -- `llama/src/main/java/net/ladenthin/llama/args/RopeScalingType.java` · `12ebd19` · 2026-10-17 · kein Fund — `--rope-scaling` nimmt nur none/linear/yarn; `ModelParameters.setRopeScaling` entfernt die Option bei `UNSPECIFIED` und wirft bei `LONGROPE`/`MAX_VALUE`, wie dokumentiert. -- `llama/src/main/java/net/ladenthin/llama/args/LoadMode.java` · `8f8b7be` · 2026-10-17 · kein Fund — Werte auto/none/mmap/mlock/mmap+mlock/dio entsprechen `--load-mode` in `common/arg.cpp`. -- `llama/src/main/java/net/ladenthin/llama/args/ReasoningFormat.java` · `ea87677` · 2026-10-17 · kein Fund — none/auto/deepseek/deepseek-legacy entsprechen `common_reasoning_format_from_name` (b11639, `chat.cpp`). +- `llama/src/main/java/net/ladenthin/llama/args/CliArg.java` · `8516cfd` · 2026-10-17 · kein Fund — Schnittstelle für Enum-Werte auf der Kommandozeile. +- `llama/src/main/java/net/ladenthin/llama/args/CliContract.java` · `a288676` · 2026-10-17 · kein Fund — Zwei Vertragsarten der CLI-Registry. +- `llama/src/main/java/net/ladenthin/llama/args/ContinuationMode.java` · `b01f540` · 2026-10-17 · kein Fund — Zwei Feldnamen der Antwort, als Wert durchgereicht. +- `llama/src/main/java/net/ladenthin/llama/args/DraftSampling.java` · `9c1a37f` · 2026-10-17 · kein Fund — greedy/probabilistic entsprechen der Draft-Sampling-Option in `common/arg.cpp`. +- `llama/src/main/java/net/ladenthin/llama/args/FlashAttn.java` · `310dcc5` · 2026-10-17 · kein Fund — on/off/auto entsprechen `--flash-attn`. - `llama/src/main/java/net/ladenthin/llama/args/GpuSplitMode.java` · `93b37e4` · 2026-10-17 · kein Fund — none/layer/row/tensor entsprechen `--split-mode`. -- `llama/src/main/java/net/ladenthin/llama/args/NumaStrategy.java` · `47fece9` · 2026-10-17 · kein Fund — distribute/isolate/numactl entsprechen `--numa`. -- `llama/src/main/java/net/ladenthin/llama/args/MiroStat.java` · `691102a` · 2026-10-17 · kein Fund — 0/1/2 entsprechen `--mirostat`. - `llama/src/main/java/net/ladenthin/llama/args/LazyMode.java` · `d5225f0` · 2026-10-17 · kein Fund — off/auto/on entsprechen `--lazy-mode`. -- `llama/src/main/java/net/ladenthin/llama/args/FlashAttn.java` · `310dcc5` · 2026-10-17 · kein Fund — on/off/auto entsprechen `--flash-attn`. -- `llama/src/main/java/net/ladenthin/llama/args/DraftSampling.java` · `9c1a37f` · 2026-10-17 · kein Fund — greedy/probabilistic entsprechen der Draft-Sampling-Option in `common/arg.cpp`. -- `llama/src/main/java/net/ladenthin/llama/value/Pair.java` · `a209738` · 2026-10-17 · kein Fund — Unveränderlicher Träger, `equals`/`hashCode` über beide Felder. -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/SlotOp.java` · `f6fdfd1` · 2026-10-17 · kein Fund — Codes 1/2/3 stimmen mit `jllama_slot_op` in `jllama_api.h` überein. +- `llama/src/main/java/net/ladenthin/llama/args/LoadMode.java` · `8f8b7be` · 2026-10-17 · kein Fund — Werte auto/none/mmap/mlock/mmap+mlock/dio entsprechen `--load-mode` in `common/arg.cpp`. +- `llama/src/main/java/net/ladenthin/llama/args/LogFormat.java` · `f3f742b` · 2026-10-17 · kein Fund — Zwei Formate. +- `llama/src/main/java/net/ladenthin/llama/args/MiroStat.java` · `691102a` · 2026-10-17 · kein Fund — 0/1/2 entsprechen `--mirostat`. +- `llama/src/main/java/net/ladenthin/llama/args/NumaStrategy.java` · `47fece9` · 2026-10-17 · kein Fund — distribute/isolate/numactl entsprechen `--numa`. - `llama/src/main/java/net/ladenthin/llama/args/Optimizer.java` · `5d3e2a1` · 2026-10-17 · kein Fund — 0/1 entsprechen `GGML_OPT_OPTIMIZER_TYPE_ADAMW`/`SGD` (b11639, `ggml-opt.h`), die `train_engine.cpp` direkt übergibt. -- `llama/src/main/java/net/ladenthin/llama/args/ContinuationMode.java` · `b01f540` · 2026-10-17 · kein Fund — Zwei Feldnamen der Antwort, als Wert durchgereicht. -- `llama/src/main/java/net/ladenthin/llama/exception/ModelUnavailableException.java` · `9eebb86` · 2026-10-17 · kein Fund — Unterklasse von `LlamaException`, nur Konstruktoren; F56 sorgt dafür, dass `OfflineModelGuard` sie auch bei nicht darstellbaren Pfaden wirft. -- `llama/src/main/java/net/ladenthin/llama/exception/LlamaException.java` · `5af534c` · 2026-10-17 · kein Fund — Basisklasse, nur Konstruktoren. +- `llama/src/main/java/net/ladenthin/llama/args/PoolingType.java` · `376422b` · 2026-10-18 · kein Fund — sechs Werte gegen `include/llama.h` (-1..4) und die CLI-Strings von `--pooling`; `UNSPECIFIED` als Wächter ohne CLI-String, beim einzigen Verbraucher `setPoolingType` fand sich F133. +- `llama/src/main/java/net/ladenthin/llama/args/QuantizationType.java` · `d5ccc9d` · 2026-10-17 · kein Fund — Alle 36 `ftype`-Werte gegen `enum llama_ftype` in `include/llama.h` (b11639) abgeglichen; Namen und Zahlen stimmen. Die upstream entfernten 4/5/6 und 33–35 (`Q4_0_4_4` …) fehlen hier zu Recht, ebenso `GUESSED` (1024, kein Ziel). +- `llama/src/main/java/net/ladenthin/llama/args/ReasoningFormat.java` · `ea87677` · 2026-10-17 · kein Fund — none/auto/deepseek/deepseek-legacy entsprechen `common_reasoning_format_from_name` (b11639, `chat.cpp`). +- `llama/src/main/java/net/ladenthin/llama/args/RopeScalingType.java` · `12ebd19` · 2026-10-17 · kein Fund — `--rope-scaling` nimmt nur none/linear/yarn; `ModelParameters.setRopeScaling` entfernt die Option bei `UNSPECIFIED` und wirft bei `LONGROPE`/`MAX_VALUE`, wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/args/Sampler.java` · `3d1661d` · 2026-10-17 · kein Fund — Alle neun Namen in `common_sampler_types_from_names` (b11639, `sampling.cpp`); upstream kennt zusätzlich `top_n_sigma` und `adaptive_p`, die hier fehlen – Vollständigkeit, kein Fund. `setSamplers` verbindet mit `;` wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/callback/CancellationToken.java` · `70365a6` · 2026-10-17 · kein Fund (Verhalten) — `volatile`-Flag, `cancel()` idempotent. `LlamaModel.complete(params, token)` setzt den Token bewusst erst im `finally` zurück, nicht beim Eintritt, sodass ein vorab gesetztes `cancel()` wirkt. Doku-Abweichung: Javadoc von `reset()` sagt „Package-private.“, die Methode ist `public` – nur beschrieben (kein Test, Regel Schritt 4). +- `llama/src/main/java/net/ladenthin/llama/callback/LoadProgressCallback.java` · `8eae161` · 2026-10-17 · kein Fund — `false` bricht das Laden ab (siehe Patch 0002 / C-API). +- `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` · `9546b9a` · 2026-10-17 · kein Fund — (2026-10-17, kein Fund) Funktionale Schnittstelle für Werkzeugaufrufe. ‖ (2026-10-17, kein Fund) Durchsicht „`ToolCallingAgent.java` + `callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug)“: `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. - `llama/src/main/java/net/ladenthin/llama/exception/InvalidRequestException.java` · `971de14` · 2026-10-17 · kein Fund — Unterklasse von `LlamaException` für upstream-400; Zuordnung in `jllama.cpp`/`JllamaNative.check`. -- `llama/src/main/java/net/ladenthin/llama/value/LogLevel.java` · `6662b54` · 2026-10-17 · kein Fund — Vier Stufen, Abbildung der ggml-Stufen liegt im JNI-Code (außerhalb des Ziels). +- `llama/src/main/java/net/ladenthin/llama/exception/LlamaException.java` · `5af534c` · 2026-10-17 · kein Fund — Basisklasse, nur Konstruktoren. +- `llama/src/main/java/net/ladenthin/llama/exception/ModelUnavailableException.java` · `9eebb86` · 2026-10-17 · kein Fund — Unterklasse von `LlamaException`, nur Konstruktoren; F56 sorgt dafür, dass `OfflineModelGuard` sie auch bei nicht darstellbaren Pfaden wirft. +- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` · `31d8fb7` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). ‖ (2026-10-17, kein Fund) Durchsicht „`json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`)“: `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. ‖ (2026-10-18, kein Fund) Durchsicht „`json/ChatResponseParser.java` (Probe mit 6 Formen)“: `content: null` mit `tool_calls` → leerer Text und ein Aufruf, `finish_reason` übernommen; leere/fehlende `choices`, `{}` und `""` ergeben eine leere Antwort ohne Ausnahme (F37 deckt `""`); fehlende `usage`/`timings` als Nullwerte; eine in ein Array gehüllte Antwort ergibt leer, die native Seite liefert für eine Chat-Antwort aber ein Objekt +- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` · `707296e` · 2026-10-18 · kein Fund, kein Fund (Randnotiz) — (2026-10-17, kein Fund) java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… ‖ (2026-10-18, kein Fund (Randnotiz)) Durchsicht „`value/StopReason.java` + `json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639)“: Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. ‖ (2026-10-18, kein Fund) Durchsicht „`json/CompletionResponseParser.java` + `value/StopReason.java` ↔ upstream `stop_type_to_str` (Schnittstelle Stopptypen, b11639)“: upstream kennt `eos`/`word`/`limit` (dazu `none` für Zwischenschritte), `StopReason` bildet alle vier ab; der gestreamte Completion-Weg macht `limit` zu `length`, sonst `stop`, wie upstreams `finish_reason`. ‖ (2026-10-18, kein Fund) Durchsicht „`json/CompletionResponseParser.java` (Probe mit 7 Formen)“: `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) +- `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` · `6f2a458` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `json/EmbeddingResponseParser`: übersprungene Nicht-Array-Einträge würden die Zuordnung verschieben, `LlamaModel.embed(Collection)` prüft aber die Anzahl (`LlamaModel.java:431`) und wirft; nach `index` stabil sortiert. ‖ (2026-10-18, kein Fund) Durchsicht „`json/EmbeddingResponseParser.java` (Probe mit 9 Formen)“: liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab +- `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` · `6a637ed` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) `readTree("")` liefert `MissingNode`, dessen `isArray()` falsch ist → leere Liste wie dokumentiert (F37-Muster hier unschädlich). `toRequestJson` weist NaN/±∞ ab (würde nicht als JSON-Zahl serialisieren); unbekannte Ids ignoriert die native Seite tatsächlich (`parse_lora_request` füllt eine `std::map`, angewandt wird nur über geladene Adapter), passend zur Javadoc von `LlamaModel.setLoraAdapters`. `null`-Schlüssel/-Werte → NPE, für `Map` Aufruferfehler. ‖ (2026-10-18, kein Fund) Durchsicht „`json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639)“: liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab +- `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` · `8327d64` · 2026-10-19 · kein Fund — (2026-10-17, kein Fund) Zahlen mit `Locale.ROOT` formatiert (kein Dezimalkomma); Leerlauf-Prüfung `promptN == 0 && predictedN == 0` passt zu `Timings.fromJson`, das bei fehlendem/`null`-Knoten und fehlenden Feldern 0 liefert (upstream lässt `timings` bei `prompt_n < 0` ganz weg). Draft-Segment nur bei `draftN > 0`; Aufrufer `CompletionResponseParser`/`ChatResponseParser` übergeben nie `null`. `isInfoEnabled` vor der String-Bildung. ‖ (2026-10-19, kein Fund) Durchsicht „`json/TimingsLogger.java` (zweite Durchsicht)“: `Locale.ROOT`-Formatierung, leere Timings (0/0) und `null` werden übersprungen, Draft-Teil nur bei spekulativer Dekodierung. +- `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` · `a15ed7b` · 2026-10-19 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `Diagnose`, `loader/DiagnosticReport`: Backend-Filter-Auswertung (`namedBy`) deckt sich mit `LlamaLoader.selectModules` (inkl. Leer-/Whitespace-Fall); Versionsvergleich per `startsWith` würde nur bei einer 6-stelligen Build-Nummer mit gleichem Präfix irren. ‖ (2026-10-19, kein Fund) Durchsicht „`loader/DiagnosticReport.java` ↔ `LlamaLoader.selectModules`“: `namedBy` teilt und trimmt den Filter wie der Loader (Groß-/Kleinschreibung in beiden exakt), der Hinweis auf ungenutzte Opt-in-Module stimmt auch bei leerem Filter (der Loader behandelt ihn als unset). Commit-Kürzung nur für volle 40-Zeichen-Hashes, `-dirty` bleibt erhalten; jeder Prüfschritt fängt RuntimeException/LinkageError ab, ein Bericht scheitert also nie an einer fehlenden nativen Bibliothek. +- `llama/src/main/java/net/ladenthin/llama/loader/LlamaSystemProperties.java` · `ce16589` · 2026-10-17 · kein Fund — Reine Zugriffsschicht auf `System.getProperty`, `@Nullable` korrekt. Die fünf Namen (`lib.path`, `tmpdir`, `osinfo.architecture`, `test.ngl`, `backend`) stimmen mit der Systemeigenschaften-Tabelle der README überein. +- `llama/src/main/java/net/ladenthin/llama/loader/NativeLibraryPermissionSetter.java` · `e2c6e1b` · 2026-10-17 · kein Fund — Setzt Lesen/Schreiben/Ausführen jeweils nur für den Besitzer (`setReadable(true)`/`setExecutable(true)` sind die Besitzer-Varianten, `setWritable(true, true)` explizit) – für eine extrahierte native Bibliothek die vorsichtigere Wahl. Ein Fehlschlag wird mit allen drei Ergebnissen gemeldet und als `false` zurückgegeben, nicht verschluckt; die Senke ist im Konstruktor auf `null` geprüft. +- `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` · `138b590` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `loader/OSInfo`: Abbildung von `os.name`/`os.arch` auf die Natives-Verzeichnisse für alle ausgelieferten Plattformen (Linux/Windows x86_64 + aarch64, Mac aarch64, s390x, Android) korrekt. Randnotizen (niedrig): `isMusl()` fällt nur bei einer Ausnahme von `Files.list` auf `/etc/os-r… ‖ (2026-10-18, kein Fund) Durchsicht „`loader/OSInfo.java` (zweite Durchsicht: `getArchName`/`resolveArmArchType`/`translateOSNameToFolderName` gegen die Verzeichnisse aus `natives.csv`)“: `aarch64` steht nicht in `archMapping`, fällt aber unverändert durch `translateArchNameToFolderName` (Linux, Windows, Mac und Android aarch64 → `aarch64`), `amd64`/`x86_64` → `x86_64`, `s390x` gemappt; Android vor Musl vor Linux geprüft. Nicht verfolgt: eine JVM, die `os.arch`=`arm64` meldet, landete auf macOS über `resolveArmArchType` bei `arm` (OpenJDK-Builds melden `aarch64`); `isMusl` sucht "musl" in jedem gemappten Pfad, ein Verzeichnisname mit "musl" auf glibc würde falsch erkannt. +- `llama/src/main/java/net/ladenthin/llama/loader/ProcessRunner.java` · `372d6f4` · 2026-10-17 · kein Fund — Liest stdout erst nach `waitFor`, stderr gar nicht: ein Kind mit mehr als einem Pipe-Puffer (~64 KiB) Ausgabe bliebe bis zum Timeout hängen. Nicht erreichbar: einzige Aufrufer sind `OSInfo.isAndroidTermux` (`uname -o`) und `getHardwareName` (`uname -m`), je eine Zeile; der Pfad mit viel Ausgabe (`readelf`) läuft über `runDiscardingOutput`, das vor `waitFor` leert. Timeout → IOException, `destroyForcibly` im finally, Abschluss-Newline bei `startsWith`/`contains` harmlos. Musterdurchsicht nach F41 (abgeschnittene Exception-Meldungen) über alle Ziele: kein weiterer Treffer. +- `llama/src/main/java/net/ladenthin/llama/parameters/RequestContract.java` · `b216881` · 2026-10-17 · kein Fund — Zwei Vertragsarten der Request-Registry. +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingField.java` · `8a305f9` · 2026-10-18 · kein Fund — 15 Schlüssel gegen `TrainingParameters.toJson` (jeder genau einmal geschrieben) und gegen `jllama_train::config_keys()` (durch `test_wire_contracts.cpp` gehalten); `validate()` prüft Korpus, Epochen und `valSplit`. - `llama/src/main/java/net/ladenthin/llama/server/ChunkSink.java` · `02cb145` · 2026-10-17 · kein Fund — Funktionale Schnittstelle, `IOException` signalisiert einen getrennten Client. -- `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` · `9546b9a` · 2026-10-17 · kein Fund — Funktionale Schnittstelle für Werkzeugaufrufe. -- `llama/src/main/java/net/ladenthin/llama/callback/LoadProgressCallback.java` · `8eae161` · 2026-10-17 · kein Fund — `false` bricht das Laden ab (siehe Patch 0002 / C-API). +- `llama/src/main/java/net/ladenthin/llama/server/ServerLauncher.java` · `d8433a0` · 2026-10-19 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `server/ServerLauncher`: entfernt jedes Vorkommen des Markers und dispatcht korrekt; trivial. ‖ (2026-10-19, kein Fund) Durchsicht „`server/ServerLauncher.java` (zweite Durchsicht)“: der Marker wird an jeder Stelle und mehrfach entfernt, sonst gehen die Argumente unverändert an `NativeServer.main`. +- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) Gruppierung nach `index` (TreeMap, sortierte Ausgabe), Argument-Deltas werden verkettet, `id`/`name` aus dem ersten Delta; ein Aufruf ohne Argument-Deltas ergibt `arguments: ""`, was erst der Ollama-Konsument falsch abbildete (F37). ‖ (2026-10-17, kein Fund) Durchsicht „`server/ToolCallDeltaAccumulator.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639)“: Upstream (`common/chat.cpp` Diff, `server-chat.cpp` Ausgabe) sendet bei geändertem Namen oder geänderter ID den **vollständigen** neuen Wert (ein wachsender Name kommt also ganz, nicht als Anhang), die Argumente dagegen als Differenz, und leere Felder gar nicht. Genau so verarbeitet der Akkumulator: `id`/`name` überschreiben, `arguments` anhängen, je `index` getrennt, in Indexreihenfolge ausgegeben (`TreeMap`). ‖ (2026-10-18, kein Fund) nach `index` geordnet (`TreeMap`), `id`/`name` ersetzt, `arguments` angehängt; passt zu upstreams Diff-Form (Name und Id einmal, danach nur Argumentstücke); ein Chunk ohne `index` zählt als 0 ‖ (2026-10-18, kein Fund) Durchsicht „`server/ToolCallDeltaAccumulator.java` ↔ `AnthropicStreamTranslator` / `ResponsesStreamTranslator` / `OllamaApiSupport` (Musterdurchsicht nach F128: Fragmente ohne Argumente oder vor dem Namen)“: der Akkumulator übernimmt `id`/`name` und hängt Argumente an, ohne etwas weiterzumelden; alle drei Übersetzer geben `tool_use`- bzw. `function_call`-Blöcke und Ollama-`tool_calls` erst am Ende aus dem fertigen Stand aus (`asText("")` für einen nie gelieferten Namen), es gibt keinen Teilaufruf-Event mit Vorbedingungen. Die frühere Prüfzeile zu `StreamingChunkAssembler` (17.10., „gleiche Semantik“) hatte die langchain4j-Vorbedingungen nicht betrachtet – siehe F128. +- `llama/src/main/java/net/ladenthin/llama/value/ChatChoice.java` · `ad1c75f` · 2026-10-17 · kein Fund — java-llama.cpp `value/ChatChoice`, `json/ChatResponseParser`: Null-/fehlende Felder über `path().asText(default)` abgefangen; `reasoning_content` landet nicht in `ChatChoice`, ist aber über `getRawJson()`/`extractChoiceReasoningContent` erreichbar (Design, kein Fehler). +- `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` · `453a951` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) `parts` null/leer → IAE wie dokumentiert, Kopie unveränderlich; `toolCalls` null → leere Liste. Javadoc-Versprechen „Parts haben beim Serialisieren Vorrang“ in der einzigen Serialisierung (`ParameterJsonSerializer.buildMessages(List)`, genutzt von `ChatRequest.buildMessagesJson` und `InferenceParameters.withMessages`) erfüllt, `tool_call_id`/`tool_calls` werden mitgeschrieben. `Session`/`ChatTranscript` bauen nur Textnachrichten. Ein `null`-Element in `parts` ergäbe eine NPE statt IAE – Javadoc schweigt dazu, kein Fund. ‖ (2026-10-18, kein Fund) Durchsicht „`value/ChatMessage.java` (zweite Durchsicht)“: Teile und Tool-Calls defensiv kopiert und unveränderlich zurückgegeben, `content` aus den Textteilen mit Zeilenumbruch verbunden, leere Teil-Liste abgewiesen. Ein `null`-Element in der Teil-Liste ergäbe eine NullPointerException beim Zusammenfügen (Aufruferfehler, nicht verfolgt). +- `llama/src/main/java/net/ladenthin/llama/value/ChatResponse.java` · `7b68d5f` · 2026-10-17 · kein Fund — `choices` null → leere Liste; `getFirstMessage`/`getFirstContent` behandeln leere Antworten. +- `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` · `cf96a79` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) Zweiphasen-Commit wie dokumentiert: `messagesWithPendingUserTurn` kopiert, mutiert nicht; `removePendingUserTurn` entfernt nur eine abschließende `user`-Runde. Leere/`null`-Systemnachricht wird in `snapshot()` und in `ParameterJsonSerializer.buildMessages(String, List)` gleich behandelt (weggelassen). Nicht synchronisiert, wie die Javadoc sagt; `Session` hält das Lock (bereits geprüft). `turnsSnapshot`/`resetTurns` kopieren, `Pair` ist unveränderlich. ‖ (2026-10-18, kein Fund) Durchsicht „`value/ChatTranscript.java` (zweite Durchsicht)“: `removePendingUserTurn` entfernt nur eine offene Nutzerzeile am Ende, `messagesWithPendingUserTurn` kopiert, `snapshot` lässt eine leere Systemnachricht weg, `turnsSnapshot`/`resetTurns` arbeiten auf Kopien (Pair ist unveränderlich). +- `llama/src/main/java/net/ladenthin/llama/value/CompletionResult.java` · `934a56d` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… +- `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` · `e167fa7` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `value/ContentPart`: Validierung ok. Randnotiz: Lombok-`@ToString` gibt die komplette Base64-Nutzlast (`imageUrl`/`audioData`, schnell mehrere MB) aus – teuer, falls ein `ChatMessage`/`ChatRequest` geloggt wird. ‖ (2026-10-18, kein Fund) Durchsicht „`value/ContentPart.java` (zweite Durchsicht)“: MIME/Format aus der Endung ohne Locale-Falle (`Locale.ROOT`), Data-URL `data:;base64,`, Audioformat auf `wav`/`mp3` normalisiert; `@EqualsAndHashCode` (Lombok) trägt die Wertgleichheit, die `ChatRequest` verspricht. +- `llama/src/main/java/net/ladenthin/llama/value/GgufMetadata.java` · `6261a64` · 2026-10-17 · kein Fund — java-llama.cpp `GgufInspector`, `value/GgufMetadata`: Endianness-Erkennung über das Versionsfeld nachgerechnet (BE v3 → `0x03000000` → reverse → 3), Längen-/Anzahl-Grenzen vor Allokation geprüft. Nur Randnotiz: Summe aller Strings eines Arrays ist nicht begrenzt (2^26 × bis 2^27 Byte) – bei einer… - `llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java` · `519fc3a` · 2026-10-17 · kein Fund — `b11639` stimmt mit `JLLAMA_GIT_TAG` in `llama/CMakeLists.txt` überein. -- `llama/src/main/java/net/ladenthin/llama/parameters/RequestContract.java` · `b216881` · 2026-10-17 · kein Fund — Zwei Vertragsarten der Request-Registry. -- `llama/src/main/java/net/ladenthin/llama/args/LogFormat.java` · `f3f742b` · 2026-10-17 · kein Fund — Zwei Formate. -- `llama/src/main/java/net/ladenthin/llama/args/CliContract.java` · `a288676` · 2026-10-17 · kein Fund — Zwei Vertragsarten der CLI-Registry. -- `llama/src/main/java/net/ladenthin/llama/args/CliArg.java` · `8516cfd` · 2026-10-17 · kein Fund — Schnittstelle für Enum-Werte auf der Kommandozeile. -- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Schnittstelle) · `b578b98`/`3d47cac`/`6bdd3ff` · 2026-10-17 · kein Fund — Rerank-Kette: `readDocuments` nimmt Strings oder `{text}`-Objekte, leere Liste → 400; die native Antwort aus `rerank_results_to_json` (`index`, `score`, `document`) wird zu `results`/`data` mit `relevance_score`, absteigend sortiert, `top_n ≤ 0` oder fehlend = alle. `requirePostJson`: Content-Length-Vorprüfung plus `BoundedInputStream`, leerer Body → `MissingNode` → 400; Bearer- und `x-api-key`-Vergleich in konstanter Zeit. -- `llama/src/main/java/net/ladenthin/llama/ToolCallingAgent.java` + `llama/src/main/java/net/ladenthin/llama/callback/ToolHandler.java` (Schnittstelle Agent-Schleife ↔ Werkzeug) · `b69653a`/`9546b9a` · 2026-10-17 · kein Fund — `maxToolRounds` zählt Chat-Aufrufe (mindestens einer, < 1 → IAE wie in `withMaxToolRounds`); Assistenten-Runde und alle Werkzeugergebnisse werden in Reihenfolge mit `tool_call_id` angehängt; unbekanntes Werkzeug und Ausnahme des Handlers werden als `{"error":…}` gemeldet, wie `chatWithTools` dokumentiert. Am Rundenlimit kommt die letzte Antwort zurück, auch mit unerledigten Aufrufen (dokumentiert). Ein Handler, der `null` liefert, verletzt seinen eigenen Vertrag (`@return` ein String) – kein Fund. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Schnittstelle OAI-Request ↔ `InferenceParameters`) · `5bddfea` · 2026-10-17 · kein Fund — `messages` leer → 400; `max_completion_tokens` vor `max_tokens`, nur > 0 übernommen; `stop` als String oder String-Array; `tool_choice` nur als String, wie upstreams `oaicompat_chat_params_parse` (`json_value(body, "tool_choice", "auto")`) ihn liest. `seed` über `asInt()`: Werte bis 2^32 kommen bitgleich an, weil upstreams `field_num("seed", …)` in `uint32_t` liest (b11639, `server-schema.cpp`). -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Schnittstelle Ollama ↔ OpenAI-Kern) · `c1840dc` · 2026-10-17 · kein Fund (Verdacht notiert) — `options`-Abbildung: `temperature`/`top_p`/`top_k`/`seed`, `num_predict` → `max_tokens` (Ollamas -1/-2 fallen in `readMaxTokens` unter „nur > 0“ und lassen upstreams Standard -1 stehen), `stop` als Array. `done_reason` siehe offener Verdacht im Fundbuch. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `b59c4ee` · 2026-10-17 · Fund F60 — Textblöcke ohne Trenner. Gegen upstreams `server_chat_convert_anthropic_to_oai` (b11639) abgeglichen: `tool_use` → `tool_calls` mit `input` als JSON-Text, `tool_result` → eigene `role:tool`-Nachricht vor dem Text, Bilder als `image_url` (base64 → Data-URL), System-Blöcke ohne Trenner wie upstream; upstream liest zusätzlich `thinking`-Blöcke in `reasoning_content`, die hier wegfallen – fehlende Funktion, kein Fehler. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1365c84` · 2026-10-17 · Fund F61 — Textteile ohne Trenner. Musterdurchsicht nach F60 über alle drei Übersetzungsschichten: Anthropic (F60) und Responses (F61) betroffen; Ollama reicht `content` als einen String durch, kein Zusammenfügen. `instructions` → System-Nachricht, `function_call`/`function_call_output` → `tool_calls`/`role:tool` wie upstream. -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `550a6c2` · 2026-10-17 · Fund F62 (nur beschrieben) — Musterdurchsicht nach F60/F61: mehrere `TextContent` ohne Trenner, von einem Test festgeschrieben; Entscheidung des Nutzers. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Antwortseite gegen upstream `to_json_anthropic`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — `stop_reason` (`length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn`) und `usage` (`input_tokens` = prompt − cached, `cache_read_input_tokens`, `output_tokens`) entsprechen upstream; `tool_use.input` fällt bei unparsbaren Argumenten auf `{}` zurück wie upstream. Abweichungen ohne Fehlerfolge: upstream setzt `stop_sequence` auf das auslösende Stoppwort und gibt `reasoning_content` als `thinking`-Block vor dem Text aus; beides fehlt hier, weil die OpenAI-Antwort das Stoppwort nicht trägt bzw. die Abbildung `reasoning_content` nicht liest – fehlende Funktion. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `b755abd` · 2026-10-17 · Fund F63 — Antwortseite gegen upstreams `to_json_oaicompat_resp`: `created_at` war 0. `status` immer `completed` und `usage` (`input_tokens`, `output_tokens`, `total_tokens`, `input_tokens_details.cached_tokens`) wie upstream; upstream gibt zusätzlich ein `reasoning`-Ausgabeelement aus `reasoning_content` aus, das hier fehlt – fehlende Funktion. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Schnittstelle Stream-Chunks → Responses-Ereignisse) · `51665a1` · 2026-10-17 · kein Fund — Ereignisfolge `response.created` → bei erstem Text `output_item.added` + `content_part.added` → `output_text.delta` → am Ende `output_text.done`, `content_part.done`, `output_item.done`, je Werkzeugaufruf `output_item.added`/`function_call_arguments.done`/`output_item.done`, dann `response.completed` mit gesammeltem `output` und `usage`; `output_index` und `sequence_number` streng aufsteigend. Die Nutzungsdaten kommen an, weil `OpenAiCompatServer.withUsageChunk` für die übersetzten Routen `include_usage` erzwingt. Keine `function_call_arguments.delta`-Ereignisse (Argumente erst am Ende) – zulässig, weil `.done` die vollständigen Argumente trägt. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Schnittstelle Stream-Chunks → Anthropic-SSE) · `7f002e9` · 2026-10-17 · kein Fund — Folge `message_start` → Textblock (`content_block_start`/`text_delta`) → je Werkzeugaufruf `tool_use`-Block mit `input: {}`, einem `input_json_delta` mit den vollständigen Argumenten und `content_block_stop` → `message_delta` (`stop_reason` wie die Nicht-Stream-Antwort, Nutzung aus dem erzwungenen Usage-Chunk, `input_tokens` ohne Cache) → `message_stop`; Blockindizes fortlaufend. Leere Argumente ergeben ein leeres `partial_json`, das die Clients mit dem Start-`input` `{}` überbrücken. -- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `58a9bab` · 2026-10-17 · kein Fund — Upstream (`common/chat.cpp` Diff, `server-chat.cpp` Ausgabe) sendet bei geändertem Namen oder geänderter ID den **vollständigen** neuen Wert (ein wachsender Name kommt also ganz, nicht als Anhang), die Argumente dagegen als Differenz, und leere Felder gar nicht. Genau so verarbeitet der Akkumulator: `id`/`name` überschreiben, `arguments` anhängen, je `index` getrennt, in Indexreihenfolge ausgegeben (`TreeMap`). -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (Schnittstelle gegen upstreams Tool-Call-Deltas, b11639) · `b224a28` · 2026-10-17 · kein Fund — Gleiche Semantik wie `ToolCallDeltaAccumulator`: `id`/`name` überschreiben (nur nicht-leer), `arguments` anhängen, je `index` getrennt; Startwerte `""`, also nie `null` an `PartialToolCall`. `reasoning_content`-Deltas werden zu `onPartialThinking` und `AiMessage.thinking`, Nutzung aus dem Usage-Chunk. -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `339b3e0` · 2026-10-17 · Fund F64 — Werkzeugaufrufe ohne `type`. Gegen upstreams Nachrichtenparser (`common/chat.cpp`) geprüft: verlangt `type == "function"`, `function.name` und `function.arguments` (String oder Objekt), `id` optional; Bilder (`images`) werden zu `image_url`-Parts. Werkzeug-Ergebnisse (`role: tool`) ohne `tool_call_id` nimmt upstream an. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Durchsicht nach F64: Pflichtfelder des upstream-Parsers) · `b755abd`/`b59c4ee` · 2026-10-17 · kein Fund — Beide setzen `type: "function"` für jeden erzeugten Werkzeugaufruf, `function.name` und `function.arguments` (String) sind immer vorhanden; Assistenten-Runden mit Werkzeugaufrufen tragen `content: null`, was `common/chat.cpp` annimmt (nur ein Nicht-String/Nicht-Array/Nicht-null wird abgelehnt); jede erzeugte Nachricht hat `content` oder `tool_calls`; Werkzeugergebnisse sind Strings. Der Ollama-Pfad war der einzige ohne `type` (F64). -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Werkzeugdefinitionen gegen `common_chat_tools_parse_oaicompat`, b11639) · `b59c4ee`/`b755abd` · 2026-10-17 · kein Fund — upstream verlangt je Werkzeug `type == "function"` und `function.name`, `parameters` optional. Anthropic setzt beides (auch für eingebaute Werkzeugtypen ohne `input_schema`, die dann leere Parameter bekommen); Responses überspringt Nicht-Funktionswerkzeuge, eine dadurch leere Liste lässt `OpenAiRequestMapper` weg; Ollama reicht seine `tools` durch, die das OpenAI-Format mit `type` schon haben. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toolResultText`, `toOpenAiToolChoice` gegen upstreams `server_chat_convert_anthropic_to_oai`, b11639) · `b59c4ee` · 2026-10-17 · kein Fund — Anders als die Nachrichtentexte (F60) verbindet upstream auch die Textblöcke eines `tool_result` ohne Trenner (`result_text += text`); die Übersetzung tut dasselbe, daher bewusst unverändert. `tool_choice`: `auto` → `auto`, `any`/`tool` → `required`, `none` und Unbekanntes entfallen – identisch mit upstream. Abweichung ohne Fehlerfolge: Bilder in einem `tool_result` reicht upstream als Content-Parts weiter, hier fallen sie weg (nur Text). -- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Schnittstelle Antwort-JSON ↔ `ChatResponse`/`Usage`) · `31d8fb7` · 2026-10-17 · kein Fund — `usage.prompt_tokens` (upstream: alle Prompt-Tokens inkl. Cache) und `prompt_tokens_details.cached_tokens` füllen `Usage` so, wie sein Vertrag es verlangt – hier stimmt die Zählung, anders als in `ServerMetrics.getCumulativeUsage` (F57). `content: null` einer Werkzeug-Runde ergibt `""` (`NullNode.asText(default)`), Argumente als String oder Objekt; unparsbare Antwort → leere `ChatResponse` mit Rohtext. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `09c9640` · 2026-10-17 · Fund F65 — CORS-Filter: Preflight-Header jetzt gespiegelt; fester `Access-Control-Allow-Origin` auf jeder Antwort und `OPTIONS` → 204 unverändert; kein `Vary` nötig, da der Origin konfiguriert und nicht gespiegelt wird. -- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` ↔ `llama/src/main/cpp/jllama_api.h` (Schnittstelle Slot-Aktionen und LoRA) · `96eafab` · 2026-10-17 · Fund F67 — `saveSlot`/`restoreSlot`/`eraseSlot` übergeben 1/2/3 = `JLLAMA_SLOT_SAVE/RESTORE/ERASE`; `setLoraAdapters` schickt `[{id, scale}]` an `parse_lora_request`, unbekannte Ids ignoriert und alle übrigen auf 0 (`construct_lora_list`) wie dokumentiert; das versprochene Leeren der KV-Caches findet auf diesem Weg nicht statt (F67). -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` + `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` + `llama/src/main/java/net/ladenthin/llama/LlamaIterator.java` ↔ `jllama_api.h` (Schnittstelle Streaming-Abbruch und Fehler) · `09c9640`/`3d47cac`/`f7963f2` · 2026-10-17 · kein Fund (Designabweichung notiert) — Client-Abbruch: `IOException` der Senke wird über `UncheckedIOException` aus `streamChatCompletion` getragen, dessen `finally` `cancelCompletion` = `jllama_task_release` ruft (laut Header „cancels or releases“, unbekannte Id ignoriert); nach `stop` gibt der Empfang die Aufgabe selbst frei, `LlamaIterator.next` ruft dann `releaseTask`, `close()` danach ist ein No-op. `streamCompletions` bricht per `break` ab, `LlamaIterable.close()` gibt frei. Fehler nach den Headern als SSE-Ereignis (siehe offene Verdachte). -- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` + `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` ↔ `LlamaModel.complete(…, CancellationToken)` / `LlamaIterable` / `jllama_engine::stream_next` (Schnittstelle Kotlin-Abbruch ↔ native Aufgabe) · `0ff46cf`/`45f5f14` · 2026-10-17 · kein Fund (Doku-Abweichung notiert) — Abbruch der Koroutine setzt das Token, `complete` prüft es vor jedem `receiveCompletionJson` und gibt die Aufgabe per `cancelCompletion` frei; nach `stop` und bei jedem Fehler entfernt `stream_next` den Reader selbst; `coroutineScope` wartet auf den blockierenden `async`-Block, keine laufende Generierung überlebt den Aufruf. Flows schließen die Quelle über `use` bei Ende, Fehler und Abbruch. `CancellationToken.reset()` ist `public`, die Javadoc nennt es „Package-private“ (reine Doku, kein Test möglich, nicht geändert). -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaChatModel.java` + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` ↔ `LlamaModel.chat`/`streamChatCompletion` (Schnittstelle LangChain4j-Request → `InferenceParameters`) · `550a6c2`/`62e0ac1`/`8cecaa4` · 2026-10-17 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens`(→`n_predict`)/Penalties/`stopSequences` nur bei gesetztem Wert; JSON-`responseFormat` mit Schema → `json_schema`, ohne → `json_object`; Streaming nutzt dieselbe Verdrahtung wie `LlamaModel.chat` (`tool_choice` nur zusammen mit Tools, wie dort). Beide Modelle überschreiben nur `doChat`, so bleiben das Zusammenführen der `defaultRequestParameters` und die Listener von LangChain4j wirksam; `modelName` wird ignoriert (dokumentiert). -- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` + `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` + `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` ↔ upstream `get_router_models` (Schnittstelle `/models`-Antwort, b11639) · `5396c0a` · 2026-10-17 · Fund F68 — Schlüssel `data[].id`, `status.value`/`failed`/`exit_code`, `architecture.input_modalities`/`output_modalities`, `context_length` stimmen mit upstream überein, ebenso die sechs Statusnamen; `sleeping` wurde nicht als bereit gewertet (F68), Aliase werden nicht aufgelöst (offener Verdacht). -- `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`) · `35d2521` · 2026-10-17 · kein Fund — Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. -- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` ↔ upstream `server-schema.cpp` (Schnittstelle Java-Setter ↔ Feldgrenzen, b11639) · `386d73f` · 2026-10-18 · Fund F69 — Java prüft `cache_reuse`, `slot_id`, `repeat_last_n` und `dry_penalty_last_n` auf ≥ 0 wie upstreams harte Grenzen; `n_keep` (−1 = alle), `sse_ping_interval` (−1 = aus) und `reasoning_budget_tokens` (−1 = unbegrenzt) stimmen mit den harten Untergrenzen −1 überein; weiche Grenzen (`top_k`, `top_p`, `min_p`, `temperature`, `mirostat`) klemmt upstream, Java lässt sie durch. Abweichung: `withNPredict` verspricht `-2` (F69). -- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` + `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Musterdurchsicht nach F69: dokumentierte Sonderwerte ↔ `server-schema.cpp`/`common/arg.cpp`, b11639) · `386d73f`/`90d097c` · 2026-10-18 · kein weiterer Fund (zwei Doku-Notizen) — Anfrageseite: `top_n_sigma` (< 0 = aus), `t_max_predict_ms` (−1), `n_keep` (−1), `reasoning_budget_tokens` (−1), `sse_ping_interval` (−1) liegen in upstreams Grenzen, `dry_base` < 1 wird upstream ersetzt; einzig `n_predict = −2` fällt heraus (F69). Startoptionen: `-n −2` ist dort gültig. Reine Doku, nicht geändert: `setMirostat` sagt wörtlich „default: PLACEHOLDER“ (upstream 0), `setKeep` nennt „default: -1 = all“, upstreams Vorgabe ist `n_keep = 0`. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` ↔ `ModelParameters` / upstream `common/arg.cpp` (Schnittstelle CLI-Teilmenge → Startoptionen, b11639) · `fea260b` · 2026-10-18 · kein Fund — Fehlender Wert, Nicht-Zahl, unbekannter Cache-Typ und ein leeres oder nicht-objektförmiges `--chat-template-kwargs` (`readTree("")` → `MissingNode`, über `isObject()` abgefangen) enden in einer `IllegalArgumentException` mit Hilfetext; Zahlen ≤ 0 werden nicht weitergereicht. `-ngl` wird immer gesetzt, auch mit 0: so gilt die dokumentierte Vorgabe „0 = CPU only“, obwohl upstreams eigene Vorgabe `auto` (−1) ist. Die Werte `auto`/`all` von upstreams `-ngl` kennt die bewusst kleine Teilmenge nicht (sie lehnt sie mit Meldung ab); `-p` heißt hier `--port`, upstream ist `-p` = `--prompt` (eigene CLI, dokumentiert). -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` (`toServerConfig`) + `OpenAiServerConfig` + `OpenAiCompatServer` (`/api/show`, `/props`) ↔ upstream Slot-Kontext (Schnittstelle beworbene Budgets ↔ tatsächliches Fenster, b11639) · `2003840` · 2026-10-18 · Fund F70 — Kontextlänge in `/api/show` und `/props` = `maxInputTokens + maxOutputTokens`; ohne `-c` gelten die Vorgaben; mit `--parallel N` war es das ganze `-c` statt des Fensters je Slot (F70). +- `llama/src/main/java/net/ladenthin/llama/value/LlamaOutput.java` · `e928537` · 2026-10-17 · kein Fund — reiner Träger, `toString` gibt den Text wie dokumentiert. +- `llama/src/main/java/net/ladenthin/llama/value/LogLevel.java` · `6662b54` · 2026-10-17 · kein Fund — Vier Stufen, Abbildung der ggml-Stufen liegt im JNI-Code (außerhalb des Ziels). +- `llama/src/main/java/net/ladenthin/llama/value/LoraAdapter.java` · `0351765` · 2026-10-17 · kein Fund — java-llama.cpp `value/LoraAdapter` (+ `json/LoraAdapterResponseParser`): Schlüssel `id/path/scale/task_name/prompt_prefix` gegen `server-task.cpp` b11639 identisch; das Javadoc „nicht gelistete Adapter werden auf 0 gesetzt“ stimmt mit `construct_lora_list` überein. +- `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` · `e9aa330` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `value/ModelMeta`: alle Getter-Schlüssel gegen den Erzeuger in `jllama_engine.cpp` abgeglichen (`vocab_type`, `n_ctx_train`, `n_params`, `size`, `modalities.*`, `input/output_modalities`, `special_tokens.bos/eos/eot`, `metadata`, `chat_template`, …) – identisch; Default `-1` für fe… ‖ (2026-10-18, kein Fund) Durchsicht „`value/ModelMeta.java` ↔ `jllama_engine.cpp` (Modell-Meta-JSON)“: alle 21 gelesenen Schlüssel/Pfade werden nativ geschrieben (`modalities.{vision,audio,video}`, `special_tokens.{bos,eos,eot}`, `metadata`, Ein-/Ausgabemodalitäten), Vorgaben für fehlende Felder passen zum Vocab-only-Zweig (der nur `vocab_type`, `n_vocab`, `special_tokens` liefert). `vocab_type` wird nativ als int gecastet (common_json-Falle, CLAUDE.md). Außerhalb des Bereichs bemerkt (C++, nicht geändert): Metadaten-Schlüssel landen in einem festen 256-Byte-Puffer, längere würden abgeschnitten. +- `llama/src/main/java/net/ladenthin/llama/value/Pair.java` · `a209738` · 2026-10-17 · kein Fund — Unveränderlicher Träger, `equals`/`hashCode` über beide Felder. +- `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` · `a6b12c5` · 2026-10-18 · kein Fund — (2026-10-17, kein Fund) java-llama.cpp `value/RpcEndpoint`: Parser (genau ein ':', Port 1..65535, max. 5 Ziffern vor `parseInt` → kein Überlauf), Host-Zeichensatz und Länge, `parseList` mit leeren Teilen korrekt; equals/hashCode über host+port. ‖ (2026-10-18, kein Fund) Durchsicht „`value/RpcEndpoint.java` (Probe mit 21 Schreibweisen)“: fehlender/leerer/0/65536/negativer/`+80`-Port, leerer Host, IPv6 mit und ohne Klammern und `host:80:90` werden mit klarer Meldung abgelehnt; `080` wird 80 (wie upstreams `std::stoi`), Leerraum um das Ganze getrimmt, nicht zwischen Doppelpunkt und Port; `parseList` überspringt leere Einträge und lehnt eine leere Liste ab. `GgufInspector` erneut gegen feindliche Längen gehalten: Obergrenzen für Schlüsselzahl, Stringlänge und Arraylänge, Strings werden nur aus vorhandenen Bytes gelesen (F72 deckt die Verschachtelung) +- `llama/src/main/java/net/ladenthin/llama/value/SessionCheckpoint.java` · `15a5811` · 2026-10-17 · kein Fund — Runden werden kopiert und unveränderlich gespeichert, `Pair` ist unveränderlich; `toString` nennt Pfad und Rundenzahl. +- `llama/src/main/java/net/ladenthin/llama/value/SlotMetrics.java` · `0ddd441` · 2026-10-17 · kein Fund — java-llama.cpp `value/SlotMetrics` (+ `ServerMetrics.getSlotMetrics`): alle gelesenen Schlüssel (`id`, `n_ctx`, `is_processing`, `n_prompt_tokens{,_processed,_cache}`, `next_token[0].n_remain/n_decoded`) gegen `server_slot::to_json` bei b11639 abgeglichen – identisch, `next_token` ist dort ein Ar… +- `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` · `cb51433` · 2026-10-18 · kein Fund, kein Fund (Randnotiz) — (2026-10-17, kein Fund) Namen gegen `stop_type_to_str` (b11639, `server-task.cpp`) abgeglichen: `eos`/`word`/`limit`, `none` und Unbekanntes → `NONE`. ‖ (2026-10-18, kein Fund (Randnotiz)) Durchsicht „`value/StopReason.java` + `json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639)“: Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. ‖ Frühere Durchsichten anderer Blobs: kein Fund (`ohne Hash`, 2026-10-18) +- `llama/src/main/java/net/ladenthin/llama/value/Timings.java` · `e187e37` · 2026-10-17 · kein Fund — Schlüssel von `fromJson` gegen upstreams `server_slot_stats::to_json()` (b11639, `server-common.cpp`) abgeglichen: `cache_n`, `prompt_n/_ms/_per_second`, `predicted_n/_ms/_per_second` stimmen; `draft_n`/`draft_n_accepted` sendet upstream nur bei Draft-Tokens > 0, der Standardwert 0 passt. `null`/Missing/JSON-null → Nullobjekt. `*_per_token_ms` wird nicht gelesen (kein Getter versprochen). +- `llama/src/main/java/net/ladenthin/llama/value/TokenLogprob.java` · `645a0f9` · 2026-10-17 · kein Fund — java-llama.cpp `value/CompletionResult`, `json/CompletionResponseParser`. **Doku-Hinweis (niedrig, nicht geändert):** `TokenLogprob.getLogprob()`-Javadoc sagt „a value in [0, 1] is a probability; a non-positive value is a natural-log probability“. Der Wert **0.0** fällt in beide Fälle: `logprob =… +- `llama/src/main/java/net/ladenthin/llama/value/ToolDefinition.java` · `1b8732b` · 2026-10-17 · kein Fund — unveränderlicher Träger; das Schema bleibt Text, die Prüfung leerer/ungültiger Schemata liegt in `ChatRequest.buildToolsJson` (F55). +- `llama/src/main/java/net/ladenthin/llama/value/Usage.java` · `ce0b9a7` · 2026-10-17 · Fund F57 (beim Aufrufer `ServerMetrics`) — Klasse selbst korrekt: `getProcessedPromptTokens` auf ≥ 0 begrenzt, `getTotalTokens` = prompt + completion wie bei OAI. + +## Abgelaufen – erneut zu prüfen + +Diese Dateien wurden nach ihrer letzten Lesung geändert; die Zeile nennt den damals gelesenen Blob und was sich +seitdem geändert hat. Sie gelten als ungeprüft, bis jemand sie erneut liest. + +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` · `41c9e8b` · 2026-10-18 · kein Fund (nur gelesen, kein JDK 22 hier) — Durchsicht „`FfmTaskStream.java` + `FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM)“: Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. ‖ **Abgelaufen:** Blob am HEAD `5b065ae`, seitdem 2 Commits an der Datei, darunter der Fix F186. Frühere Durchsichten: kein Fund (`41c9e8b`, 2026-10-17). +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` · `21145a1` · 2026-10-17 · Fund F59 — Race `isRunning`/`close`. Sonst stimmen die Bindungen mit `jllama_api.h` überein: `start(argc, argv, out)`, `start_attached(model, argc, argv, out)`, `stop`, `is_running` mit `JAVA_BOOLEAN`, `set_worker_command` mit `""` für „löschen“ wie dokumentiert; `argv[0]` = Programmname. ‖ **Abgelaufen:** Blob am HEAD `4351a53`, seitdem 1 Commit an der Datei. Frühere Durchsichten: kein Fund (`ohne Hash`, 2026-10-18). +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` · `7b5e31f` · 2026-10-17 · kein Fund — Signaturen passen zu `jllama_rpc_server_serve/stop/listening/devices`; `cacheDir` darf null sein (C-Vertrag), `devices` null → `""`. Das bekannte `takeStrings`-Leck bei Anzahl 0 steht als offener Verdacht im Fundbuch. ‖ **Abgelaufen:** Blob am HEAD `3e17643`, seitdem 1 Commit an der Datei, darunter der Fix F241. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTextToSpeech.java` · `8d99f3b` · 2026-10-17 · Fund F16 — java-llama.cpp: `close()` parallel zu `synthesize()` gibt die TTS-Engine frei, während sie läuft (Use-after-free → JVM-Absturz) ‖ **Abgelaufen:** Blob am HEAD `51ac5db`, seitdem 1 Commit an der Datei, darunter der Fix F198. +- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/JllamaNative.java` · `2e2c48f` · 2026-10-17 · Fund F168 — java-llama.cpp `llama-ffm/.../ffm/JllamaNative.java` `takeStrings`: Bei `count == 0` kehrt die Methode vor dem `try` zurück und gibt das Array nicht frei, das `jllama_rpc_server_devices` auch für 0 Einträge per `calloc(max(n,1))` anlegt (8 Byte Leck je Aufruf). `rpc::server_devices()` fällt aber … **Nachtrag (Branch-Review 2026-10-20):** als F168 behoben (`takeStrings` gibt das Array auch bei 0 Einträgen frei). ‖ **Abgelaufen:** Blob am HEAD `a7490f1`, seitdem 2 Commits an der Datei, darunter der Fix F186. +- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` · `b224a28` · 2026-10-18 · kein Fund — Durchsicht „`LangChain4jMapping.java` (`toFinishReason`) + `StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream)“: `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` ‖ **Abgelaufen:** Blob am HEAD `42fa7fd`, seitdem 4 Commits an der Datei, darunter der Fix F242. Frühere Durchsichten: kein Fund (`b224a28`, 2026-10-17); Fund F128 (`dd4d519`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java` · `3cd7b3d` · 2026-10-20 · Fund F200 — `runEachOnItsOwnThread` hatte einen Thread je Anfrage ohne Obergrenze und warf einen `Error` vor dem Abwarten der übrigen Anfragen (beides F200, behoben). `submitAllThenReceive`: alle Anfragen werden vor dem ersten Empfang auf dem rufenden Thread abgeschickt, bei einem Fehler beim Abschicken die schon abgeschickten freigegeben, bei einem Fehler beim Empfang die übrigen trotzdem empfangen — kein Fund. ‖ **Abgelaufen:** Blob am HEAD `53527b7`, seitdem 2 Commits an der Datei, darunter die Fixes F235, F236. +- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` · `96eafab` · 2026-10-18 · kein Fund — Durchsicht „`LlamaModel.java` (`complete(params, token)` ↔ `stream_next`)“: bei nativem Fehler gibt `stream_next` den Reader selbst frei, ein fehlendes `cancelCompletion` im Fehlerpfad leckt also nichts ‖ **Abgelaufen:** Blob am HEAD `ef98aab`, seitdem 9 Commits an der Datei, darunter die Fixes F176, F177, F186, F200, F236. Frühere Durchsichten: Fund F67 (`96eafab`, 2026-10-17); Verdacht notiert (`96eafab`, 2026-10-18); kein Fund (`96eafab`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` · `9d751fc` · 2026-10-17 · kein Fund (Verhalten) — Native Rückgabe `null`/leer = Erfolg, sonst `LlamaException` mit der Meldung; Konfiguration wird vor dem nativen Aufruf über `toJson()` geprüft (F01). Javadoc nennt die `IllegalArgumentException` dieser Prüfung nicht – als Doku-Lücke beim F01-Eintrag notiert. ‖ **Abgelaufen:** Blob am HEAD `72247c3`, seitdem 1 Commit an der Datei. Frühere Durchsichten: Fund F71 (`ohne Hash`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/RpcServer.java` · `0585de5` · 2026-10-18 · kein Fund — Durchsicht „`RpcServer.java` (zweite Durchsicht nach F30: `start`, `close`, `Options.parse`, `awaitListening`/`stopAndJoin`; Musterdurchsicht nach F140 – wirft ein früher Schritt nach dem Belegen von `ACTIVE`?)“: `ACTIVE` wird bei jedem gescheiterten Start im `finally` freigegeben, auch wenn die Geräteauflösung wirft; der Server-Thread ist Daemon, ein gescheiterter Start hält die JVM also nicht. Randfall notiert: endet der Server-Thread in `close()` nicht binnen `STOP_TIMEOUT_MILLIS` (30 s), gibt `close()` `ACTIVE` trotzdem frei, und ein neuer Start träfe auf ggmls noch belegten globalen Serverzustand – nicht reproduzierbar ohne native Bibliothek. ‖ **Abgelaufen:** Blob am HEAD `98bb23f`, seitdem 3 Commits an der Datei, darunter die Fixes F237, F241. Frühere Durchsichten: Fund F30 (`0585de5`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/args/ModelFlag.java` · `0807fe4` · 2026-10-17 · übersprungen — java-llama.cpp `args/ModelFlag`: reine Wire-Name-Registry, gegen den echten llama.cpp-Parser durch `test_model_flags.cpp` erzwungen – kein eigenständig prüfbarer Code. ‖ **Abgelaufen:** Blob am HEAD `613ae97`, seitdem 1 Commit an der Datei. +- `llama/src/main/java/net/ladenthin/llama/args/ModelOption.java` · `729cbc6` · 2026-10-17 · kein Fund — 110 Konstanten, 110 verschiedene Namen, keiner doppelt mit `ModelFlag`; alle 110 Namen kommen in `common/arg.cpp` von b11639 vor (die Server-Parser-Zuordnung prüft `test_model_flags.cpp`). Randnotiz: die Javadoc von `ModelFlag.getCliFlag()` nennt `--flash-attn` als Beispiel, das ist seit der Umstellung ein `ModelOption` – Stil, kein Fund. ‖ **Abgelaufen:** Blob am HEAD `d088bb3`, seitdem 1 Commit an der Datei, darunter der Fix F185. +- `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` · `2448442` · 2026-10-19 · kein Fund — Durchsicht „`json/ChatStreamChunkParser.java` (zweite Durchsicht)“: Array/Objekt/fehlend wie dokumentiert, unlesbarer oder leerer Umschlag beendet den Strom (verhindert eine Endlosschleife). ‖ **Abgelaufen:** Blob am HEAD `2cd32b3`, seitdem 1 Commit an der Datei. Frühere Durchsichten: Fund F53 (`2448442`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` · `2591bb0` · 2026-10-19 · kein Fund — Durchsicht „`json/RerankResponseParser.java` ↔ `rerank_results_to_json`“: `document` und `score` werden nativ geschrieben. Kleinigkeit: `parse(String)` liefert bei unlesbarem JSON eine unveränderliche, `parse(JsonNode)` bei leerem Array eine veränderliche Liste (der Kommentar dort nennt gerade diese Einheitlichkeit als Grund). ‖ **Abgelaufen:** Blob am HEAD `675daa0`, seitdem 1 Commit an der Datei, darunter der Fix F176. Frühere Durchsichten: kein Fund (`2591bb0`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-18 · kein Fund — `status.value`/`failed`/`exit_code`, `architecture.*_modalities`, `context_length` wie upstream emittiert ‖ **Abgelaufen:** Blob am HEAD `cb49f91`, seitdem 1 Commit an der Datei, darunter der Fix F175. Frühere Durchsichten: kein Fund (`fd64a69`, 2026-10-17); Fund F68 (`ohne Hash`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` · `35d2521` · 2026-10-17 · kein Fund — Durchsicht „`loader/LlamaLoader.java` ↔ `llama/cmake/jllama-build-info.cmake` (Schnittstelle `jllama-build.txt`/`jllama-files.txt`/`jllama-extras.txt`)“: Der Schreiber erzeugt eine `#`-Kopfzeile, LF-Zeilenenden und `key=value` mit genau den Schlüsseln, die der Loader als `BUILD_KEY_*` kennt (`llama.cpp`, `llama.cpp.commit`, `backend`, `kind`, `classifier`, `jllama.version`, `jllama.commit`, `patches`, `patches.sha256`, `compiler`, `toolkit`, `signed`); `parseBuild` teilt am ersten `=`, so überstehen Werte mit `=` oder Leerzeichen (Compilerkennung), Kommentare und Leerzeilen werden übersprungen, Listen getrimmt. Musterdurchsicht nach F68: keine weitere Stelle wertet nur `loaded` aus. ‖ **Abgelaufen:** Blob am HEAD `988c770`, seitdem 1 Commit an der Datei, darunter der Fix F239. Frühere Durchsichten: Fund F09 (`35d2521`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` · `658e69a` · 2026-10-17 · Fund F56 — nicht darstellbarer Pfad ergab `InvalidPathException`. Durchsicht der übrigen `Paths.get`-Stellen: `RpcServer.Options.parse` (`--cache`) wirft dieselbe Ausnahme, die dort als `IllegalArgumentException` der Argumentprüfung passt; `OpenAiServerCli.getModelId` nur bei unsinnigem Modellpfad, den der native Lader ohnehin ablehnt – kein Fund. ‖ **Abgelaufen:** Blob am HEAD `8c94266`, seitdem 1 Commit an der Datei, darunter der Fix F238. +- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` · `7ac6e49` · 2026-10-18 · kein Fund — Durchsicht „`parameters/ChatRequest.java` + `FfmNativeServer.java` + `server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“)“: `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (damals unsicher, 17.; inzwischen F168, behoben), da `server_devices()` nie leer liefert. ‖ **Abgelaufen:** Blob am HEAD `11e0f17`, seitdem 2 Commits an der Datei, darunter der Fix F233. Frühere Durchsichten: Fund F55 (`7ac6e49`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` · `b0428f5` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/CliParameters`: argv-Aufbau (Option + Wert, Flag ohne Wert, `argv[0] = ""`), Floats als `String.valueOf` (`1.0E-5`, von `std::stof` gelesen) korrekt. Randnotiz (niedrig, Sicherheit): `ModelParameters.toString()` gibt den Wert von `--hf-token` (`setHfToken`) im Klartext … ‖ **Abgelaufen:** Blob am HEAD `d299f38`, seitdem 2 Commits an der Datei, darunter der Fix F234. +- `llama/src/main/java/net/ladenthin/llama/parameters/InferenceParameters.java` · `386d73f` · 2026-10-18 · Fund F134 — Durchsicht „`parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung)“: die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. ‖ **Abgelaufen:** Blob am HEAD `50d5fb8`, seitdem 8 Commits an der Datei, darunter die Fixes F180, F183, F202. Frühere Durchsichten: Fund F26 (`386d73f`, 2026-10-17); Fund F69 (`386d73f`, 2026-10-18); kein weiterer Fund (zwei Doku-Notizen) (`386d73f`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` · `0ebc857` · 2026-10-18 · Fund F134 — Durchsicht „`parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung)“: die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. ‖ **Abgelaufen:** Blob am HEAD `6001eb4`, seitdem 1 Commit an der Datei. Frühere Durchsichten: Fund F38 (`1c4c9f7`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` · `a84e7da` · 2026-10-18 · Fund F133 — Durchsicht „`parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter)“: `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. ‖ **Abgelaufen:** Blob am HEAD `1c7a18a`, seitdem 7 Commits an der Datei, darunter die Fixes F185, F234, F243. Frühere Durchsichten: Fund F13 (`90d097c`, 2026-10-17); kein weiterer Fund (zwei Doku-Notizen) (`90d097c`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` · `408b4ae` · 2026-10-18 · kein Fund — Durchsicht „`parameters/ParameterJsonSerializer.java` (`buildMessages`) ↔ upstream `common_chat_msgs_parse_oaicompat` (`common/chat.cpp:342`, b11639; Schnittstelle Nachrichten-Array ↔ Pflichtfelder)“: Jede Nachricht trägt `role` und `content` (String oder Teile-Array), Werkzeugaufrufe `id`, `type: "function"`, `function.name` und `function.arguments` als String, Werkzeugantworten `tool_call_id`: keine der `throw std::invalid_argument`-Stellen (fehlende Rolle, fehlender Typ/Name, weder `content` noch `tool_calls`, falscher `content`-Typ) ist erreichbar. Teile vom Typ `image_url`/`input_audio` wandelt upstreams Serververarbeitung vorher in Medienmarker; `reasoning_content` früherer Antworten und `name` werden nicht mitgeschickt (beide optional). ‖ **Abgelaufen:** Blob am HEAD `5627e1f`, seitdem 4 Commits an der Datei, darunter die Fixes F202, F203. Frühere Durchsichten: Fund F54 (`408b4ae`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/parameters/RequestField.java` · `dbb8b3b` · 2026-10-17 · kein Fund — java-llama.cpp `parameters/RequestField`: alle `SCHEMA`-Schlüssel gegen `server-schema.cpp` b11639 (64 Felder) abgeglichen – vorhanden, `grammar` als Alias von `json_schema` (`add_alias`). Wird zusätzlich vom C++-Test `test_wire_contracts.cpp` erzwungen. ‖ **Abgelaufen:** Blob am HEAD `c3785fe`, seitdem 2 Commits an der Datei, darunter die Fixes F183, F202. +- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` · `12ecdbf` · 2026-10-18 · Fund F71 — Durchsicht „`parameters/TrainingParameters.java` + `LlamaTrainer.java` ↔ `train_engine.cpp`/`train_params.hpp` (Schnittstelle Trainingskonfiguration über JNI)“: Schlüssel und Vorgaben stimmen (`epochs` 2, `learning_rate` 1e-5, `val_split` 0.05, `n_gpu_layers` −1, `n_batch`/`n_ubatch` 0 = Vorgabe); Java prüft, was die Engine nicht prüft (genau ein Korpus, `epochs` ≥ 1, `val_split` in [0, 1)). Die Übergabe per `GetStringUTFChars` zerbrach Zeichen außerhalb der BMP (F71). ‖ **Abgelaufen:** Blob am HEAD `47dd112`, seitdem 1 Commit an der Datei. Frühere Durchsichten: Fund F01 (`8447e35`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `6ae6dd6` · 2026-10-18 · kein Fund — Durchsicht „`server/AnthropicApiSupport.java` (Inhaltsblöcke, Probe mit 6 Varianten)“: Bild als Base64 (`media_type` übernommen) und als URL, `tool_result` mit Text- und Bildteil → `role: tool` mit Teilen, `thinking` → `reasoning_content`; System-Blöcke werden ohne Trenner verbunden und `document` (PDF) fällt weg – beides genau wie upstream (`server_chat_convert_anthropic_to_oai`, `server-chat.cpp:348`, b11639: `system_content += system_text`, kein `document`-Zweig) ‖ **Abgelaufen:** Blob am HEAD `d86b738`, seitdem 6 Commits an der Datei, darunter die Fixes F172, F183, F202, F221, F223. Frühere Durchsichten: Fund F39 (`4c59b6c`, 2026-10-17); Fund F60 (`b59c4ee`, 2026-10-17); kein Fund (`b59c4ee`, 2026-10-17); kein Fund (`b59c4ee`, 2026-10-17); kein Fund (`b59c4ee`, 2026-10-17); kein Fund (`b59c4ee`, 2026-10-17); kein Fund (Randnotiz) (`b59c4ee`, 2026-10-18); Fund F75 (`ohne Hash`, 2026-10-18); Fund F76 (`8a426d9`, 2026-10-18); Fund F80 (`5682409`, 2026-10-18); Fund F81 (`edd2c9a`, 2026-10-18); Fund F84 (`fc5f49b`, 2026-10-18); Fund F88 (`565f7f8`, 2026-10-18); Fund F90 (`9b64484`, 2026-10-18); kein Fund (`9b64484`, 2026-10-18); Fund F96 (`7376c4a`, 2026-10-18); Fund F107 (`6ae6dd6`, 2026-10-18); kein Fund (`6ae6dd6`, 2026-10-18); kein Fund (Anmerkung) (`6ae6dd6`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `bd6ac4e` · 2026-10-18 · kein Fund — Durchsicht „`server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk)“: Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) ‖ **Abgelaufen:** Blob am HEAD `9824b5f`, seitdem 4 Commits an der Datei, darunter die Fixes F183, F221, F222. Frühere Durchsichten: kein Fund (`7f002e9`, 2026-10-17); kein Fund (`7f002e9`, 2026-10-17); Fund F81 (`bd6ac4e`, 2026-10-18); kein Fund (`bd6ac4e`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Verdacht notiert — Durchsicht „`server/LlamaModelBackend.java` (`streamCompletions` ↔ upstream `to_json_oaicompat`)“: letzter Chunk ohne `usage`, sonst wie upstream (`finish_reason` `length`/`stop`, `logprobs: null`); Sink-Fehler bricht ab und schließt die Aufgabe über `LlamaIterable.close()` ‖ **Abgelaufen:** Blob am HEAD `052500f`, seitdem 4 Commits an der Datei, darunter die Fixes F177, F186, F232. Frühere Durchsichten: kein Fund (`3d47cac`, 2026-10-17); kein Fund (`3d47cac`, 2026-10-17); kein Fund (Designabweichung notiert) (`3d47cac`, 2026-10-17); kein Fund (Randnotiz) (`ohne Hash`, 2026-10-18); Fund F77 (`ohne Hash`, 2026-10-18); Fund F79 (`ohne Hash`, 2026-10-18); Fund F111 (`1e88958`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` ‖ **Abgelaufen:** Blob am HEAD `5895cc8`, seitdem 1 Commit an der Datei. Frühere Durchsichten: kein Fund (`ohne Hash`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream ‖ **Abgelaufen:** Blob am HEAD `27ffd89`, seitdem 5 Commits an der Datei, darunter die Fixes F177, F230, F231, F232. Frühere Durchsichten: kein Fund (`b578b98`, 2026-10-17); kein Fund (`b578b98`, 2026-10-17). ‖ Die Notizen vom 2026-10-17 zu `top_n` („`top_n<=0` = alle“, „`top_n ≤ 0` oder fehlend = alle“) sind seit F177 falsch: `top_n: 0` behält kein Ergebnis, ein negatives `top_n` wird mit 400 abgewiesen, nur ein fehlendes `top_n` behält alle. +- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `73f11e1` · 2026-10-18 · kein Fund — Durchsicht „`server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe)“: Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) ‖ **Abgelaufen:** Blob am HEAD `8425e69`, seitdem 8 Commits an der Datei, darunter die Fixes F178, F179, F205, F213, F215, F216, F217. Frühere Durchsichten: Fund F37 (`c1840dc`, 2026-10-17); kein Fund (Verdacht notiert) (`c1840dc`, 2026-10-17); Fund F64 (`339b3e0`, 2026-10-17); kein Fund (`ohne Hash`, 2026-10-17); Fund F74 (`122da1d`, 2026-10-18); Fund F75 (`ohne Hash`, 2026-10-18); kein Fund (`ed9aff1`, 2026-10-18); Fund F97 (`6f86c26`, 2026-10-18); Fund F98, F97 (`7e6c0b0`, 2026-10-18); Fund F104 (`a944f9a`, 2026-10-18); Fund F114 (`906cf46`, 2026-10-18); kein Fund nach F114 (`906cf46`, 2026-10-18); Fund F115 (`a058a51`, 2026-10-18); Fund F116 (`73f11e1`, 2026-10-18); kein Fund (`73f11e1`, 2026-10-18); kein Fund (Anmerkung) (`73f11e1`, 2026-10-18); Verdacht notiert (`73f11e1`, 2026-10-18); kein Fund (`73f11e1`, 2026-10-18); kein Fund (`73f11e1`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java` · `3353536` · 2026-10-17 · kein Fund — Naht ohne Logik; der Standard von `streamCompletions` wirft `UnsupportedOperationException`, die `OpenAiCompatServer.streamCompletions` als Server-Fehler-Ereignis im SSE-Strom meldet (Kopfzeilen sind dann schon gesendet), statt die Verbindung abzubrechen. Der einzige Produktions-Backend `LlamaModelBackend` überschreibt sie. ‖ **Abgelaufen:** Blob am HEAD `347617d`, seitdem 1 Commit an der Datei. +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `a6ed187` · 2026-10-18 · kein Fund — Durchsicht „`server/OpenAiCompatServer.java` (Zugangsprüfung je Route)“: Schlüssel in konstanter Zeit verglichen (`MessageDigest.isEqual`, `Bearer` und `x-api-key`); `/v1/models`, `/metrics`, `/slots` und alle POST-Routen über `authorized`; `/health`, `/props` und die Ollama-Entdeckungsrouten bewusst offen und kommentiert (nur öffentliche Modelldaten); Größenprüfung erst nach der Zugangsprüfung, ein Unbefugter bekommt also kein 413-Orakel ‖ **Abgelaufen:** Blob am HEAD `0e42edc`, seitdem 13 Commits an der Datei, darunter die Fixes F174, F178, F179, F182, F199, F205, F213, F214, F215, F216, F223. Frühere Durchsichten: Fund F25 (`6bdd3ff`, 2026-10-17); kein Fund (`6bdd3ff`, 2026-10-17); Fund F65 (`09c9640`, 2026-10-17); kein Fund (Designabweichung notiert) (`09c9640`, 2026-10-17); Fund F70 (`ohne Hash`, 2026-10-18); kein Fund (Verdacht notiert) (`ohne Hash`, 2026-10-18); Fund F89 (`bc376ce`, 2026-10-18); Fund F98 (`77913cb`, 2026-10-18); kein Fund (`77913cb`, 2026-10-18); Fund F112 (`e38eb7e`, 2026-10-18); kein Fund nach F114 (`ohne Hash`, 2026-10-18); Fund F117 (`65df73d`, 2026-10-18); Fund F118 (`a6ed187`, 2026-10-18); kein Fund (`a6ed187`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `20548dd` · 2026-10-18 · Fund F123 — Durchsicht „`server/OpenAiRequestMapper.java` (Probe mit 14 Feldvarianten)“: `stop` als String und Array, `max_tokens` als Ganz- und Gleitzahl (als String verworfen), `max_completion_tokens` vorrangig, `seed`/`top_k` als Gleitzahl abgeschnitten, Strafen übernommen; `logprobs` fehlte (F123); `n` und `user` bleiben unbeachtet (`n` ist offener F95-Rest) ‖ **Abgelaufen:** Blob am HEAD `7049036`, seitdem 11 Commits an der Datei, darunter die Fixes F180, F183, F186, F202, F218, F219, F220, F227. Frühere Durchsichten: kein Fund (`5bddfea`, 2026-10-17); kein Fund (`5bddfea`, 2026-10-17); Fund F77 (`9eee221`, 2026-10-18); Fund F79 (`d89f722`, 2026-10-18); Fund F95 (`f10f4c6`, 2026-10-18); Fund F102 (`69becc1`, 2026-10-18); Fund F103 (`530fb40`, 2026-10-18); Fund F109, F103, F102 (`bfa8160`, 2026-10-18); Verdacht notiert (`bfa8160`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerCli.java` · `2003840` · 2026-10-18 · Fund F70 — Durchsicht „`server/OpenAiServerCli.java` (`toServerConfig`) + `OpenAiServerConfig` + `OpenAiCompatServer` (`/api/show`, `/props`) ↔ upstream Slot-Kontext (Schnittstelle beworbene Budgets ↔ tatsächliches Fenster, b11639)“: Kontextlänge in `/api/show` und `/props` = `maxInputTokens + maxOutputTokens`; ohne `-c` gelten die Vorgaben; mit `--parallel N` war es das ganze `-c` statt des Fensters je Slot (F70). ‖ **Abgelaufen:** Blob am HEAD `75d0614`, seitdem 6 Commits an der Datei, darunter die Fixes F174, F185, F201. Frühere Durchsichten: Fund F24, F125 (`c9677a3`, 2026-10-18); kein Fund (`fea260b`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` · `190803b` · 2026-10-18 · Fund F138 — Durchsicht „`server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`)“: Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. ‖ **Abgelaufen:** Blob am HEAD `3401489`, seitdem 2 Commits an der Datei, darunter der Fix F174. Frühere Durchsichten: Fund F25 (`7f9c370`, 2026-10-17); Fund F70 (`ohne Hash`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `e5eb40e` · 2026-10-19 · kein Fund — Durchsicht „`server/OpenAiSseFormatter.java` (dritte Durchsicht)“: `ensureUsageCachedTokens` ergänzt nur fehlendes `cached_tokens` und gibt jede andere Form (`usage` null, mit Leerzeichen, als Text im Inhalt, kein Objekt, unlesbar) unverändert weiter; SSE-Rahmen mit Leerzeile, Fehlerobjekt mit `code` null. ‖ **Abgelaufen:** Blob am HEAD `0f04b96`, seitdem 2 Commits an der Datei, darunter die Fixes F174, F186. Frühere Durchsichten: kein Fund (`1a04e66`, 2026-10-17); kein Fund (Verdacht notiert) (`1a04e66`, 2026-10-18); Fund F113 (`e5eb40e`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `12c6846` · 2026-10-18 · kein Fund — Durchsicht „`server/ResponsesApiSupport.java` (`input`-Items, Probe mit 4 Folgen)“: aufeinanderfolgende `function_call`-Items werden zu einer Assistentennachricht mit mehreren `tool_calls` zusammengefasst, eine vorangehende `output_text`-Nachricht wird ihr Text; `function_call_output` als String und als Teilliste → `role: tool`; `developer` bleibt erhalten und wird von upstreams `common/chat.cpp:1336` (`map_developer_role_to_system`, außer GPT-OSS) zu `system` ‖ **Abgelaufen:** Blob am HEAD `bc5484d`, seitdem 10 Commits an der Datei, darunter die Fixes F172, F173, F224, F225, F226, F227, F228, F229. Frühere Durchsichten: Fund F17 (`ac50ae4`, 2026-10-17); Fund F61 (`1365c84`, 2026-10-17); Fund F63 (`b755abd`, 2026-10-17); kein Fund (`b755abd`, 2026-10-17); kein Fund (`b755abd`, 2026-10-17); kein Fund (Randnotiz) (`b755abd`, 2026-10-18); Fund F75 (`446fa5f`, 2026-10-18); Fund F82 (`2a6035a`, 2026-10-18); Fund F85 (`a2333b5`, 2026-10-18); Fund F86 (`210b00c`, 2026-10-18); Fund F87 (`32fe049`, 2026-10-18); Fund F89 (`1fd936a`, 2026-10-18); Fund F91 (`945d6fd`, 2026-10-18); Fund F99 (`2358a6a`, 2026-10-18); Fund F105 (`886b3cb`, 2026-10-18); Fund F106 (`1724b42`, 2026-10-18); Fund F108 (`e44a70b`, 2026-10-18); Fund F109, F108, F106 (`12c6846`, 2026-10-18); kein Fund (`12c6846`, 2026-10-18); kein Fund (Anmerkung) (`12c6846`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `6d97f3a` · 2026-10-18 · kein Fund — Durchsicht „`server/ResponsesStreamTranslator.java` (Probe: Text, Werkzeugaufruf, `tool_calls`, Usage)“: Nachrichten-Item wird vor dem Werkzeugaufruf vollständig geschlossen (`output_text.done`, `content_part.done`, `output_item.done`), dann `function_call`-Item mit `call_id`, Name und vollständigen Argumenten (`added` → `function_call_arguments.done` → `done`), dann `response.completed` mit beiden Items; Folge­nummern lückenlos; Codex liest genau `output_item.done`, das kommt für beide Items ‖ **Abgelaufen:** Blob am HEAD `bb101a1`, seitdem 1 Commit an der Datei, darunter der Fix F229. Frühere Durchsichten: kein Fund (`51665a1`, 2026-10-17); kein Fund (`51665a1`, 2026-10-17); Fund F83 (`007523f`, 2026-10-18); Fund F117 (`6d97f3a`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` · `f23050e` · 2026-10-19 · Fund F143 — Durchsicht „`server/RouterClient.java` (`awaitModelLoaded`, Fristgrenzen)“: bei Frist 0 keine einzige Abfrage. ‖ **Abgelaufen:** Blob am HEAD `02a6154`, seitdem 2 Commits an der Datei, darunter der Fix F175. Frühere Durchsichten: Fund F06 (`3f14aec`, 2026-10-17); Fund F68 (`5396c0a`, 2026-10-17); kein Fund (`5396c0a`, 2026-10-18). +- `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-18 · kein Fund — Statusnamen decken `server_model_status_to_string` vollständig ab, `unknown` → `UNKNOWN` ‖ **Abgelaufen:** Blob am HEAD `12d852c`, seitdem 1 Commit an der Datei, darunter der Fix F175. Frühere Durchsichten: kein Fund (`878a722`, 2026-10-17); Fund F68 (`ohne Hash`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` · `2c10c02` · 2026-10-18 · kein Fund — Durchsicht „`value/ServerMetrics.java` ↔ `server_metrics_to_json` (`json_helpers.hpp`)“: alle 22 gelesenen Schlüssel werden auf der C++-Seite geschrieben (Abgleich per grep), Zeiten in Millisekunden (`to_ms` aus Mikrosekunden), Raten und `getDraftAcceptanceRate` gegen Division durch 0 geschützt, Argumentreihenfolge des `Timings`-Konstruktors stimmt. ‖ **Abgelaufen:** Blob am HEAD `eff2577`, seitdem 1 Commit an der Datei, darunter der Fix F57. Frühere Durchsichten: kein Fund (`2c10c02`, 2026-10-17). +- `llama/src/main/java/net/ladenthin/llama/value/ToolCall.java` · `acce5a3` · 2026-10-17 · kein Fund — unveränderlicher Träger (id, name, Argumente als JSON-Text); `equals`/`hashCode` über alle drei Felder. ‖ **Abgelaufen:** Blob am HEAD `81208e8`, seitdem 1 Commit an der Datei. + +## Musterdurchsichten und Schnittstellen ohne Blob-Bindung + +Diese Durchsichten galten einem Muster oder einer Schnittstelle über mehrere Dateien und belegen keine einzelne +Datei; ein angegebener Hash ist der Stand der Hauptdatei zur Zeit der Durchsicht und wird nicht nachgeführt. + - Musterdurchsicht nach F70 (Kontextlänge aus `n_ctx` abgeleitet) über `llama/src/main/java`, `llama-langchain4j`, `llama-kotlin`, `llama-ffm` · – · 2026-10-18 · kein weiterer Fund (Verdacht notiert) — Einzige Ableitung ist `OpenAiServerCli.toServerConfig`; `OpenAiCompatServer` meldet in `/api/show` und `/props` nur `maxInputTokens + maxOutputTokens`; `RouterModel.contextLength` und `ModelMeta` geben upstreams Werte unverändert weiter. Ohne `-c` werden feste Vorgaben beworben (offener Verdacht). -- `llama/src/main/java/net/ladenthin/llama/TextToSpeech.java` ↔ `llama/src/main/cpp/jllama_api.h` (`jllama_tts_*`) / `tts_engine.cpp` (Schnittstelle Lebenszyklus und Nebenläufigkeit) · `7b6b88d` · 2026-10-18 · kein Fund — Der C-Vertrag erlaubt „one synthesis at a time per handle“, Java nimmt für `synthesize` nur die Lese-Sperre; das ist sicher, weil `tts_engine.cpp` jede Synthese unter dem `synthesize_mutex` der Engine ausführt. `close()` wartet über die Schreib-Sperre auf laufende Synthesen, gibt einmal frei und setzt den Handle auf 0, danach meldet `synthesize` `IllegalStateException`. `seed` (Java `int`) wird zu `unsigned int`; `gpuLayers` 0 im Kurzkonstruktor heißt CPU, −1 (auto) ist explizit übergebbar. -- `llama/src/main/java/net/ladenthin/llama/parameters/TrainingParameters.java` + `llama/src/main/java/net/ladenthin/llama/LlamaTrainer.java` ↔ `train_engine.cpp`/`train_params.hpp` (Schnittstelle Trainingskonfiguration über JNI) · `12ecdbf` · 2026-10-18 · Fund F71 — Schlüssel und Vorgaben stimmen (`epochs` 2, `learning_rate` 1e-5, `val_split` 0.05, `n_gpu_layers` −1, `n_batch`/`n_ubatch` 0 = Vorgabe); Java prüft, was die Engine nicht prüft (genau ein Korpus, `epochs` ≥ 1, `val_split` in [0, 1)). Die Übergabe per `GetStringUTFChars` zerbrach Zeichen außerhalb der BMP (F71). - Musterdurchsicht nach F71 (Modified UTF-8 an der JNI-Grenze) über `llama/src/main/cpp/*` als Vertragsreferenz · – · 2026-10-18 · kein Fund im Ziel (Verdacht außerhalb notiert) — Eingehend nutzt nur `train_engine.cpp` `GetStringUTFChars` (behoben über F71 auf der Java-Seite); `jllama.cpp` und `native_server.cpp` gehen ausdrücklich über `String.getBytes("UTF-8")`. Ausgehend gehen Nutzdaten über `utf8_to_jstring_impl`; nur `jllama_build_info` (ASCII), Gerätenamen in `rpc_bridge.cpp` und die Fehlermeldungen nutzen `NewStringUTF`/`ThrowNew` (offener Verdacht, C++). -- `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmTaskStream.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmLlamaModel.java` (`receive`, `releaseTask`, `close`) ↔ `jllama_api.h` (Schnittstelle Streaming-Aufgabe über FFM) · `11dd00a`/`41c9e8b` · 2026-10-18 · kein Fund (nur gelesen, kein JDK 22 hier) — Nach `stop` gibt `next()` die Aufgabe genau einmal frei (`released`), ein frühes `close()` ebenso; wirft der Empfang (Fehlerergebnis), hat die Engine den Reader schon entfernt, ein späteres `close()` trifft eine unbekannte Id, die laut Header ignoriert wird. Nach `model.close()` ruft `releaseTask` nichts mehr auf. Das Prüfen-dann-Aufrufen von `handle()` gegen ein paralleles `close()` ist in der Klassen-Javadoc als Vertrag festgehalten (siehe F59); deren Vergleich „as with `LlamaModel`“ stimmt nur halb, denn das JNI-`LlamaModel` liest den Handle unter `g_ctx_mutex` und übersteht ein paralleles `close()`. -- `llama/src/main/java/net/ladenthin/llama/parameters/ParameterJsonSerializer.java` (`buildMessages`) ↔ upstream `common_chat_msgs_parse_oaicompat` (`common/chat.cpp:342`, b11639; Schnittstelle Nachrichten-Array ↔ Pflichtfelder) · `408b4ae` · 2026-10-18 · kein Fund — Jede Nachricht trägt `role` und `content` (String oder Teile-Array), Werkzeugaufrufe `id`, `type: "function"`, `function.name` und `function.arguments` als String, Werkzeugantworten `tool_call_id`: keine der `throw std::invalid_argument`-Stellen (fehlende Rolle, fehlender Typ/Name, weder `content` noch `tool_calls`, falscher `content`-Typ) ist erreichbar. Teile vom Typ `image_url`/`input_audio` wandelt upstreams Serververarbeitung vorher in Medienmarker; `reasoning_content` früherer Antworten und `name` werden nicht mitgeschickt (beide optional). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` + `AnthropicApiSupport.java` + `AnthropicStreamTranslator.java` + `ResponsesApiSupport.java` (Musterdurchsicht leere Werkzeug-Argumente) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — Rückweg OpenAI → Ollama/Anthropic wandelt `""` und Unparsbares in ein leeres Objekt (F37/F38); Hinweg Responses/Anthropic → OpenAI kann `""` weitergeben (offener Verdacht „Leere Werkzeug-Argumente“). -- `llama/src/main/java/net/ladenthin/llama/value/StopReason.java` + `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `LlamaModelBackend.completionFinishReason` ↔ upstream `stop_type_to_str` / `finish_reason` (`server-task.cpp:243`, `:375`, b11639) · `cb51433`/`707296e` · 2026-10-18 · kein Fund (Randnotiz) — Die vier Werte `eos`/`word`/`limit`/`none` entsprechen genau upstreams `stop_type`; `stop_type` wird nur bei `stop: true` ausgewertet. Upstream setzt `finish_reason` standardmäßig auf `"length"` und nur bei `word`/`eos` auf `"stop"`; `completionFinishReason` mappt dagegen nur `MAX_TOKENS` auf `"length"` und alles andere auf `"stop"` – unterschiedlich nur für ein Endergebnis mit `stop_type` `none`, das im normalen Ablauf kaum vorkommt. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`anthropicStopReason`, Antwortgerüst) + `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Status) ↔ upstream `to_json_anthropic`/`to_json_oaicompat_resp` (`server-task.cpp:723`, `:541`, b11639) · `b59c4ee`/`b755abd` · 2026-10-18 · kein Fund (Randnotiz) — `length` → `max_tokens`, `tool_calls` → `tool_use`, sonst `end_turn` wie upstream; `input_tokens` = Prompt minus Cache, `cache_read_input_tokens` wie upstream; Responses meldet wie upstream immer `completed`. Abweichung ohne Fund: upstream füllt `stop_sequence` mit dem ausgelösten Stoppwort, die Übersetzung schreibt immer `null`, weil die OpenAI-Antwort, aus der sie übersetzt, kein `stopping_word` trägt. - `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `ResponsesStreamTranslator.java` + `OpenAiCompatServer.java` (`withUsageChunk`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:576`, b11639; Schnittstelle Usage) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `input_tokens` = alle Prompt-Token inkl. Cache, `output_tokens`, `total_tokens` = Summe, `input_tokens_details.cached_tokens` wie upstream, blockierend und gestreamt; die Anthropic- und Responses-Streams fordern `include_usage` an. Der Ollama-Stream nicht (Nachtrag zum `done_reason`-Verdacht). -- `llama/src/main/java/net/ladenthin/llama/GgufInspector.java` ↔ `ggml/src/gguf.cpp` (Schnittstelle GGUF-Metadatenleser ↔ upstream-Leser, b11639) · `3b4fafc` · 2026-10-18 · Fund F72 — Typ-Ids, Kopf (Magie, Version mit Byte-Order-Erkennung, Tensor- und KV-Anzahl) und Grenzen vor Allokation wie zuvor geprüft; Abweichung: Arrays von Arrays, die upstream ablehnt, wurden rekursiv gelesen (F72). - Musterdurchsicht nach F72 (Rekursion auf Fremdeingabe ohne Tiefengrenze) über alle Ziele der drei Repos · – · 2026-10-18 · kein weiterer Fund — Heuristik: Methoden, die ihren eigenen Namen im Rumpf aufrufen; fast alles Delegation an gleichnamige Methoden anderer Objekte. Echte Rekursion auf Fremdeingabe nur in BAF `SeparatorFormat.splitRecursive` (Tiefe = Anzahl der Trennzeichen, fest) und eben `GgufInspector.readArray` (F72). Jackson begrenzt die Schachtelungstiefe beim Parsen selbst (`StreamReadConstraints`), die Fehler landen dort als `JsonProcessingException`. -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate`, `toOpenAiCompletionRequest`/`toInfillRequest`) ↔ upstream `post_infill`/`handle_completions_impl` (Schnittstelle Ollama-FIM → `/infill`, b11639) · `122da1d` · 2026-10-18 · Fund F74 — upstream verlangt `input_prefix` und `input_suffix` (beide immer gesetzt, leer bei fehlendem Feld), `prompt` optional, `input_extra` optional; Sampling-Optionen liest derselbe Schema-Parser wie bei `/completions`, der FIM-Pfad übernahm aber nur zwei davon (F74). Dazu Rerank (`OaiRerankSupport`) gegen `format_response_rerank` verglichen: Sortierung und Felder gleich, `top_n: 0` und `usage` weichen ab (offener Verdacht). -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `AnthropicApiSupport.java` + `OllamaApiSupport.java` (Musterdurchsicht nach F74: Optionsmengen der Übersetzer gegen upstreams `convert_responses_to_chatcmpl`/`convert_anthropic_to_oai`, b11639) · `446fa5f` · 2026-10-18 · Fund F75 — Ollama-Chat und -Generate übernehmen jetzt dieselben sechs Optionen; Anthropic übernimmt fünf, verwirft aber `thinking` (offener Verdacht); Responses verwarf `reasoning.effort` (F75). upstreams Responses-Umsetzer kopiert den ganzen Body, wir nur bekannte Felder; `top_k`/`seed`/`stop` kennt das Responses-Protokoll nicht, daher keine weitere Abweichung von Gewicht. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:560`–`620`, b11639; Schnittstelle Anthropic-Optionen) · `8a426d9` · 2026-10-18 · Fund F76 — `stop_sequences`, `temperature`/`top_p`/`top_k`, `max_tokens` wie upstream; `thinking` fehlte (F76); `metadata.user_id` setzt upstream nur intern (`__metadata_user_id`), ohne Wirkung auf die Generierung; `max_tokens`-Vorgabe und `chat_template_kwargs` als Verdacht. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (`propsJson`) + `OpenAiCompatServer.handleProps` ↔ upstream `get_res_props` (`server-context.cpp:5126`, b11639; Schnittstelle `/props`) · `1a04e66` · 2026-10-18 · kein Fund (Verdacht notiert) — `default_generation_settings.n_ctx` = Fenster je Slot (seit F70, wie upstreams `slot_n_ctx`), `model_alias`, `modalities.vision`/`audio`/`video` mit denselben Schlüsseln; `chat_template` leer und `params`, `build_info` usw. fehlen (bewusste Teilmenge). `total_slots` fest 1 statt `n_parallel` (offener Verdacht). -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` + `LlamaModelBackend.java` ↔ upstream `oaicompat_chat_params_parse` (Schnittstelle übersetzter Chat-Request → `InferenceParameters` → native Seite, b11639) · `9eee221` · 2026-10-18 · Fund F77 — Folgefrage zu F75/F76: der Chat-Pfad reicht den Body nicht durch, sondern baut `InferenceParameters` aus einer festen Liste (Sampling, Cache, `stop`, `stream_options`, `response_format`, Werkzeuge); die drei Reasoning-Steuerungen fehlten (F77). `/v1/completions` geht dagegen unverändert an `handleCompletionsOai`. - Musterdurchsicht nach F77 (Felder, die ein Übersetzer oder der Mapper still verwirft) über `OllamaApiSupport`, `AnthropicApiSupport`, `ResponsesApiSupport`, `OpenAiRequestMapper` · `ed9aff1` · 2026-10-18 · Fund F78 — Ollama `think` fehlte (F78); Antwortseite ohne `message.thinking` (Verdacht). Weiter offen: der gestreamte `/v1/completions`-Pfad baut `InferenceParameters` über den Mapper, der nicht gestreamte reicht den Body unverändert durch – dieselbe Anfrage verliert gestreamt Felder wie `suffix`, `min_p`, `grammar`, `logprobs` (nächstes Ziel). -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters`) + `LlamaModelBackend.completions`/`streamCompletions` ↔ upstream `oaicompat_completion_params_parse` (Schnittstelle gestreamt ↔ nicht gestreamt, b11639) · `d89f722` · 2026-10-18 · Fund F79 — `stop` als String wird wie upstream zur Liste; die Abweisungen von upstream fehlten gestreamt (F79); der übrige Feldverlust beim Streaming ist als Verdacht notiert (Umbau nötig). -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`) ↔ upstream `to_json_anthropic` (`server-task.cpp:723`, b11639; Schnittstelle Denkinhalt in der Antwort) · `5682409` · 2026-10-18 · Fund F80 — Reihenfolge der Blöcke wie upstream (thinking, text, tool_use); fehlender Denkblock (F80); Stream- und Responses-Weg als Verdacht. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` + `AnthropicApiSupport.java` (Ereignisse) ↔ upstream `to_json_anthropic_stream` (`server-task.cpp:1340`–`1420`, `:905`–`930`, b11639; Schnittstelle Denkinhalt im Stream) · `bd6ac4e`/`edd2c9a` · 2026-10-18 · Fund F81 — Text- und Werkzeugblöcke wie zuvor; `reasoning_content` fehlte (F81). Indizes fortlaufend in der Reihenfolge des Öffnens (thinking, text, tool_use). -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toResponsesResponse`) ↔ upstream `to_json_oaicompat_resp` (`server-task.cpp:510`–`580`, b11639; Schnittstelle Denkinhalt in der Antwort) · `2a6035a` · 2026-10-18 · Fund F82 — Reihenfolge reasoning, message, function_call wie upstream; fehlendes reasoning-Item (F82). Abweichung ohne Wirkung: wir geben das `message`-Item auch bei leerem Text aus, upstream nur bei Inhalt. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` ↔ upstream `to_json_oaicompat_resp_stream` (`server-task.cpp:591`, `:1198`, b11639; Schnittstelle Denkinhalt im Stream) · `007523f` · 2026-10-18 · Fund F83 — Folgenummern lückenlos, `output_index` in der Reihenfolge des Öffnens; der Denkinhalt fehlte (F83). -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`appendOpenAiMessages`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:395`–`540`, b11639; Schnittstelle Verlauf) · `fc5f49b` · 2026-10-18 · Fund F84 — `text` (mit `\n` verbunden, F60), `image` (base64/url → `image_url`), `tool_use` (`input` → `arguments`), `tool_result` (eigene `role: tool`-Nachricht) wie upstream; `thinking` fehlte (F84). Abweichung ohne Wirkung: Werkzeug-Runde ohne Text trägt `content: null`, upstream `""` (beides vom Parser angenommen). -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:40`–`260`, b11639; Schnittstelle Responses-Eingabe-Items) · `a2333b5` · 2026-10-18 · Fund F85 — `message` (String/Teile, Bilder), `function_call`, `function_call_output` (String) wie upstream; `reasoning` wurde zur Benutzernachricht (F85); Array-`output` und das Zusammenführen paralleler Aufrufe als Verdacht. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call_output`) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:189`–`220`, b11639) · `210b00c` · 2026-10-18 · Fund F86 — String-`output` wie upstream; Array-`output` kam leer an (F86). -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `function_call`) ↔ upstream `convert_responses_to_chatcmpl` (`merge_prev`, `server-chat.cpp:44`, `:177`, b11639) · `32fe049` · 2026-10-18 · Fund F87 — Mit F85–F87 folgen alle Item-Typen der Responses-Eingabe upstreams Regeln; Abweichungen ohne Wirkung: unbekannte Typen und Teile werden übersprungen statt mit 400 abgewiesen. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`tool_result`) ↔ upstream `convert_anthropic_to_oai` (`server-chat.cpp:453`–`512`, b11639; Durchsicht der Typ-Weichen nach F85) · `565f7f8` · 2026-10-18 · Fund F88 — Blocktypen `text`, `thinking`, `image`, `tool_use`, `tool_result` wie upstream (unbekannte überspringen beide); Bilder im `tool_result` fehlten (F88). Kleine Abweichung ohne Wirkung: Textteile eines `tool_result` werden bei uns ohne Trenner verbunden, wie bei upstream (`result_text += text`). -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (Inhaltsteile) + `OpenAiCompatServer.handleResponses` ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:71`–`146`, b11639) · `1fd936a`/`bc376ce` · 2026-10-18 · Fund F89 — `input_text`/`input_image`/`output_text` wie upstream; `input_file` wurde still verworfen (F89); `refusal` als Verdacht (upstream scheitert dort selbst). Die übrigen Übersetzer-Routen (Anthropic, Ollama) rufen den Übersetzer ebenfalls vor der Fehlerbehandlung, werfen dort aber nichts. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`systemText`) ↔ upstream `server_chat_convert_anthropic_to_oai` (System-Prompt, `server-chat.cpp:310`–`368`, b11639) · `9b64484` · 2026-10-18 · Fund F90 — String und Text-Blöcke ohne Trenner verbunden wie upstream; die Normalisierung des Billing-Stempels fehlte (F90). Upstream verlangt `messages` (sonst 400), wir liefern ohne `messages` eine leere Liste – ohne Wirkung, der Chat-Mapper weist leere `messages` ab. -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (ganzer Umsetzer) ↔ upstream `server_chat_convert_anthropic_to_oai` (`server-chat.cpp:520`–`620`, b11639; zweite Lesung von oben bis unten nach F90) · `9b64484` · 2026-10-18 · kein Fund — Werkzeuge (`name`, `description`, `input_schema` → `parameters`), `tool_choice` (`auto`; `any`/`tool` → `required`; `none` ohne Abbildung wie upstream), `stop_sequences`, `thinking` (F76), `max_tokens` (Vorgabe 4096 fehlt, Verdacht) stimmen. **Bewusst anders und richtig:** upstream hängt die `role: tool`-Nachrichten einer Benutzernachricht **hinter** deren Text, Java davor, also direkt hinter die `tool_calls` des Assistenten; Claude Code schickt `tool_result` + ``-Text in einer Nachricht, und der OpenAI-Vertrag verlangt die Werkzeugergebnisse unmittelbar nach dem Aufruf. Java nicht ändern. -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (ganzer Umsetzer) ↔ upstream `convert_responses_to_chatcmpl` (`server-chat.cpp:1`–`310`, b11639; Lesung von oben bis unten) · `945d6fd` · 2026-10-18 · Fund F91 — `instructions` → System-Nachricht, `input` als String → Benutzernachricht, Item-Typen und Inhaltsteile wie in F85–F89; `merge_prev` fehlte noch für Assistenten-Nachrichten (F91). `max_output_tokens` → `max_tokens`, `reasoning.effort` (F75), Werkzeuge ohne `type: function` übersprungen (upstream ebenso). -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`versionJson`, `tagsJson`, `showJson`, `toOllamaChatResponse`, `toOllamaDoneLine`; Schnittstelle Ollama-Discovery ↔ Clients) · `ed9aff1` · 2026-10-18 · kein Fund — `/api/show` nennt `general.architecture` und den passenden Schlüssel `.context_length`, den Ollama-Clients (Copilot) für das Kontextfenster lesen; `capabilities` mit `tools`/`insert`/`vision`. Fähigkeit `thinking` wird nicht beworben; Clients, die `think` nur bei dieser Fähigkeit schicken, nutzen F78 daher nicht – kein Fehlverhalten. `done_reason` und fehlende Zähler im Stream-Ende sind bereits offene Verdachte. Dazu die offenen BAF-Verdachte durchgesehen: alle Entscheidung des Nutzers, außerhalb des Ziels oder mit OpenCL-/CI-Bedarf. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` ↔ upstream `oaicompat_chat_params_parse` (`server-common.cpp:1270`–`1470`, b11639; Abweisungen und Sonderfelder) · – · 2026-10-18 · kein Fund (Verdacht ergänzt) — `tool_choice` als Objekt fällt bei upstream ebenso auf `auto` zurück (`json_value(…, std::string)`); `stop` als String wird zur Liste wie bei uns; die Abweisungen (Grammatik + Werkzeuge, `response_format`-Typ, `messages` fehlt/leer, `logprobs` + Werkzeuge im Stream) treffen bei uns über die native Seite ein. `n` fehlt (Verdacht „Feldverlust“ ergänzt). -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`applySampling`, `applyResponseFormat`, `toToolChoiceString`, `toFinishReason`) ↔ `ChatRequestParameters` von langchain4j (Schnittstelle Standardparameter, Musterdurchsicht nach F77) · `550a6c2` · 2026-10-18 · kein Fund — alle Standardparameter (`temperature`, `topP`, `topK`, `maxOutputTokens` → `n_predict`, Frequenz-/Präsenzstrafe, `stopSequences`), `toolChoice` (`AUTO`/`REQUIRED`/`NONE`), JSON-`responseFormat` mit und ohne Schema; blockierend und gestreamt über dieselbe Abbildung (`toJllamaRequest`). `finishReason` deckt `stop`/`length`/`tool_calls`/`content_filter` ab, Unbekanntes → `OTHER`. -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `195bf32` · 2026-10-18 · Fund F92 — blockierender Pfad ohne `thinking()`, jetzt aus `reasoning_content` gesetzt -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `832ca3e` · 2026-10-18 · Fund F93 — gestreamte Parameter ohne `include_usage`, Token-Nutzung fehlte im Stream -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` · `e054789` · 2026-10-18 · Fund F94 — Textteile ohne Trenner verbunden; Assistent mit `thinking()` im Verlauf wird ohne Denkinhalt gesendet (wie LangChain4js OpenAI-Adapter ohne `sendThinking`, kein Fund) -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` ↔ `LlamaModel.handleRerank` · `37473c2` · 2026-10-18 · kein Fund — Schnittstelle (2.3): `rerank_results_to_json` liefert je Ergebnis `index` und lehnt fehlende/ungültige ab, die Rückfall-Zuordnung nach Arrayposition greift also nie; Scores werden nach `index` einsortiert, doppelte Dokumente (anders als in `LlamaModel.rerank(String, String...)`, das nach Text in eine Map schreibt) gehen hier nicht verloren. `JllamaEmbeddingModel` (`9a4b965`): ein `embed` je Segment, Reihenfolge erhalten. - Musterdurchsicht `awaitTermination`/`shutdownNow` nach F66 (BAF `KeyProducerJavaSocket.interrupt`, `Finder.shutdownAndAwaitTermination`; java-llama.cpp `OpenAiCompatServer.close`) · – · 2026-10-18 · kein Fund — Socket-Producer schließt die Verbindungen nach `shutdownNow`, das blockierte `read` endet dadurch; `Finder` reicht nach Ablauf der Wartezeit `interrupt()` an alle Producer weiter, bevor der Consumer gestoppt wird (der Consumer-Fall ist F66); `OpenAiCompatServer.close` gibt das native Modell nicht frei, laufende Streams enden über den Schreibfehler des geschlossenen Sockets und den Abbruch in `streamChatCompletion`. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `f10f4c6` · 2026-10-18 · Fund F95 — llama.cpp-Sampling-Felder (`min_p`, `repeat_penalty`, `grammar`, `n_predict` …) verworfen; Feldmenge gegen `server-schema.cpp` gehalten -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `7376c4a` · 2026-10-18 · Fund F96 — `max_tokens`-Vorgabe 4096 und `chat_template_kwargs` wie upstreams `convert_anthropic_to_oai` -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `6f86c26` · 2026-10-18 · Fund F97 — `message.thinking` ⇄ `reasoning_content` in Antwort, Stream und Verlauf -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `77913cb` · 2026-10-18 · Fund F98 — `streamOllamaChat` ohne Usage-Chunk -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `7e6c0b0` · 2026-10-18 · Fund F98, F97 — Tokenzahlen in der Abschlusszeile, `usageOf` -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `2358a6a` · 2026-10-18 · Fund F99 — blockierende `output`-Liste gegen upstreams `to_json_oaicompat_resp` und den eigenen Stream gehalten (Reasoning-, Message-, `function_call`-Items, Usage gleich) -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `69becc1` · 2026-10-18 · Fund F102 — Setter-Prüfungen gegen upstreams Grenzen gehalten: `id_slot` -1 ist upstreams Standard; `n_cache_reuse` (0..), `repeat_last_n`/`dry_penalty_last_n` (0..) decken sich mit dem Schema -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `530fb40` · 2026-10-18 · Fund F103 — Obergrenze 0 übernommen; `seed` über `asInt` (uint32 upstream) nur für Werte über 2^31 abweichend, nicht weiter verfolgt; `top_k`/`temperature`/`top_p` ohne Java-seitige Prüfung, upstreams Grenzen greifen nativ -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a944f9a` · 2026-10-18 · Fund F104 — Optionsmenge der drei Ollama-Routen gegen llama.cpps Namen und Sonderwerte gehalten -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `886b3cb` · 2026-10-18 · Fund F105 — `previous_response_id` abgewiesen; Feldmenge gegen upstreams Kopie des ganzen Körpers gehalten (Verdacht notiert) -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `1724b42` · 2026-10-18 · Fund F106 — Felder, die der Chat-Mapper liest, werden durchgereicht -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` · `6ae6dd6` · 2026-10-18 · Fund F107 — Anthropic-Feldmenge und `tool_choice` gegen upstreams `convert_anthropic_to_oai` gehalten (Feldmenge gleich seit F96) -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `e44a70b` · 2026-10-18 · Fund F108 — `text.format` → `response_format` -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` · `bfa8160` · 2026-10-18 · Fund F109, F103, F102 — benanntes Werkzeug in `tool_choice` → `required` -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` · `12c6846` · 2026-10-18 · Fund F109, F108, F106 — benanntes Werkzeug in `tool_choice` → `required` - Nachprüfung der Kommentare neben den Fixes dieser Sitzung (Erkenntnis aus F110): streambuffer `StreamBuffer.java` (`trim`-Erwähnungen außer F110 zutreffend), java-llama.cpp `OpenAiRequestMapper`/`ResponsesApiSupport` (zwei veraltete Kommentare zu `tool_choice`, als Nachtrag zu F109 notiert) · – · 2026-10-18 · kein neuer Fund -- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` + `value/StopReason.java` ↔ upstream `stop_type_to_str` (Schnittstelle Stopptypen, b11639) · `707296e` · 2026-10-18 · kein Fund — upstream kennt `eos`/`word`/`limit` (dazu `none` für Zwischenschritte), `StopReason` bildet alle vier ab; der gestreamte Completion-Weg macht `limit` zu `length`, sonst `stop`, wie upstreams `finish_reason`. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Authentifizierung) · `77913cb` · 2026-10-18 · kein Fund — `x-api-key` (Anthropic-Clients) und `Authorization: Bearer` (Schema ohne Groß-/Kleinschreibung) werden mit `MessageDigest.isEqual` verglichen; die CORS-Vorabfrage erlaubt beide Kopfzeilen. -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JsonSchemaElementSerializer.java` (Typ-Weiche gegen langchain4j-core 1.22.0) · `6399310` · 2026-10-18 · kein Fund — alle elf Elementtypen haben einen Zweig, ein unbekannter wirft (kein stilles Verwerfen); `$defs`/`#/$defs/` wie langchain4js interne Konvention, `required`/`additionalProperties` nur wenn gesetzt. -- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream -- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Fund F111 — `rerank` über `OaiRerankSupport.toResponse` -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `e38eb7e` · 2026-10-18 · Fund F112 — Routenliste gegen upstreams `server.cpp` gehalten; Kontexte jetzt exakt -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` · `e5eb40e` · 2026-10-18 · Fund F113 — Modellobjekt gegen OpenAI-Pflichtfelder und upstreams `get_res_model_info` gehalten -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `906cf46` · 2026-10-18 · Fund F114 — `/api/version` gegen Copilots Mindestversion gehalten; `/api/tags`- und `/api/show`-Felder wie bisher -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`tagsJson`, `showJson`) + `OpenAiCompatServer.handleOllamaShow` ↔ VS Code Copilots `ollamaProvider.ts` (Schnittstelle Ollama-Anbieter) · `906cf46` · 2026-10-18 · kein Fund nach F114 — Copilot liest `models[].model` aus `/api/tags`, schickt `{"model": id}` an `/api/show` (der Server bedient sein eines Modell, Körper egal), nimmt `capabilities` (`tools`, `vision`) und `model_info[".context_length"]` (wir: `llama` + `llama.context_length`), fällt bei fehlendem `general.basename` auf die Modell-ID zurück und chattet dann über `/v1/chat/completions`. -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `a058a51` · 2026-10-18 · Fund F115 — `/api/show` gegen Continues `Ollama.ts` gehalten (`parameters`/`num_ctx`; FIM über `template` als Verdacht notiert) -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `73f11e1` · 2026-10-18 · Fund F116 — Chat-Antwort und Verlauf gegen Continues `Ollama.ts` gehalten (`thinking`, `tool_calls` mit Objekt-Argumenten, `tool_name`) -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Ereignisfolge für Claude Code) · `bd6ac4e` · 2026-10-18 · kein Fund — `message_start`, dann Blöcke nacheinander mit steigendem Index (Denkblock mit `signature_delta` vor dem Schließen, Text, danach je Werkzeug `tool_use` + `input_json_delta` + Stopp), `message_delta` mit Usage und `stop_reason`, `message_stop`. Ein Denk-Delta nach begonnenem Text würde einen zweiten Block neben dem offenen Text öffnen; llama.cpps Diffs lassen `reasoning_content` aber vor `content` wachsen, praktisch unerreichbar. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`streamResponses`) · `65df73d` · 2026-10-18 · Fund F117 — Fehlerzweige des Responses-Streams gegen Codex (`responses.rs`, `responses_error.rs`) gehalten; jetzt `response.failed` vor dem `error`-Ereignis -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` · `6d97f3a` · 2026-10-18 · Fund F117 — Ereignisfolge gegen Codex gehalten: `type` + `sequence_number` in den Daten, `response.completed` mit `id` und Usage (`input_tokens`/`output_tokens`/`total_tokens`), `output_item.done`-Items parsebar; neu `failed()` -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Anthropic-Fehlerpfade) · `a6ed187` · 2026-10-18 · Fund F118 — Fehlerkörper aller vier Oberflächen verglichen: Ollama `{"error":…}` wie Ollama selbst, Responses seit F117 mit `response.failed`, Anthropic jetzt `api_error` für 500 -- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`streamChatCompletion` ↔ `jllama_task_release`/`stream_next`) · `96eafab` · 2026-10-18 · kein Fund — Abbruch durch werfenden Sink oder nativen Fehler: `stream_next` gibt den Reader schon beim Fehler frei, `erase_reader` ist für eine unbekannte Id ein No-op, das `cancelCompletion` im `finally` verdeckt die ursprüngliche Ausnahme also nie -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream` ↔ Heartbeat) · `a6ed187` · 2026-10-18 · kein Fund — Ereignisse und `: ping` unter einem Lock je Ereignis geschrieben, ein Heartbeat nach `close()` wird verschluckt; NDJSON-Routen (Ollama) haben bewusst keinen SSE-Heartbeat -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaStreamingChatModel.java` · `8cecaa4` · 2026-10-18 · kein Fund — erneut gründlich: `doChat` reicht jede Exception aus dem Stream an `onError`, `complete()` erst danach; Fehler im Assembler → F128. -- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaSuspend.kt` (↔ `LlamaModel.complete(params, token)`) · `0ff46cf` · 2026-10-18 · kein Fund — Abbruch der Koroutine setzt das Token, `coroutineScope` wartet auf das Kind, `complete` gibt die Aufgabe an der nächsten Token-Grenze frei; vorab abgebrochenes Token gibt sofort frei; Javadoc „chat ohne Token-Überladung“ stimmt -- `llama-kotlin/src/main/kotlin/net/ladenthin/llama/kotlin/LlamaFlows.kt` · `45f5f14` · 2026-10-18 · kein Fund — `use` schließt die Quelle bei Ende, Fehler und Abbruch (`take(n)`); Blockieren des Sammel-Dispatchers ist dokumentiert -- `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (`complete(params, token)` ↔ `stream_next`) · `96eafab` · 2026-10-18 · kein Fund — bei nativem Fehler gibt `stream_next` den Reader selbst frei, ein fehlendes `cancelCompletion` im Fehlerpfad leckt also nichts -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`toCompletionParameters` ↔ upstream `oaicompat_completion_params_parse`) · `bfa8160` · 2026-10-18 · Verdacht notiert — Ablehnungen (`echo`, `best_of`, `suffix`) wie upstream; `applyCommonFields` für Chat und Completion gemeinsam; nur ein String-`prompt` erlaubt -- `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` (`streamCompletions` ↔ upstream `to_json_oaicompat`) · `1e88958` · 2026-10-18 · Verdacht notiert — letzter Chunk ohne `usage`, sonst wie upstream (`finish_reason` `length`/`stop`, `logprobs: null`); Sink-Fehler bricht ab und schließt die Aufgabe über `LlamaIterable.close()` -- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (↔ upstream `post_router_models_load`/`_unload`/`get_router_models`, `server-models.cpp` b11639) · `5396c0a` · 2026-10-18 · kein Fund — Körper `{"model":…}` wie upstream; `load` setzt `LOADING` synchron vor der Antwort, ein früherer Fehlschlag (`UNLOADED` + `exit_code`) wird beim Warten also nicht fälschlich als Fehlschlag gelesen; `SLEEPING` gilt wie upstreams `is_ready` als geladen; Alias-Verdacht steht schon -- `llama/src/main/java/net/ladenthin/llama/json/RouterModelsResponseParser.java` · `fd64a69` · 2026-10-18 · kein Fund — `status.value`/`failed`/`exit_code`, `architecture.*_modalities`, `context_length` wie upstream emittiert -- `llama/src/main/java/net/ladenthin/llama/value/RouterModel.java` · `878a722` · 2026-10-18 · kein Fund — Statusnamen decken `server_model_status_to_string` vollständig ab, `unknown` → `UNKNOWN` -- `llama/src/main/java/net/ladenthin/llama/value/RpcEndpoint.java` (Probe mit 21 Schreibweisen) · `a6b12c5` · 2026-10-18 · kein Fund — fehlender/leerer/0/65536/negativer/`+80`-Port, leerer Host, IPv6 mit und ohne Klammern und `host:80:90` werden mit klarer Meldung abgelehnt; `080` wird 80 (wie upstreams `std::stoi`), Leerraum um das Ganze getrimmt, nicht zwischen Doppelpunkt und Port; `parseList` überspringt leere Einträge und lehnt eine leere Liste ab. `GgufInspector` erneut gegen feindliche Längen gehalten: Obergrenzen für Schlüsselzahl, Stringlänge und Arraylänge, Strings werden nur aus vorhandenen Bytes gelesen (F72 deckt die Verschachtelung) -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (Probe mit 14 Feldvarianten) · `20548dd` · 2026-10-18 · Fund F123 — `stop` als String und Array, `max_tokens` als Ganz- und Gleitzahl (als String verworfen), `max_completion_tokens` vorrangig, `seed`/`top_k` als Gleitzahl abgeschnitten, Strafen übernommen; `logprobs` fehlte (F123); `n` und `user` bleiben unbeachtet (`n` ist offener F95-Rest) -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 7 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — `format: "json"` → `json_object`, `format` als Schema → `json_schema`, `options.stop`/`seed`/`top_k` übernommen; `think: true` bewusst ohne Wirkung (Denken ist Standard), `false` und Stufen schon behandelt; `keep_alive` und `num_ctx` gelten pro Server, nicht pro Anfrage; `options.stop` als String ist in Ollamas API kein gültiger Wert -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — `stop_sequences`, `top_k`, `temperature: 0`, `thinking.budget_tokens` (→ `reasoning_budget_tokens`) und `max_tokens` (→ `n_predict`) kommen in den `InferenceParameters` an; `metadata` ohne Gegenstück, wie upstream -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest` → `OpenAiRequestMapper`, Probe-Kette mit 10 Varianten) · `12c6846` · 2026-10-18 · kein Fund — `instructions` → System-Nachricht, `max_output_tokens` → `n_predict`, Sampling, `reasoning.effort` → `chat_template_kwargs`, flache Funktionswerkzeuge → `function`-Form mit `description`, benanntes `tool_choice` → `required` (F109), `text.format` → `response_format` (F108), `input_text`/`input_image` → `text`/`image_url`; Nicht-Funktions-Werkzeuge werden wie upstream übersprungen; `strict` fällt weg, upstream setzt es, liest es in `common/chat.cpp` aber nirgends -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` + `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (Rückrichtung: OpenAI-Antwort → Responses/Anthropic/Ollama, Probe) · `12c6846`/`6ae6dd6`/`73f11e1` · 2026-10-18 · kein Fund (Anmerkung) — `finish_reason: length` wird Anthropic `max_tokens` und Ollama `done_reason: length`; unlesbare Werkzeug-Argumente werden Anthropic/Ollama zu `{}`, Responses reicht den Text wie geliefert durch (`arguments` ist dort ein String); `content: null` ergibt leeren Text. Responses meldet bei `length` `status: completed` statt OpenAIs `incomplete` + `incomplete_details.reason: max_output_tokens` – genau wie upstream (`server-task.cpp`, jeder Responses-Status ist `completed`), daher kein Fund -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (Zugangsprüfung je Route) · `a6ed187` · 2026-10-18 · kein Fund — Schlüssel in konstanter Zeit verglichen (`MessageDigest.isEqual`, `Bearer` und `x-api-key`); `/v1/models`, `/metrics`, `/slots` und alle POST-Routen über `authorized`; `/health`, `/props` und die Ollama-Entdeckungsrouten bewusst offen und kommentiert (nur öffentliche Modelldaten); Größenprüfung erst nach der Zugangsprüfung, ein Unbefugter bekommt also kein 413-Orakel -- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` · `58a9bab` · 2026-10-18 · kein Fund — nach `index` geordnet (`TreeMap`), `id`/`name` ersetzt, `arguments` angehängt; passt zu upstreams Diff-Form (Name und Id einmal, danach nur Argumentstücke); ein Chunk ohne `index` zählt als 0 -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`/api/generate` ↔ Continues `_getGenerateOptions`, `raw`/`system`) · `73f11e1` · 2026-10-18 · Verdacht notiert — ohne `raw` wendet Ollama die Vorlage an, wir nie; `system` unbeachtet -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images` einer Chat-Nachricht, Probe mit 5 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — rohes Base64 wird zu `image_url` mit `data:`-URL, Medientyp an den ersten Bytes erkannt (PNG/JPEG/GIF), leerer Text fällt weg, leere `images` lassen den String-Inhalt unverändert, System-Nachricht unberührt -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`images`, weitere 4 Varianten) · `73f11e1` · 2026-10-18 · kein Fund — WEBP erkannt, eine schon fertige `data:`-URL bleibt unverändert, unbekannte Bytes fallen wie dokumentiert auf `image/png` zurück; eine HTTP-URL wird als Base64 behandelt, Ollamas API erlaubt in `images` aber nur Base64 -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (Inhaltsblöcke, Probe mit 6 Varianten) · `6ae6dd6` · 2026-10-18 · kein Fund — Bild als Base64 (`media_type` übernommen) und als URL, `tool_result` mit Text- und Bildteil → `role: tool` mit Teilen, `thinking` → `reasoning_content`; System-Blöcke werden ohne Trenner verbunden und `document` (PDF) fällt weg – beides genau wie upstream (`server_chat_convert_anthropic_to_oai`, `server-chat.cpp:348`, b11639: `system_content += system_text`, kein `document`-Zweig) -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`input`-Items, Probe mit 4 Folgen) · `12c6846` · 2026-10-18 · kein Fund — aufeinanderfolgende `function_call`-Items werden zu einer Assistentennachricht mit mehreren `tool_calls` zusammengefasst, eine vorangehende `output_text`-Nachricht wird ihr Text; `function_call_output` als String und als Teilliste → `role: tool`; `developer` bleibt erhalten und wird von upstreams `common/chat.cpp:1336` (`map_developer_role_to_system`, außer GPT-OSS) zu `system` -- `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk) · `bd6ac4e` · 2026-10-18 · kein Fund — Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) -- `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (Probe: Text, Werkzeugaufruf, `tool_calls`, Usage) · `6d97f3a` · 2026-10-18 · kein Fund — Nachrichten-Item wird vor dem Werkzeugaufruf vollständig geschlossen (`output_text.done`, `content_part.done`, `output_item.done`), dann `function_call`-Item mit `call_id`, Name und vollständigen Argumenten (`added` → `function_call_arguments.done` → `done`), dann `response.completed` mit beiden Items; Folge­nummern lückenlos; Codex liest genau `output_item.done`, das kommt für beide Items -- `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe) · `73f11e1` · 2026-10-18 · kein Fund — Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toFinishReason`) + `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`finish_reason`/Usage im Stream) · `e054789`/`b224a28` · 2026-10-18 · kein Fund — `stop`/`length`/`tool_calls`/`content_filter` auf die passenden Enum-Werte, fehlend → `STOP`, Unbekanntes → `OTHER`; im Stream wird `finish_reason` nur übernommen, wenn es ein String ist (JSON-`null` der Zwischen-Chunks überschreibt nichts), Usage aus dem Chunk mit leeren `choices` -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaRequest`, `applySampling`, `applyResponseFormat`, `toToolChoiceString`) · `e054789` · 2026-10-18 · kein Fund — `temperature`/`topP`/`topK`/`maxOutputTokens` (→ `n_predict`)/`frequencyPenalty`/`presencePenalty`/`stopSequences` übernommen, leere Stoppliste ausgelassen; JSON mit Schema → `json_schema`, ohne → `json_object`, Text-Format unverändert; `ToolChoice` `REQUIRED`/`NONE`/`AUTO` als Zeichenkette; blockierender und gestreamter Pfad nutzen dieselbe Abbildung -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/LangChain4jMapping.java` (`toJllamaMessage`, Nachrichtentypen) · `e054789` · 2026-10-18 · kein Fund (Anmerkung) — System, User (ein Text, mehrere Texte mit `\n` seit F94, gemischte Teile als Teilliste), Assistent mit/ohne Werkzeugaufrufen (fehlende Id → `""`, fehlende Argumente → `{}`), Werkzeugergebnis mit `tool_call_id`; der Werkzeugname des Ergebnisses fällt weg (`ChatMessage.toolResult` kennt kein `name`), die Id genügt aber zur Zuordnung – anders als bei F116, wo Ollama keine Id schickt; `AiMessage.thinking()` wird nicht zurückgeschickt, wie langchain4js eigener OpenAI-Adapter es ohne `sendThinking` hält - Schnittstelle `llama-langchain4j` → `value.ChatMessage` → `ParameterJsonSerializer.buildMessages` (Werkzeugergebnis- und Aufrufkette) · – · 2026-10-18 · kein Fund — `tool_call_id` nur bei vorhandener Id gesetzt, `tool_calls` mit `id`/`type`/`function.name`/`function.arguments`; die Pflichtfeldprüfung gegen upstreams `common_chat_msgs_parse_oaicompat` ist schon dokumentiert (Zeile zu `buildMessages`), nichts Neues -- `llama/src/main/java/net/ladenthin/llama/json/ChatResponseParser.java` (Probe mit 6 Formen) · `31d8fb7` · 2026-10-18 · kein Fund — `content: null` mit `tool_calls` → leerer Text und ein Aufruf, `finish_reason` übernommen; leere/fehlende `choices`, `{}` und `""` ergeben eine leere Antwort ohne Ausnahme (F37 deckt `""`); fehlende `usage`/`timings` als Nullwerte; eine in ein Array gehüllte Antwort ergibt leer, die native Seite liefert für eine Chat-Antwort aber ein Objekt -- `llama/src/main/java/net/ladenthin/llama/json/CompletionResponseParser.java` (Probe mit 7 Formen) · `707296e` · 2026-10-18 · kein Fund — `stop_type` `limit`/`word`/`eos`/`none` → `MAX_TOKENS`/`STOP_STRING`/`EOS`/`NONE`; Wahrscheinlichkeiten vor (`logprob`, `top_logprobs`, `id`) und nach dem Sampling (`prob`, `top_probs`, ohne `id` → −1); dass ein `prob` in `TokenLogprob.getLogprob()` landet, ist dokumentiert (Wert unverändert gespeichert, [0,1] = Wahrscheinlichkeit, ≤ 0 = Log) -- `llama/src/main/java/net/ladenthin/llama/json/EmbeddingResponseParser.java` (Probe mit 9 Formen) · `6f2a458` · 2026-10-18 · kein Fund — liest nur die OAI-Form `{data:[{index,embedding}]}` und sortiert nach `index`; native Formen ergeben leer (werden über diesen Weg nie geliefert); ein Base64-Eintrag würde übersprungen und die Zuordnung verschieben (schon am 17. notiert), `toBatchRequestJson` fordert aber nie `encoding_format` an; ein verschachtelter Vektor ergäbe Unsinn, upstream lehnt `pooling none` am OAI-Endpunkt aber ab -- `llama/src/main/java/net/ladenthin/llama/json/LoraAdapterResponseParser.java` (↔ upstream `server_task_result_get_lora::to_json`, `parse_lora_request`, b11639) · `6a637ed` · 2026-10-18 · kein Fund — liest `id`/`path`/`scale`/`task_name`/`prompt_prefix` wie upstream sie schreibt (aLoRA-Felder bleiben ungenutzt), Nicht-Array → leer; `toRequestJson` schreibt `{id, scale}` wie `parse_lora_request` liest und lehnt `NaN`/unendlich vorher ab - `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/*.java` ↔ langchain4j-core 1.22.0 (Musterdurchsicht nach F128: Vorbedingungen der Fremdklassen, `javap -c`) · `f7f3f0f` · 2026-10-18 · kein Fund — `AiMessage(String)` `ensureNotNull` (`ChatResponse.getFirstContent()` liefert nie null, `Optional.map` macht aus einem null-Inhalt ""), `AiMessage(List)` `ensureNotEmpty` (nicht benutzt, der Builder prüft nichts), `PartialThinking` `ensureNotEmpty` (vorher auf leer geprüft), `CompleteToolCall`/`PartialToolCall` `ensureNotNegative(index)` (Index aus upstreams `tool_call_index`, nie negativ), `ChatResponse` `ensureNotNull(aiMessage)` (immer gesetzt), `Embedding(float[])` `ensureNotNull` (natives `embed` liefert ein Array), `ToolExecutionRequest`/`TokenUsage` ohne Prüfungen. `llama-kotlin` baut keine Fremdobjekte mit Vorbedingungen. -- `llama/src/main/java/net/ladenthin/llama/server/ToolCallDeltaAccumulator.java` ↔ `AnthropicStreamTranslator` / `ResponsesStreamTranslator` / `OllamaApiSupport` (Musterdurchsicht nach F128: Fragmente ohne Argumente oder vor dem Namen) · `58a9bab` · 2026-10-18 · kein Fund — der Akkumulator übernimmt `id`/`name` und hängt Argumente an, ohne etwas weiterzumelden; alle drei Übersetzer geben `tool_use`- bzw. `function_call`-Blöcke und Ollama-`tool_calls` erst am Ende aus dem fertigen Stand aus (`asText("")` für einen nie gelieferten Namen), es gibt keinen Teilaufruf-Event mit Vorbedingungen. Die frühere Prüfzeile zu `StreamingChunkAssembler` (17.10., „gleiche Semantik“) hatte die langchain4j-Vorbedingungen nicht betrachtet – siehe F128. - -- `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (`setPoolingType` gegen die anderen `UNSPECIFIED`-Setter) · `a84e7da` · 2026-10-18 · Fund F133 — `UNSPECIFIED` entfernte ein früher gesetztes `--pooling` nicht; sonst kein Setter, der bei einem Wächterwert nur das Setzen überspringt. -- `llama/src/main/java/net/ladenthin/llama/parameters/JsonParameters.java` + `InferenceParameters.java` (alle Wither mit Wächterwert: `null`, leere Sammlung, Logit-Bias-Überschreibung) · `0ebc857`/`386d73f` · 2026-10-18 · Fund F134 — die `null`-Wither löschten nicht; die leeren Sammlungen sind als No-op dokumentiert, die vier Logit-Bias-Setter als gegenseitig überschreibend – beides wie beschrieben. -- `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` + `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmNativeServer.java` + `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` (Musterdurchsicht nach F134: Javadoc „`null` clears“/„to clear“) · `7ac6e49` · 2026-10-18 · kein Fund — `ChatRequest.withToolChoice`/`withInferenceCustomizer` bauen das Objekt mit dem neuen Wert neu; `setWorkerCommand` übergibt bei FFM `""` und bei JNI `null` → `""`, beides löscht laut `jllama_api.h`; `JllamaNative.takeStrings` (damals unsicher, 17.; inzwischen F168, behoben), da `server_devices()` nie leer liefert. -- `llama/src/main/java/net/ladenthin/llama/Session.java` (zweite Durchsicht: `fork`, `checkpoint`/`rewind`, `close`, `buildParams`) · `61d2505` · 2026-10-18 · Fund F137 — negative Ziel-ID in `fork` erst nach den Modellaufrufen abgewiesen. `close()` löscht den Slot ohne Stream-Prüfung (dokumentiert: nur KV-Cache, nicht das Transkript); `slotId` wird nach dem Customizer erzwungen. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java` (zweite Durchsicht: Builder-Werte gegen ihre Verbraucher in `OpenAiCompatServer`) · `190803b` · 2026-10-18 · Fund F138 — Heartbeat 0 brach jeden Stream. `port` außerhalb des Bereichs scheitert schon in `start()` (InetSocketAddress), `maxRequestBodyBytes` ≤ 0 lehnt jeden Rumpf mit 413 ab – sichtbar und eindeutig, nicht geändert. -- `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (zweite Durchsicht nach F30: `start`, `close`, `Options.parse`, `awaitListening`/`stopAndJoin`; Musterdurchsicht nach F140 – wirft ein früher Schritt nach dem Belegen von `ACTIVE`?) · `0585de5` · 2026-10-18 · kein Fund — `ACTIVE` wird bei jedem gescheiterten Start im `finally` freigegeben, auch wenn die Geräteauflösung wirft; der Server-Thread ist Daemon, ein gescheiterter Start hält die JVM also nicht. Randfall notiert: endet der Server-Thread in `close()` nicht binnen `STOP_TIMEOUT_MILLIS` (30 s), gibt `close()` `ACTIVE` trotzdem frei, und ein neuer Start träfe auf ggmls noch belegten globalen Serverzustand – nicht reproduzierbar ohne native Bibliothek. -- `llama/src/main/java/net/ladenthin/llama/loader/OSInfo.java` (zweite Durchsicht: `getArchName`/`resolveArmArchType`/`translateOSNameToFolderName` gegen die Verzeichnisse aus `natives.csv`) · `138b590` · 2026-10-18 · kein Fund — `aarch64` steht nicht in `archMapping`, fällt aber unverändert durch `translateArchNameToFolderName` (Linux, Windows, Mac und Android aarch64 → `aarch64`), `amd64`/`x86_64` → `x86_64`, `s390x` gemappt; Android vor Musl vor Linux geprüft. Nicht verfolgt: eine JVM, die `os.arch`=`arm64` meldet, landete auf macOS über `resolveArmArchType` bei `arm` (OpenJDK-Builds melden `aarch64`); `isMusl` sucht "musl" in jedem gemappten Pfad, ein Verzeichnisname mit "musl" auf glibc würde falsch erkannt. -- `llama/src/main/java/net/ladenthin/llama/value/ChatTranscript.java` (zweite Durchsicht) · `cf96a79` · 2026-10-18 · kein Fund — `removePendingUserTurn` entfernt nur eine offene Nutzerzeile am Ende, `messagesWithPendingUserTurn` kopiert, `snapshot` lässt eine leere Systemnachricht weg, `turnsSnapshot`/`resetTurns` arbeiten auf Kopien (Pair ist unveränderlich). -- `llama/src/main/java/net/ladenthin/llama/value/ServerMetrics.java` ↔ `server_metrics_to_json` (`json_helpers.hpp`) · `2c10c02` · 2026-10-18 · kein Fund — alle 22 gelesenen Schlüssel werden auf der C++-Seite geschrieben (Abgleich per grep), Zeiten in Millisekunden (`to_ms` aus Mikrosekunden), Raten und `getDraftAcceptanceRate` gegen Division durch 0 geschützt, Argumentreihenfolge des `Timings`-Konstruktors stimmt. -- `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` ↔ `jllama_engine.cpp` (Modell-Meta-JSON) · `e9aa330` · 2026-10-18 · kein Fund — alle 21 gelesenen Schlüssel/Pfade werden nativ geschrieben (`modalities.{vision,audio,video}`, `special_tokens.{bos,eos,eot}`, `metadata`, Ein-/Ausgabemodalitäten), Vorgaben für fehlende Felder passen zum Vocab-only-Zweig (der nur `vocab_type`, `n_vocab`, `special_tokens` liefert). `vocab_type` wird nativ als int gecastet (common_json-Falle, CLAUDE.md). Außerhalb des Bereichs bemerkt (C++, nicht geändert): Metadaten-Schlüssel landen in einem festen 256-Byte-Puffer, längere würden abgeschnitten. -- `llama/src/main/java/net/ladenthin/llama/value/ContentPart.java` (zweite Durchsicht) · `e167fa7` · 2026-10-18 · kein Fund — MIME/Format aus der Endung ohne Locale-Falle (`Locale.ROOT`), Data-URL `data:;base64,`, Audioformat auf `wav`/`mp3` normalisiert; `@EqualsAndHashCode` (Lombok) trägt die Wertgleichheit, die `ChatRequest` verspricht. -- `llama/src/main/java/net/ladenthin/llama/value/ChatMessage.java` (zweite Durchsicht) · `453a951` · 2026-10-18 · kein Fund — Teile und Tool-Calls defensiv kopiert und unveränderlich zurückgegeben, `content` aus den Textteilen mit Zeilenumbruch verbunden, leere Teil-Liste abgewiesen. Ein `null`-Element in der Teil-Liste ergäbe eine NullPointerException beim Zusammenfügen (Aufruferfehler, nicht verfolgt). -- `llama/src/main/java/net/ladenthin/llama/loader/DiagnosticReport.java` ↔ `LlamaLoader.selectModules` · `a15ed7b` · 2026-10-19 · kein Fund — `namedBy` teilt und trimmt den Filter wie der Loader (Groß-/Kleinschreibung in beiden exakt), der Hinweis auf ungenutzte Opt-in-Module stimmt auch bei leerem Filter (der Loader behandelt ihn als unset). Commit-Kürzung nur für volle 40-Zeichen-Hashes, `-dirty` bleibt erhalten; jeder Prüfschritt fängt RuntimeException/LinkageError ab, ein Bericht scheitert also nie an einer fehlenden nativen Bibliothek. -- `llama/src/main/java/net/ladenthin/llama/server/ServerLauncher.java` (zweite Durchsicht) · `d8433a0` · 2026-10-19 · kein Fund — der Marker wird an jeder Stelle und mehrfach entfernt, sonst gehen die Argumente unverändert an `NativeServer.main`. -- `llama/src/main/java/net/ladenthin/llama/json/TimingsLogger.java` (zweite Durchsicht) · `8327d64` · 2026-10-19 · kein Fund — `Locale.ROOT`-Formatierung, leere Timings (0/0) und `null` werden übersprungen, Draft-Teil nur bei spekulativer Dekodierung. -- `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` (zweite Durchsicht) · `2448442` · 2026-10-19 · kein Fund — Array/Objekt/fehlend wie dokumentiert, unlesbarer oder leerer Umschlag beendet den Strom (verhindert eine Endlosschleife). -- `llama/src/main/java/net/ladenthin/llama/json/RerankResponseParser.java` ↔ `rerank_results_to_json` · `2591bb0` · 2026-10-19 · kein Fund — `document` und `score` werden nativ geschrieben. Kleinigkeit: `parse(String)` liefert bei unlesbarem JSON eine unveränderliche, `parse(JsonNode)` bei leerem Array eine veränderliche Liste (der Kommentar dort nennt gerade diese Einheitlichkeit als Grund). -- `llama/src/main/java/net/ladenthin/llama/server/RouterClient.java` (`awaitModelLoaded`, Fristgrenzen) · `f23050e` · 2026-10-19 · Fund F143 — bei Frist 0 keine einzige Abfrage. -- `llama/src/main/java/net/ladenthin/llama/server/OpenAiSseFormatter.java` (dritte Durchsicht) · `e5eb40e` · 2026-10-19 · kein Fund — `ensureUsageCachedTokens` ergänzt nur fehlendes `cached_tokens` und gibt jede andere Form (`usage` null, mit Leerzeichen, als Text im Inhalt, kein Objekt, unlesbar) unverändert weiter; SSE-Rahmen mit Leerzeile, Fehlerobjekt mit `code` null. -- `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/JllamaScoringModel.java` + `LangChain4jMapping.parseRerankScores` · `37473c2` · 2026-10-19 · kein Fund — Werte werden über `index` (Eingabeposition) zurückgeordnet, ohne `index` über die Reihenfolge; außerhalb liegende Indizes verworfen, doppelte Dokumente bleiben getrennt. Nicht verfolgt: `scoreAll` mit leerer Liste reicht ein leeres Dokument-Array an `handleRerank` weiter (native Seite, ohne Bibliothek nicht messbar). -- `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java` · `3cd7b3d` · 2026-10-20 · Fund F200 — `runEachOnItsOwnThread` hatte einen Thread je Anfrage ohne Obergrenze und warf einen `Error` vor dem Abwarten der übrigen Anfragen (beides F200, behoben). `submitAllThenReceive`: alle Anfragen werden vor dem ersten Empfang auf dem rufenden Thread abgeschickt, bei einem Fehler beim Abschicken die schon abgeschickten freigegeben, bei einem Fehler beim Empfang die übrigen trotzdem empfangen — kein Fund. From 6bd24c72418978ac9db5a7642cdd53cb22b97a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:02:55 +0000 Subject: [PATCH 475/589] Point four ledger entries at their renamed or removed tests The entries for the streamed-completion refusals, the llama.cpp sampling fields, logit_bias/n_probs and the model alias still named tests that later commits renamed or deleted. Each now has a dated addendum: the completion-path tests went with toCompletionParameters when /v1/completions moved to upstream's own stream, the refusal of echo, best_of and suffix is held by upstream's parser tests plus the C API's INVALID_REQUEST case, the streamed-probabilities refusal was removed because that stream now carries them, and the alias test was renamed when the case without --model-id was added. Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index e197665a8..da2341cc4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -712,6 +712,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `toCompletionParameters` weist `echo: true`, `suffix` und `best_of` mit upstreams Meldungen ab (`IllegalArgumentException`, vom Server als Anfragefehler gemeldet). - **Reproduktion:** `OpenAiRequestMapperTest#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, die drei Übersetzer-Tests, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. +- **Nachtrag (Branch-Review 2026-10-20):** Der Test oben gibt es nicht mehr: F186 (`4fb82c1`) hat `toCompletionParameters` entfernt und mit ihm `#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo`. Das gestreamte `/v1/completions` geht seitdem wie das blockierende durch upstreams `oaicompat_completion_params_parse`, das `echo: true`, `best_of` und `suffix` abweist; `completions_oai_stream_request` (`jllama_engine.cpp`) macht daraus `jllama_invalid_request` (400). Gehalten durch `OaicompatCompletionParams.EchoTrue_Throws`, `UnsupportedParam_BestOf_Throws` und `UnsupportedParam_Suffix_Throws` (`test_utils.cpp`) für den Parser und durch die Abweisungsfälle von `CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` (`test_c_api.cpp`, fehlender Prompt → `JLLAMA_STATUS_INVALID_REQUEST`) für die Abbildung auf 400. - **Status:** gefixt - **Commit:** java-llama.cpp `4a40a1d` @@ -914,6 +915,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiRequestMapperTest#llamaCppSamplingExtensionsReachTheStreamedCompletion`, `#nPredictWinsOverMaxTokensLikeUpstream`, `#jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven` - **Rot-Grün-Nachweis:** ohne Fix rot (3 Fehlschläge: „Expected: is a numeric value within <1.0E-4> of <0.05> but: <0.0>“, „Expected: is <7> but: was <50>“, „Expected: is "object" but: was ""“), mit Fix grün (25/25 der Klasse, skipped 0); Gegenprobe per `git stash` rot (3), nach `pop` grün. `OpenAiCompatServerHttpTest` 46/46, `AnthropicApiSupportTest` 19/19, `ResponsesApiSupportTest` 18/18, `OllamaApiSupportTest` 24/24, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün (eine erste Fassung mit allen Feldern in einer Methode meldete `CE_CLASS_ENVY`, eine Map-Tabelle `DMC_DUBIOUS_MAP_COLLECTION`; jetzt je Feld ein Aufruf über `withFloat`/`withInt`). - **Nachtrag (Branch-Review 2026-10-20):** Das hier „weiter offen“ Genannte ist erledigt: `logit_bias` und `n_probs` mit F180, das gestreamte `/v1/completions` mit F186 (upstreams eigener Stream, Wahrscheinlichkeiten eingeschlossen), `n > 1` mit F180 und auf allen nativen Wegen mit F197. +- **Nachtrag (Branch-Review 2026-10-20):** Die Reproduktionstests heißen seit F186 (`4fb82c1`) anders, weil die Mapper-Tests gemeinsamer Felder seitdem über den Chat-Weg laufen: `#llamaCppSamplingExtensionsReachTheStreamedCompletion` ist `#llamaCppSamplingExtensionsReachTheMappedRequest`, `#jsonSchemaReachesTheStreamedCompletionUnlessAGrammarIsGiven` wurde erst `#jsonSchemaReachesTheMappedRequestUnlessAGrammarIsGiven` und mit F218 in `#jsonSchemaReachesTheMappedRequest` und `#jsonSchemaAndGrammarAreBothForwardedSoUpstreamCanRefuseThePair` geteilt; `#nPredictWinsOverMaxTokensLikeUpstream` ist unverändert. - **Status:** gefixt - **Commit:** java-llama.cpp `1149533` @@ -1456,6 +1458,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#logitBiasAndNProbsAreForwardedOnBothPaths`, `#moreThanOneChoiceIsRefusedBecauseOneAnswerComesBack`, `#probabilitiesOnTheStreamedCompletionAreRefused`, `#unknownFieldsIgnored` (ohne `n`); `InferenceParametersTest#withLogitBiasJson_storesTheValueAndRefusesASecondOne`. - **Rot-Grün-Nachweis:** mit Platzhalter-Setter und unverändertem Mapper rot (4 Tests), mit dem Fix grün (327 Tests der Mapper-, Parameter-, Registry- und Server-Klassen); SpotBugs grün. - **Nachtrag (Branch-Review 2026-10-20):** Die Beschreibung stimmte für die Responses-Übersetzung nicht: deren Feldliste `CHAT_PASSTHROUGH_FIELDS` filtert, was den Mapper erreicht, und enthielt `logit_bias`/`n_probs` nicht; nachgezogen mit F226. +- **Nachtrag (Branch-Review 2026-10-20):** Zwei der Tests oben gibt es so nicht mehr, beide durch F186 (`4fb82c1`): `#logitBiasAndNProbsAreForwardedOnBothPaths` heißt `#logitBiasAndNProbsAreForwarded` (nur noch der Chat-Weg baut Parameter im Mapper), und `#probabilitiesOnTheStreamedCompletionAreRefused` ist absichtlich entfernt, weil das gestreamte `/v1/completions` seitdem Wahrscheinlichkeiten liefert (`CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` in `test_c_api.cpp` fordert `logprobs` an und prüft sie in den Chunks). - **Status:** gefixt (der Rest unter (14), Wahrscheinlichkeiten im gestreamten `/v1/completions`, mit F186) - **Commit:** java-llama.cpp `cceaaa0` @@ -1526,6 +1529,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `name_completion_task` setzt beide Felder wie upstream (eine `chatcmpl-`-Id je Aufgabe, also je Stream eine). Neu `ModelOption.ALIAS` (`--alias`, Vertrag `SERVER_PARSER`, vom C++-Vertragstest gegen upstreams Parser geprüft) und `ModelParameters.setAlias`; die CLI reicht `--model-id` als Alias weiter. - **Tests:** C++ `CApi.OpenAiAnswersCarryAnIdAndTheModelName` (`test_c_api.cpp`, Zufallsmodell: blockierende Completion und Chat mit `chatcmpl-`-Id und dem Modellnamen aus den Meta-Daten, verschiedene Ids, ein Stream mit genau einer Id über alle Chunks); Java `ModelParametersTest#testSetAlias`, `OpenAiServerCliTest#anExplicitModelIdBecomesTheModelsAlias`; `test_model_flags.cpp` (`--alias` im Server-Parser). - **Rot-Grün-Nachweis:** C++ vor dem Fix rot (`id` und `model` leer), danach grün, Suite 642/642; Java mit wirkungslosem Platzhalter rot (2), danach grün (304 Tests der Parameter-, CLI- und Registry-Klassen). Ende-zu-Ende über den echten Server mit `--model-id my-model`: `/v1/models` und die Chat-Antwort nennen beide `my-model`. +- **Nachtrag (Branch-Review 2026-10-20):** `OpenAiServerCliTest#anExplicitModelIdBecomesTheModelsAlias` heißt seit F189 (`5fff6b8`) `#theAdvertisedModelIdBecomesTheModelsAliasWithAndWithoutTheFlag` und prüft den Fall ohne `--model-id` mit (siehe F189). - **Status:** gefixt - **Commit:** java-llama.cpp `a8faaef` From 9b823978daa36107af9c2b0a7db82c8d31757b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:08:34 +0000 Subject: [PATCH 476/589] Record this pass's fixes in the changelog Every fix since the Ollama chat-message refusal gets its user-visible line under [Unreleased]: the Ollama, chat-mapper, Anthropic, Responses and rerank refusals and corrections, the backend IOException before the first chunk, the batch error handling, the RpcServer slot and the C API/FFM stop, the offline guard, the loader's .tmp sweep, the LoRA slot start, the langchain4j tool-call completion and the agent's alias, plus the new enableContextShift(). The Upgrading list names the new refusals. Lines this pass made wrong are corrected: stop_reason is stop_sequence for a matched stop word now, the streamed-failure line covers an IOException before the first chunk, a tool schema that is not one JSON value is refused rather than sent as a string, and the loader also sweeps the old layout's .tmp copies. The /api/generate entry names its three deliberate differences from Ollama. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- CHANGELOG.md | 170 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 130 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6691955f..b44225050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,12 +59,15 @@ the first two items break every existing consumer at upgrade. absolute Windows path, which llama.cpp's `FNAME:SCALE` format cannot express (load the adapter with `addLoraAdapter` and set its scale with `LlamaModel.setLoraAdapter`, or use a relative path); an empty or blank JSON fragment (`withJsonSchema("")`, `withToolsJson("")`, ...) and a `NaN` or - `+Infinity` token bias are refused instead of being sent as `null` or dropped. + `+Infinity` token bias are refused instead of being sent as `null` or dropped; so are a + `ToolDefinition` parameter schema that is not exactly one JSON value (it was sent as a string, + which llama.cpp then refused without naming the tool) and an empty path for `addLoraAdapter` / + `addControlVector`. 9. **The repeatable adapter options add up.** A second `addLoraAdapter`, `addLoraScaledAdapter`, `addControlVector` or `addControlVectorScaled` call adds to the first instead of replacing it, as their Javadoc always said; code that relied on the last call winning now loads every adapter. 10. **One request, one prompt, one completion.** Several prompts in one request - (`"prompt": ["a", "b"]`) and `"n"` above 1 are refused as invalid requests + (`"prompt": ["a", "b"]`) and an `"n"` other than 1 are refused as invalid requests (`InvalidRequestException`, `400` in the servers) on every native path -- `LlamaModel.handle*` and the streams, the C API, `llama-ffm` and every route of `OpenAiCompatServer` -- where the first prompt and a single choice used to be answered without a word. Send separate requests; a token array is @@ -79,7 +82,12 @@ the first two items break every existing consumer at upgrade. leading or trailing whitespace, and a model file name with a comma is advertised with `_` in its place; `/api/generate` without `"raw": true` applies the model's chat template, as Ollama does (send `raw: true` for a bare completion); a route answers its own path only, so a sub-path such as - `/v1/messages/count_tokens` is `404`; a negative rerank `top_n` is `400`. + `/v1/messages/count_tokens` is `404`; a negative rerank `top_n` is `400`. Values the translations + used to replace or drop without a word are refused with `400`, as upstream or the protocol does + (see Fixed): a `max_tokens` below `-1`, `top_logprobs` without `logprobs: true`, a `tool_choice` + naming a tool the request does not carry, an Anthropic `max_tokens` that is not a number, and on + `/v1/responses` an input item of an unknown type, an `input_image` without a URL and a + `function_call` whose `arguments` is not a string. ### Added - **The C API of `libjllama` (`jllama_api.h`, shipped in every natives directory).** Every operation @@ -285,6 +293,10 @@ the first two items break every existing consumer at upgrade. of one slot and the slot count of the loaded model, which `OpenAiCompatServer.main` now passes. - **`RerankResponseParser.toDocumentScores(...)`**: the document-to-score map of a rerank result in the order of the documents (see Fixed). +- **`ModelParameters.enableContextShift()`** (`--context-shift`, `ModelFlag.CONTEXT_SHIFT`): llama.cpp + has context shifting off by default, so a slot stops when its context is full; this turns it on. + `disableContextShift()` said the default was "enabled"; its Javadoc now names upstream's default, and + each of the two methods replaces an earlier call of the other, so the later call wins. ### Changed - Dependencies and build tooling bumped in step with the sibling repositories: Jackson 2.22.3 -> @@ -317,6 +329,9 @@ the first two items break every existing consumer at upgrade. - `JsonParameters` refuses an empty or blank fragment (`withJsonSchema("")`, `withResponseFormat("")`, `withToolsJson("")`, ...), which the strict reader parsed to nothing and the request carried as `"key": null`, i.e. generation ran without the schema the caller set. + - `ModelParameters.addLoraAdapter("")` and `addControlVector("")` are refused ("`--lora` needs a + non-empty file path"): an empty path was silently overwritten by the next call, or reached + llama.cpp, which failed the load with a message that did not name the option. - `OpenAiServerConfig.Builder.heartbeatMillis` refuses a value of `0` or below: as the period of the heartbeat timer it made every streamed request fail with `200` and an `IllegalArgumentException` error event, reported as the client's fault. @@ -330,7 +345,17 @@ the first two items break every existing consumer at upgrade. in order, releasing the requests already submitted when a submission fails; `chatBatch`, whose native call submits and waits in one, runs each request on a daemon thread of its own, at most 64 at once, and throws the first failure of any kind only after every request has finished (an - `Error` used to end the wait while other requests still ran). + `Error` used to end the wait while other requests still ran). In all three, an `Error` -- an + `OutOfMemoryError` while a large request is rendered or its result read -- no longer skips the + release of the requests already submitted or the receiving of the others, and every failure after + the first is attached to it as a suppressed exception instead of being dropped. +- **`llama-langchain4j` reports a streamed tool call complete when the next one starts.** + `onCompleteToolCall` of every call came only at the end of the stream, after the partial calls of + the following ones; it now fires for the earlier calls as soon as a call with a higher index + arrives, as langchain4j's own OpenAI integration does, so a handler can run a tool while the model + still streams the next. A call that has no name yet stays open until the stream ends, and a + fragment that would change a call already reported complete fails the stream through `onError` + (an `IllegalStateException` naming the index) instead of silently diverging from the reported call. - **Ollama `/api/generate` follows Ollama's semantics.** It completed every prompt raw; now a request with `suffix` is fill-in-the-middle as before, one with `"raw": true` a bare completion (a `system`, `template` or `context` next to it is refused with 400, as Ollama does), and any other goes through @@ -338,7 +363,11 @@ the first two items break every existing consumer at upgrade. and `think` honoured, answering `thinking` next to `response`. A request with neither a prompt nor a suffix -- how clients preload a model -- is answered at once with Ollama's load response (`done: true`, empty `response`, `done_reason` `"load"`; a single object even when streamed) - instead of answering an empty user turn. + instead of answering an empty user turn. Three deliberate differences from Ollama remain: a request + with a suffix but no prompt stays fill-in-the-middle at the start of the file (Ollama answers it + with the load response), an empty `"suffix": ""` counts as a suffix, and `keep_alive: 0` does not + unload the model and is answered `"load"` (Ollama unloads and answers `"unload"`). `/api/chat` + without messages is answered the same way, as Ollama preloads with it; it was refused with `400`. - **`OpenAiCompatServer --model-id` / `--model-alias` take a value llama.cpp keeps as one alias.** llama.cpp splits `--alias` at commas and strips every part, so `--model-id my,model` was advertised as `my,model` in `/v1/models` and named `model` in every answer, and a blank id fell back to the @@ -769,14 +798,23 @@ the first two items break every existing consumer at upgrade. `thinking_budget_tokens` were dropped; they follow the rules of upstream's chat parser now. - **llama.cpp's own sampling fields are forwarded**: `min_p`, `typical_p`, `repeat_penalty`, `repeat_last_n`, `top_n_sigma`, `xtc_*`, `dynatemp_*`, `min_keep`, `n_keep`, `ignore_eos`, - `grammar` and `json_schema` (unless a `grammar` is given), and `n_predict`, which wins over - `max_tokens` / `max_completion_tokens` as upstream; so are `logit_bias` and `n_probs`, and - `logprobs: true` becomes `n_probs` (`top_logprobs`, else 20, as upstream). + `grammar` and `json_schema` (both together are refused with upstream's `400`, as llama-server + does; the schema used to be dropped next to a grammar), and `n_predict`, which wins over + `max_tokens` / `max_completion_tokens` as upstream; so are `logit_bias` and `n_probs`. + - **`logprobs` is read as upstream's chat parser reads it**: `logprobs: true` becomes `n_probs` + (`top_logprobs`, else 20), a number is `n_probs` itself (`"logprobs": 2` asked for 20 and + overrode an `n_probs` next to it), and `top_logprobs` without `logprobs: true` is `400` with + upstream's message, as is a `logprobs` that is neither a boolean nor a number; both were dropped + or misread without a word. - **`id_slot: -1` means any slot**, upstream's default, instead of a `400`; and `max_tokens: 0` is - passed on as upstream reads it instead of meaning "no limit". + passed on as upstream reads it instead of meaning "no limit". A `max_tokens` (or `n_predict`, + `max_completion_tokens`) below `-1` is answered with upstream's `400` and an `n` other than 1 is + `400`; both ran with the defaults (no limit, one answer) without a word. - **A `tool_choice` naming a function** (`{"type": "function", ...}`, also the Responses form) became - `auto`, so the model could answer without calling it; it is `required` now, the closest value - llama.cpp knows. + `auto`, so the model could answer without calling it, or any other tool. Only the named tool is + passed on now, with `required`, so the model must call exactly that one (llama.cpp knows no + named choice; the template therefore no longer shows the model the other tools). A name no tool + of the request carries, and a `function` choice without a name, are `400`. - **Ollama routes (`/api/*`).** - **Images reach the model.** `/api/chat` ignored a message's `images`, although `/api/show` advertises `vision` with `--mmproj`; they become `image_url` parts (the MIME type from the magic @@ -785,9 +823,11 @@ the first two items break every existing consumer at upgrade. `type`, which llama.cpp's parser requires, so every request carrying one was refused with `400`; `"function"` is set now. A tool result's `tool_name` becomes the `name` llama.cpp reads, and a tool call without arguments is answered with `"arguments": {}` instead of `null`. - - **`think` works**: `false` switches thinking off, a level string (`"low"`, ...) is passed as - `reasoning_effort`. The thinking itself is answered as `message.thinking`, blocking and per stream - line, and `thinking` in the history goes back to the model as `reasoning_content`; both were lost. + - **`think` works**: `false` switches thinking off, `true` switches it on even on a server started with + thinking disabled (as `enable_thinking: true` does on the OpenAI route), a level string (`"low"`, + ...) is passed as `reasoning_effort`. The thinking itself is answered as `message.thinking`, blocking + and per stream line, and `thinking` in the history goes back to the model as `reasoning_content`; + both were lost. - **The sampling options are forwarded on all three routes** (`/api/chat`, `/api/generate`, and fill-in-the-middle with `suffix`, which took only `temperature` and `num_predict`): `temperature`, `top_p`, `top_k`, `seed`, `stop`, `repeat_penalty`, `repeat_last_n`, `min_p`, @@ -802,6 +842,10 @@ the first two items break every existing consumer at upgrade. 0.6.4, and the route said `0.1.0`, so the whole Copilot path failed at that check. - **`/api/show` names the context length where Continue reads it**: `parameters` carries `num_ctx `. + - **A malformed `/api/chat` is answered with `400`.** A `messages` element that is not an object + (a string, `null`, an array) ended the request with a closed connection and no status line, and + a `messages` that is not an array was read as "no messages"; both are refused with upstream's + message and the element's index now. - **Anthropic Messages (`/v1/messages`).** - **Images reach the model**: `image` blocks of a message (`base64` or `url` source) and of a `tool_result` become `image_url` parts; both were dropped. @@ -827,6 +871,13 @@ the first two items break every existing consumer at upgrade. - **The stop word is fetched alone**: the translated request asks for `response_fields: ["stopping_word"]` with `verbose`, instead of upstream's whole debug object with the rendered prompt. + - **A streamed answer opens one content block at a time.** When the model thought again after + text, the new `thinking` block was opened while the text block was still open, and the text that + followed was sent to the earlier block after the later one had stopped; the text block is closed + first now, so thinking and text alternate as separate blocks, as Claude itself streams them. + - **A `max_tokens` that is not a number is `400`** (the Messages API types it as an integer, and + upstream's schema refuses it); `"max_tokens": "100"` ran with the default 4096. A missing or + `null` value still gets 4096, and the refusal comes before a stream starts. - **OpenAI Responses (`/v1/responses`).** - **Images and tool output parts reach the model**: `input_image` parts of a message and an array-valued `function_call_output` (`input_text` / `input_image`) were flattened to their text or @@ -840,13 +891,21 @@ the first two items break every existing consumer at upgrade. - **Reasoning works in both directions**: `reasoning.effort` is passed on (`"none"` switches thinking off), and the answer carries the reasoning as a `reasoning` item first, streamed as `response.reasoning_text.delta`s. - - **The request fields the chat path reads are passed on** -- `top_k`, `seed`, `stop`, the - penalties, `chat_template_kwargs`, `response_format`, the cache fields and llama.cpp's sampling - fields; only `temperature`, `top_p` and `max_output_tokens` got through. **`text.format` - (structured output) becomes `response_format`**, so a JSON schema constrains the answer. + - **The request fields the chat path reads are passed on** -- `top_k`, `seed`, `stop`, the penalties, + `chat_template_kwargs`, `response_format`, the cache fields, llama.cpp's sampling fields and + `logit_bias` / `n_probs`; only `temperature`, `top_p` and `max_output_tokens` got through. + **`text.format` (structured output) becomes `response_format`**, so a JSON schema constrains the + answer. - **What cannot be served is refused with `400`** instead of answered as if it were absent: - `previous_response_id` (there is no response store; send the earlier turns in `input`) and - `input_file` parts, both with upstream's message. + `previous_response_id` (there is no response store; send the earlier turns in `input`), + `input_file` parts, an `input_image` without a URL (a `file_id` only), a tool output part that + is neither `input_text` nor `input_image`, a `function_call` whose `arguments` is not a string + (an object became `"{}"`), and an input item of an unknown type (`custom_tool_call`, + `item_reference`, ..., which became an empty user message), each with upstream's message. + - **An answer cut off by `max_output_tokens` is `incomplete`**, blocking and streamed: `status` + `incomplete` with `incomplete_details.reason` `max_output_tokens`, the message item `incomplete` + and `response.incomplete` as the last event, where it was reported `completed`. llama.cpp + reports a full context the same way, so the reason is the closest the API defines. - **A streamed failure ends with `response.failed`** (with `error.code` / `error.message`) before the `error` event; Responses clients such as Codex read the message only from there and reported "stream closed before response.completed". @@ -855,8 +914,12 @@ the first two items break every existing consumer at upgrade. - **Reranking.** `/rerank` accepts Hugging Face text-embeddings-inference requests (`texts` instead of `documents`) and answers them in TEI's form, as upstream; `top_n: 0` returns no result instead of all, a negative `top_n` is `400`, and the OpenAI-shaped answer carries `usage` (the native rerank - result passes `tokens_evaluated` on). `LlamaModel.rerank(String, String...)` returns its map in the - order of the documents instead of hash order. + result passes `tokens_evaluated` on). A `top_n` written as a decimal (`2.0`, as a Python client + sends `len(docs) / 2`) is read like upstream's integer cast instead of keeping every result, so a + negative one is `400` in both spellings; `"documents": null` next to `texts` is served from + `texts`; and a native answer that is not a JSON array is a `500` with the reason instead of an + empty result list. `LlamaModel.rerank(String, String...)` returns its map in the order of the + documents instead of hash order. - **Java API.** - **`InferenceParameters.withMessages(List)` keeps `tool_calls` and `tool_call_id`**, which only `ChatRequest` added, so a tool round built through it sent an assistant turn without @@ -867,8 +930,11 @@ the first two items break every existing consumer at upgrade. `withGrammar(g).withGrammar(null)` still sent the grammar. - **JSON values are taken as given or refused, never cut short.** A `ToolDefinition` schema with trailing content (`{"type":"object"} {"required":["x"]}`) was sent as its first value, and a - `chat_template_kwargs` value holding two JSON values as the first of them; both now take the - documented fallback to a string, as does an empty schema or kwarg value, which became `null`. + `chat_template_kwargs` value holding two JSON values as the first of them. A schema that is not + exactly one JSON value -- trailing content, an empty or an unparsable string -- is refused now + with an `IllegalArgumentException` naming the tool (a tool without parameters declares + `{"type":"object","properties":{}}`); a `chat_template_kwargs` value that is not one JSON value, + an empty one included, is passed on as a string, where an empty value became `null`. - **`LlamaIterator` stays finished after `cancel()` from another thread**; a result that arrived afterwards revived it, and the next `next()` failed with "Task not found". - **`TextToSpeech.close()` waits for a running `synthesize()`** instead of freeing the engine under @@ -876,13 +942,21 @@ the first two items break every existing consumer at upgrade. - **`NativeServer` start, `isRunning()` and `close()` are serialized**: two `close()` calls at once (a shutdown hook and try-with-resources) could stop and free the native server twice, and `isRunning()` could read a freed handle. - - **A failed `RpcServer.start` stops the server.** A stop that arrived before the native server - bound its socket was lost, so after a start timeout or an interrupt the server kept listening on a - daemon thread although `start` had thrown; the stop is now repeated until the thread ends. + - **A failed `RpcServer.start` stops the server.** A stop that arrived before the native server bound + its socket was lost, so after a start timeout or an interrupt the server kept listening on a daemon + thread although `start` had thrown; the stop is now repeated until the thread ends. If the thread + still lives 30 s after the stop, `start` says so (instead of "could not listen ... is the port + already in use?") and the single-instance slot stays taken until the thread ends -- it used to be + freed, so a second server could start on the same process-wide state. `close()` holds it the same + way; it throws a `LlamaException` when the native server has not ended 30 s after the stop, and logs + a warning when only the thread is still alive then. The C API and `llama-ffm` keep such a stop as + well: `jllama_rpc_server_stop` / `FfmRpcServer.stop` during a running serve hold the request until + the serve returns (failing after 30 s), and a stop with no serve running stays a no-op. - **`RouterClient`**: `awaitModelLoaded` with `Long.MAX_VALUE` overflowed into an immediate timeout, and with `0` reported a timeout without asking; it polls at least once now, counts a `sleeping` model as loaded (as the router itself does), and finds a model by its alias; an IPv6 literal host - (`"::1"`) is bracketed in the URL instead of failing with `MalformedURLException`. + (`"::1"`) is bracketed in the URL instead of failing with `MalformedURLException`, and in + `toString()` too. - **`LlamaTrainer.finetune` accepts any Unicode**: a character outside the Basic Multilingual Plane (an emoji) in the training text or a path failed the whole run with "invalid training config". - **`GgufInspector.read` rejects nested arrays with an `IOException`**, as llama.cpp's reader does; a @@ -890,13 +964,18 @@ the first two items break every existing consumer at upgrade. - **`Session.fork` refuses a negative slot id before touching the model**, with the constructor's `IllegalArgumentException`, instead of writing the slot file and failing in `restoreSlot`. - **An offline load (`--offline`) of a path the file system cannot represent** throws the documented - `ModelUnavailableException` instead of an `InvalidPathException`; and `ChatStreamChunkParser` treats + `ModelUnavailableException` instead of an `InvalidPathException`; a path the JVM cannot encode in + its file-name charset -- a non-ASCII model name in a JVM running with the POSIX locale + (`LC_ALL=C`) -- is no longer reported as missing although the file exists, but left to the native + loader, which reads paths as UTF-8 and loads it; and `ChatStreamChunkParser` treats an empty envelope as the end of the stream, as its Javadoc says. - **`LlamaLoader` deletes only its own files from the temp directory.** The clean-up of the pre-6.0.0 layout deleted, recursively, every entry of `java.io.tmpdir` (or `net.ladenthin.llama.tmpdir`) whose name starts with `jllama`, `llama` or `ggml` -- a `/tmp/llama.cpp` checkout, a `/tmp/ggml-models/` - folder, a downloaded `/tmp/llama-2-7b.Q4_K_M.gguf`. It removes regular files with a library suffix - (`.so`, `.dll`, `.dylib`, `.jnilib`, `.metal`) now, nothing else. + folder, a downloaded `/tmp/llama-2-7b.Q4_K_M.gguf`. It removes the regular files the old layout + wrote now -- the library names with a library suffix (`.so`, `.dll`, `.dylib`, `.jnilib`, `.metal`) + and, once they are ten minutes old, the `..tmp` copies an interrupted extraction of + that layout left -- and nothing else. - **`llama-ffm`.** `FfmTextToSpeech.close()` waits for a running `synthesize()` and `FfmNativeServer.close()` for a running `isRunning()`, as their JNI twins do, instead of freeing the native object under them; and an empty, non-null string array returned by the library is freed. @@ -926,7 +1005,11 @@ the first two items break every existing consumer at upgrade. the model name resolves to the path for a local file. `OpenAiCompatServer` now always sets the alias from the advertised id, so the two agree and no local path reaches an answer. An embedding caller that loads its own `LlamaModel` still has to set `ModelParameters.setAlias(…)` itself — the server cannot, - the model is already loaded; `OpenAiServerConfig.getModelId()` says so. + the model is already loaded; `OpenAiServerConfig.getModelId()` says so. The Atmosphere agent's + in-process server had the same gap: its answers named the absolute model path while `/v1/models` + listed `--model-id` (default `local-model`); it sets the alias now, and with `--model` refuses a + `--model-id` with a comma, an empty one and one with leading or trailing whitespace, as + `OpenAiServerCli` does. - **A streamed `/v1/completions` is upstream's stream.** `OpenAiCompatServer` built the chunks of a streamed completion itself from the Java generator: no `usage` on the last chunk, no probabilities (a request for them was refused with a pointer to `stream: false`), and a prompt given as a token @@ -951,20 +1034,24 @@ the first two items break every existing consumer at upgrade. `llama-ffm`, `LlamaModel.handle*`, both `/v1/completions` routes and the chat paths; the server's request mapper had dropped `n` on the chat routes as well. It is an invalid request now, checked in one place for the blocking, streamed and chat paths. -- **`/v1/messages` reports the matched stop word as `stop_sequence`**, blocking and streamed, as - upstream does (`stop_reason` stays `end_turn`); it was always `null`. The OpenAI answer the route - translates from does not carry the word, so the translated request asks for llama.cpp's `verbose` +- **`/v1/messages` reports the matched stop word as `stop_sequence`**, blocking and streamed, and + `stop_reason` is `"stop_sequence"` then, as the Messages API specifies (upstream reports `end_turn` + there); the word was always `null`, and an answer stopped by it said `end_turn`. The OpenAI answer the + route translates from does not carry the word, so the translated request asks for llama.cpp's `verbose` answer and reads `__verbose.stopping_word`, which never reaches the client. New - `InferenceParameters.withVerbose(boolean)` (`RequestField.VERBOSE`), and the OpenAI mapper forwards - a client's `verbose`. + `InferenceParameters.withVerbose(boolean)` (`RequestField.VERBOSE`), and the OpenAI mapper forwards a + client's `verbose`. - **A streamed request that fails before its first chunk is answered with the error status.** `OpenAiCompatServer` sent `200` and the stream's headers before the engine ran, so a request llama.cpp refused at once arrived as `200` with an error event -- on all five streaming routes (`/v1/chat/completions`, `/v1/completions`, `/api/chat`, `/v1/messages`, `/v1/responses`). Upstream's server reads the first result before it starts a stream; this one now does the same: the response starts with the first write (a chunk, the protocol's opening events or a heartbeat), and a failure - before it is `400` or `500` with the route's own error body, as without `stream`. A failure after the - stream started is still an event. One visible effect: the Atmosphere agent now reports such a failure + before it is `400` or `500` with the route's own error body, as without `stream` -- an `IOException` + an `OpenAiBackend` reports before the first chunk included, which was taken for a client that had + gone away and ended the request with a closed connection and no status line. A failure after the + stream started is still an error event; an `IOException` there still counts as the client having + gone away and ends the stream. One visible effect: the Atmosphere agent now reports such a failure instead of ending the turn with an empty answer. - **Error messages with a 4-byte character reach Java intact.** The JNI shells handed messages to `ThrowNew` and `NewStringUTF`, which read Modified UTF-8; a message quoting a path or request text @@ -983,7 +1070,10 @@ the first two items break every existing consumer at upgrade. llama.cpp's own LoRA test pair: a third, unrelated text until the slot was erased). Upstream clears the cache when a request names its own adapters but not when the server's adapters change; the patch applies the same rule there, which also fixes upstream's `POST /lora-adapters` on the embedded - server. `setLoraAdapters`' Javadoc no longer tells callers to erase the slots; it names the one cache + server. Every slot starts with the server's adapters, so a slot's first request on a server + started with `--lora` keeps a prompt restored into it (`restoreSlot`) or loaded from the prompt + cache instead of treating it as computed without adapters and throwing it away. + `setLoraAdapters`' Javadoc no longer tells callers to erase the slots; it names the one cache that still does not record adapters, the host-memory prompt cache (`setCacheRamMib(0)` disables it). - **`ServerMetrics.getCumulativeUsage()` counts the cached prompt tokens.** It returned `Usage(processed, predicted)`, so `getPromptTokens()` was the evaluated tokens only and From 96a82d6cc292233934fc1bd1b9a381c5d175cce4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:09:19 +0000 Subject: [PATCH 477/589] Give the newest ledger entries the anchors their overview links use The overview lists every entry, but the 31 entries of this pass had no anchor, so their overview links led nowhere; every older entry carries one. Overview rows, entries, severities and status words now match one to one (152 each). Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index da2341cc4..c5cc5161c 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -1728,6 +1728,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Commit:** java-llama.cpp `200cc90` - **Nachtrag (Branch-Review 2026-10-20, Funde J1-4, J3-3, J16-2):** Beide Regeln sind bewusste Abweichungen von Ollama, nicht Ollamas Verhalten: Ollamas `GenerateHandler` prüft nur `req.Prompt == ""` (der `suffix` fügt dort nur `CapabilityInsert` hinzu), beantwortet also einen leeren Prompt mit `suffix` mit der Ladeantwort, und antwortet bei `keep_alive: 0` ohne Prompt mit `done_reason "unload"`. Hier bleibt der leere Prompt mit `suffix` FIM am Dateianfang (auch bei `"suffix":""`, `hasSuffix` fragt nur nach einem String), und `keep_alive: 0` bekommt die Ladeantwort, weil nichts entladen wird — ein „unload“ wäre eine Meldung über eine Aktion, die nicht stattfand. Die Javadoc von `isLoadOnly`/`toOllamaLoadResponse`, die README-Zeile der Ollama-Routen und `TODO.md` sagen das jetzt; vorher schrieb die Javadoc die `suffix`-Bedingung Ollama zu. Reine Doku, kein Test. + ### F213 – java-llama.cpp: Ollama `/api/chat` mit einem `messages`-Element, das kein Objekt ist, schließt die Verbindung ohne Antwort - **Zeit:** 2026-10-20 19:55 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaChat`) @@ -1740,6 +1741,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `6295831` + ### F214 – java-llama.cpp: Eine `IOException` des Backends vor dem ersten Chunk gilt als Verbindungsabbruch — keine Antwort, nur DEBUG - **Zeit:** 2026-10-20 20:09 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`ResponseStream`; die `IOException`-Zweige von `streamCompletions`, `streamChat`, `streamOllamaChat`, `streamAnthropic`, `streamResponses`) @@ -1752,6 +1754,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `d00936b` + ### F215 – java-llama.cpp: Ollama `/api/generate` mit `raw: true` verwirft `system`, `template` und `context` still — Ollama antwortet 400 - **Zeit:** 2026-10-20 20:20 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `hasRawConflict`, `RAW_CONFLICT_MESSAGE`; Javadoc von `isRaw`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaGenerate`) @@ -1764,6 +1767,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `7933eb0` + ### F216 – java-llama.cpp: Ollama `/api/chat` ohne Nachrichten (Ollamas Vorladen) wird mit 400 abgewiesen statt mit der Ladeantwort beantwortet - **Zeit:** 2026-10-20 20:27 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `isChatLoadOnly`, `toOllamaChatLoadResponse`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaChat`) @@ -1776,6 +1780,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `a4d0604` + ### F217 – java-llama.cpp: Ollamas `think: true` wird verworfen — ein Server mit abgeschaltetem Denken übergeht es still - **Zeit:** 2026-10-20 20:31 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiChatRequest`; über `GENERATE_CHAT_FIELDS` auch `/api/generate`) @@ -1788,6 +1793,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `eade620` + ### F218 – java-llama.cpp: Chat-Anfrage mit `json_schema` und `grammar` verliert das Schema still statt upstreams 400 - **Zeit:** 2026-10-20 21:24 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyLlamaCppSampling`) @@ -1800,6 +1806,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `fea0a1d` + ### F219 – java-llama.cpp: Chat-Mapper normalisiert `max_tokens` unter -1 und `n` unter 1 still auf die Vorgaben - **Zeit:** 2026-10-20 21:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyCommonFields`) @@ -1812,6 +1819,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `63f05e1` + ### F220 – java-llama.cpp: Chat-Mapper verwirft `top_logprobs` ohne `logprobs` still und liest ein ganzzahliges `logprobs` als `n_probs` 20 - **Zeit:** 2026-10-20 21:40 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java` (`applyLogprobs`, neu; vorher in `toInferenceParameters`) @@ -1824,6 +1832,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ca1baa9` + ### F221 – java-llama.cpp: Anthropic meldet ein getroffenes Stoppwort als `stop_reason` `end_turn` statt `stop_sequence` - **Zeit:** 2026-10-20 22:30 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toAnthropicResponse`, `anthropicStopReason(String, String)` neu), `AnthropicStreamTranslator.java` (`end`) @@ -1836,6 +1845,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `7e965d9` + ### F222 – java-llama.cpp: Gestreamte Anthropic-Antwort öffnet bei Denkinhalt nach Text einen zweiten Thinking-Block, während der Textblock offen bleibt - **Zeit:** 2026-10-20 22:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` (`onChunk`, `end`, `closeTextBlock` neu) @@ -1848,6 +1858,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `cb123a9` + ### F223 – java-llama.cpp: Anthropic `max_tokens`, das keine Zahl ist, wird still durch die Vorgabe 4096 ersetzt statt abgewiesen - **Zeit:** 2026-10-20 22:40 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`toOpenAiChatRequest`), `OpenAiCompatServer.java` (`handleAnthropicMessages`) @@ -1860,6 +1871,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `82bef4a` + ### F224 – java-llama.cpp: Responses `input_image` ohne URL und fremde Werkzeugergebnis-Teile werden still verworfen statt abgewiesen - **Zeit:** 2026-10-20 23:05 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`inputContentWithImages`, `toolOutputParts`, neu `imageUrl`) @@ -1872,6 +1884,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `e372019` + ### F225 – java-llama.cpp: Responses `function_call` mit Objekt-`arguments` wird still zu `"{}"` - **Zeit:** 2026-10-20 23:07 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, Fall `function_call`) @@ -1884,6 +1897,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4913acf` + ### F226 – java-llama.cpp: Responses-Route verwirft `logit_bias` und `n_probs` still, obwohl der Chat-Mapper sie liest - **Zeit:** 2026-10-20 23:12 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`CHAT_PASSTHROUGH_FIELDS` und seine Javadoc) @@ -1896,6 +1910,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `88f823e` + ### F227 – java-llama.cpp: `tool_choice`, das eine Funktion benennt, lässt bei mehreren Werkzeugen jedes zu - **Zeit:** 2026-10-20 23:21 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`toOpenAiChatRequest`, Werkzeugblock), `OpenAiRequestMapper.java` (`toInferenceParameters`, neu `namedToolChoice`) @@ -1908,6 +1923,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `740938d` + ### F228 – java-llama.cpp: Responses-Eingabe-Items unbekannten Typs werden zu leeren Nutzernachrichten statt abgewiesen - **Zeit:** 2026-10-20 23:26 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesApiSupport.java` (`appendInput`, `switch` über den Item-Typ; Klassen-Javadoc) @@ -1920,6 +1936,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `beaffcc` + ### F229 – java-llama.cpp: Abgeschnittene Responses-Antwort (`finish_reason` `length`) wird als `completed` gemeldet - **Zeit:** 2026-10-20 23:33 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/ResponsesStreamTranslator.java` (`onChunk`, `end`), `ResponsesApiSupport.java` (`toResponsesResponse`, neu `isTruncated`, `markIncomplete`) @@ -1932,6 +1949,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `ad8b0b1` + ### F230 – java-llama.cpp: `/v1/rerank`: ein `top_n` als Gleitkommazahl gilt als „fehlt“, ein negatives (`-1.5`) umgeht die Abweisung - **Zeit:** 2026-10-21 00:14 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` (`readTopN`) @@ -1944,6 +1962,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `5781ef5` + ### F231 – java-llama.cpp: `/v1/rerank`: ein explizites `"documents": null` neben `texts` wird abgewiesen statt aus `texts` bedient - **Zeit:** 2026-10-21 00:19 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` (`readDocuments`) @@ -1956,6 +1975,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `893ede3` + ### F232 – java-llama.cpp: `/v1/rerank`: ein nativer Antwortkörper, der kein JSON-Array ist, wird still zu einer leeren Ergebnisliste (200) - **Zeit:** 2026-10-21 00:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` (`toTeiResponse`, `toOaiResponse`, neu `readNativeArray`), `LlamaModelBackend.java` (`rerank`) @@ -1968,6 +1988,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `5c5a892` + ### F233 – java-llama.cpp: ein leeres oder unparsbares Tool-Schema geht als String an llama.cpp statt in Java abgewiesen zu werden - **Zeit:** 2026-10-21 01:05 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/ChatRequest.java` (`appendTool`, `buildToolsJson`, neu `parseSchemaOrThrow`) @@ -1981,6 +2002,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `0a49c54` + ### F234 – java-llama.cpp: `appendCsvValue` überschreibt einen leeren Vorwert still statt ihn zu ergänzen - **Zeit:** 2026-10-21 01:14 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/parameters/CliParameters.java` (`appendCsvValue`), `ModelParameters.java` (Javadoc von `addLoraAdapter`, `addControlVector`) @@ -1993,6 +2015,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b8c3992` + ### F235 – java-llama.cpp: `submitAllThenReceive` gibt bei einem `Error` nichts frei und bricht das Empfangen ab - **Zeit:** 2026-10-21 01:27 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java` (`submitAllThenReceive`), `llama/spotbugs-exclude.xml` (Kommentare zu `BatchDispatch`) @@ -2005,6 +2028,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `edad4ca` + ### F236 – java-llama.cpp: die Batch-Methoden verwerfen jeden Fehler nach dem ersten ohne Spur - **Zeit:** 2026-10-21 01:35 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/BatchDispatch.java` (`submitAllThenReceive`, `runEachOnItsOwnThread`, neu `suppress`), `LlamaModel.java` (Javadoc von `completeBatch`, `chatBatch`) @@ -2017,6 +2041,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4b410d1` + ### F237 – java-llama.cpp: `RpcServer` gibt den Einzelinstanz-Platz nach 30 s frei, obwohl der Server-Thread noch lebt, und meldet „could not listen“ - **Zeit:** 2026-10-21 01:42 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (`start`, `stopAndJoin`, `close`, neu `releaseSlotShare`) @@ -2029,6 +2054,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `2eb3fff` + ### F238 – java-llama.cpp: `--offline` meldet eine vorhandene Modelldatei mit Nicht-ASCII-Namen als fehlend, wenn die JVM mit der POSIX-Locale läuft - **Zeit:** 2026-10-21 01:52 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/OfflineModelGuard.java` (`localFileExists`, neu `countsAsMissing`, `fileNameEncoding`, `jnuEncoding`) @@ -2041,6 +2067,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `8809fe4` + ### F239 – java-llama.cpp: der Loader lässt die `.tmp`-Reste unterbrochener Kopien des alten Layouts für immer liegen - **Zeit:** 2026-10-21 01:56 UTC - **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` (`isStaleExtraction`, neu `isOlderThanTheStaleAge`, `FLAT_LAYOUT_TEMP_COPY`; Javadoc von `cleanup`, `isFlatLayoutLibraryName`, `FLAT_LAYOUT_SUFFIXES`) @@ -2053,6 +2080,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `4205d20` + ### F240 – java-llama.cpp: Patch 0021 hält einen nie benutzten Slot für einen Adapterwechsel und wirft dessen geladenen Prompt weg - **Zeit:** 2026-10-21 02:22 UTC - **Datei:** `llama/patches/0021-server-clear-the-slot-cache-when-the-server-adapters-change.patch` (zweiter Hunk in `init()` von `tools/server/server-context.cpp`, Kopftext), `llama/src/test/cpp/test_lora_cache.cpp`, `docs/patches.md`, `CLAUDE.md` @@ -2065,6 +2093,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `166a267` + ### F241 – java-llama.cpp: `jllama_rpc_server_stop`/`FfmRpcServer.stop` verlieren einen Stopp vor dem Bind; der Server lauscht weiter - **Zeit:** 2026-10-21 02:35 UTC - **Datei:** `llama/src/main/cpp/jllama_api.cpp` (`jllama_rpc_server_serve`, `jllama_rpc_server_stop`, neu `rpc_serving_scope`), `llama/src/main/cpp/jllama_api.h`, `llama/src/main/cpp/rpc_support.hpp` (Test-Seam), `llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java` (Javadoc), `llama/src/main/java/net/ladenthin/llama/RpcServer.java` (Kommentar von `stopAndJoin`), `CLAUDE.md` @@ -2078,6 +2107,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `1047673` + ### F242 – java-llama.cpp: `llama-langchain4j` meldet `onCompleteToolCall` eines parallelen Werkzeugaufrufs erst am Stream-Ende, nach den Teilstücken des nächsten - **Zeit:** 2026-10-21 02:47 UTC - **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`acceptToolCallFragment`, neu `completeCallsBelow`, `complete`) @@ -2090,6 +2120,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b90de3c` + ### F243 – java-llama.cpp: der Atmosphere-Agent nennt in jeder Antwort seines In-Process-Servers den absoluten Modellpfad statt der Modell-Id - **Zeit:** 2026-10-21 02:50 UTC - **Datei:** `llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java` (`modelParameters`), `llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java` (`parse`, neu `requireAliasSafeModelId`), `llama/src/main/java/net/ladenthin/llama/parameters/ModelParameters.java` (Javadoc von `setAlias`) From c73cc355a580f51f01c2b9453acfee1950d1ed66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:11:27 +0000 Subject: [PATCH 478/589] List what the branch review left open in TODO.md A new section collects the points the review fixes left open or the reviewers raised outside their findings' scope, grouped by area: the Ollama fields still dropped on /api/generate and the non-text prompt answered as a load; a backend IOException after the stream started; the open decision whether a malformed stream envelope should fail rather than end the stream, with both reviewers' positions; the chat mapper's remaining loose reads (a non-numeric max_tokens, n or n_probs, the unreachable logprobs-with-tools refusal, Anthropic's named tool choice and other tool_choice object types); the Anthropic image source and media-type questions; the missing or environment- bound tests; and an agent options copy that drops fields. The deliberate deviations the fixes chose are listed once so they are not rediscovered. Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- TODO.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/TODO.md b/TODO.md index 18f142dd6..d3744820f 100644 --- a/TODO.md +++ b/TODO.md @@ -523,6 +523,105 @@ they are not rediscovered: Ollama itself unloads there and answers `done_reason "unload"`; this server does not claim an unload it did not do. +### Open points from the branch review of 2026-10-20/21 (details: `BUGHUNT-FOUND.md`) + +The review of the bug-hunt branch confirmed its findings; the fixes are F213 … F243 and the dated +addenda in the ledger. What the fixes left open, or what the reviewers raised outside the findings' +scope, is listed here; the ledger entry in brackets holds the analysis. Delete an item when it is done. + +**Ollama routes** +- **`/api/generate` with `raw: true` drops `format` and `images` without a word** (F215). Ollama keeps + both. Either map them (`format` as `json_schema`, `images` as multimodal data, as the non-raw path + does) or refuse them with `400`; today neither happens. +- **`/api/generate` without `raw` drops `template` and `context` without a word** (F215); Ollama + applies them. The chat-template path has no slot for a caller's template or a token context, so the + honest options are a `400` or a documented deviation. +- **A non-text `prompt` (`["a"]`, `{}`) on `/api/generate` is answered `200` with `done_reason + "load"`** (F205): `isLoadOnly` treats every non-textual prompt as absent. Ollama's field is a string; + the fix is a `400` for a present, non-null, non-text prompt (`requireTextualPrompt`), with a + model-free `OllamaApiSupportTest` case and an HTTP test, red/green. + +**Server transport** +- **After a stream started, a backend `IOException` is taken for a disconnect** (F214): it is logged + at DEBUG and the stream ends without an error event. A third-party `OpenAiBackend` may report a + generation failure that way; a quiet error event (`writeQuietly` of the route's error event) would + cost nothing when the client really is gone. Optional, outside what F182 promised. +- **Decision open: should a malformed stream envelope fail instead of ending the stream?** + `ChatStreamChunkParser.feed` treats an unparseable or empty envelope as the end of the stream, and + `StreamingChunkAssembler` follows it, so a broken envelope looks like a short answer. One reviewer: + the path is not reachable from `LlamaModel`, whose native side always writes a well-formed envelope, + so hardening buys nothing. The other: throw an `IllegalArgumentException` naming the envelope + instead of a silent end, as the rerank and tool-schema readers now do (F232, F233). Decide, then either pin the + current contract or change it with a red/green test. + +**Chat mapper (`OpenAiRequestMapper`, every chat route)** +- **A non-numeric `max_tokens` (`"100"`) is read as `-1`, i.e. no limit** (F223 names it): upstream's + schema answers `400`. The Anthropic route now refuses it before the mapper; the OpenAI route itself + does not. +- **A non-numeric `n` or `n_probs` (a string) is ignored** where upstream's schema answers `400`. + Pre-existing, not a regression of the branch. +- **Upstream's "logprobs is not supported with tools + stream" refusal is unreachable** (F220): the + mapper turns `logprobs` into `n_probs` and does not see `stream`. +- **Anthropic `{"type":"tool","name":…}` still means `required` over all tools, and a `tool_choice` + object of another type (`allowed_tools`, `mcp`, …) falls back to `auto` without a word** on the chat + and Responses paths (F227). The named-function narrowing of F227 is the model for the first; the + second needs a decision between `400` and a documented fallback. + +**Anthropic Messages** +- **The image source `file` (Files API) is dropped without a word**, here and upstream (F29). Pin it + in a test (`aFileImageSourceIsDroppedLikeUpstream`) or refuse it; a decision first. +- **A `base64` image without `media_type` defaults to `image/png` here and to `image/jpeg` upstream** + (F29); neither aligned nor pinned by a test. A decision first. +- **Upstream candidate:** `stop_reason` `stop_sequence` for a matched stop word (F221) is one line in + each of upstream's `to_json_anthropic*` plus an assertion in `test_compat_anthropic.py`; add it to + the upstream inventory below for the day after the release. + +**Decided deviations, recorded so they are not rediscovered** (no action): unknown part types inside +a Responses `message` are not refused, because the same branch carries assistant `output_text` / +`refusal` parts (F224); upstream's stricter Responses role checks are not adopted (F228); a boolean or +string rerank `top_n` and one outside the `int` range keep every result, where upstream converts +`true` to 1 and wraps the cast (F230); a `documents` of the wrong type stays `400` rather than +falling back to `texts` (F231); `chat_template_kwargs` keeps its string fallback, because a string +literal is a valid kwarg value while a tool schema must be an object (F233); a stop issued before +the serving thread has entered `jllama_rpc_server_serve` cannot be told from "nothing running" and +stays a no-op -- callers repeat the stop until the serve returns, as `RpcServer` does (F241). + +**Tests** +- **The model-backed `ErrorHandlingTest` case for a 4-byte character in a refusal is missing**: + `handleChatCompletions` with a `response_format` of type `😀` must throw `InvalidRequestException` + with `but got: 😀` in the message. The model-free half (a failed `LlamaModel` call keeps a 4-byte + character) is pinned. +- **The argument prefix that arrives before a tool call's name never reaches `onPartialToolCall`** + (`StreamingChunkAssembler`; documented in its Javadoc since this pass). If it is fixed, the contract + assertion `assertPartialsMakeUpTheCompleteCall` in `toolCallDeltaBeforeItsName_doesNotFailTheStream` + lands in the same commit; today it is red (`Expected: is "{\"city\":1}" but: was "ty\":1}"`). +- **`LoraCacheFreshSlot` writes `slot0.bin` into the test's working directory**, not under its + `--slot-save-path`: `engine::slot_action` uses the file name as the path, as + `LlamaModel.saveSlot(int, String filepath)` documents. Probably the test should pass a path under + the save directory; needs a decision. +- **`RpcClient.AStaleServerIsKeptOutOfALaterLoadThatDidNotAskForIt` fails under + `--gtest_repeat=2`** in one process (also filtered alone); it passes in a normal `ctest` run. The + test drives ggml's process-wide registry directly; find out which state the first run leaves. +- **Three FIM tests fail against a model without FIM tokens** (`500`): + `OpenAiServerCompletionIntegrationTest#infillReturnsContent`, `#ollamaGenerateWithSuffixUsesInfill` + and `ErrorHandlingTest#testHandleInfillMissingInputSuffix`. Not a defect -- CI's model has the + tokens -- but they should self-skip on a model without them, as the model-gated tests do. +- **`MemoryManagementTest` (context 32) assumes a context shift** that llama.cpp no longer makes by + default (`ctx_shift = false` at b11639); check whether it still tests what it says, and use the new + `enableContextShift()` if it should. +- **`llama-ffm`'s `FfmRpcServerTest` has no natives assumption**: without a native library it fails + instead of skipping like the module's other tests. +- **The model-backed rerank tests were not run locally** (no reranker model in the sandbox): + `OpenAiServerRerankIntegrationTest` covers F230 … F232 end to end only in CI, and F177's caveat that + its end-to-end case had not run locally can go once a CI run with the jina reranker shows it green. +- **The agent's 4xx path is tested without retries**: the tests run with `RetryPolicy.NONE`, so + nothing pins that Atmosphere skips retrying a `4xx` (optional). + +**Atmosphere agent** +- **`AgentOptions.withWorkspace` copies only `baseUrl`, `modelPath` and `workspace`**: the copy falls + back to the default `modelId` and every other option's default. Noticed while fixing F243, not + changed. + ### The release: 6.0.0, with an "Upgrading from 5.1" section (decided 2026-10-15; roadmap item 9) **Nothing has been published since 5.1.0 on 2026-08-29.** `pom.xml` is `6.0.0-SNAPSHOT`, and From df182e3149da4580bf9669ba4869a92308e99c00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:17:54 +0000 Subject: [PATCH 479/589] Correct three details of the closing documentation pass The changelog said every new /v1/responses refusal carries upstream's message; the function_call one names the call instead of upstream's "Cannot determine type of 'item'". The OaiRerankSupport note in BUGHUNT-CHECKED.md said only an absent top_n keeps every result, while since the float top_n fix a top_n that is no int-sized number keeps them too. And the F79 addendum had a grammar slip. Documentation only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 2 +- BUGHUNT-FOUND.md | 2 +- CHANGELOG.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 5ceba4e34..30354a153 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -144,7 +144,7 @@ seitdem geändert hat. Sie gelten als ungeprüft, bis jemand sie erneut liest. - `llama/src/main/java/net/ladenthin/llama/server/AnthropicStreamTranslator.java` · `bd6ac4e` · 2026-10-18 · kein Fund — Durchsicht „`server/AnthropicStreamTranslator.java` (Probe: Text, dann Werkzeugaufruf in drei Argumentstücken, `finish_reason: tool_calls`, Usage-Chunk)“: Ereignisfolge `message_start` → Textblock 0 → Stopp → `tool_use`-Block 1 mit den gesammelten Argumenten als ein `input_json_delta` → Stopp → `message_delta` (`stop_reason: tool_use`, Usage aus dem Usage-Chunk) → `message_stop`; das Sammeln der Argumente bis zum Ende ist spezifikationskonform (Anthropic garantiert keine Stückelung) ‖ **Abgelaufen:** Blob am HEAD `9824b5f`, seitdem 4 Commits an der Datei, darunter die Fixes F183, F221, F222. Frühere Durchsichten: kein Fund (`7f002e9`, 2026-10-17); kein Fund (`7f002e9`, 2026-10-17); Fund F81 (`bd6ac4e`, 2026-10-18); kein Fund (`bd6ac4e`, 2026-10-18). - `llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java` · `1e88958` · 2026-10-18 · Verdacht notiert — Durchsicht „`server/LlamaModelBackend.java` (`streamCompletions` ↔ upstream `to_json_oaicompat`)“: letzter Chunk ohne `usage`, sonst wie upstream (`finish_reason` `length`/`stop`, `logprobs: null`); Sink-Fehler bricht ab und schließt die Aufgabe über `LlamaIterable.close()` ‖ **Abgelaufen:** Blob am HEAD `052500f`, seitdem 4 Commits an der Datei, darunter die Fixes F177, F186, F232. Frühere Durchsichten: kein Fund (`3d47cac`, 2026-10-17); kein Fund (`3d47cac`, 2026-10-17); kein Fund (Designabweichung notiert) (`3d47cac`, 2026-10-17); kein Fund (Randnotiz) (`ohne Hash`, 2026-10-18); Fund F77 (`ohne Hash`, 2026-10-18); Fund F79 (`ohne Hash`, 2026-10-18); Fund F111 (`1e88958`, 2026-10-18). - `llama/src/main/java/net/ladenthin/llama/server/NativeServer.java` · `c679619` · 2026-10-17 · Fund F18 — Lebenszyklus (`start`/`isRunning`/`close`) unter `lifecycleLock`, abgesichert durch `NativeServerLifecycleTest` über die Naht `NativeServer.Backend` ‖ **Abgelaufen:** Blob am HEAD `5895cc8`, seitdem 1 Commit an der Datei. Frühere Durchsichten: kein Fund (`ohne Hash`, 2026-10-18). -- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream ‖ **Abgelaufen:** Blob am HEAD `27ffd89`, seitdem 5 Commits an der Datei, darunter die Fixes F177, F230, F231, F232. Frühere Durchsichten: kein Fund (`b578b98`, 2026-10-17); kein Fund (`b578b98`, 2026-10-17). ‖ Die Notizen vom 2026-10-17 zu `top_n` („`top_n<=0` = alle“, „`top_n ≤ 0` oder fehlend = alle“) sind seit F177 falsch: `top_n: 0` behält kein Ergebnis, ein negatives `top_n` wird mit 400 abgewiesen, nur ein fehlendes `top_n` behält alle. +- `llama/src/main/java/net/ladenthin/llama/server/OaiRerankSupport.java` · `b3c1c31` · 2026-10-18 · Fund F111 — TEI-Format (`texts`, bloßes Array, `return_text`) wie upstream ‖ **Abgelaufen:** Blob am HEAD `27ffd89`, seitdem 5 Commits an der Datei, darunter die Fixes F177, F230, F231, F232. Frühere Durchsichten: kein Fund (`b578b98`, 2026-10-17); kein Fund (`b578b98`, 2026-10-17). ‖ Die Notizen vom 2026-10-17 zu `top_n` („`top_n<=0` = alle“, „`top_n ≤ 0` oder fehlend = alle“) sind seit F177 falsch: `top_n: 0` behält kein Ergebnis, ein negatives `top_n` wird mit 400 abgewiesen, ein fehlendes `top_n` behält alle, seit F230 auch eines, das keine als `int` darstellbare Zahl ist (Wahrheitswert, Zeichenkette, außerhalb des `int`-Bereichs). - `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` · `73f11e1` · 2026-10-18 · kein Fund — Durchsicht „`server/OllamaApiSupport.java` (`toOllamaContentLine`/`toOllamaDoneLine` mit Werkzeugaufruf im Stream, Probe)“: Textstücke als eigene Zeilen, Werkzeug-Fragmente erzeugen keine Zeile, die Abschlusszeile trägt die gesammelten `tool_calls` mit als Objekt geparsten Argumenten sowie `prompt_eval_count`/`eval_count`; `done_reason: stop` bei Werkzeugaufrufen wie bei Ollama selbst (der `length`-Fall steht schon als Verdacht) ‖ **Abgelaufen:** Blob am HEAD `8425e69`, seitdem 8 Commits an der Datei, darunter die Fixes F178, F179, F205, F213, F215, F216, F217. Frühere Durchsichten: Fund F37 (`c1840dc`, 2026-10-17); kein Fund (Verdacht notiert) (`c1840dc`, 2026-10-17); Fund F64 (`339b3e0`, 2026-10-17); kein Fund (`ohne Hash`, 2026-10-17); Fund F74 (`122da1d`, 2026-10-18); Fund F75 (`ohne Hash`, 2026-10-18); kein Fund (`ed9aff1`, 2026-10-18); Fund F97 (`6f86c26`, 2026-10-18); Fund F98, F97 (`7e6c0b0`, 2026-10-18); Fund F104 (`a944f9a`, 2026-10-18); Fund F114 (`906cf46`, 2026-10-18); kein Fund nach F114 (`906cf46`, 2026-10-18); Fund F115 (`a058a51`, 2026-10-18); Fund F116 (`73f11e1`, 2026-10-18); kein Fund (`73f11e1`, 2026-10-18); kein Fund (Anmerkung) (`73f11e1`, 2026-10-18); Verdacht notiert (`73f11e1`, 2026-10-18); kein Fund (`73f11e1`, 2026-10-18); kein Fund (`73f11e1`, 2026-10-18). - `llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java` · `3353536` · 2026-10-17 · kein Fund — Naht ohne Logik; der Standard von `streamCompletions` wirft `UnsupportedOperationException`, die `OpenAiCompatServer.streamCompletions` als Server-Fehler-Ereignis im SSE-Strom meldet (Kopfzeilen sind dann schon gesendet), statt die Verbindung abzubrechen. Der einzige Produktions-Backend `LlamaModelBackend` überschreibt sie. ‖ **Abgelaufen:** Blob am HEAD `347617d`, seitdem 1 Commit an der Datei. - `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` · `a6ed187` · 2026-10-18 · kein Fund — Durchsicht „`server/OpenAiCompatServer.java` (Zugangsprüfung je Route)“: Schlüssel in konstanter Zeit verglichen (`MessageDigest.isEqual`, `Bearer` und `x-api-key`); `/v1/models`, `/metrics`, `/slots` und alle POST-Routen über `authorized`; `/health`, `/props` und die Ollama-Entdeckungsrouten bewusst offen und kommentiert (nur öffentliche Modelldaten); Größenprüfung erst nach der Zugangsprüfung, ein Unbefugter bekommt also kein 413-Orakel ‖ **Abgelaufen:** Blob am HEAD `0e42edc`, seitdem 13 Commits an der Datei, darunter die Fixes F174, F178, F179, F182, F199, F205, F213, F214, F215, F216, F223. Frühere Durchsichten: Fund F25 (`6bdd3ff`, 2026-10-17); kein Fund (`6bdd3ff`, 2026-10-17); Fund F65 (`09c9640`, 2026-10-17); kein Fund (Designabweichung notiert) (`09c9640`, 2026-10-17); Fund F70 (`ohne Hash`, 2026-10-18); kein Fund (Verdacht notiert) (`ohne Hash`, 2026-10-18); Fund F89 (`bc376ce`, 2026-10-18); Fund F98 (`77913cb`, 2026-10-18); kein Fund (`77913cb`, 2026-10-18); Fund F112 (`e38eb7e`, 2026-10-18); kein Fund nach F114 (`ohne Hash`, 2026-10-18); Fund F117 (`65df73d`, 2026-10-18); Fund F118 (`a6ed187`, 2026-10-18); kein Fund (`a6ed187`, 2026-10-18). diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c5cc5161c..44b04fcca 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -712,7 +712,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `toCompletionParameters` weist `echo: true`, `suffix` und `best_of` mit upstreams Meldungen ab (`IllegalArgumentException`, vom Server als Anfragefehler gemeldet). - **Reproduktion:** `OpenAiRequestMapperTest#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo` - **Rot-Grün-Nachweis:** ohne Fix rot („Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“), mit Fix grün (22/22 der Klasse, skipped 0); Gegenprobe per `git stash` rot, nach `pop` grün. `OpenAiCompatServerHttpTest` 45/45, die drei Übersetzer-Tests, `LlamaArchitectureTest` 13/13, `spotbugs:check` grün. -- **Nachtrag (Branch-Review 2026-10-20):** Der Test oben gibt es nicht mehr: F186 (`4fb82c1`) hat `toCompletionParameters` entfernt und mit ihm `#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo`. Das gestreamte `/v1/completions` geht seitdem wie das blockierende durch upstreams `oaicompat_completion_params_parse`, das `echo: true`, `best_of` und `suffix` abweist; `completions_oai_stream_request` (`jllama_engine.cpp`) macht daraus `jllama_invalid_request` (400). Gehalten durch `OaicompatCompletionParams.EchoTrue_Throws`, `UnsupportedParam_BestOf_Throws` und `UnsupportedParam_Suffix_Throws` (`test_utils.cpp`) für den Parser und durch die Abweisungsfälle von `CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` (`test_c_api.cpp`, fehlender Prompt → `JLLAMA_STATUS_INVALID_REQUEST`) für die Abbildung auf 400. +- **Nachtrag (Branch-Review 2026-10-20):** Den Test oben gibt es nicht mehr: F186 (`4fb82c1`) hat `toCompletionParameters` entfernt und mit ihm `#completionParamsUpstreamRejectsAreRejectedOnTheStreamedPathToo`. Das gestreamte `/v1/completions` geht seitdem wie das blockierende durch upstreams `oaicompat_completion_params_parse`, das `echo: true`, `best_of` und `suffix` abweist; `completions_oai_stream_request` (`jllama_engine.cpp`) macht daraus `jllama_invalid_request` (400). Gehalten durch `OaicompatCompletionParams.EchoTrue_Throws`, `UnsupportedParam_BestOf_Throws` und `UnsupportedParam_Suffix_Throws` (`test_utils.cpp`) für den Parser und durch die Abweisungsfälle von `CApi.OaiCompletionsStreamDeliversTextCompletionChunksEndingWithUsage` (`test_c_api.cpp`, fehlender Prompt → `JLLAMA_STATUS_INVALID_REQUEST`) für die Abbildung auf 400. - **Status:** gefixt - **Commit:** java-llama.cpp `4a40a1d` diff --git a/CHANGELOG.md b/CHANGELOG.md index b44225050..b0ae3fe0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -901,7 +901,8 @@ the first two items break every existing consumer at upgrade. `input_file` parts, an `input_image` without a URL (a `file_id` only), a tool output part that is neither `input_text` nor `input_image`, a `function_call` whose `arguments` is not a string (an object became `"{}"`), and an input item of an unknown type (`custom_tool_call`, - `item_reference`, ..., which became an empty user message), each with upstream's message. + `item_reference`, ..., which became an empty user message), each with upstream's message but the + `function_call` one, which names the call where upstream says "Cannot determine type of 'item'". - **An answer cut off by `max_output_tokens` is `incomplete`**, blocking and streamed: `status` `incomplete` with `incomplete_details.reason` `max_output_tokens`, the message item `incomplete` and `response.incomplete` as the last event, where it was reported `completed`. llama.cpp From 1503b9497447d3b919c432d815a2d1aed7335160 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:28:58 +0000 Subject: [PATCH 480/589] Refuse a non-string prompt on Ollama's /api/generate (F244) isLoadOnly read the prompt with asText(""), which Jackson answers with "" for an array or an object, so {"prompt":["a"]} and {"prompt":{}} were taken for a preload and answered 200 with done_reason "load". Ollama binds the prompt into a string field and refuses such a body with a 400. There was no 400 path to fall back on: the three generate translators read the prompt the same way and never throw, so with a suffix such a prompt ran into fill-in-the-middle as an empty one. requireTextualPrompt now refuses a prompt that is present, not null and not a string with IllegalArgumentException("'prompt' must be a string"); handleOllamaGenerate calls it first, before the raw conflict, the load answer and fill-in-the-middle, and answers it with 400 and Ollama's error body. isLoadOnly counts only an absent, null or empty string prompt as no prompt. The WEM suppression joins the request parsers' existing category, and the item is removed from TODO.md. Red against the unchanged code (with a no-op requireTextualPrompt so the tests compile): "Expected: is but: was " for isLoadOnly of {"prompt":["a"]}, "Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." and the HTTP test "Expected: is <400> but: was <200>"; green with the fix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 16 ++++++++++++ TODO.md | 4 --- llama/spotbugs-exclude.xml | 8 +++++- .../llama/server/OllamaApiSupport.java | 26 ++++++++++++++++++- .../llama/server/OpenAiCompatServer.java | 7 +++++ .../llama/server/OllamaApiSupportTest.java | 25 ++++++++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 22 ++++++++++++++++ 7 files changed, 102 insertions(+), 6 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 44b04fcca..f89a67476 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -166,6 +166,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F241](#f241) — niedrig bis mittel — *gefixt* - [F242](#f242) — niedrig — *gefixt* - [F243](#f243) — niedrig — *gefixt* +- [F244](#f244) — niedrig — *gefixt* ## Funde @@ -1727,6 +1728,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `200cc90` - **Nachtrag (Branch-Review 2026-10-20, Funde J1-4, J3-3, J16-2):** Beide Regeln sind bewusste Abweichungen von Ollama, nicht Ollamas Verhalten: Ollamas `GenerateHandler` prüft nur `req.Prompt == ""` (der `suffix` fügt dort nur `CapabilityInsert` hinzu), beantwortet also einen leeren Prompt mit `suffix` mit der Ladeantwort, und antwortet bei `keep_alive: 0` ohne Prompt mit `done_reason "unload"`. Hier bleibt der leere Prompt mit `suffix` FIM am Dateianfang (auch bei `"suffix":""`, `hasSuffix` fragt nur nach einem String), und `keep_alive: 0` bekommt die Ladeantwort, weil nichts entladen wird — ein „unload“ wäre eine Meldung über eine Aktion, die nicht stattfand. Die Javadoc von `isLoadOnly`/`toOllamaLoadResponse`, die README-Zeile der Ollama-Routen und `TODO.md` sagen das jetzt; vorher schrieb die Javadoc die `suffix`-Bedingung Ollama zu. Reine Doku, kein Test. +- **Nachtrag (Branch-Review 2026-10-20, Fund J1-4, Verhaltenshälfte):** `isLoadOnly` las den Prompt mit `asText("")` und hielt damit auch einen `prompt`, der ein Array oder Objekt ist, für „kein Prompt“ — ein fehlerhafter Body bekam die Ladeantwort (200), wo Ollama beim Binden 400 antwortet. Behoben als [F244](#f244): nur ein fehlender, `null` oder leerer String-Prompt ist load-only, ein Prompt anderen Typs wird vorher mit 400 abgewiesen. ### F213 – java-llama.cpp: Ollama `/api/chat` mit einem `messages`-Element, das kein Objekt ist, schließt die Verbindung ohne Antwort @@ -1779,6 +1781,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit Stubs, die `false` bzw. `{}` liefern) rot: der HTTP-Test mit „Expected: is but: was “ für `done` bei `{"model":"test-model"}` (das Testbackend streamte eine Antwort; das echte Backend weist die leere Konversation mit 400 ab), der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 41, `OpenAiCompatServerHttpTest` 68, `LlamaArchitectureTest` 13), `spotbugs:check` grün. - **Status:** gefixt - **Commit:** java-llama.cpp `a4d0604` +- **Nachtrag (Branch-Review 2026-10-20, Fund J1-4):** Die Chat-Route ist von F244 nicht betroffen: `isChatLoadOnly` prüft `messages` auf fehlend, `null` oder leeres Array, und ein `messages` falschen Typs weist die Übersetzung ab (F213). Auf `/api/generate` las `isLoadOnly` dagegen einen `prompt` falschen Typs als leer und gab die Ladeantwort; das behebt [F244](#f244), mit derselben Regel wie hier: nur die Abwesenheit ist Vorladen, ein falscher Typ ist 400. ### F217 – java-llama.cpp: Ollamas `think: true` wird verworfen — ein Server mit abgeschaltetem Denken übergeht es still @@ -2133,6 +2136,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f10aa6a` + +### F244 – java-llama.cpp: Ollama `/api/generate` mit einem `prompt`, der kein String ist, bekommt die Ladeantwort (200) statt 400 +- **Zeit:** 2026-10-21 08:26 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`isLoadOnly`, neu `requireTextualPrompt`), `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (`handleOllamaGenerate`), `llama/spotbugs-exclude.xml` (WEM-Unterdrückung der neuen Prüfung, Kategorie der Request-Parser) +- **Schwere:** niedrig (ein fehlerhafter Body wird als erfolgreiches Vorladen beantwortet; mit `suffix` oder nach einer Verschärfung von `isLoadOnly` allein liefe er als leerer Prompt in die Generierung) +- **Historie:** Gefunden beim Review des Branches am 2026-10-20 (Fund J1-4, Verhaltenshälfte; die Doku-Hälfte steht im Nachtrag zu F205). Eingeführt mit F205, dessen `isLoadOnly` den Prompt mit `asText("")` las. +- **Beschreibung:** Jacksons `asText("")` liefert für ein Array oder Objekt `""` (Container-Knoten) und ersetzt nur `null`; `isLoadOnly` hielt `{"prompt":["a"]}` und `{"prompt":{}}` deshalb für eine Anfrage ohne Prompt, und `handleOllamaGenerate` antwortete 200 mit `done_reason "load"`, ohne das Backend zu rufen. Ollama bindet den Body in ein `string`-Feld (`api.GenerateRequest.Prompt`, `ShouldBindJSON`) und antwortet auf einen solchen Body mit 400. Ein 400-Weg existierte hier nicht: auch die drei Übersetzer (`toOpenAiChatRequestFromGenerate`, `toOpenAiCompletionRequest`, `toInfillRequest`) lesen den Prompt mit `asText("")` und werfen nie, sodass ein solcher Prompt mit `suffix` als leerer Prompt in die FIM-Generierung lief. +- **Fix:** `requireTextualPrompt` wirft `IllegalArgumentException("'prompt' must be a string")`, wenn `prompt` vorhanden, nicht `null` und kein String ist; `handleOllamaGenerate` ruft es als erste Prüfung nach dem Einlesen, vor dem `raw`-Konflikt (F215), der Ladeantwort und FIM — Ollama scheitert schon beim Binden —, und beantwortet die Ausnahme mit 400 und Ollamas Fehlerkörper. `isLoadOnly` zählt nur einen fehlenden, `null` oder leeren String-Prompt als „kein Prompt“; seine Javadoc sagt das und verweist auf die Prüfung. +- **Tests:** `OllamaApiSupportTest.aNonTextPromptIsNotALoadRequest` (`isLoadOnly` falsch für Array und Objekt, wahr für `null`), `OllamaApiSupportTest.aPromptThatIsNotAStringIsRefused` (wirft für Array, Objekt, Zahl mit dem Wortlaut; nicht für String, leeren String, `null`, fehlend), `OpenAiCompatServerHttpTest.ollamaGenerateWithANonTextPromptIsABadRequest` (Array, Objekt mit `suffix`, Zahl mit `raw` und `stream`: 400 mit `error`, das Backend wird nicht gerufen). +- **Rot-Grün-Nachweis:** Gegen den unveränderten Code (mit einem `requireTextualPrompt`, das nichts tut, damit der Test übersetzt) rot: `aNonTextPromptIsNotALoadRequest` mit „Expected: is but: was “, `aPromptThatIsNotAStringIsRefused` mit „{"prompt":["a"]} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, der HTTP-Test mit „{"model":"test-model","prompt":["a"]} Expected: is <400> but: was <200>“; mit dem Fix grün (`OllamaApiSupportTest` 48, `OpenAiCompatServerHttpTest` 70, `LlamaArchitectureTest` 13), `spotbugs:check` und `javadoc:javadoc` grün. Der offene Punkt in `TODO.md` („Open points from the branch review“, Ollama routes) ist entfernt. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/TODO.md b/TODO.md index d3744820f..ec2ee7d72 100644 --- a/TODO.md +++ b/TODO.md @@ -536,10 +536,6 @@ scope, is listed here; the ledger entry in brackets holds the analysis. Delete a - **`/api/generate` without `raw` drops `template` and `context` without a word** (F215); Ollama applies them. The chat-template path has no slot for a caller's template or a token context, so the honest options are a `400` or a documented deviation. -- **A non-text `prompt` (`["a"]`, `{}`) on `/api/generate` is answered `200` with `done_reason - "load"`** (F205): `isLoadOnly` treats every non-textual prompt as absent. Ollama's field is a string; - the fix is a `400` for a present, non-null, non-text prompt (`requireTextualPrompt`), with a - model-free `OllamaApiSupportTest` case and an HTTP test, red/green. **Server transport** - **After a stream started, a backend `IOException` is taken for a disconnect** (F214): it is logged diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml index d67a7267c..6b8450d74 100644 --- a/llama/spotbugs-exclude.xml +++ b/llama/spotbugs-exclude.xml @@ -570,7 +570,8 @@ SPDX-License-Identifier: MIT absent by definition at the throw point. Same rationale as the ChatMessage.requireNonNull WEM suppression above. OpenAiRequestMapper.applyLogprobs additionally keeps upstream's message verbatim ("top_logprobs requires logprobs to be set to true", oaicompat_chat_params_parse), so - a client sees the same 400 text from both server modes. + a client sees the same 400 text from both server modes. OllamaApiSupport.requireTextualPrompt + ("'prompt' must be a string", F244) is the same guard for Ollama's /api/generate. --> @@ -589,6 +590,11 @@ SPDX-License-Identifier: MIT + + + + + diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 745054e11..45e1627e4 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -38,8 +38,10 @@ * *

    The {@code stream} flag is intentionally not mapped here — streaming is selected by the caller * ({@link net.ladenthin.llama.LlamaModel#chatComplete} forces it off, - * {@link net.ladenthin.llama.LlamaModel#streamChatCompletion} forces it on). Stateless and free of JNI - * and model dependencies, so it is unit-testable with JSON literals alone. + * {@link net.ladenthin.llama.LlamaModel#streamChatCompletion} forces it on). It is read for one check + * only, read the way the server picks the streaming path: upstream's refusal of {@code logprobs} on a + * streamed request with tools (see {@link #applyLogprobs}). Stateless and free of JNI and model + * dependencies, so it is unit-testable with JSON literals alone. */ final class OpenAiRequestMapper { @@ -150,7 +152,8 @@ InferenceParameters toInferenceParameters(JsonNode request) { * true} is the chat form and becomes {@code n_probs = top_logprobs} (20 without it); a * {@code top_logprobs} without it is refused with upstream's message; a number is the schema's alias * of {@code n_probs} (the legacy completions form), read only without an {@code n_probs}, and any - * other non-null value read there is refused, as upstream's schema refuses it. + * other non-null value read there is refused, as upstream's schema refuses it. {@code logprobs: true} + * on a streamed request with tools is refused with upstream's message, as upstream refuses it. * * @param params the parameters to extend * @param request the parsed OpenAI request object @@ -161,6 +164,10 @@ private static InferenceParameters applyLogprobs(InferenceParameters params, Jso JsonNode logprobs = request.path("logprobs"); JsonNode topLogprobs = request.path("top_logprobs"); if (logprobs.isBoolean() && logprobs.asBoolean()) { + JsonNode tools = request.path("tools"); + if (tools.isArray() && tools.size() > 0 && request.path("stream").asBoolean(false)) { + throw new IllegalArgumentException("logprobs is not supported with tools + stream"); + } JsonNode count = optionalNumber(request, "top_logprobs"); return params.withNProbs(count != null ? count.asInt() : DEFAULT_TOP_LOGPROBS); } diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 23fa64e62..ad2001386 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -978,6 +978,27 @@ public void anUnsupportedAnthropicToolChoiceIsA400() throws IOException { } } + /** + * Upstream refuses {@code logprobs} on a streamed chat request with tools; the chat route answers + * the same {@code 400} before the stream starts, and the blocking request still generates. + */ + @Test + public void logprobsWithToolsOnAStreamedChatIsA400LikeUpstream() throws IOException { + MappingBackend backend = new MappingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String body = "{\"stream\":true,\"logprobs\":true,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]," + + "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]}"; + Response response = post(server.getPort(), "/v1/chat/completions", body, ""); + assertThat(response.code, is(400)); + assertThat(response.body, containsString("logprobs is not supported with tools + stream")); + assertThat("nothing was generated", backend.called, is("")); + Response blocking = post( + server.getPort(), "/v1/chat/completions", body.replace("\"stream\":true", "\"stream\":false"), ""); + assertThat(blocking.code, is(200)); + assertThat(backend.called, is("complete")); + } + } + @Test public void aPlainLlamaExceptionStaysAServerError() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FailingBackend(), config()).start()) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java index 4f5f2312e..0fb5dac0d 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiRequestMapperTest.java @@ -301,6 +301,37 @@ public void aLogprobsThatIsNeitherBooleanNorNumberIsRefused() throws IOException assertThat(out.path("n_probs").asInt(-1), is(4)); } + private static final String TOOLS = "\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"f\"}}]"; + + @Test + public void logprobsWithToolsAndStreamIsRefusedLikeUpstream() { + // upstream's oaicompat_chat_params_parse (server-common.cpp:1457-1461, b11639): "logprobs is not + // supported with tools + stream" (400); the mapper turned logprobs into n_probs and never refused it. + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"stream\":true," + + "\"logprobs\":true," + TOOLS + "}")); + assertThat(e.getMessage(), is("logprobs is not supported with tools + stream")); + } + + @Test + public void logprobsWithToolsOrStreamAloneIsAccepted() throws IOException { + // the refusal needs all three, as upstream's: without stream, without tools, or with an integer + // logprobs (the schema's n_probs alias, which upstream's chat parser does not read as the chat form) + JsonNode blocking = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"logprobs\":true," + TOOLS + "}"); + assertThat(blocking.path("n_probs").asInt(-1), is(20)); + JsonNode noTools = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"stream\":true,\"logprobs\":true}"); + assertThat(noTools.path("n_probs").asInt(-1), is(20)); + JsonNode emptyTools = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," + + "\"stream\":true,\"logprobs\":true,\"tools\":[]}"); + assertThat(emptyTools.path("n_probs").asInt(-1), is(20)); + JsonNode alias = mapAndSerialize( + "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"stream\":true,\"logprobs\":2," + TOOLS + "}"); + assertThat(alias.path("n_probs").asInt(-1), is(2)); + } + @Test public void maxCompletionTokensPreferredOverMaxTokens() throws IOException { JsonNode out = mapAndSerialize("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]," From 02d187ff07d64a2d9268747af35304f1637b8c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:41:29 +0000 Subject: [PATCH 523/589] Record the commit of F255 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 0b26efdc2..779a2be6b 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2307,7 +2307,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiRequestMapperTest#logprobsWithToolsAndStreamIsRefusedLikeUpstream` (Meldung wörtlich), `#logprobsWithToolsOrStreamAloneIsAccepted` (ohne `stream`, ohne `tools`, mit leerem `tools`, mit ganzzahligem `logprobs`; vorher und nachher grün); `OpenAiCompatServerHttpTest#logprobsWithToolsOnAStreamedChatIsA400LikeUpstream` (gestreamt 400 mit der Meldung und nichts generiert, derselbe Body blockierend 200 über `complete`). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „OpenAiRequestMapperTest.logprobsWithToolsAndStreamIsRefusedLikeUpstream:310 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „OpenAiCompatServerHttpTest.logprobsWithToolsOnAStreamedChatIsA400LikeUpstream:992 Expected: is <400> but: was <200>“; mit dem Fix grün (`OpenAiRequestMapperTest` 57/57, `OpenAiCompatServerHttpTest` 73/73, `AnthropicApiSupportTest` 42/42, `ResponsesApiSupportTest` 70/70, `OllamaApiSupportTest` 49/49). `spotless:apply` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `de3c64f` ## Offene Verdachte ohne Fund-ID From 80d2d9c18c5b1d4d94c42b035f95267df00db4d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:46:58 +0000 Subject: [PATCH 524/589] Name an Anthropic image without media_type by its bytes (F256) A base64 image source without media_type became a data URL of type image/png whatever it held; upstream's converter defaults to image/jpeg. llama.cpp decodes the image by its content, so the type is informational, but it was wrong for every JPEG, GIF and WebP and differed from upstream. The type is now read from the encoded magic bytes by ImageMediaTypes, the check the Ollama route already did (now shared), and falls back to image/jpeg as upstream does; an explicit media_type still wins. Red before the fix: "Expected: is "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ" but: was "data:image/png;base64,/9j/4AAQSkZJRgABAQ"" and the same for unknown bytes; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 ++++++ README.md | 3 ++ TODO.md | 2 - .../llama/server/AnthropicApiSupport.java | 16 +++++-- .../llama/server/ImageMediaTypes.java | 43 ++++++++++++++++++ .../llama/server/OllamaApiSupport.java | 19 +++----- .../llama/server/AnthropicApiSupportTest.java | 44 +++++++++++++++++++ 7 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 llama/src/main/java/net/ladenthin/llama/server/ImageMediaTypes.java diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 779a2be6b..59d52093a 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -178,6 +178,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F253](#f253) — niedrig bis mittel — *gefixt* - [F254](#f254) — niedrig — *gefixt* - [F255](#f255) — niedrig — *gefixt* +- [F256](#f256) — niedrig — *gefixt* ## Funde @@ -361,6 +362,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Commit:** java-llama.cpp `3d38dd0` - **Nachtrag (Branch-Review 2026-10-20, Fund J4-5):** Die `url`-Quelle und der Rückfall für eine andere Quelle hatten keinen Test (nur `base64`). `AnthropicApiSupportTest#requestTurnsUrlImageSourcesIntoImageUrlParts` pinnt die URL selbst als `image_url`, in einer Nachricht und in einem `tool_result`; `#anUnsupportedImageSourceLeavesTheMessageText` (Quelle `ftp`) die Textform der Nachricht und den Text eines `tool_result`, das nur ein solches Bild trägt. Gegenprobe rot: ohne den `url`-Zweig („Expected: is "image_url" but: was """), mit `url`-Übernahme für jede Quelle („Expected: is but: was “); mit dem Code grün (`AnthropicApiSupportTest` 36/36). Die Quelle `file` (Files-API) und die Vorgabe `image/png` statt upstreams `image/jpeg` für ein `base64` ohne `media_type` sind nicht Teil dieses Nachtrags. - **Nachtrag (Branch-Review 2026-10-20, Fund JF-3):** Die Quelle `file` (Files-API, `{"type":"file","file_id":…}`) pinnt jetzt `AnthropicApiSupportTest#aFileImageSourceIsDroppedLikeUpstream`: in einer Nachricht und in einem `tool_result` entsteht kein `image_url`-Teil, die Nachricht behält ihre Textform (`what is this?`), das `tool_result` seinen Text (`done`), nichts wirft. Das ist upstreams Verhalten bei b11639: `server_chat_convert_anthropic_to_oai` (`tools/server/server-chat.cpp:417-441`, im `tool_result` `:478-494`; der Brief nannte `server-common.cpp`) kennt nur `base64` und `url` und verwirft alles andere still; der Kommentar im Test nennt die Stellen. Gegenprobe mit einer Mutation, die `imageUrl` für `file` eine leere URL liefern lässt (nicht committet): rot (`AnthropicApiSupportTest.aFileImageSourceIsDroppedLikeUpstream:210 Expected: is but: was `, der Inhalt war ein Array mit `image_url`); am HEAD grün (37/37). Die Vorgabe für `base64` ohne `media_type` (`image/png` hier, `image/jpeg` upstream, `server-chat.cpp:423`/`:483`) bleibt eine Entscheidung, TODO.md beschreibt beide Punkte zutreffend. +- **Nachtrag (Branch-Review 2026-10-21, Abschluss vor 6.0.0):** Ein `base64`-Bild ohne `media_type` heißt nicht mehr immer `image/png`: F256 erkennt PNG/JPEG/GIF/WebP an den Bytes und fällt wie upstream auf `image/jpeg` zurück. ### F30 – java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt @@ -2309,6 +2311,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `de3c64f` +### F256 – java-llama.cpp: Anthropic-`base64`-Bild ohne `media_type` wird immer `image/png`, upstream `image/jpeg` +- **Zeit:** 2026-10-21 14:46 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`imageUrl`), neu `ImageMediaTypes.java`, `OllamaApiSupport.java` (`toImageDataUrl` nutzt den gemeinsamen Helfer), `README.md` +- **Schwere:** niedrig (die Data-URL trug für ein JPEG, GIF oder WebP den falschen Typ; llama.cpp dekodiert nach Inhalt, der Typ ist nur eine Angabe – aber falsch und von upstream verschieden) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Anthropic Messages“, zweiter Punkt; Brief JR-9), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F29 führte die Bildblöcke mit dem Rückfall `image/png` ein. +- **Beschreibung:** `imageUrl` las `source.path("media_type").asText("image/png")`: ohne `media_type` hieß jedes Bild `image/png`. upstream (`server_chat_convert_anthropic_to_oai`, `tools/server/server-chat.cpp:423` und `:483`, b11639) nimmt `json_value(source, "media_type", std::string("image/jpeg"))`, also `image/jpeg`. Die Ollama-Route erkennt den Typ schon an den kodierten Magic-Bytes (`toImageDataUrl`). +- **Fix:** Ein gemeinsamer Helfer `ImageMediaTypes.ofBase64` (PNG `iVBORw0KGgo`, JPEG `/9j/`, GIF `R0lGOD`, WebP `UklGR`, sonst `null`; die ersten drei Bytes bestimmen die ersten vier Base64-Zeichen, verglichen wird also das kodierte Präfix). Anthropic: ein nicht leeres `media_type` bleibt maßgeblich, ohne es entscheiden die Bytes, unerkannt `image/jpeg` wie upstream. Ollama nutzt denselben Helfer mit seinem bisherigen Rückfall `image/png` (Verhalten unverändert). +- **Tests:** `AnthropicApiSupportTest#aBase64ImageWithoutMediaTypeIsNamedByItsBytes` (PNG, JPEG, GIF, WebP), `#aBase64ImageOfUnknownBytesWithoutMediaTypeIsJpegLikeUpstream`, `#anExplicitMediaTypeWinsOverTheBytes`; die Ollama-Bildtests bleiben grün. +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „aBase64ImageWithoutMediaTypeIsNamedByItsBytes:180 image/jpeg Expected: is "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ" but: was "data:image/png;base64,/9j/4AAQSkZJRgABAQ"“ und „aBase64ImageOfUnknownBytesWithoutMediaTypeIsJpegLikeUpstream:191 Expected: is "data:image/jpeg;base64,AAAAAAAA" but: was "data:image/png;base64,AAAAAAAA"“ (die PNG-Zeile war vorher zufällig grün und ist jetzt gepinnt); mit dem Fix grün (`AnthropicApiSupportTest` 45/45, `OllamaApiSupportTest` 49/49, `OpenAiCompatServerHttpTest` 73/73, `LlamaArchitectureTest` 13/13). `spotless:apply` grün. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/README.md b/README.md index 407ec7f70..991566f52 100644 --- a/README.md +++ b/README.md @@ -1035,6 +1035,9 @@ where it does; where llama-server would drop part of a request without a word, t that is neither a string nor an object (on Anthropic, one that is no object). - `logprobs: true` on a streamed chat request with tools is refused with llama-server's own message (`logprobs is not supported with tools + stream`); blocking, or without tools, it is answered. +- An Anthropic `base64` image without `media_type` is named by its bytes (PNG, JPEG, GIF, WebP) and as + `image/jpeg` otherwise; llama-server names every such image `image/jpeg`. The type is informational, + llama.cpp decodes the image by its content. Embed it in your app: diff --git a/TODO.md b/TODO.md index 0b5ecd09d..46d6e6422 100644 --- a/TODO.md +++ b/TODO.md @@ -561,8 +561,6 @@ scope, is listed here; the ledger entry in brackets holds the analysis. Delete a `server-chat.cpp:417-441`/`:478-494`, b11639). Pinned and documented since the branch review by `AnthropicApiSupportTest#aFileImageSourceIsDroppedLikeUpstream`; refusing it with a `400` instead would deviate from upstream and stays a decision. -- **A `base64` image without `media_type` defaults to `image/png` here and to `image/jpeg` upstream** - (F29); neither aligned nor pinned by a test. A decision first. - **Upstream candidate:** `stop_reason` `stop_sequence` for a matched stop word (F221); recorded in the upstream inventory ("Upstream submissions", last bullet) for the day after the release. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index 7d9fcac8a..ffd7c6bfe 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -296,13 +296,21 @@ private static void putReasoning(ObjectNode message, CharSequence reasoning) { /** * The OpenAI {@code image_url} URL for an Anthropic image {@code source}: a data URL for a base64 - * source, the URL itself for a url source, {@code null} for anything else. + * source, the URL itself for a url source, {@code null} for anything else. A base64 source without + * {@code media_type} is named by its encoded magic bytes ({@link ImageMediaTypes#ofBase64}), and as + * {@code image/jpeg} when they are not recognised, upstream's default + * ({@code server_chat_convert_anthropic_to_oai}, {@code tools/server/server-chat.cpp:423}, b11639). */ - private static @org.jspecify.annotations.Nullable String imageUrl(JsonNode source) { + private static @Nullable String imageUrl(JsonNode source) { String sourceType = source.path("type").asText(""); if ("base64".equals(sourceType) && source.path("data").isTextual()) { - return "data:" + source.path("media_type").asText("image/png") + ";base64," - + source.path("data").asText(); + String data = source.path("data").asText(); + String mediaType = source.path("media_type").asText(""); + if (mediaType.isEmpty()) { + String sniffed = ImageMediaTypes.ofBase64(data); + mediaType = sniffed != null ? sniffed : "image/jpeg"; + } + return "data:" + mediaType + ";base64," + data; } if ("url".equals(sourceType) && source.path("url").isTextual()) { return source.path("url").asText(); diff --git a/llama/src/main/java/net/ladenthin/llama/server/ImageMediaTypes.java b/llama/src/main/java/net/ladenthin/llama/server/ImageMediaTypes.java new file mode 100644 index 000000000..fbd995035 --- /dev/null +++ b/llama/src/main/java/net/ladenthin/llama/server/ImageMediaTypes.java @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama.server; + +import org.jspecify.annotations.Nullable; + +/** + * Reads the media type of a base64-encoded image from its encoded magic bytes, for the protocol + * translations that receive an image without one (Ollama's bare base64 {@code images}, an Anthropic + * {@code base64} source without {@code media_type}). llama.cpp decodes an image by its content and + * requires only the {@code data:image/...;base64,} shape of the URL, so the type is informational; each + * caller picks its own fallback for bytes this does not recognise. The first three bytes of an image + * fix the first four base64 characters, so the encoded prefix is compared rather than decoded. + */ +final class ImageMediaTypes { + + private ImageMediaTypes() {} + + /** + * The media type of the base64-encoded image, read from its magic bytes. + * + * @param base64 the encoded image + * @return {@code image/png}, {@code image/jpeg}, {@code image/gif} or {@code image/webp}, or + * {@code null} for bytes of another format + */ + static @Nullable String ofBase64(String base64) { + if (base64.startsWith("iVBORw0KGgo")) { + return "image/png"; + } + if (base64.startsWith("/9j/")) { + return "image/jpeg"; + } + if (base64.startsWith("R0lGOD")) { + return "image/gif"; + } + if (base64.startsWith("UklGR")) { + return "image/webp"; + } + return null; + } +} diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 7627fda17..891c47445 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -248,25 +248,16 @@ private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { /** * Turns an Ollama image (bare base64, or already a data URL) into the data URL an OpenAI - * {@code image_url} part carries. The MIME type is read from the encoded magic bytes; llama.cpp - * decodes by content and only requires the {@code data:image/...;base64,} shape, so an unknown - * format falls back to {@code image/png}. + * {@code image_url} part carries. The MIME type is read from the encoded magic bytes + * ({@link ImageMediaTypes#ofBase64}); llama.cpp decodes by content and only requires the + * {@code data:image/...;base64,} shape, so an unknown format falls back to {@code image/png}. */ private static String toImageDataUrl(String image) { if (image.startsWith("data:")) { return image; } - String mimeType; - if (image.startsWith("/9j/")) { - mimeType = "image/jpeg"; - } else if (image.startsWith("R0lGOD")) { - mimeType = "image/gif"; - } else if (image.startsWith("UklGR")) { - mimeType = "image/webp"; - } else { - mimeType = "image/png"; - } - return "data:" + mimeType + ";base64," + image; + String mimeType = ImageMediaTypes.ofBase64(image); + return "data:" + (mimeType != null ? mimeType : "image/png") + ";base64," + image; } /** diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 675362b59..97e192909 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -154,6 +154,50 @@ public void disabledThinkingSetsNoBudget() throws IOException { assertThat(openAi.has("thinking_budget_tokens"), is(false)); } + private static String imageUrlOf(String source) throws IOException { + JsonNode messages = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[{\"role\":\"user\"," + + "\"content\":[{\"type\":\"image\",\"source\":" + source + "}]}]}")) + .path("messages"); + return messages.path(0) + .path("content") + .path(0) + .path("image_url") + .path("url") + .asText(); + } + + @Test + public void aBase64ImageWithoutMediaTypeIsNamedByItsBytes() throws IOException { + // Without media_type the data URL named every image image/png. The type is read from the + // encoded magic bytes as on the Ollama route (PNG, JPEG, GIF, WebP). + String[][] images = { + {"iVBORw0KGgoAAAANSUhEUg", "image/png"}, + {"/9j/4AAQSkZJRgABAQ", "image/jpeg"}, + {"R0lGODlhAQABAIAAAP", "image/gif"}, + {"UklGRh4AAABXRUJQVlA4", "image/webp"} + }; + for (String[] image : images) { + assertThat( + image[1], + imageUrlOf("{\"type\":\"base64\",\"data\":\"" + image[0] + "\"}"), + is("data:" + image[1] + ";base64," + image[0])); + } + } + + @Test + public void aBase64ImageOfUnknownBytesWithoutMediaTypeIsJpegLikeUpstream() throws IOException { + // upstream's server_chat_convert_anthropic_to_oai defaults media_type to image/jpeg + // (tools/server/server-chat.cpp:423, b11639) + assertThat(imageUrlOf("{\"type\":\"base64\",\"data\":\"AAAAAAAA\"}"), is("data:image/jpeg;base64,AAAAAAAA")); + } + + @Test + public void anExplicitMediaTypeWinsOverTheBytes() throws IOException { + assertThat( + imageUrlOf("{\"type\":\"base64\",\"media_type\":\"image/webp\",\"data\":\"iVBORw0KGgo\"}"), + is("data:image/webp;base64,iVBORw0KGgo")); + } + @Test public void requestTurnsUrlImageSourcesIntoImageUrlParts() throws IOException { // A url source is passed on as the image_url itself, as upstream's convert_anthropic_to_oai does, From 25ff1d45a311d11934b1ca61d5a6f86155198855 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:46:58 +0000 Subject: [PATCH 525/589] Record the commit of F256 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 59d52093a..fac8dcf17 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2321,7 +2321,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#aBase64ImageWithoutMediaTypeIsNamedByItsBytes` (PNG, JPEG, GIF, WebP), `#aBase64ImageOfUnknownBytesWithoutMediaTypeIsJpegLikeUpstream`, `#anExplicitMediaTypeWinsOverTheBytes`; die Ollama-Bildtests bleiben grün. - **Rot-Grün-Nachweis:** Ohne den Fix rot: „aBase64ImageWithoutMediaTypeIsNamedByItsBytes:180 image/jpeg Expected: is "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ" but: was "data:image/png;base64,/9j/4AAQSkZJRgABAQ"“ und „aBase64ImageOfUnknownBytesWithoutMediaTypeIsJpegLikeUpstream:191 Expected: is "data:image/jpeg;base64,AAAAAAAA" but: was "data:image/png;base64,AAAAAAAA"“ (die PNG-Zeile war vorher zufällig grün und ist jetzt gepinnt); mit dem Fix grün (`AnthropicApiSupportTest` 45/45, `OllamaApiSupportTest` 49/49, `OpenAiCompatServerHttpTest` 73/73, `LlamaArchitectureTest` 13/13). `spotless:apply` grün. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `80d2d9c` ## Offene Verdachte ohne Fund-ID From baecd5a2cd57c1a52c9a856b2728eee2359bf1db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:53:51 +0000 Subject: [PATCH 526/589] Refuse an Anthropic image source that cannot be served (F257) An image whose source was neither base64 nor url, the Files API's file source among them, and a base64 or url source without its data or url gave no image part: the message went on as text and the model answered as if no image had been sent. Upstream drops such an image the same way; a local server cannot fetch a file_id, and saying so beats answering without it. imageUrl now refuses those sources with an IllegalArgumentException naming the type ("image source type 'file' is not supported; send base64 or url") or the missing field, in a message and inside a tool_result; the Anthropic route answers 400 invalid_request_error before any stream starts. The two tests that pinned the old dropping are replaced by tests of the refusal. Red before the fix: "Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown." and, over HTTP, "Expected: is <400> but: was <200>"; green after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 ++++ README.md | 4 + TODO.md | 4 - .../llama/server/AnthropicApiSupport.java | 48 ++++++----- .../llama/server/AnthropicApiSupportTest.java | 83 +++++++++++-------- .../server/OpenAiCompatServerHttpTest.java | 18 ++++ 6 files changed, 111 insertions(+), 60 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index fac8dcf17..a3a4db889 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -179,6 +179,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F254](#f254) — niedrig — *gefixt* - [F255](#f255) — niedrig — *gefixt* - [F256](#f256) — niedrig — *gefixt* +- [F257](#f257) — niedrig bis mittel — *gefixt* ## Funde @@ -363,6 +364,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag (Branch-Review 2026-10-20, Fund J4-5):** Die `url`-Quelle und der Rückfall für eine andere Quelle hatten keinen Test (nur `base64`). `AnthropicApiSupportTest#requestTurnsUrlImageSourcesIntoImageUrlParts` pinnt die URL selbst als `image_url`, in einer Nachricht und in einem `tool_result`; `#anUnsupportedImageSourceLeavesTheMessageText` (Quelle `ftp`) die Textform der Nachricht und den Text eines `tool_result`, das nur ein solches Bild trägt. Gegenprobe rot: ohne den `url`-Zweig („Expected: is "image_url" but: was """), mit `url`-Übernahme für jede Quelle („Expected: is but: was “); mit dem Code grün (`AnthropicApiSupportTest` 36/36). Die Quelle `file` (Files-API) und die Vorgabe `image/png` statt upstreams `image/jpeg` für ein `base64` ohne `media_type` sind nicht Teil dieses Nachtrags. - **Nachtrag (Branch-Review 2026-10-20, Fund JF-3):** Die Quelle `file` (Files-API, `{"type":"file","file_id":…}`) pinnt jetzt `AnthropicApiSupportTest#aFileImageSourceIsDroppedLikeUpstream`: in einer Nachricht und in einem `tool_result` entsteht kein `image_url`-Teil, die Nachricht behält ihre Textform (`what is this?`), das `tool_result` seinen Text (`done`), nichts wirft. Das ist upstreams Verhalten bei b11639: `server_chat_convert_anthropic_to_oai` (`tools/server/server-chat.cpp:417-441`, im `tool_result` `:478-494`; der Brief nannte `server-common.cpp`) kennt nur `base64` und `url` und verwirft alles andere still; der Kommentar im Test nennt die Stellen. Gegenprobe mit einer Mutation, die `imageUrl` für `file` eine leere URL liefern lässt (nicht committet): rot (`AnthropicApiSupportTest.aFileImageSourceIsDroppedLikeUpstream:210 Expected: is but: was `, der Inhalt war ein Array mit `image_url`); am HEAD grün (37/37). Die Vorgabe für `base64` ohne `media_type` (`image/png` hier, `image/jpeg` upstream, `server-chat.cpp:423`/`:483`) bleibt eine Entscheidung, TODO.md beschreibt beide Punkte zutreffend. - **Nachtrag (Branch-Review 2026-10-21, Abschluss vor 6.0.0):** Ein `base64`-Bild ohne `media_type` heißt nicht mehr immer `image/png`: F256 erkennt PNG/JPEG/GIF/WebP an den Bytes und fällt wie upstream auf `image/jpeg` zurück. +- **Nachtrag (Branch-Review 2026-10-21, Abschluss vor 6.0.0):** Die Entscheidung zur `file`-Quelle ist gefallen: F257 weist sie (und jede andere unbekannte Quelle sowie eine Quelle ohne `data`/`url`) mit 400 ab; `#aFileImageSourceIsDroppedLikeUpstream` und `#anUnsupportedImageSourceLeavesTheMessageText` sind durch `#aFileImageSourceIsRefused` und `#anImageSourceOfAnUnknownTypeIsRefused` ersetzt. ### F30 – java-llama.cpp: Fehlgeschlagener `RpcServer.start` lässt den Server weiterlaufen, wenn der Stopp vor dem Bind kommt @@ -2323,6 +2325,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `80d2d9c` +### F257 – java-llama.cpp: Anthropic-Bildquelle `file` (Files-API) und jede andere unbekannte Quelle werden still verworfen +- **Zeit:** 2026-10-21 14:53 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java` (`imageUrl`, `appendOpenAiMessages`, `toolResultContent`), `README.md` +- **Schwere:** niedrig bis mittel (ein Bild per `file_id` kam ohne Hinweis nicht beim Modell an; es antwortete, als wäre keines geschickt worden) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Anthropic Messages“, erster Punkt; Brief JR-10), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F29 führte die Bildblöcke ein, der Nachtrag JF-3 pinnte das Verwerfen der `file`-Quelle als upstream-gleich (`#aFileImageSourceIsDroppedLikeUpstream`), J4-5 den Rückfall für eine andere Quelle (`#anUnsupportedImageSourceLeavesTheMessageText`). +- **Beschreibung:** `imageUrl` lieferte für eine Quelle, die weder `base64` mit String-`data` noch `url` mit String-`url` war, `null`, und der Aufrufer ließ den Block weg; die Nachricht ging als Text weiter, in einem `tool_result` ebenso. upstream (`server_chat_convert_anthropic_to_oai`, `tools/server/server-chat.cpp:417-441` für eine Nachricht, `:478-494` in einem `tool_result`, b11639) wandelt nur `base64` und `url` und verwirft alles andere ebenfalls ohne Fehler. Die Files-API-Quelle `{"type":"file","file_id":…}` kann ein lokaler Server nicht abrufen. +- **Fix:** Entscheidung (explizit schlägt still, auch gegen upstream): eine Quelle anderen Typs ist eine `IllegalArgumentException` „image source type 'file' is not supported; send base64 or url“ (der Typ steht in der Meldung, ein fehlender als `''`), eine `base64`-Quelle ohne String-`data` „image source of type 'base64' needs a string 'data' (got missing)“, eine `url`-Quelle ohne String-`url` entsprechend; `handleAnthropicMessages` beantwortet sie als 400 `invalid_request_error`, gestreamt wie blockierend vor dem Strom (F223). `imageUrl` gibt nie mehr `null` zurück, beide Aufrufer fügen den Teil ohne Prüfung an. Abweichung vom Brief: die fehlende `data`/`url` einer bekannten Quelle mit erfasst (dieselbe stille Klasse: der Block fiel ebenso weg); `url` wird weiter unterstützt wie bisher. +- **Tests:** `AnthropicApiSupportTest#aFileImageSourceIsRefused` (ersetzt `#aFileImageSourceIsDroppedLikeUpstream`; Nachricht und `tool_result`), `#anImageSourceOfAnUnknownTypeIsRefused` (ersetzt `#anUnsupportedImageSourceLeavesTheMessageText`; `ftp` und leer), `#anImageSourceWithoutItsDataOrUrlIsRefused`; `OpenAiCompatServerHttpTest#aFileImageSourceOnTheAnthropicRouteIsA400` (gestreamt und blockierend 400 `invalid_request_error` mit der Meldung, nichts generiert). Die alten Pin-Tests prüften das Gegenteil: „content.isTextual() is true“ und „asText() is "what is this?"“ nach dem Verwerfen. +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „AnthropicApiSupportTest.aFileImageSourceIsRefused:237 {…"source":{"type":"file","file_id":"file_011a"}…} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, ebenso `anImageSourceOfAnUnknownTypeIsRefused:249 ftp` und `anImageSourceWithoutItsDataOrUrlIsRefused:266`, und „OpenAiCompatServerHttpTest.aFileImageSourceOnTheAnthropicRouteIsA400:1012 … Expected: is <400> but: was <200>“; mit dem Fix grün (`AnthropicApiSupportTest` 46/46, `OpenAiCompatServerHttpTest` 74/74, `AnthropicStreamTranslatorTest` 11/11, `OllamaApiSupportTest` 49/49, `OpenAiRequestMapperTest` 57/57, `ResponsesApiSupportTest` 70/70). `spotless:apply` grün; die festen Meldungen der ersten Fassung wurden um den gefundenen JSON-Typ ergänzt (die WEM-Regel von SpotBugs, vgl. F227). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/README.md b/README.md index 991566f52..ef8cc4add 100644 --- a/README.md +++ b/README.md @@ -1038,6 +1038,10 @@ where it does; where llama-server would drop part of a request without a word, t - An Anthropic `base64` image without `media_type` is named by its bytes (PNG, JPEG, GIF, WebP) and as `image/jpeg` otherwise; llama-server names every such image `image/jpeg`. The type is informational, llama.cpp decodes the image by its content. +- An Anthropic image whose source is neither `base64` nor `url` (the Files API's `file`, which a local + server cannot fetch) or lacks its `data` / `url` is refused (`image source type 'file' is not + supported; send base64 or url`), in a message and inside a `tool_result`; llama-server drops such an + image without a word, and the model answers as if none had been sent. Embed it in your app: diff --git a/TODO.md b/TODO.md index 46d6e6422..21dc34ff9 100644 --- a/TODO.md +++ b/TODO.md @@ -557,10 +557,6 @@ scope, is listed here; the ledger entry in brackets holds the analysis. Delete a current contract or change it with a red/green test. **Anthropic Messages** -- **The image source `file` (Files API) is dropped without a word**, here and upstream (F29; - `server-chat.cpp:417-441`/`:478-494`, b11639). Pinned and documented since the branch review by - `AnthropicApiSupportTest#aFileImageSourceIsDroppedLikeUpstream`; refusing it with a `400` instead - would deviate from upstream and stays a decision. - **Upstream candidate:** `stop_reason` `stop_sequence` for a matched stop word (F221); recorded in the upstream inventory ("Upstream submissions", last bullet) for the day after the release. diff --git a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java index ffd7c6bfe..edfb45834 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/AnthropicApiSupport.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import java.io.IOException; +import java.util.Locale; import org.jspecify.annotations.Nullable; /** @@ -226,14 +227,11 @@ private static void appendOpenAiMessages(ArrayNode out, JsonNode anthropicMessag reasoning.append(block.path("thinking").asText("")); break; case "image": - String url = imageUrl(block.path("source")); - if (url != null) { - parts.addObject() - .put("type", "image_url") - .putObject("image_url") - .put("url", url); - hadImage = true; - } + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", imageUrl(block.path("source"))); + hadImage = true; break; case "tool_use": // Assistant tool call: Anthropic input (object) -> OpenAI arguments (JSON string). @@ -296,14 +294,20 @@ private static void putReasoning(ObjectNode message, CharSequence reasoning) { /** * The OpenAI {@code image_url} URL for an Anthropic image {@code source}: a data URL for a base64 - * source, the URL itself for a url source, {@code null} for anything else. A base64 source without + * source, the URL itself for a url source. Any other source ({@code file}, the Files API, which a + * local server cannot fetch) and a source without its {@code data} or {@code url} are refused, where + * upstream drops the image without a word and the model answers as if none had been sent. A base64 source without * {@code media_type} is named by its encoded magic bytes ({@link ImageMediaTypes#ofBase64}), and as * {@code image/jpeg} when they are not recognised, upstream's default * ({@code server_chat_convert_anthropic_to_oai}, {@code tools/server/server-chat.cpp:423}, b11639). */ - private static @Nullable String imageUrl(JsonNode source) { + private static String imageUrl(JsonNode source) { String sourceType = source.path("type").asText(""); - if ("base64".equals(sourceType) && source.path("data").isTextual()) { + if ("base64".equals(sourceType)) { + if (!source.path("data").isTextual()) { + throw new IllegalArgumentException("image source of type 'base64' needs a string 'data' (got " + + source.path("data").getNodeType().name().toLowerCase(Locale.ROOT) + ")"); + } String data = source.path("data").asText(); String mediaType = source.path("media_type").asText(""); if (mediaType.isEmpty()) { @@ -312,10 +316,15 @@ private static void putReasoning(ObjectNode message, CharSequence reasoning) { } return "data:" + mediaType + ";base64," + data; } - if ("url".equals(sourceType) && source.path("url").isTextual()) { + if ("url".equals(sourceType)) { + if (!source.path("url").isTextual()) { + throw new IllegalArgumentException("image source of type 'url' needs a string 'url' (got " + + source.path("url").getNodeType().name().toLowerCase(Locale.ROOT) + ")"); + } return source.path("url").asText(); } - return null; + throw new IllegalArgumentException( + "image source type '" + sourceType + "' is not supported; send base64 or url"); } /** @@ -334,14 +343,11 @@ private static JsonNode toolResultContent(JsonNode content) { .put("type", "text") .put("text", block.path("text").asText("")); } else if ("image".equals(type)) { - String url = imageUrl(block.path("source")); - if (url != null) { - parts.addObject() - .put("type", "image_url") - .putObject("image_url") - .put("url", url); - hadImage = true; - } + parts.addObject() + .put("type", "image_url") + .putObject("image_url") + .put("url", imageUrl(block.path("source"))); + hadImage = true; } } if (hadImage) { diff --git a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java index 97e192909..58fe5b91a 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/AnthropicApiSupportTest.java @@ -218,44 +218,57 @@ public void requestTurnsUrlImageSourcesIntoImageUrlParts() throws IOException { } @Test - public void anUnsupportedImageSourceLeavesTheMessageText() throws IOException { - // A source that is neither base64 nor url gives no image part (upstream pushes nothing either): - // the message keeps its textual form, and a tool_result holding only such an image its text. - JsonNode messages = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[" - + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"what is this?\"}," - + "{\"type\":\"image\",\"source\":{\"type\":\"ftp\",\"url\":\"ftp://x/a.png\"}}]}," - + "{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\",\"input\":{}}]}," - + "{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"content\":[" - + "{\"type\":\"image\",\"source\":{\"type\":\"ftp\",\"url\":\"ftp://x/b.png\"}}]}]}]}")) - .path("messages"); - JsonNode content = messages.path(0).path("content"); - assertThat(content.isTextual(), is(true)); - assertThat(content.asText(), is("what is this?")); - JsonNode toolContent = messages.path(2).path("content"); - assertThat(toolContent.isTextual(), is(true)); - assertThat(toolContent.asText(), is("")); + public void aFileImageSourceIsRefused() throws IOException { + // A Files-API source ({type:file, file_id}) has nothing a local server could fetch. Upstream's + // server_chat_convert_anthropic_to_oai (tools/server/server-chat.cpp:417-441 for a message, + // :478-494 inside a tool_result, b11639) drops it without an error, and the model answered as if + // no image had been sent; it is refused instead, in a message and inside a tool_result. + String[] requests = { + "{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"what is this?\"}," + + "{\"type\":\"image\",\"source\":{\"type\":\"file\",\"file_id\":\"file_011a\"}}]}]}", + "{\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\"," + + "\"name\":\"read\",\"input\":{}}]}," + + "{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"content\":[" + + "{\"type\":\"text\",\"text\":\"done\"}," + + "{\"type\":\"image\",\"source\":{\"type\":\"file\",\"file_id\":\"file_011b\"}}]}]}]}" + }; + for (String json : requests) { + JsonNode request = read(json); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> AnthropicApiSupport.toOpenAiChatRequest(request), json); + assertThat(e.getMessage(), is("image source type 'file' is not supported; send base64 or url")); + } } @Test - public void aFileImageSourceIsDroppedLikeUpstream() throws IOException { - // A Files-API source ({type:file, file_id}) has nothing a local server could fetch. Upstream's - // server_chat_convert_anthropic_to_oai (tools/server/server-chat.cpp:417-441 for a message, - // :478-494 inside a tool_result, b11639) converts only base64 and url sources and drops it - // without an error; this pins the same behaviour: no image_url part, the message keeps its text. - JsonNode messages = AnthropicApiSupport.toOpenAiChatRequest(read("{\"messages\":[" - + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"what is this?\"}," - + "{\"type\":\"image\",\"source\":{\"type\":\"file\",\"file_id\":\"file_011a\"}}]}," - + "{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"read\",\"input\":{}}]}," - + "{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"t1\",\"content\":[" - + "{\"type\":\"text\",\"text\":\"done\"}," - + "{\"type\":\"image\",\"source\":{\"type\":\"file\",\"file_id\":\"file_011b\"}}]}]}]}")) - .path("messages"); - JsonNode content = messages.path(0).path("content"); - assertThat(content.isTextual(), is(true)); - assertThat(content.asText(), is("what is this?")); - JsonNode toolContent = messages.path(2).path("content"); - assertThat(toolContent.isTextual(), is(true)); - assertThat(toolContent.asText(), is("done")); + public void anImageSourceOfAnUnknownTypeIsRefused() throws IOException { + // Neither base64 nor url gave no image part, and the message went on as text without a word. + for (String type : new String[] {"ftp", ""}) { + JsonNode request = read("{\"messages\":[{\"role\":\"user\",\"content\":[" + + "{\"type\":\"image\",\"source\":{\"type\":\"" + type + "\",\"url\":\"ftp://x/a.png\"}}]}]}"); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> AnthropicApiSupport.toOpenAiChatRequest(request), type); + assertThat(e.getMessage(), is("image source type '" + type + "' is not supported; send base64 or url")); + } + } + + @Test + public void anImageSourceWithoutItsDataOrUrlIsRefused() throws IOException { + String[][] cases = { + { + "{\"type\":\"base64\",\"media_type\":\"image/png\"}", + "image source of type 'base64' needs a string 'data' (got missing)" + }, + {"{\"type\":\"base64\",\"data\":7}", "image source of type 'base64' needs a string 'data' (got number)"}, + {"{\"type\":\"url\"}", "image source of type 'url' needs a string 'url' (got missing)"} + }; + for (String[] c : cases) { + JsonNode request = read("{\"messages\":[{\"role\":\"user\",\"content\":[" + + "{\"type\":\"image\",\"source\":" + c[0] + "}]}]}"); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, () -> AnthropicApiSupport.toOpenAiChatRequest(request), c[0]); + assertThat(e.getMessage(), is(c[1])); + } } @Test diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index ad2001386..6aee52a1c 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -999,6 +999,24 @@ public void logprobsWithToolsOnAStreamedChatIsA400LikeUpstream() throws IOExcept } } + /** An Anthropic image the server cannot fetch is a 400, streamed or not, before anything is generated. */ + @Test + public void aFileImageSourceOnTheAnthropicRouteIsA400() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + for (String stream : new String[] {"false", "true"}) { + String body = "{\"model\":\"test-model\",\"max_tokens\":8,\"stream\":" + stream + + ",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"what?\"}," + + "{\"type\":\"image\",\"source\":{\"type\":\"file\",\"file_id\":\"file_1\"}}]}]}"; + Response response = post(server.getPort(), "/v1/messages", body, ""); + assertThat(body, response.code, is(400)); + assertThat(body, response.body, containsString("\"type\":\"invalid_request_error\"")); + assertThat(body, response.body, containsString("image source type 'file' is not supported")); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + @Test public void aPlainLlamaExceptionStaysAServerError() throws IOException { try (OpenAiCompatServer server = new OpenAiCompatServer(new FailingBackend(), config()).start()) { From 346ada79037a7a984602ba1dc038b3ee2ff67add Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:53:51 +0000 Subject: [PATCH 527/589] Record the commit of F257 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index a3a4db889..7460dda84 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2335,7 +2335,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `AnthropicApiSupportTest#aFileImageSourceIsRefused` (ersetzt `#aFileImageSourceIsDroppedLikeUpstream`; Nachricht und `tool_result`), `#anImageSourceOfAnUnknownTypeIsRefused` (ersetzt `#anUnsupportedImageSourceLeavesTheMessageText`; `ftp` und leer), `#anImageSourceWithoutItsDataOrUrlIsRefused`; `OpenAiCompatServerHttpTest#aFileImageSourceOnTheAnthropicRouteIsA400` (gestreamt und blockierend 400 `invalid_request_error` mit der Meldung, nichts generiert). Die alten Pin-Tests prüften das Gegenteil: „content.isTextual() is true“ und „asText() is "what is this?"“ nach dem Verwerfen. - **Rot-Grün-Nachweis:** Ohne den Fix rot: „AnthropicApiSupportTest.aFileImageSourceIsRefused:237 {…"source":{"type":"file","file_id":"file_011a"}…} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, ebenso `anImageSourceOfAnUnknownTypeIsRefused:249 ftp` und `anImageSourceWithoutItsDataOrUrlIsRefused:266`, und „OpenAiCompatServerHttpTest.aFileImageSourceOnTheAnthropicRouteIsA400:1012 … Expected: is <400> but: was <200>“; mit dem Fix grün (`AnthropicApiSupportTest` 46/46, `OpenAiCompatServerHttpTest` 74/74, `AnthropicStreamTranslatorTest` 11/11, `OllamaApiSupportTest` 49/49, `OpenAiRequestMapperTest` 57/57, `ResponsesApiSupportTest` 70/70). `spotless:apply` grün; die festen Meldungen der ersten Fassung wurden um den gefundenen JSON-Typ ergänzt (die WEM-Regel von SpotBugs, vgl. F227). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `baecd5a` ## Offene Verdachte ohne Fund-ID From 27933f7011c6d8ab0964b4d6cdc8a8ee3a5c57a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:58:46 +0000 Subject: [PATCH 528/589] Read each mapper number once and keep the cap aliases in a constant SpotBugs, run once over the batch, flagged the number reader introduced by F253: "n" and "id_slot" called asInt() twice on the same node, and readMaxTokens built its alias array on every call. Each value is now read into a local and the aliases are a static constant; behaviour is unchanged (OpenAiRequestMapperTest 57/57, OpenAiCompatServerHttpTest 74/74, spotbugs:check green). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- .../llama/server/OpenAiRequestMapper.java | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 7460dda84..00679e914 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2285,7 +2285,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Beschreibung:** Der Mapper las jedes Zahlenfeld mit `isNumber()`/`isInt()`/`isIntegralNumber()` und ließ alles andere still weg; `readMaxTokens` lieferte dann -1 (unbegrenzt), `n` prüfte nur Zahlen, `n_probs` nur `int` (ein `3.0` fiel weg), `repeat_last_n`, `min_keep`, `n_keep`, `n_cache_reuse` nur ganze Zahlen (ein `32.7` fiel weg). upstreams Schema (`field_num::eval`, `server-schema.cpp:531-549`, b11639) liest den ersten vorhandenen Namen eines Felds mit `get()`: ein String, ein Objekt oder ein Array ist `400` „Field 'max_tokens': … type must be number, but is string“, `null` gilt als fehlend (`has_value`), ein Bruch wird in ein Ganzzahlfeld abgeschnitten. Dieselbe stille Klasse hatten die Übersetzungen davor: Anthropics `temperature`/`top_p`/`top_k` (upstream reicht sie durch, das Schema weist ab), Responses' `temperature`/`top_p`/`max_output_tokens` (upstream kopiert den ganzen Body) und Ollamas `options` (Ollama weist einen falsch typisierten Wert ab). - **Fix:** Ein gemeinsamer Leser `OpenAiRequestMapper.optionalNumber(node, field)`: fehlend oder `null` → `null` (Vorgabe), eine Zahl → der Wert, alles andere → `IllegalArgumentException` mit upstreams Text ohne nlohmanns Präfix („Field 'max_tokens': type must be number, but is string“). Er gilt für jedes Zahlenfeld des Mappers (`n_predict`/`max_completion_tokens`/`max_tokens` in upstreams Reihenfolge, der erste vorhandene zählt; `n`, `n_probs`, `n_cache_reuse`, `id_slot`, `temperature`, `top_p`, `top_k`, `seed`, die Strafen, die llama.cpp-Sampling-Felder, `top_logprobs` neben `logprobs: true`, `reasoning_budget_tokens`/`thinking_budget_tokens`); Ganzzahlfelder schneiden einen Bruch ab wie upstream. Anthropic liest `max_tokens` (F223) und `temperature`/`top_p`/`top_k`, Responses `temperature`/`top_p`/`max_output_tokens`, Ollama jede Zahl-Option (auch auf `/api/generate` und dem FIM-Weg) über denselben Leser; die Anthropic-eigene Meldung von F223 entfällt. Bewusst strenger als upstream, im README genannt: ein Boolean wird abgewiesen (nlohmann liest ihn als 0/1), ebenso ein falsch typisiertes `top_logprobs`, `id_slot` und Reasoning-Budget, wo upstream (`json_value`) eine Warnung loggt und die Vorgabe nimmt. Abweichung vom Brief: Responses und Ollama mit erfasst, weil dieselbe stille Klasse sonst auf zwei Chat-Routen offen geblieben wäre. - **Tests:** `OpenAiRequestMapperTest#aStringMaxTokensIsRefusedRatherThanReadAsNoLimit`, `#everyNumberFieldThatIsNotANumberIsRefusedLikeUpstreamsSchema` (26 Felder × String/Boolean/Objekt/Array, Meldung wörtlich), `#aTopLogprobsThatIsNotANumberIsRefused`, `#aNullNumberFieldIsTheDefaultLikeUpstream`, `#aFractionalIntegerFieldIsTruncatedLikeUpstream`, `#onlyTheFirstPresentTokenCapAliasIsReadLikeUpstream`; `AnthropicApiSupportTest#aSamplingFieldThatIsNotANumberIsRefused`; `ResponsesApiSupportTest#aSamplingFieldThatIsNotANumberIsRefused`; `OllamaApiSupportTest#anOptionThatIsNotANumberIsRefused` (12 Optionen); `OpenAiCompatServerHttpTest#aStringMaxTokensOnTheChatRouteIsA400` (neues `MappingBackend`, das wie `LlamaModelBackend` erst den echten Mapper aufruft; gestreamt und blockierend 400 `invalid_request_error` mit der Meldung, nichts generiert). -- **Rot-Grün-Nachweis:** Ohne den Fix rot (8 Fehlschläge): „OpenAiRequestMapperTest.aStringMaxTokensIsRefusedRatherThanReadAsNoLimit:543 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, „{"messages":[{"role":"user","content":"hi"}],"n_predict":"3"} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, „aFractionalIntegerFieldIsTruncatedLikeUpstream:597 Expected: is <32>“, je „temperature ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ in `AnthropicApiSupportTest`, `ResponsesApiSupportTest`, `OllamaApiSupportTest`, und „OpenAiCompatServerHttpTest.aStringMaxTokensOnTheChatRouteIsA400:943 … Expected: is <400>“ (200); mit dem Fix grün (`OpenAiRequestMapperTest` 53/53, `AnthropicApiSupportTest` 38/38, `ResponsesApiSupportTest` 69/69, `OllamaApiSupportTest` 49/49, `OpenAiCompatServerHttpTest` 71/71, `LlamaModelBackendTest` 4/4, `AnthropicStreamTranslatorTest` 11/11). `spotless:apply` grün. +- **Rot-Grün-Nachweis:** Ohne den Fix rot (8 Fehlschläge): „OpenAiRequestMapperTest.aStringMaxTokensIsRefusedRatherThanReadAsNoLimit:543 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, „{"messages":[{"role":"user","content":"hi"}],"n_predict":"3"} ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, „aFractionalIntegerFieldIsTruncatedLikeUpstream:597 Expected: is <32>“, je „temperature ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ in `AnthropicApiSupportTest`, `ResponsesApiSupportTest`, `OllamaApiSupportTest`, und „OpenAiCompatServerHttpTest.aStringMaxTokensOnTheChatRouteIsA400:943 … Expected: is <400>“ (200); mit dem Fix grün (`OpenAiRequestMapperTest` 53/53, `AnthropicApiSupportTest` 38/38, `ResponsesApiSupportTest` 69/69, `OllamaApiSupportTest` 49/49, `OpenAiCompatServerHttpTest` 71/71, `LlamaModelBackendTest` 4/4, `AnthropicStreamTranslatorTest` 11/11). `spotless:apply` grün; `spotbugs:check` am Ende des Batches meldete zweimal `PRMC_POSSIBLY_REDUNDANT_METHOD_CALLS` (`choices.asInt()`, `slotId.asInt()`) und einmal `SACM_STATIC_ARRAY_CREATED_IN_METHOD` (die Alias-Liste in `readMaxTokens`), im Folge-Commit behoben, danach grün. - **Status:** gefixt - **Commit:** java-llama.cpp `5abe67e` diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java index 45e1627e4..4f6aae633 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiRequestMapper.java @@ -45,6 +45,9 @@ */ final class OpenAiRequestMapper { + /** The names of the output-token cap in the order upstream's schema reads them (see {@link #readMaxTokens}). */ + private static final String[] TOKEN_CAP_FIELDS = {"n_predict", "max_completion_tokens", "max_tokens"}; + /** Upstream's {@code n_probs} for a chat request with {@code logprobs} but no {@code top_logprobs}. */ private static final int DEFAULT_TOP_LOGPROBS = 20; @@ -201,8 +204,9 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo // one and "n" never reaches upstream from here, so every other value is refused rather than // silently answered once. JsonNode choices = optionalNumber(request, "n"); - if (choices != null && choices.asInt() != 1) { - throw new IllegalArgumentException("'n' must be 1 (one answer is returned) but was " + choices.asInt()); + int answers = choices != null ? choices.asInt() : 1; + if (answers != 1) { + throw new IllegalArgumentException("'n' must be 1 (one answer is returned) but was " + answers); } // logit_bias as upstream's schema reads it: [token, bias] pairs or OpenAI's object form. JsonNode logitBias = request.path("logit_bias"); @@ -226,8 +230,9 @@ private InferenceParameters applyCommonFields(InferenceParameters params, JsonNo params = withInt(params, request, "n_cache_reuse", InferenceParameters::withCacheReuse); // upstream's default -1 means "any slot"; leaving the field unset is the same request. JsonNode slotId = optionalNumber(request, "id_slot"); - if (slotId != null && slotId.asInt() >= 0) { - params = params.withSlotId(slotId.asInt()); + int slot = slotId != null ? slotId.asInt() : -1; + if (slot >= 0) { + params = params.withSlotId(slot); } params = withFloat(params, request, "temperature", InferenceParameters::withTemperature); @@ -404,7 +409,7 @@ private static InferenceParameters withInt( * @throws IllegalArgumentException when the first field present is not a number */ private int readMaxTokens(JsonNode request) { - for (String field : new String[] {"n_predict", "max_completion_tokens", "max_tokens"}) { + for (String field : TOKEN_CAP_FIELDS) { JsonNode value = optionalNumber(request, field); if (value != null) { return value.asInt(); From 595a8bea98dc6ad5c68529e36c992d280268949a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:40:38 +0000 Subject: [PATCH 529/589] Take upstream's multimodal prompt object on the raw paths (F258) upstream's raw schema carries an image as "prompt": {"prompt_string": "... ...", "multimodal_data": [""]}, tokenized by its HTTP handler with the projector. The engine cannot reach that context and tokenized the object without it, so every raw completion with an image was refused with "model does not support multimodal requests", also with --mmproj loaded. populate_completion_task now decodes the entries and hands prompt and bytes to the worker through the task's CLI fields, which tokenizes them with the projector as the chat paths do. Two more holes on the same path: the marker is random per process, so model_meta now reports it (media_marker, as upstream's /props does, read by ModelMeta.getMediaMarker()); and a prompt that ends with its image leaves no text token for the logits, on which update_slots aborts the process (GGML_ASSERT(batch.size() > 0)) -- refused as a bad request now, like empty or non-base64 data. tiny_llama_gguf.hpp also writes a random llava-style projector, so the image path runs hermetically in the C++ tests. Red before the fix: "Expected equality ... jllama_completions(...) Which is: 2 JLLAMA_STATUS_OK Which is: 0 ... Multimodal data provided, but model does not support multimodal requests."; the image-last prompt ended the test JVM with the GGML_ASSERT. Green: ctest -R CApi 49/49, ModelMetaTest 20/20. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 ++ CLAUDE.md | 4 +- docs/c-api.md | 2 +- llama/src/main/cpp/jllama_engine.cpp | 41 +++++- .../net/ladenthin/llama/value/ModelMeta.java | 13 ++ llama/src/test/cpp/test_c_api.cpp | 122 ++++++++++++++++++ llama/src/test/cpp/tiny_llama_gguf.hpp | 76 +++++++++++ .../llama/MultimodalIntegrationTest.java | 30 +++++ .../ladenthin/llama/value/ModelMetaTest.java | 15 +++ 9 files changed, 312 insertions(+), 4 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 00679e914..9eb6d8efc 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -180,6 +180,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F255](#f255) — niedrig — *gefixt* - [F256](#f256) — niedrig — *gefixt* - [F257](#f257) — niedrig bis mittel — *gefixt* +- [F258](#f258) — mittel — *gefixt* ## Funde @@ -2337,6 +2338,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `baecd5a` +### F258 – java-llama.cpp: die rohen Completion-Pfade weisen upstreams multimodales Prompt-Objekt ab, obwohl ein Projektor geladen ist +- **Zeit:** 2026-10-21 15:40 UTC +- **Datei:** `llama/src/main/cpp/jllama_engine.cpp` (`populate_completion_task`, `model_meta`), `llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java` (`getMediaMarker`), `llama/src/test/cpp/tiny_llama_gguf.hpp` +- **Schwere:** mittel (ein Bild über den rohen Pfad ging mit einem Projektor nie; ein Prompt, der mit dem Bild endet, hätte den Prozess beendet) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Ollama routes“; Brief JR-2), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. Gefunden bei der Umsetzung von JR-2: der Brief setzt voraus, dass der rohe Pfad `multimodal_data` versteht; am HEAD tat er es nicht, und der Ollama-Teil (F259) baut darauf auf. +- **Beschreibung:** upstreams rohes Schema nimmt ein Bild als `"prompt": {"prompt_string": "… …", "multimodal_data": [""]}` (`tools/server/README.md`, `tokenize_input_subprompt`), tokenisiert im HTTP-Handler mit dem `mtmd_context`. Die Engine erreicht diesen Kontext nicht und rief `tokenize_input_prompts(vocab, nullptr, …)`; das Objekt endete in „Multimodal data provided, but model does not support multimodal requests.“ – auch mit `--mmproj`. Dazu kommt der Marker: upstream zieht ihn je Prozess zufällig (`get_media_marker`, `LLAMA_MEDIA_MARKER` pinnt ihn) und veröffentlicht ihn in `/props`; über `LlamaModel` war er nicht zu erfahren. Und ein Prompt, dessen letztes Stück das Bild ist, lässt dem Slot kein Text-Token für die Logits: `update_slots` bricht dann mit `GGML_ASSERT(batch.size() > 0)` den ganzen Prozess ab (lokal gemessen, JVM-Abbruch mit Exit 134). +- **Fix:** `populate_completion_task` erkennt das Objekt (`prompt_string` + `multimodal_data`), dekodiert die Einträge (`base64::decode`, ungültige Zeichen sind ein Fehler) und gibt `prompt_string` mit den Bytes über die CLI-Felder der Aufgabe an den Worker, der sie wie die Chat-Pfade mit dem Projektor tokenisiert (`configure_multimodal_task_impl`). Ohne Projektor bleibt upstreams Meldung; ein leeres oder nicht aus Strings bestehendes `multimodal_data` und ein Prompt, der auf den Marker endet, sind `invalid_argument` (400). `model_meta` liefert `media_marker` (wie upstreams `/props`), `ModelMeta#getMediaMarker()` liest ihn. Für einen hermetischen Test schreibt `tiny_llama_gguf.hpp` zusätzlich einen zufälligen llava-artigen Projektor (`write_tiny_mmproj_gguf`, 32×32-Bild, vier Patches). +- **Tests:** `test_c_api.cpp`: `CApiVision.ModelMetaNamesTheMediaMarker`, `CApiVision.ARawCompletionTakesUpstreamsMultimodalPromptObject` (blockierend, gestreamt, OpenAI-Pfad: `tokens_evaluated` = Text-Tokens + 4 Bild-Embeddings), `CApiVision.AMultimodalPromptWhoseMarkersDoNotMatchItsImagesIsRefused` (kein Marker, zwei Marker für ein Bild, kein Base64, keine Daten, Bild am Ende), `CApi.AMultimodalPromptObjectOnAModelWithoutAProjectorIsTheCallersFault`; `ModelMetaTest#testGetMediaMarker`, `#testGetMediaMarkerEmptyWhenAbsent`; `MultimodalIntegrationTest#aRawCompletionTakesTheMultimodalPromptObject` (JNI, `handleCompletionsOai`). +- **Rot-Grün-Nachweis:** Ohne den Fix rot: „test_c_api.cpp:775: Failure Value of: j.contains("media_marker") Expected: true“ und, mit dem Marker, „test_c_api.cpp:792: Failure Expected equality of these values: jllama_completions(model_, request.c_str(), &completion.s) Which is: 2 JLLAMA_STATUS_OK Which is: 0 … Multimodal data provided, but model does not support multimodal requests.“; der Java-Test mit einem Prompt, der auf das Bild endete, beendete die Test-JVM („server-context.cpp:4067: GGML_ASSERT(batch.size() > 0) failed“, Exit 134), bevor die Schutzprüfung da war. Mit dem Fix grün: `ctest -R CApi` 49/49, `ModelMetaTest` 20/20, `MultimodalIntegrationTest` mit dem Winzmodell und dem Winz-Projektor 5/6 – rot bleibt nur `#imageBytesAffectTheAnswer`, das echte Farberkennung braucht (Rauschen eines Zufallsmodells; mit SmolVLM in CI). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CLAUDE.md b/CLAUDE.md index 4dce2de77..8cae1b905 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2621,12 +2621,12 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" | `src/test/cpp/test_model_flags.cpp` | 4 | **The contract between the Java CLI-flag registries and llama.cpp's server argument parser.** CMake reads `ModelFlag.java` + `ModelOption.java` (`cmake/extract-java-wire-names.cmake` → a generated header of `{name, contract}` pairs), and this file asserts every `SERVER_PARSER` name is in `common_params_parser_init(params, LLAMA_EXAMPLE_SERVER).options`. It exists because **no Java test can catch this class**: `ModelFlagTest`/`ModelParametersExtendedTest` pin the *string mapping* (`hasKey("--mlock")`), never that llama.cpp still accepts the string, so they stay green forever while the flag is dead — and `common_params_parse` treats an unregistered option as a hard error, so the affected builder method makes the model **unloadable**, not merely ineffective. **A grep over `arg.cpp` is not a substitute**: `--grp-attn-n`/`-w` are present there at every pinned tag but `set_examples()`-scoped to `LLAMA_EXAMPLE_COMPLETION`/`PASSKEY`, so the server parser rejects them exactly like a deleted flag — only the real option table sees that. `--vocab-only` is the one exemption, and it declares itself `CliContract.PROJECT_PSEUDO` on its own constant rather than appearing in a list inside this file; the test asserts such a name is **still unknown** to the parser (an exemption upstream later registers would be hiding a real check) and that the exempt set is non-empty. | | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). | | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. | -| `src/test/cpp/test_c_api.cpp` | 45 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. `CApiSlotFiles` loads the random model with `--slot-save-path`: a saved slot file lands under that directory by its name and restores, and a name with a directory part, `..`, an absolute path or nothing is `JLLAMA_STATUS_INVALID_REQUEST` with nothing written (red before F251: the name was the path, relative to the working directory); a model without the directory refuses save and restore with `JLLAMA_STATUS_ERROR`. Three `CApiRpc` cases pin `jllama_rpc_server_stop()` around the bind: a stop issued before ggml binds the socket -- from the serving thread through the test seam `jllama::rpc::set_serve_hook_for_testing()` (`rpc_support.hpp`, not exported), or from another thread while the serve is held in that seam, where the stop must not return before the serve has -- ends the serve instead of being lost (patches/0015 clears ggml's stop flag at the bind; red before F241), and a stop while nothing serves does not stop the next serve. Two `CApiFim` cases load the same random model with FIM tokens (`write_tiny_llama_gguf(prefix, true)`): a raw completion with `input_prefix`/`input_suffix`, blocking and streamed, evaluates exactly the prompt `jllama_infill` builds from the same fields, and one of the two fields alone is refused naming the other (red before F246: the completion path completed the empty prompt and dropped both). Two more `CApi` cases pin `messages` on the raw paths: blocking and streamed, the request evaluates exactly the tokens of a completion of the prompt `jllama_apply_template` renders from them (an empty `prompt` beside them counts as none), and a non-empty `prompt` or the infill fields beside them are refused (red before F247: the field was ignored). Two more: the chat fields the raw paths cannot apply beside `messages` (`response_format`, `tools`, `tool_choice`, `parallel_tool_calls`, `top_logprobs`, `thinking_budget_tokens`) are refused by name on both raw paths, and `CApiTemplateStops` loads the random model with an end-of-generation byte of its own (`write_tiny_llama_gguf(prefix, false, 4)`) and a template upstream adds the stop string `` to, which a grammar forces: the raw paths stop there as the chat path does, also beside a `stop` of the request (red before F248: dropped, `stop_type` `eos` and the string in `content`). And `CApiReasoningBudget` loads it with a template that opens a `` block in its generation prompt: a raw completion with `messages` and `reasoning_budget_tokens` 1 completes the forced `` and then the chat path's answer, blocking and streamed, while an output-format grammar beside it still runs (the generation prompt the budget is primed with is left out there); a template without thinking tags leaves the request unchanged (red before F249: the tags were dropped and the budget did nothing). A third case loads the random model with ``, `` and `` as control tokens (`write_tiny_llama_gguf(prefix, false, 4, special)`): the forced end tag reads as text in the raw content, blocking and streamed, and a request's own `preserved_tokens` stays in force beside the template's (red before F250: the session's preserved tokens were not taken over, and the special end tag was an empty piece). A fourth, without `messages`, names the tags in the request with a logit bias that makes the model write the start tag: a budget of zero forces the end tag after each one, and a `reasoning_budget_message` before it (red before F252: without the message the budget forced nothing). | +| `src/test/cpp/test_c_api.cpp` | 49 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. `CApiSlotFiles` loads the random model with `--slot-save-path`: a saved slot file lands under that directory by its name and restores, and a name with a directory part, `..`, an absolute path or nothing is `JLLAMA_STATUS_INVALID_REQUEST` with nothing written (red before F251: the name was the path, relative to the working directory); a model without the directory refuses save and restore with `JLLAMA_STATUS_ERROR`. Three `CApiRpc` cases pin `jllama_rpc_server_stop()` around the bind: a stop issued before ggml binds the socket -- from the serving thread through the test seam `jllama::rpc::set_serve_hook_for_testing()` (`rpc_support.hpp`, not exported), or from another thread while the serve is held in that seam, where the stop must not return before the serve has -- ends the serve instead of being lost (patches/0015 clears ggml's stop flag at the bind; red before F241), and a stop while nothing serves does not stop the next serve. Two `CApiFim` cases load the same random model with FIM tokens (`write_tiny_llama_gguf(prefix, true)`): a raw completion with `input_prefix`/`input_suffix`, blocking and streamed, evaluates exactly the prompt `jllama_infill` builds from the same fields, and one of the two fields alone is refused naming the other (red before F246: the completion path completed the empty prompt and dropped both). Two more `CApi` cases pin `messages` on the raw paths: blocking and streamed, the request evaluates exactly the tokens of a completion of the prompt `jllama_apply_template` renders from them (an empty `prompt` beside them counts as none), and a non-empty `prompt` or the infill fields beside them are refused (red before F247: the field was ignored). Two more: the chat fields the raw paths cannot apply beside `messages` (`response_format`, `tools`, `tool_choice`, `parallel_tool_calls`, `top_logprobs`, `thinking_budget_tokens`) are refused by name on both raw paths, and `CApiTemplateStops` loads the random model with an end-of-generation byte of its own (`write_tiny_llama_gguf(prefix, false, 4)`) and a template upstream adds the stop string `` to, which a grammar forces: the raw paths stop there as the chat path does, also beside a `stop` of the request (red before F248: dropped, `stop_type` `eos` and the string in `content`). And `CApiReasoningBudget` loads it with a template that opens a `` block in its generation prompt: a raw completion with `messages` and `reasoning_budget_tokens` 1 completes the forced `` and then the chat path's answer, blocking and streamed, while an output-format grammar beside it still runs (the generation prompt the budget is primed with is left out there); a template without thinking tags leaves the request unchanged (red before F249: the tags were dropped and the budget did nothing). A third case loads the random model with ``, `` and `` as control tokens (`write_tiny_llama_gguf(prefix, false, 4, special)`): the forced end tag reads as text in the raw content, blocking and streamed, and a request's own `preserved_tokens` stays in force beside the template's (red before F250: the session's preserved tokens were not taken over, and the special end tag was an empty piece). A fourth, without `messages`, names the tags in the request with a logit bias that makes the model write the start tag: a budget of zero forces the end tag after each one, and a `reasoning_budget_message` before it (red before F252: without the message the budget forced nothing). Four cases pin the raw multimodal prompt: `CApiVision` loads the random model with a random llava-style vision projector the shared fixture writes too (`write_tiny_mmproj_gguf()`, `--mmproj`) and finds the server's `media_marker` in the model meta; a raw completion whose `prompt` is upstream's `{"prompt_string": "......", "multimodal_data": [""]}` evaluates the text tokens plus the image's four patch embeddings on the blocking, streamed and OpenAI paths (red before F258: refused as if the model had no projector), while markers that do not match the images, data that is not base64, no data and an image at the very end (an abort of the process in `update_slots`) are refused, and the same object on the model without a projector is the caller's fault. | | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. | | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. | | `src/test/cpp/test_lora_cache.cpp` | 5 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`timings.cache_n > 0`; not `tokens_cached`, which counts every token the slot holds and is positive after any request). After each adapter change the next request must take nothing from the cache (`timings.cache_n == 0`), so those three cases are red without the patch whether or not the random model happens to answer differently. **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. A second fixture (`LoraCacheFreshSlot`: two slots, `--slot-save-path`) saves slot 0 (by its file name inside that directory) after a request and restores it into the never-used slot 1, whose request must take all of the prompt but at most two tokens from it and answer like slot 0 -- red before the `init()` hunk (`Expected: (prompt_n) <= (2), actual: 77 vs 2`), green with it (F240). | -**Current total: 660 tests (all passing).** +**Current total: 664 tests (all passing).** #### Upstream source location (in CMake build tree) diff --git a/docs/c-api.md b/docs/c-api.md index c2ff80876..8f36483e1 100644 --- a/docs/c-api.md +++ b/docs/c-api.md @@ -35,7 +35,7 @@ Every operation of `LlamaModel` and `LlamaQuantizer` is in it (the roadmap's "B. | `jllama_last_error()` | the message of the last failed call on this thread (thread-local) | | `jllama_string_free()`, `jllama_floats_free()`, `jllama_tokens_free()` | release what the library returned through an out-parameter | | `jllama_model_load()` / `jllama_model_free()` | a model from llama-server's command line (`-m`, `-c`, `-ngl`, ... and `--vocab-only`), with an optional load-progress callback | -| `jllama_model_meta()` | the model's metadata object (vocab, context, modalities, chat template, the GGUF key/value map) | +| `jllama_model_meta()` | the model's metadata object (vocab, context, modalities, the `media_marker` a raw multimodal prompt places, chat template, the GGUF key/value map) | | `jllama_chat_completions()` | one OpenAI chat completion, blocking | | `jllama_chat_completions_stream_request()` / `_receive()` / `jllama_task_release()` | a streamed one: a task id, then one `{"data": , "stop": }` envelope per call until `stop` | | `jllama_chat_completions_request()` | a streamed chat completion whose results are llama.cpp's raw result objects (received with `jllama_completions_stream_receive`) | diff --git a/llama/src/main/cpp/jllama_engine.cpp b/llama/src/main/cpp/jllama_engine.cpp index 10cd0c155..e10bc3f70 100644 --- a/llama/src/main/cpp/jllama_engine.cpp +++ b/llama/src/main/cpp/jllama_engine.cpp @@ -12,6 +12,7 @@ #include "server-common.h" #include "server-chat.h" #include "server-http.h" +#include "base64.hpp" #include "json-schema-to-grammar.h" #include "utils.hpp" #include "jllama_engine.hpp" @@ -352,7 +353,41 @@ json parse_oai_chat_params(jllama_context *jctx, json &body, std::vector &logit_bias_eog, const json &data, bool has_mtmd, std::vector files) { - if (!configure_multimodal_task_impl(task, has_mtmd, data, std::move(files))) { + // upstream's raw multimodal prompt, {"prompt_string": "......", "multimodal_data": [base64]}, + // is tokenized by its HTTP handler with the projector, which this engine cannot reach; the task's + // CLI fields carry it to the worker, which tokenizes it with the projector as the chat paths do. + // Tokenized here without one, it was refused as if the model had no projector. + const json *prompt_field = data.contains("prompt") ? &data.at("prompt") : nullptr; + if (files.empty() && prompt_field != nullptr && prompt_field->is_object() && + prompt_field->contains("prompt_string") && prompt_field->contains("multimodal_data")) { + const json &prompt = *prompt_field; + if (!has_mtmd) { + throw std::invalid_argument("Multimodal data provided, but model does not support multimodal requests."); + } + const json &media = prompt.at("multimodal_data"); + if (!media.is_array() || media.empty()) { + throw std::invalid_argument("\"multimodal_data\" must be a non-empty array of base64 strings"); + } + for (const auto &entry : media) { + if (!entry.is_string()) { + throw std::invalid_argument("\"multimodal_data\" must be a non-empty array of base64 strings"); + } + const std::string bytes = base64::decode(entry.get()); + files.emplace_back(bytes.begin(), bytes.end()); + } + // A prompt that ends with its media leaves the slot no text token to take the logits from, and + // the server aborts the process on it (GGML_ASSERT(batch.size() > 0) in update_slots); upstream's + // chat paths never get there because the template puts text after the last image. + const json &prompt_string = prompt.at("prompt_string"); + if (prompt_string.is_string() && string_ends_with(prompt_string.get(), get_media_marker())) { + throw std::invalid_argument("a multimodal prompt must have text after its last media marker"); + } + json multimodal = data; + multimodal["prompt"] = prompt.at("prompt_string"); + if (!configure_multimodal_task_impl(task, has_mtmd, multimodal, std::move(files))) { + throw std::logic_error("a multimodal prompt carried no media"); + } + } else if (!configure_multimodal_task_impl(task, has_mtmd, data, std::move(files))) { auto tokenized_prompts = tokenize_input_prompts(jctx->vocab, nullptr, data.at("prompt"), true, true, mtmd_helper_init_opt_default()); // A task carries one prompt. Upstream answers several prompts with one choice each; taking the @@ -605,6 +640,10 @@ std::string model_meta(jllama_context *jctx) { {"name", m.model_name}, {"architecture", arch}, {"ftype", m.model_ftype}, + // The placeholder a raw multimodal prompt puts where each image goes ({"prompt_string": ..., + // "multimodal_data": [...]}). Random per process unless LLAMA_MEDIA_MARKER pins it, so a caller + // reads it here, as upstream's /props publishes it. + {"media_marker", get_media_marker()}, }; // The input/output modalities of upstream's GET /models `architecture` object (b11429, #29987), // flattened to the top level because "architecture" above already names the GGUF architecture diff --git a/llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java b/llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java index e9aa33046..343505a39 100644 --- a/llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java +++ b/llama/src/main/java/net/ladenthin/llama/value/ModelMeta.java @@ -199,6 +199,19 @@ public String getFtype() { return node.path("ftype").asText(""); } + /** + * The placeholder a raw multimodal prompt puts where each image or audio clip goes: a completion + * request whose {@code prompt} is {@code {"prompt_string": "... ...", "multimodal_data": + * ["", ...]}} (llama.cpp's raw schema) needs one marker per entry, in order. llama.cpp draws + * it at random per process unless the environment variable {@code LLAMA_MEDIA_MARKER} pins it, so + * it is read here rather than written into a prompt by hand. + * + * @return the media marker, or {@code ""} if the native layer does not report it + */ + public String getMediaMarker() { + return node.path("media_marker").asText(""); + } + /** * The model's resolved default chat template (Jinja), from GGUF * {@code tokenizer.chat_template} metadata. diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index c6a5964d0..6f14ed794 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -707,6 +707,128 @@ TEST_F(CApiFim, ARawCompletionWithOnlyOneInfillFieldIsRefused) { EXPECT_NE(std::string(jllama_last_error()).find("input_prefix"), std::string::npos) << jllama_last_error(); } +namespace { + +// The same random model with a random vision projector (--mmproj): a multimodal server. +class CApiVision : public ::testing::Test { + protected: + static void SetUpTestSuite() { + path_ = write_tiny_llama_gguf("jllama-c-api-vision"); + mmproj_ = write_tiny_mmproj_gguf(); + ASSERT_FALSE(path_.empty()); + ASSERT_FALSE(mmproj_.empty()); + const std::vector args = {"jllama", "-m", path_, "--mmproj", mmproj_, "-c", + "128", "-ngl", "0", "--no-warmup", "-t", "2"}; + std::vector argv; + for (const auto &a : args) { + argv.push_back(a.c_str()); + } + ASSERT_EQ(jllama_model_load(static_cast(argv.size()), argv.data(), nullptr, nullptr, &model_), + JLLAMA_STATUS_OK) + << jllama_last_error(); + } + static void TearDownTestSuite() { + jllama_model_free(model_); + model_ = nullptr; + std::error_code ignored; + std::filesystem::remove(path_, ignored); + std::filesystem::remove(mmproj_, ignored); + } + // The server's media marker, which a raw multimodal prompt places where each image goes. + static std::string media_marker() { + Owned meta; + if (jllama_model_meta(model_, &meta.s) != JLLAMA_STATUS_OK) { + ADD_FAILURE() << jllama_last_error(); + return std::string(); + } + return json::parse(meta.str()).value("media_marker", std::string()); + } + static std::string path_; + static std::string mmproj_; + static jllama_model *model_; +}; +std::string CApiVision::path_; +std::string CApiVision::mmproj_; +jllama_model *CApiVision::model_ = nullptr; + +// A raw request whose prompt is upstream's multimodal prompt object. +std::string multimodal_prompt_request(const std::string &prompt_string, int n_images, const char *length_field) { + json images = json::array(); + for (int i = 0; i < n_images; ++i) { + images.push_back(TINY_PNG_BASE64); + } + return json{{"prompt", {{"prompt_string", prompt_string}, {"multimodal_data", images}}}, + {length_field, 2}, + {"temperature", 0}} + .dump(); +} + +} // namespace + +TEST_F(CApiVision, ModelMetaNamesTheMediaMarker) { + // The marker is random per process (upstream's get_media_marker); a raw multimodal prompt cannot + // be written without it, and upstream publishes it in /props. + Owned meta; + ASSERT_EQ(jllama_model_meta(model_, &meta.s), JLLAMA_STATUS_OK) << jllama_last_error(); + const json j = json::parse(meta.str()); + EXPECT_TRUE(j.at("modalities").at("vision").get()); + ASSERT_TRUE(j.contains("media_marker")) << meta.str(); + EXPECT_FALSE(j.at("media_marker").get().empty()); +} + +TEST_F(CApiVision, ARawCompletionTakesUpstreamsMultimodalPromptObject) { + // upstream's raw schema: "prompt": {"prompt_string": "......", "multimodal_data": [""]}. + // The raw paths tokenized it without the projector and refused it as if the model had none. + const std::string marker = media_marker(); + ASSERT_FALSE(marker.empty()); + Owned text_only; + ASSERT_EQ(jllama_completions(model_, R"({"prompt":"look now","n_predict":1,"temperature":0})", &text_only.s), + JLLAMA_STATUS_OK) + << jllama_last_error(); + const int text_tokens = json::parse(text_only.str()).at("tokens_evaluated").get(); + + const std::string request = multimodal_prompt_request("look " + marker + " now", 1, "n_predict"); + Owned completion; + ASSERT_EQ(jllama_completions(model_, request.c_str(), &completion.s), JLLAMA_STATUS_OK) << jllama_last_error(); + // four 16x16 patches of the 32x32 image, one embedding each, between the text tokens + EXPECT_EQ(json::parse(completion.str()).at("tokens_evaluated").get(), text_tokens + 4) << completion.str(); + + int task = -1; + ASSERT_EQ(jllama_completions_stream_request(model_, request.c_str(), &task), JLLAMA_STATUS_OK) + << jllama_last_error(); + EXPECT_EQ(receive_to_stop(model_, task).at("tokens_evaluated").get(), text_tokens + 4); + + Owned oai; + ASSERT_EQ(jllama_completions_oai( + model_, multimodal_prompt_request("look " + marker + " now", 1, "max_tokens").c_str(), &oai.s), + JLLAMA_STATUS_OK) + << jllama_last_error(); + EXPECT_EQ(json::parse(oai.str()).at("usage").at("prompt_tokens").get(), text_tokens + 4) << oai.str(); +} + +TEST_F(CApiVision, AMultimodalPromptWhoseMarkersDoNotMatchItsImagesIsRefused) { + const std::string marker = media_marker(); + ASSERT_FALSE(marker.empty()); + Owned response; + const json bad_media = {{"prompt", {{"prompt_string", marker}, {"multimodal_data", json::array({"not base64!"})}}}}; + const json no_media = {{"prompt", {{"prompt_string", marker}, {"multimodal_data", json::array()}}}}; + for (const std::string &request : + {multimodal_prompt_request("no marker", 1, "n_predict"), + multimodal_prompt_request(marker + " and " + marker, 1, "n_predict"), bad_media.dump(), no_media.dump(), + // the image last leaves no text token for the logits: the server aborted the process on it + multimodal_prompt_request("look " + marker, 1, "n_predict")}) { + EXPECT_EQ(jllama_completions(model_, request.c_str(), &response.s), JLLAMA_STATUS_INVALID_REQUEST) << request; + } +} + +TEST_F(CApi, AMultimodalPromptObjectOnAModelWithoutAProjectorIsTheCallersFault) { + Owned response; + const std::string request = multimodal_prompt_request("look <__media__> now", 1, "n_predict"); + EXPECT_EQ(jllama_completions(model_, request.c_str(), &response.s), JLLAMA_STATUS_INVALID_REQUEST); + EXPECT_NE(std::string(jllama_last_error()).find("does not support multimodal"), std::string::npos) + << jllama_last_error(); +} + TEST_F(CApi, ARawCompletionWithMessagesCompletesThePromptTheChatTemplateRenders) { // The raw completion paths render "messages" through the chat template into the prompt, as // jllama_apply_template does; they used to ignore the field and complete the prompt next to it. diff --git a/llama/src/test/cpp/tiny_llama_gguf.hpp b/llama/src/test/cpp/tiny_llama_gguf.hpp index 82628c7bc..80b56b817 100644 --- a/llama/src/test/cpp/tiny_llama_gguf.hpp +++ b/llama/src/test/cpp/tiny_llama_gguf.hpp @@ -169,4 +169,80 @@ inline std::string write_tiny_llama_gguf(const std::string &prefix = "jllama-c-a return ok ? path : std::string(); } +// A random llava-style (MLP) vision projector for the model above: 32x32 images in 16x16 patches, +// a two-layer encoder of width 16 and a projection to the model's N_EMBD. Loaded with --mmproj it +// makes the server multimodal, so the image paths run end to end without a download; like the +// model, its answers are noise. llava's preprocessing resizes every image to 32x32, so any image +// stb_image decodes will do. +inline std::string write_tiny_mmproj_gguf(const std::string &prefix = "jllama-mmproj") { + constexpr int IMG = 32, PATCH = 16, V_EMBD = 16, V_HEAD = 2, V_FF = 32, V_LAYER = 2, PROJ = 32; + constexpr int N_PATCHES = (IMG / PATCH) * (IMG / PATCH); + static std::atomic counter{0}; + static const unsigned run_id = std::random_device{}(); + const std::string path = (std::filesystem::temp_directory_path() / + (prefix + "-" + std::to_string(run_id) + "-" + std::to_string(counter++) + ".gguf")) + .string(); + std::mt19937 rng(7); + gguf_context *g = gguf_init_empty(); + gguf_set_val_str(g, "general.architecture", "clip"); + gguf_set_val_str(g, "clip.projector_type", "mlp"); + gguf_set_val_bool(g, "clip.has_vision_encoder", true); + gguf_set_val_bool(g, "clip.use_gelu", true); + gguf_set_val_u32(g, "clip.vision.image_size", IMG); + gguf_set_val_u32(g, "clip.vision.patch_size", PATCH); + gguf_set_val_u32(g, "clip.vision.embedding_length", V_EMBD); + gguf_set_val_u32(g, "clip.vision.feed_forward_length", V_FF); + gguf_set_val_u32(g, "clip.vision.block_count", V_LAYER); + gguf_set_val_u32(g, "clip.vision.projection_dim", PROJ); + gguf_set_val_u32(g, "clip.vision.attention.head_count", V_HEAD); + gguf_set_val_f32(g, "clip.vision.attention.layer_norm_epsilon", 1e-5f); + const float mean_std[] = {0.5f, 0.5f, 0.5f}; + gguf_set_arr_data(g, "clip.vision.image_mean", GGUF_TYPE_FLOAT32, mean_std, 3); + gguf_set_arr_data(g, "clip.vision.image_std", GGUF_TYPE_FLOAT32, mean_std, 3); + + ggml_init_params ip = {4u * 1024 * 1024, nullptr, false}; + ggml_context *ctx = ggml_init(ip); + // value < 0: small noise (weights, biases); otherwise every element is value (norms). + auto add = [&](const std::string &name, std::vector ne, float value) { + ggml_tensor *t = ggml_new_tensor(ctx, GGML_TYPE_F32, static_cast(ne.size()), ne.data()); + std::normal_distribution dist(0.0f, 0.1f); + auto *data = (float *)t->data; + for (int64_t i = 0; i < ggml_nelements(t); ++i) { + data[i] = value < 0 ? dist(rng) : value; + } + ggml_set_name(t, name.c_str()); + gguf_add_tensor(g, t); + }; + add("v.patch_embd.weight", {PATCH, PATCH, 3, V_EMBD}, -1); + add("v.patch_embd.bias", {V_EMBD}, -1); + add("v.position_embd.weight", {V_EMBD, N_PATCHES}, -1); + for (int il = 0; il < V_LAYER; ++il) { + const std::string p = "v.blk." + std::to_string(il) + "."; + for (const char *n : {"attn_q", "attn_k", "attn_v", "attn_out"}) { + add(p + n + ".weight", {V_EMBD, V_EMBD}, -1); + add(p + n + ".bias", {V_EMBD}, -1); + } + for (const char *n : {"ln1", "ln2"}) { + add(p + n + ".weight", {V_EMBD}, 1.0f); + add(p + n + ".bias", {V_EMBD}, 0.0f); + } + add(p + "ffn_up.weight", {V_EMBD, V_FF}, -1); + add(p + "ffn_up.bias", {V_FF}, -1); + add(p + "ffn_down.weight", {V_FF, V_EMBD}, -1); + add(p + "ffn_down.bias", {V_EMBD}, -1); + } + add("mm.0.weight", {V_EMBD, PROJ}, -1); + add("mm.0.bias", {PROJ}, -1); + add("mm.2.weight", {PROJ, N_EMBD}, -1); + add("mm.2.bias", {N_EMBD}, -1); + const bool ok = gguf_write_to_file(g, path.c_str(), false); + gguf_free(g); + ggml_free(ctx); + return ok ? path : std::string(); +} + +// A 2x2 red PNG, base64: an image stb_image decodes, small enough to inline, for the projector above. +constexpr const char *TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg=="; + } // namespace jllama_test diff --git a/llama/src/test/java/net/ladenthin/llama/MultimodalIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/MultimodalIntegrationTest.java index 656a2b214..7ecf7afc6 100644 --- a/llama/src/test/java/net/ladenthin/llama/MultimodalIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/MultimodalIntegrationTest.java @@ -251,6 +251,36 @@ public void multimodalThenTextOnSameModel() throws Exception { "text-only call after multimodal must still produce tokens; got: \"" + secondReply + "\""); } + /** + * llama.cpp's raw schema takes an image as {@code "prompt": {"prompt_string": "... ...", + * "multimodal_data": [""]}}, with the server's media marker where the image goes. The raw + * completion paths tokenized that object without the projector and refused it with "model does not + * support multimodal requests" although one was loaded. + */ + @Timeout(value = 240_000, unit = TimeUnit.MILLISECONDS) + @Test + public void aRawCompletionTakesTheMultimodalPromptObject() throws Exception { + String marker = model.getModelMeta().getMediaMarker(); + assertFalse(marker.isEmpty(), "a vision model must report its media marker"); + int textTokens = MAPPER.readTree( + model.handleCompletionsOai("{\"prompt\":\"Describe: \",\"max_tokens\":1,\"temperature\":0}")) + .path("usage") + .path("prompt_tokens") + .asInt(); + com.fasterxml.jackson.databind.node.ObjectNode request = MAPPER.createObjectNode(); + request.putObject("prompt") + .put("prompt_string", "Describe: " + marker + " in one word:") + .putArray("multimodal_data") + .add(Base64.getEncoder().encodeToString(RED_PNG)); + request.put("max_tokens", 1).put("temperature", 0); + + JsonNode answer = MAPPER.readTree(model.handleCompletionsOai(request.toString())); + + assertTrue( + answer.path("usage").path("prompt_tokens").asInt() > textTokens, + "the image must add its embeddings to the prompt; got: " + answer); + } + private static String identifyColor(byte[] png) { ChatMessage message = ChatMessage.userMultimodal( ContentPart.text("What is the dominant color? Reply with only red or blue."), diff --git a/llama/src/test/java/net/ladenthin/llama/value/ModelMetaTest.java b/llama/src/test/java/net/ladenthin/llama/value/ModelMetaTest.java index dd2c654c3..1ff6aa666 100644 --- a/llama/src/test/java/net/ladenthin/llama/value/ModelMetaTest.java +++ b/llama/src/test/java/net/ladenthin/llama/value/ModelMetaTest.java @@ -140,6 +140,21 @@ public void testGetFtypeEmptyWhenAbsent() throws Exception { assertThat(meta.getFtype(), is("")); } + @Test + public void testGetMediaMarker() throws Exception { + ModelMeta meta = + parse("{\"n_vocab\":100,\"modalities\":{\"vision\":true}," + "\"media_marker\":\"<__media_abc__>\"}"); + + assertThat(meta.getMediaMarker(), is("<__media_abc__>")); + } + + @Test + public void testGetMediaMarkerEmptyWhenAbsent() throws Exception { + ModelMeta meta = parse("{\"n_vocab\":100,\"modalities\":{\"vision\":false}}"); + + assertThat(meta.getMediaMarker(), is("")); + } + @Test public void testGetArchitectureEmptyWhenAbsent() throws Exception { ModelMeta meta = parse("{\"vocab_type\":1,\"n_vocab\":100,\"n_ctx_train\":4096," From b2e73954f37a93979f7b63618d3ead4dd1917b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:40:38 +0000 Subject: [PATCH 530/589] Record the commit of F258 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 9eb6d8efc..eca9476e4 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2348,7 +2348,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `test_c_api.cpp`: `CApiVision.ModelMetaNamesTheMediaMarker`, `CApiVision.ARawCompletionTakesUpstreamsMultimodalPromptObject` (blockierend, gestreamt, OpenAI-Pfad: `tokens_evaluated` = Text-Tokens + 4 Bild-Embeddings), `CApiVision.AMultimodalPromptWhoseMarkersDoNotMatchItsImagesIsRefused` (kein Marker, zwei Marker für ein Bild, kein Base64, keine Daten, Bild am Ende), `CApi.AMultimodalPromptObjectOnAModelWithoutAProjectorIsTheCallersFault`; `ModelMetaTest#testGetMediaMarker`, `#testGetMediaMarkerEmptyWhenAbsent`; `MultimodalIntegrationTest#aRawCompletionTakesTheMultimodalPromptObject` (JNI, `handleCompletionsOai`). - **Rot-Grün-Nachweis:** Ohne den Fix rot: „test_c_api.cpp:775: Failure Value of: j.contains("media_marker") Expected: true“ und, mit dem Marker, „test_c_api.cpp:792: Failure Expected equality of these values: jllama_completions(model_, request.c_str(), &completion.s) Which is: 2 JLLAMA_STATUS_OK Which is: 0 … Multimodal data provided, but model does not support multimodal requests.“; der Java-Test mit einem Prompt, der auf das Bild endete, beendete die Test-JVM („server-context.cpp:4067: GGML_ASSERT(batch.size() > 0) failed“, Exit 134), bevor die Schutzprüfung da war. Mit dem Fix grün: `ctest -R CApi` 49/49, `ModelMetaTest` 20/20, `MultimodalIntegrationTest` mit dem Winzmodell und dem Winz-Projektor 5/6 – rot bleibt nur `#imageBytesAffectTheAnswer`, das echte Farberkennung braucht (Rauschen eines Zufallsmodells; mit SmolVLM in CI). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `595a8be` ## Offene Verdachte ohne Fund-ID From e5d35b143d5ccc8417f46ca80e19ecb90dc589a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:54:21 +0000 Subject: [PATCH 531/589] Map format and images of a raw Ollama generation (F259) /api/generate with "raw": true kept only the prompt and the sampling options: format was dropped and the answer unconstrained, the images never reached the model. Ollama honours both. format now becomes the completion's json_schema ("json" a JSON object, as Ollama's own grammar and upstream's json_object; a schema object itself), on the fill-in-the-middle path too; a format that is neither is refused on every route, as Ollama refuses it, where it used to be ignored. Ollama places raw images with [img-N] tags in the prompt. Each tag now becomes the server's media marker (OpenAiBackend.mediaMarker, from the model meta since F258) and image N goes into multimodal_data in that order -- llama.cpp's raw multimodal prompt. A tag naming no image and an image no tag places are 400 (Ollama drops the latter); images beside a suffix are refused. Red before the fix: "aRawGenerationTakesItsFormatAsTheJsonSchema Expected: is <{"type":"object"}> but: was <>", the HTTP test "Expected: is <400> but: was <200>", the model-backed format test answered free text. Green: OllamaApiSupportTest 54/54, OpenAiCompatServerHttpTest 76/76, OpenAiServerCompletionIntegrationTest 10/12 (two need FIM). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 ++ README.md | 2 +- .../llama/server/LlamaModelBackend.java | 5 + .../llama/server/OllamaApiSupport.java | 158 +++++++++++++++++- .../ladenthin/llama/server/OpenAiBackend.java | 13 ++ .../llama/server/OpenAiCompatServer.java | 4 +- .../llama/server/OllamaApiSupportTest.java | 107 ++++++++++++ .../server/OpenAiCompatServerHttpTest.java | 53 ++++++ ...OpenAiServerCompletionIntegrationTest.java | 15 ++ 9 files changed, 364 insertions(+), 7 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index eca9476e4..821595a10 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -181,6 +181,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F256](#f256) — niedrig — *gefixt* - [F257](#f257) — niedrig bis mittel — *gefixt* - [F258](#f258) — mittel — *gefixt* +- [F259](#f259) — niedrig bis mittel — *gefixt* ## Funde @@ -1785,6 +1786,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `OllamaApiSupport.hasRawConflict` prüft genau Ollamas Bedingung (nicht leeres `system`/`template`, nicht leeres `context`-Array; leere Werte sind kein Konflikt); `handleOllamaGenerate` beantwortet einen Konflikt direkt nach dem Einlesen, vor Ladeantwort und FIM, mit 400 und Ollamas Wortlaut, ohne das Backend zu rufen — gestreamt wie ungestreamt. CHANGELOG-Zeile und die Fix-Zeile von F179 sind korrigiert. Nicht Teil dieses Fixes: `format` und `images` im `raw`-Weg (Ollama behält sie, hier fallen sie weg) und `template`/`context` im Weg ohne `raw` (Ollama wendet sie an) — beides offen. - **Tests:** `OpenAiCompatServerHttpTest.ollamaGenerateWithRawAndASystemPromptIsRejectedLikeOllama` (`system`, `template`, `context`, mit `suffix`, ohne Prompt; das Backend wird nicht gerufen) ersetzt `ollamaGenerateWithRawCompletesTheBarePromptAndIgnoresSystem`; `ollamaGenerateWithRawCompletesTheBarePrompt` (der Weg ohne Konflikt); `OllamaApiSupportTest.rawWithSystemTemplateOrContextConflictsLikeOllama` (vier Konflikte, vier zulässige Formen). - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit einem `hasRawConflict`, das `false` liefert) rot: der HTTP-Test mit „Expected: is <400> but: was <200>“ für `{"model":"test-model","prompt":"once upon","system":"x","raw":true,"stream":false}`, der Einheitstest mit „Expected: is but: was “; mit dem Fix grün (`OllamaApiSupportTest` 40, `OpenAiCompatServerHttpTest` 67, `LlamaArchitectureTest` 13), `spotbugs:check` und `javadoc:javadoc` grün. +- **Nachtrag (Branch-Review 2026-10-21, Abschluss vor 6.0.0):** Der Rest aus der TODO-Liste ist erledigt: `format` und `images` auf dem rohen Weg bildet F259 ab (`format` als `json_schema`, Bilder an ihren `[img-N]`-Marken über den Medien-Marker des Servers; ein Bild ohne Marke ist 400), `template` und `context` ohne `raw` weist F260 mit 400 ab. - **Status:** gefixt - **Commit:** java-llama.cpp `7933eb0` @@ -2350,6 +2352,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `595a8be` +### F259 – java-llama.cpp: Ollama `/api/generate` mit `raw: true` verwirft `format` und `images` still +- **Zeit:** 2026-10-21 15:54 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (`toOpenAiCompletionRequest`, `toInfillRequest`, neu `formatSchema`, `copyFormatAsJsonSchema`, `hasImages`, `toRawMultimodalPrompt`), `OpenAiCompatServer.java` (`handleOllamaGenerate`), `OpenAiBackend.java` (neu `mediaMarker`), `LlamaModelBackend.java`, `README.md` +- **Schwere:** niedrig bis mittel (eine rohe Anfrage mit `format` bekam freien Text, ihre Bilder erreichten das Modell nie; beides ohne Hinweis) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Ollama routes“, erster Punkt; Brief JR-2), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F215 hatte den Rest beim Abweisen von `system`/`template`/`context` neben `raw` offen gelassen; F258 macht den rohen Pfad erst bildfähig. +- **Beschreibung:** `toOpenAiCompletionRequest` übernahm nur `prompt`, Sampling und `stop`. Ollama hält `format` auch roh ein und nimmt Bilder roh über `[img-N]`-Marken im Prompt (sein Runner teilt den Prompt dort); hier kamen weder Grammatik noch Bilder an. Daneben: ein `format`, das weder `"json"` noch ein Schema-Objekt ist, ignorierten alle drei Wege (Chat, Generate mit und ohne `raw`), Ollama weist es ab („invalid format …; expected "json" or a valid JSON Schema object“); der FIM-Weg (`suffix`) verwarf `format` und `images` ebenso. +- **Fix:** Entscheidung nach dem Brief (Ollama-Verhalten, kein 400 für die Felder selbst): `format` wird auf dem rohen und dem FIM-Weg `json_schema` (`"json"` = `{"type":"object"}`, wie Ollamas JSON-Grammatik und upstreams `json_object`; ein Objekt ist das Schema; `""`/`null` heißen „nicht gesetzt“, wie bei Ollama), ein anderer Wert ist auf allen Wegen 400 „invalid format: ; expected "json" or a JSON Schema object“. Bilder: jede `[img-N]`-Marke wird durch den Medien-Marker des Servers ersetzt (`OpenAiBackend#mediaMarker`, in `LlamaModelBackend` `ModelMeta#getMediaMarker()`, F258), Bild `N` in dieser Reihenfolge nach `multimodal_data`; ein `data:`-URL wird auf seinen Base64-Teil gekürzt. Abweichungen von Ollama, beide laut: eine Marke ohne Bild ist 400 „invalid image index: N“ (bei Ollama ein Fehler des Runners), ein Bild ohne Marke 400 „image N is not placed in the raw prompt; put [img-N] where it goes“ (Ollama lässt es weg). Ein Bild auf dem FIM-Weg ist 400. Abweichung vom Brief: der Brief nahm an, der rohe Pfad nehme `multimodal_data` schon an, und dass Ollamas Bilder ohne Platz im Prompt übergeben werden; beides stimmte nicht (F258, Ollamas `[img-N]`). +- **Tests:** `OllamaApiSupportTest#aRawGenerationTakesItsFormatAsTheJsonSchema`, `#aFormatThatIsNeitherJsonNorASchemaIsRefused` (roh und Chat, `"yaml"`, `1`, `["json"]`, `true`; `""` gilt als nicht gesetzt), `#aRawGenerationPutsItsImagesWhereTheirTagsAre` (zwei Bilder in vertauschter Reihenfolge, `data:`-URL; ein Prompt mit `[img-0]` ohne Bilder bleibt Text), `#aRawImageWithoutItsTagOrATagWithoutItsImageIsRefused`, `#anInfillGenerationTakesItsFormatAndRefusesImages`; `OpenAiCompatServerHttpTest#ollamaGenerateWithRawCarriesItsFormatAndImages`, `#ollamaGenerateWithAnUnplacedImageOrAnUnknownFormatIsABadRequest` (roh und mit Template, nichts generiert); `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawWithFormatJsonAnswersAJsonObject` (modellgestützt: die Antwort beginnt mit `{`). +- **Rot-Grün-Nachweis:** Gegen den unveränderten Code (mit den neuen Signaturen als Stummel) rot: „OllamaApiSupportTest.aRawGenerationTakesItsFormatAsTheJsonSchema:265 Expected: is <{"type":"object"}> but: was <>“, „aRawGenerationPutsItsImagesWhereTheirTagsAre:315 Expected: is "a b c" but: was """, „aFormatThatIsNeitherJsonNorASchemaIsRefused:287 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, ebenso `aRawImageWithoutItsTagOrATagWithoutItsImageIsRefused:329`, `anInfillGenerationTakesItsFormatAndRefusesImages` („Expected: is <{"type":"object"}> but: was <>“), und „OpenAiCompatServerHttpTest.ollamaGenerateWithAnUnplacedImageOrAnUnknownFormatIsABadRequest:520 … Expected: is <400> but: was <200>“, „ollamaGenerateWithRawCarriesItsFormatAndImages:493 Expected: is <{"type":"object"}> but: was <>“. Der modellgestützte Test rot ohne die `json_schema`-Zeile: „OpenAiServerCompletionIntegrationTest.ollamaGenerateRawWithFormatJsonAnswersAJsonObject:321 d"d"dV"d Expected: is but: was “ (Winzmodell, freier Text). Mit dem Fix grün: `OllamaApiSupportTest` 54/54, `OpenAiCompatServerHttpTest` 76/76, `LlamaModelBackendTest` 4/4, `OpenAiServerCompletionIntegrationTest` 10/12 mit dem Winzmodell (die zwei übersprungenen brauchen FIM-Token). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/README.md b/README.md index ef8cc4add..45ac6ad2b 100644 --- a/README.md +++ b/README.md @@ -1006,7 +1006,7 @@ path), all supporting tools and streaming: | Surface | Routes | For | |---|---|---| -| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does; the bare prompt with `raw: true`; FIM with `suffix`; without a prompt and without a suffix Ollama's "model loaded" answer; unlike Ollama, a request with only a suffix stays fill-in-the-middle at the start of the file, and `keep_alive: 0` does not unload and is answered "load") | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | +| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does; the bare prompt with `raw: true`, where `format` constrains the answer and each image goes where its `[img-N]` tag stands, as in Ollama -- an image no tag places is refused; FIM with `suffix`, which takes `format` too but no images; a `format` other than `"json"` or a schema object is refused; without a prompt and without a suffix Ollama's "model loaded" answer; unlike Ollama, a request with only a suffix stays fill-in-the-middle at the start of the file, and `keep_alive: 0` does not unload and is answered "load") | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | | **Anthropic Messages** | `POST /v1/messages` (SSE event stream) | Claude-shaped clients (Claude Code); Copilot `messages` apiType | | **OpenAI Responses** | `POST /v1/responses` (SSE event stream) | Copilot `responses` apiType; Responses-API clients | diff --git a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java index 052500f6d..3fad8465d 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java @@ -116,6 +116,11 @@ static void relay(Consumer> modelCall, ChunkSink sink) throws I } } + @Override + public String mediaMarker() { + return model.getModelMeta().getMediaMarker(); + } + @Override public String embeddings(JsonNode request) { // oaiCompat=true so the response uses the OpenAI {"object":"list","data":[{embedding}]} shape. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index 891c47445..b69b171a0 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -10,6 +10,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; /** @@ -31,6 +35,9 @@ final class OllamaApiSupport { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** Ollama's placeholder for image {@code N} of a raw prompt; its runner splits the prompt there. */ + private static final Pattern RAW_IMAGE_TAG = Pattern.compile("\\[img-(\\d+)\\]"); + /** Ollama's answer to {@code "raw": true} with a {@code system}, {@code template} or {@code context}. */ static final String RAW_CONFLICT_MESSAGE = "raw mode does not support template, system, or context"; @@ -186,17 +193,51 @@ static ObjectNode toOpenAiChatRequest(JsonNode ollamaRequest) { } // Ollama "format": "json" or a JSON schema → OpenAI response_format. - JsonNode format = ollamaRequest.path("format"); - if (format.isTextual() && "json".equals(format.asText())) { + JsonNode schema = formatSchema(ollamaRequest); + if (schema != null && ollamaRequest.path("format").isTextual()) { openAi.putObject("response_format").put("type", "json_object"); - } else if (format.isObject()) { + } else if (schema != null) { ObjectNode responseFormat = openAi.putObject("response_format"); responseFormat.put("type", "json_schema"); - responseFormat.putObject("json_schema").set("schema", format.deepCopy()); + responseFormat.putObject("json_schema").set("schema", schema); } return openAi; } + /** + * The JSON schema Ollama's {@code format} constrains the answer to: {@code "json"} is a JSON object + * (Ollama's own JSON grammar, upstream's {@code json_object}), an object is the schema itself, and an + * empty string, {@code null} or no field mean none, as in Ollama. Anything else is refused, as Ollama + * refuses it, rather than generating unconstrained. + * + * @param request the Ollama request + * @return the schema, or {@code null} when the request names no format + * @throws IllegalArgumentException if {@code format} is neither {@code "json"} nor a schema object + */ + private static @Nullable JsonNode formatSchema(JsonNode request) { + JsonNode format = request.path("format"); + if (format.isMissingNode() + || format.isNull() + || (format.isTextual() && format.asText().isEmpty())) { + return null; + } + if (format.isTextual() && "json".equals(format.asText())) { + return OBJECT_MAPPER.createObjectNode().put("type", "object"); + } + if (format.isObject()) { + return format.deepCopy(); + } + throw new IllegalArgumentException("invalid format: " + format + "; expected \"json\" or a JSON Schema object"); + } + + /** Set {@code json_schema}, which the completion schema of the raw and infill tasks reads, from {@code format}. */ + private static void copyFormatAsJsonSchema(JsonNode request, ObjectNode to) { + JsonNode schema = formatSchema(request); + if (schema != null) { + to.set("json_schema", schema); + } + } + private static ObjectNode toOpenAiMessage(JsonNode ollamaMessage) { ObjectNode message = ollamaMessage.deepCopy(); // Ollama carries images as an "images" array of bare base64 strings beside "content"; the @@ -698,8 +739,34 @@ static ObjectNode toOpenAiChatRequestFromGenerate(JsonNode request) { * @return an OpenAI completion request object */ static ObjectNode toOpenAiCompletionRequest(JsonNode request) { + return toOpenAiCompletionRequest(request, null); + } + + /** + * Translate an Ollama {@code /api/generate} request into the internal OpenAI {@code /v1/completions} + * request shape, with its images. Ollama's raw prompt places image {@code N} with an + * {@code [img-N]} tag; llama.cpp's raw schema wants the server's media marker in its place and the + * bytes in {@code multimodal_data}, in the order of the markers, so the prompt becomes + * {@code {"prompt_string": ..., "multimodal_data": [...]}}. {@code format} becomes {@code json_schema}. + * + * @param request the parsed Ollama generate request + * @param mediaMarker the server's media marker; needed only when the request carries images + * @return an OpenAI completion request object + * @throws IllegalArgumentException if an image is not a string, no tag places it, a tag names no + * image, or {@code format} is neither {@code "json"} nor a schema object + */ + static ObjectNode toOpenAiCompletionRequest(JsonNode request, @Nullable String mediaMarker) { ObjectNode openAi = OBJECT_MAPPER.createObjectNode(); - openAi.put("prompt", request.path("prompt").asText("")); + String prompt = request.path("prompt").asText(""); + if (hasImages(request)) { + if (mediaMarker == null) { + throw new IllegalStateException("a raw prompt with images needs the server's media marker"); + } + openAi.set("prompt", toRawMultimodalPrompt(prompt, request.path("images"), mediaMarker)); + } else { + openAi.put("prompt", prompt); + } + copyFormatAsJsonSchema(request, openAi); JsonNode options = request.path("options"); copyNumber(options, "temperature", openAi, "temperature"); copyNumber(options, "top_p", openAi, "top_p"); @@ -713,6 +780,83 @@ static ObjectNode toOpenAiCompletionRequest(JsonNode request) { return openAi; } + /** + * Whether the {@code /api/generate} request carries images. A non-empty {@code images} array counts; + * {@code images} that is present but no array is refused. + * + * @param request the parsed Ollama generate request + * @return {@code true} if {@code images} is a non-empty array + * @throws IllegalArgumentException if {@code images} is neither an array nor {@code null} + */ + static boolean hasImages(JsonNode request) { + JsonNode images = request.path("images"); + if (images.isMissingNode() || images.isNull()) { + return false; + } + if (!images.isArray()) { + throw new IllegalArgumentException("'images' must be an array of base64 strings, got " + images); + } + return images.size() > 0; + } + + /** + * llama.cpp's raw multimodal prompt for Ollama's raw prompt: every {@code [img-N]} tag replaced by + * the media marker and image {@code N} appended to {@code multimodal_data} in that order. Ollama's + * runner refuses a tag naming no image ("invalid image index") and leaves an image no tag places + * out; such an image is refused here instead of being dropped. + */ + private static ObjectNode toRawMultimodalPrompt(String prompt, JsonNode images, String mediaMarker) { + List data = new ArrayList<>(images.size()); + for (int i = 0; i < images.size(); i++) { + JsonNode image = images.get(i); + if (!image.isTextual() || image.asText().isEmpty()) { + throw new IllegalArgumentException("'images[" + i + "]' must be a base64 string"); + } + data.add(stripDataUrl(image.asText())); + } + boolean[] placed = new boolean[data.size()]; + StringBuilder text = new StringBuilder(prompt.length()); + ArrayNode media = OBJECT_MAPPER.createArrayNode(); + Matcher tag = RAW_IMAGE_TAG.matcher(prompt); + int end = 0; + while (tag.find()) { + String digits = tag.group(1); + int index = imageIndex(digits != null ? digits : "", data.size()); + text.append(prompt, end, tag.start()).append(mediaMarker); + media.add(data.get(index)); + placed[index] = true; + end = tag.end(); + } + text.append(prompt, end, prompt.length()); + for (int i = 0; i < placed.length; i++) { + if (!placed[i]) { + throw new IllegalArgumentException( + "image " + i + " is not placed in the raw prompt; put [img-" + i + "] where it goes"); + } + } + ObjectNode multimodal = OBJECT_MAPPER.createObjectNode(); + multimodal.put("prompt_string", text.toString()); + multimodal.set("multimodal_data", media); + return multimodal; + } + + private static int imageIndex(String digits, int images) { + // digits only, by the pattern; a number too long for an int names no image either + if (!digits.isEmpty() && digits.length() <= 9) { + int index = Integer.parseInt(digits); + if (index < images) { + return index; + } + } + throw new IllegalArgumentException("invalid image index: " + digits); + } + + /** The base64 payload of a {@code data:} URL; any other string is taken as bare base64, as Ollama sends it. */ + private static String stripDataUrl(String image) { + int comma = image.indexOf(','); + return image.startsWith("data:") && comma >= 0 ? image.substring(comma + 1) : image; + } + /** * Translate an Ollama {@code /api/generate} request with a {@code suffix} into the native infill * request shape ({@code input_prefix} / {@code input_suffix}). @@ -721,7 +865,11 @@ static ObjectNode toOpenAiCompletionRequest(JsonNode request) { * @return a native {@code /infill} request object */ static ObjectNode toInfillRequest(JsonNode request) { + if (hasImages(request)) { + throw new IllegalArgumentException("images are not supported with a suffix (fill-in-the-middle)"); + } ObjectNode infill = OBJECT_MAPPER.createObjectNode(); + copyFormatAsJsonSchema(request, infill); infill.put("input_prefix", request.path("prompt").asText("")); infill.put("input_suffix", request.path("suffix").asText("")); JsonNode options = request.path("options"); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java index 347617dfd..1ff4e8166 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java @@ -89,6 +89,19 @@ default void streamCompletions(JsonNode request, ChunkSink sink) throws IOExcept throw new UnsupportedOperationException("streaming /v1/completions is not supported by this backend"); } + /** + * The placeholder a raw multimodal prompt puts where each image goes (llama.cpp's + * {@code {"prompt_string": ..., "multimodal_data": [...]}} prompt object), which the server draws at + * random per process. Asked only for a raw request that carries images. The default throws + * {@link UnsupportedOperationException}; backends that take images override it. + * + * @return the media marker + * @throws IOException if the marker cannot be read + */ + default String mediaMarker() throws IOException { + throw new UnsupportedOperationException("raw prompts with images are not supported by this backend"); + } + /** * Generate embeddings ({@code POST /v1/embeddings}). Requires the model to have been loaded in * embedding mode; otherwise the native call fails and the caller surfaces a server error. diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 537a2216a..2793e3b0b 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -669,8 +669,10 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { generated = OllamaApiSupport.Generated.fromInfill( backend.infill(OllamaApiSupport.toInfillRequest(request))); } else if (OllamaApiSupport.isRaw(request)) { + // the marker the raw prompt's images go to is the server's, asked only when there are any + String marker = OllamaApiSupport.hasImages(request) ? backend.mediaMarker() : null; generated = OllamaApiSupport.Generated.fromCompletion( - backend.completions(OllamaApiSupport.toOpenAiCompletionRequest(request))); + backend.completions(OllamaApiSupport.toOpenAiCompletionRequest(request, marker))); } else { // Ollama applies the chat template unless raw is true generated = OllamaApiSupport.Generated.fromChat( diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java index 2ad766927..55cc4dc88 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java @@ -253,6 +253,113 @@ public void infillCopiesTheLlamaCppSamplingOptions() throws IOException { "{\"model\":\"m\",\"prompt\":\"p\",\"suffix\":\"s\",\"options\":{\"min_p\":0.05,\"typical_p\":0.9,\"repeat_penalty\":1.1,\"repeat_last_n\":32,\"presence_penalty\":0.2,\"frequency_penalty\":0.3,\"num_keep\":8}}"))); } + /** + * Ollama constrains a raw generation by {@code format} as it does a templated one; the raw route + * dropped it and completed unconstrained. upstream's completion schema reads {@code json_schema}; + * {@code "json"} is a JSON object, as Ollama's own JSON grammar and upstream's {@code json_object}. + */ + @Test + public void aRawGenerationTakesItsFormatAsTheJsonSchema() throws IOException { + JsonNode json = + OllamaApiSupport.toOpenAiCompletionRequest(read("{\"prompt\":\"p\",\"raw\":true,\"format\":\"json\"}")); + assertThat(json.path("json_schema"), is(read("{\"type\":\"object\"}"))); + assertThat(json.has("response_format"), is(false)); + + String schema = "{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\"}}}"; + JsonNode schemaRequest = OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"p\",\"raw\":true,\"format\":" + schema + "}")); + assertThat(schemaRequest.path("json_schema"), is(read(schema))); + + for (String unset : new String[] {"\"\"", "null"}) { + JsonNode none = OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"p\",\"raw\":true,\"format\":" + unset + "}")); + assertThat(unset, none.has("json_schema"), is(false)); + } + } + + /** + * Ollama refuses a {@code format} that is neither {@code "json"} nor a schema object; the routes + * ignored it and generated unconstrained. An empty string and {@code null} mean "not set", as there. + */ + @Test + public void aFormatThatIsNeitherJsonNorASchemaIsRefused() throws IOException { + for (String format : new String[] {"\"yaml\"", "1", "[\"json\"]", "true"}) { + IllegalArgumentException raw = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"p\",\"raw\":true,\"format\":" + format + "}"))); + assertThat( + raw.getMessage(), is("invalid format: " + format + "; expected \"json\" or a JSON Schema object")); + IllegalArgumentException chat = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"format\":" + format + "}"))); + assertThat(chat.getMessage(), is(raw.getMessage())); + } + JsonNode none = OllamaApiSupport.toOpenAiChatRequest( + read("{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],\"format\":\"\"}")); + assertThat(none.has("response_format"), is(false)); + } + + /** + * Ollama's raw prompt places its images with {@code [img-N]} tags (the runner splits the prompt + * there); llama.cpp's raw schema wants the server's media marker in their place and the bytes in + * {@code multimodal_data}, in the order of the markers. The raw route dropped the images. + */ + @Test + public void aRawGenerationPutsItsImagesWhereTheirTagsAre() throws IOException { + JsonNode openAi = OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"a [img-1] b [img-0] c\",\"raw\":true," + + "\"images\":[\"AAAA\",\"data:image/png;base64,BBBB\"]}"), + ""); + assertThat(openAi.path("prompt").path("prompt_string").asText(), is("a b c")); + assertThat(openAi.path("prompt").path("multimodal_data"), is(read("[\"BBBB\",\"AAAA\"]"))); + + JsonNode noImages = OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"keep [img-0] as text\",\"raw\":true,\"images\":[]}"), ""); + assertThat(noImages.path("prompt").asText(), is("keep [img-0] as text")); + } + + /** + * An image no tag places would go nowhere, and a tag naming no image has nothing to show; Ollama's + * runner refuses the second ("invalid image index") and ignores the first. Both are refused here. + */ + @Test + public void aRawImageWithoutItsTagOrATagWithoutItsImageIsRefused() throws IOException { + IllegalArgumentException untagged = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"a [img-0]\",\"raw\":true,\"images\":[\"AAAA\",\"BBBB\"]}"), "")); + assertThat(untagged.getMessage(), is("image 1 is not placed in the raw prompt; put [img-1] where it goes")); + IllegalArgumentException unknown = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"a [img-2]\",\"raw\":true,\"images\":[\"AAAA\"]}"), "")); + assertThat(unknown.getMessage(), is("invalid image index: 2")); + IllegalArgumentException notText = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toOpenAiCompletionRequest( + read("{\"prompt\":\"a [img-0]\",\"raw\":true,\"images\":[1]}"), "")); + assertThat(notText.getMessage(), is("'images[0]' must be a base64 string")); + } + + /** + * A {@code suffix} makes the request an infill, whose task reads {@code json_schema} as a completion + * does; its {@code format} was dropped. Images have no place in an infill prompt and are refused + * rather than dropped. + */ + @Test + public void anInfillGenerationTakesItsFormatAndRefusesImages() throws IOException { + JsonNode infill = + OllamaApiSupport.toInfillRequest(read("{\"prompt\":\"def f(\",\"suffix\":\")\",\"format\":\"json\"}")); + assertThat(infill.path("json_schema"), is(read("{\"type\":\"object\"}"))); + IllegalArgumentException images = assertThrows( + IllegalArgumentException.class, + () -> OllamaApiSupport.toInfillRequest( + read("{\"prompt\":\"def f(\",\"suffix\":\")\",\"images\":[\"AAAA\"]}"))); + assertThat(images.getMessage(), is("images are not supported with a suffix (fill-in-the-middle)")); + } + @Test public void numPredictMinusTwoFillsTheContextAsUpstreamsMinusOne() throws IOException { // Ollama's -2 means "fill the context"; upstream's n_predict hard limit starts at -1 and rejects it. diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 6aee52a1c..797f5ad0f 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -476,6 +476,54 @@ public void ollamaGenerateWithRawCompletesTheBarePrompt() throws IOException { } } + /** + * The raw route dropped {@code format} and {@code images}: an unconstrained completion, and the + * images never reached the model. They now arrive as llama.cpp's raw schema reads them. + */ + @Test + public void ollamaGenerateWithRawCarriesItsFormatAndImages() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String body = "{\"model\":\"test-model\",\"prompt\":\"look [img-0] now\",\"raw\":true," + + "\"format\":\"json\",\"images\":[\"AAAA\"],\"stream\":false}"; + Response response = post(server.getPort(), "/api/generate", body, ""); + + assertThat(response.body, response.code, is(200)); + assertThat(backend.called, is("completions")); + assertThat(backend.request.path("json_schema"), is(READER.readTree("{\"type\":\"object\"}"))); + assertThat(backend.request.path("prompt").path("prompt_string").asText(), is("look now")); + assertThat(backend.request.path("prompt").path("multimodal_data"), is(READER.readTree("[\"AAAA\"]"))); + } + } + + /** A raw image no {@code [img-N]} tag places, or a format Ollama does not know, is the client's fault. */ + @Test + public void ollamaGenerateWithAnUnplacedImageOrAnUnknownFormatIsABadRequest() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String[][] cases = { + { + "{\"model\":\"test-model\",\"prompt\":\"look\",\"raw\":true,\"images\":[\"AAAA\"]}", + "image 0 is not placed in the raw prompt; put [img-0] where it goes" + }, + { + "{\"model\":\"test-model\",\"prompt\":\"look\",\"raw\":true,\"format\":\"yaml\"}", + "invalid format: \"yaml\"; expected \"json\" or a JSON Schema object" + }, + { + "{\"model\":\"test-model\",\"prompt\":\"look\",\"format\":\"yaml\",\"stream\":false}", + "invalid format: \"yaml\"; expected \"json\" or a JSON Schema object" + } + }; + for (String[] c : cases) { + Response response = post(server.getPort(), "/api/generate", c[0], ""); + assertThat(c[0], response.code, is(400)); + assertThat(c[0], READER.readTree(response.body).path("error").asText(), is(c[1])); + } + assertThat("nothing was generated", backend.called, is("")); + } + } + /** * Ollama refuses {@code "raw": true} next to a {@code system}, {@code template} or {@code context} * with a 400, before its load answer and before fill-in-the-middle. The server dropped them silently @@ -1290,6 +1338,11 @@ static final class RecordingBackend extends FakeBackend { volatile String called = ""; volatile JsonNode request = READER.createObjectNode(); + @Override + public String mediaMarker() { + return ""; + } + @Override public String complete(JsonNode request) { this.called = "complete"; diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java index 0a412f3b2..92a4a197b 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java @@ -306,6 +306,21 @@ public void ollamaGenerateRawRoundTrip() throws IOException { assertThat(json.path("eval_count").asInt(-1) > 0, is(true)); } + /** + * {@code format} constrains a raw generation as it does a templated one: the grammar llama.cpp + * builds from the schema makes the first character an object's brace, whatever the model would have + * written. The raw route dropped {@code format} and completed the code unconstrained. + */ + @Test + public void ollamaGenerateRawWithFormatJsonAnswersAJsonObject() throws IOException { + String body = "{\"model\":\"" + MODEL_ID + "\",\"stream\":false,\"raw\":true,\"format\":\"json\"," + + "\"prompt\":\"def add(a, b):\\n return\",\"options\":{\"num_predict\":8,\"temperature\":0}}"; + Response response = post(port, "/api/generate", body, ""); + assertThat(response.body, response.code, is(200)); + String text = MAPPER.readTree(response.body).path("response").asText(); + assertThat(text, text.trim().startsWith("{"), is(true)); + } + @Test public void ollamaGenerateWithSuffixUsesInfill() throws IOException { FimAssumptions.assumeFimTokens(model); From af9250db50eb7651e1bd3e0996924e93a4673df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:54:21 +0000 Subject: [PATCH 532/589] Record the commit of F259 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 821595a10..25dbdacc0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2362,7 +2362,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OllamaApiSupportTest#aRawGenerationTakesItsFormatAsTheJsonSchema`, `#aFormatThatIsNeitherJsonNorASchemaIsRefused` (roh und Chat, `"yaml"`, `1`, `["json"]`, `true`; `""` gilt als nicht gesetzt), `#aRawGenerationPutsItsImagesWhereTheirTagsAre` (zwei Bilder in vertauschter Reihenfolge, `data:`-URL; ein Prompt mit `[img-0]` ohne Bilder bleibt Text), `#aRawImageWithoutItsTagOrATagWithoutItsImageIsRefused`, `#anInfillGenerationTakesItsFormatAndRefusesImages`; `OpenAiCompatServerHttpTest#ollamaGenerateWithRawCarriesItsFormatAndImages`, `#ollamaGenerateWithAnUnplacedImageOrAnUnknownFormatIsABadRequest` (roh und mit Template, nichts generiert); `OpenAiServerCompletionIntegrationTest#ollamaGenerateRawWithFormatJsonAnswersAJsonObject` (modellgestützt: die Antwort beginnt mit `{`). - **Rot-Grün-Nachweis:** Gegen den unveränderten Code (mit den neuen Signaturen als Stummel) rot: „OllamaApiSupportTest.aRawGenerationTakesItsFormatAsTheJsonSchema:265 Expected: is <{"type":"object"}> but: was <>“, „aRawGenerationPutsItsImagesWhereTheirTagsAre:315 Expected: is "a b c" but: was """, „aFormatThatIsNeitherJsonNorASchemaIsRefused:287 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“, ebenso `aRawImageWithoutItsTagOrATagWithoutItsImageIsRefused:329`, `anInfillGenerationTakesItsFormatAndRefusesImages` („Expected: is <{"type":"object"}> but: was <>“), und „OpenAiCompatServerHttpTest.ollamaGenerateWithAnUnplacedImageOrAnUnknownFormatIsABadRequest:520 … Expected: is <400> but: was <200>“, „ollamaGenerateWithRawCarriesItsFormatAndImages:493 Expected: is <{"type":"object"}> but: was <>“. Der modellgestützte Test rot ohne die `json_schema`-Zeile: „OpenAiServerCompletionIntegrationTest.ollamaGenerateRawWithFormatJsonAnswersAJsonObject:321 d"d"dV"d Expected: is but: was “ (Winzmodell, freier Text). Mit dem Fix grün: `OllamaApiSupportTest` 54/54, `OpenAiCompatServerHttpTest` 76/76, `LlamaModelBackendTest` 4/4, `OpenAiServerCompletionIntegrationTest` 10/12 mit dem Winzmodell (die zwei übersprungenen brauchen FIM-Token). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `e5d35b1` ## Offene Verdachte ohne Fund-ID From b99a25bbff76ade20ee3ff8c69b7885ba4bd127b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:58:32 +0000 Subject: [PATCH 533/589] Refuse template and context on a templated Ollama generation (F260) Without "raw", Ollama renders the prompt with a request's own template and continues the token context of an earlier answer. Neither has a place here: the model's chat template renders the prompt and no answer hands out a context. Both were dropped and the request answered as an ordinary chat turn. A non-empty template or context is now a 400 with a message naming why (also beside a suffix); empty values mean "not set", as in Ollama, and the load answer for a request without a prompt is unchanged. F215 already refused the same fields beside "raw". Red before the fix: "ollamaGenerateWithATemplateOrAContextIsRefused:553 ... Expected: is <400> but: was <200>"; green: OpenAiCompatServerHttpTest 77/77, OllamaApiSupportTest 54/54. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 13 +++++++ README.md | 2 +- .../llama/server/OllamaApiSupport.java | 35 ++++++++++++++++- .../llama/server/OpenAiCompatServer.java | 4 ++ .../server/OpenAiCompatServerHttpTest.java | 38 +++++++++++++++++++ 5 files changed, 90 insertions(+), 2 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 25dbdacc0..64205ebb1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -182,6 +182,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F257](#f257) — niedrig bis mittel — *gefixt* - [F258](#f258) — mittel — *gefixt* - [F259](#f259) — niedrig bis mittel — *gefixt* +- [F260](#f260) — niedrig — *gefixt* ## Funde @@ -2364,6 +2365,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `e5d35b1` +### F260 – java-llama.cpp: Ollama `/api/generate` ohne `raw` verwirft `template` und `context` still +- **Zeit:** 2026-10-21 15:58 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java` (neu `refuseTemplateAndContext`, `TEMPLATE_UNSUPPORTED_MESSAGE`, `CONTEXT_UNSUPPORTED_MESSAGE`; Javadoc von `isRaw`), `OpenAiCompatServer.java` (`handleOllamaGenerate`), `README.md` +- **Schwere:** niedrig (der Client bekommt eine plausible Antwort, die sein Template bzw. seinen Gesprächskontext nie gesehen hat) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Ollama routes“, zweiter Punkt; Brief JR-3), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F215 weist dieselben Felder neben `raw` ab (Ollamas eigene Regel); ohne `raw` blieb ihr Verwerfen offen. +- **Beschreibung:** Ohne `raw` rendert Ollama den Prompt mit dem `template` der Anfrage und setzt den Token-`context` einer früheren Antwort fort. Hier rendert das Chat-Template des Modells den Prompt (`toOpenAiChatRequestFromGenerate`), und keine Antwort gibt einen `context` heraus; beide Felder wurden übergangen und die Anfrage als gewöhnlicher Chat-Zug beantwortet. +- **Fix:** Entscheidung (klarer Fehler statt stiller Abweichung, Form wie F215): ohne `raw` beantwortet `handleOllamaGenerate` ein nicht leeres `template` mit 400 „template is not supported: the model's chat template renders the prompt“ und einen nicht leeren `context` mit 400 „context is not supported: token contexts cannot be replayed; send the conversation as messages to /api/chat“, auch auf dem FIM-Weg (`suffix`); `""`, `[]` und `null` gelten wie bei Ollama als nicht gesetzt. Die Ladeantwort (kein Prompt) bleibt davor. README-Abschnitt der Ollama-Routen nennt die Abweichung. +- **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithATemplateOrAContextIsRefused` (`template`, `context: [1,2,3]`, `template` neben `suffix` → 400 mit der Meldung, das Backend wird nicht gerufen; leeres `template` und `context: []` → 200 über das Chat-Template). +- **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit den Meldungskonstanten als Stummel) rot: „OpenAiCompatServerHttpTest.ollamaGenerateWithATemplateOrAContextIsRefused:553 {"model":"test-model","prompt":"why?","template":"{{ .Prompt }}","stream":false} Expected: is <400> but: was <200>“; mit dem Fix grün: `OpenAiCompatServerHttpTest` 77/77, `OllamaApiSupportTest` 54/54. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/README.md b/README.md index 45ac6ad2b..c5f864030 100644 --- a/README.md +++ b/README.md @@ -1006,7 +1006,7 @@ path), all supporting tools and streaming: | Surface | Routes | For | |---|---|---| -| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does; the bare prompt with `raw: true`, where `format` constrains the answer and each image goes where its `[img-N]` tag stands, as in Ollama -- an image no tag places is refused; FIM with `suffix`, which takes `format` too but no images; a `format` other than `"json"` or a schema object is refused; without a prompt and without a suffix Ollama's "model loaded" answer; unlike Ollama, a request with only a suffix stays fill-in-the-middle at the start of the file, and `keep_alive: 0` does not unload and is answered "load") | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | +| **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does -- a request's own `template` and a token `context` are refused there, since the model's chat template renders the prompt and no answer hands out a context; the bare prompt with `raw: true`, where `format` constrains the answer and each image goes where its `[img-N]` tag stands, as in Ollama -- an image no tag places is refused; FIM with `suffix`, which takes `format` too but no images; a `format` other than `"json"` or a schema object is refused; without a prompt and without a suffix Ollama's "model loaded" answer; unlike Ollama, a request with only a suffix stays fill-in-the-middle at the start of the file, and `keep_alive: 0` does not unload and is answered "load") | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | | **Anthropic Messages** | `POST /v1/messages` (SSE event stream) | Claude-shaped clients (Claude Code); Copilot `messages` apiType | | **OpenAI Responses** | `POST /v1/responses` (SSE event stream) | Copilot `responses` apiType; Responses-API clients | diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java index b69b171a0..ce93e9f29 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java @@ -41,6 +41,14 @@ final class OllamaApiSupport { /** Ollama's answer to {@code "raw": true} with a {@code system}, {@code template} or {@code context}. */ static final String RAW_CONFLICT_MESSAGE = "raw mode does not support template, system, or context"; + /** The answer to a {@code template} without {@code raw}: the model's chat template renders the prompt. */ + static final String TEMPLATE_UNSUPPORTED_MESSAGE = + "template is not supported: the model's chat template renders the prompt"; + + /** The answer to a {@code context} without {@code raw}: no answer hands out a token context. */ + static final String CONTEXT_UNSUPPORTED_MESSAGE = "context is not supported: token contexts cannot be replayed;" + + " send the conversation as messages to /api/chat"; + /** * Advertised Ollama API version. Clients gate on it: VS Code Copilot's Ollama provider refuses a server * below 0.6.4. 0.9.0 is the first Ollama with {@code think}, the newest request feature these routes @@ -672,7 +680,8 @@ static String toOllamaChatLoadResponse(String model) { /** * Whether the {@code /api/generate} request asks for the bare prompt ({@code "raw": true}). Without - * it Ollama applies the model's chat template, with {@code system} as the system message; with it a + * it Ollama applies the model's chat template, with {@code system} as the system message, and a + * {@code template} or {@code context} is refused ({@link #refuseTemplateAndContext}); with it a * {@code system}, {@code template} or {@code context} is refused ({@link #hasRawConflict}). * * @param request the parsed Ollama generate request @@ -701,6 +710,30 @@ static boolean hasRawConflict(JsonNode request) { || (context.isArray() && context.size() > 0); } + /** + * Refuse what a {@code /api/generate} request without {@code raw} asks for and cannot get: Ollama + * renders the prompt with a request's {@code template} and continues the token {@code context} of an + * earlier answer, but here the model's own chat template renders the prompt and no answer hands out + * a context. Both used to be dropped and the request answered as an ordinary chat turn. An empty + * {@code template}, an empty {@code context} array and {@code null} mean "not set", as in Ollama. + * + * @param request the parsed Ollama generate request + * @throws IllegalArgumentException with {@value #TEMPLATE_UNSUPPORTED_MESSAGE} or + * {@value #CONTEXT_UNSUPPORTED_MESSAGE} + */ + static void refuseTemplateAndContext(JsonNode request) { + JsonNode template = request.path("template"); + if (!template.isMissingNode() + && !template.isNull() + && !(template.isTextual() && template.asText().isEmpty())) { + throw new IllegalArgumentException(TEMPLATE_UNSUPPORTED_MESSAGE); + } + JsonNode context = request.path("context"); + if (!context.isMissingNode() && !context.isNull() && !(context.isArray() && context.size() == 0)) { + throw new IllegalArgumentException(CONTEXT_UNSUPPORTED_MESSAGE); + } + } + /** * Translate an Ollama {@code /api/generate} request without {@code raw} into the internal OpenAI chat * request, as Ollama applies the chat template: {@code system} (when given) as the system message, diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 2793e3b0b..9c87e4a92 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -665,6 +665,10 @@ private void handleOllamaGenerate(HttpExchange exchange) throws IOException { // prompt, anything else goes through the chat template as Ollama does. final OllamaApiSupport.Generated generated; try { + if (!OllamaApiSupport.isRaw(request)) { + // a raw request refused both above, in Ollama's order; without raw neither can be kept + OllamaApiSupport.refuseTemplateAndContext(request); + } if (OllamaApiSupport.hasSuffix(request)) { generated = OllamaApiSupport.Generated.fromInfill( backend.infill(OllamaApiSupport.toInfillRequest(request))); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index 797f5ad0f..c25fe8549 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -524,6 +524,44 @@ public void ollamaGenerateWithAnUnplacedImageOrAnUnknownFormatIsABadRequest() th } } + /** + * Without {@code raw}, Ollama renders the prompt with a request's {@code template} and continues the + * token {@code context} of an earlier answer. Neither can be honoured here -- the model's own chat + * template renders the prompt, and no answer hands out a context -- and both were dropped, the + * request answered as an ordinary chat turn. They are refused; empty values mean "not set". + */ + @Test + public void ollamaGenerateWithATemplateOrAContextIsRefused() throws IOException { + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String[][] cases = { + { + "{\"model\":\"test-model\",\"prompt\":\"why?\",\"template\":\"{{ .Prompt }}\",\"stream\":false}", + OllamaApiSupport.TEMPLATE_UNSUPPORTED_MESSAGE + }, + { + "{\"model\":\"test-model\",\"prompt\":\"why?\",\"context\":[1,2,3],\"stream\":false}", + OllamaApiSupport.CONTEXT_UNSUPPORTED_MESSAGE + }, + { + "{\"model\":\"test-model\",\"prompt\":\"def f(\",\"suffix\":\")\",\"template\":\"t\"}", + OllamaApiSupport.TEMPLATE_UNSUPPORTED_MESSAGE + } + }; + for (String[] c : cases) { + Response response = post(server.getPort(), "/api/generate", c[0], ""); + assertThat(c[0], response.code, is(400)); + assertThat(c[0], READER.readTree(response.body).path("error").asText(), is(c[1])); + } + assertThat("nothing was generated", backend.called, is("")); + + String empty = "{\"model\":\"test-model\",\"prompt\":\"why?\",\"template\":\"\",\"context\":[]," + + "\"stream\":false}"; + assertThat(post(server.getPort(), "/api/generate", empty, "").code, is(200)); + assertThat(backend.called, is("complete")); + } + } + /** * Ollama refuses {@code "raw": true} next to a {@code system}, {@code template} or {@code context} * with a 400, before its load answer and before fill-in-the-middle. The server dropped them silently From d971ea66390d9b77391c5ea7a7a738b93590bf7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:58:32 +0000 Subject: [PATCH 534/589] Record the commit of F260 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 64205ebb1..238f080ee 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2375,7 +2375,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithATemplateOrAContextIsRefused` (`template`, `context: [1,2,3]`, `template` neben `suffix` → 400 mit der Meldung, das Backend wird nicht gerufen; leeres `template` und `context: []` → 200 über das Chat-Template). - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit den Meldungskonstanten als Stummel) rot: „OpenAiCompatServerHttpTest.ollamaGenerateWithATemplateOrAContextIsRefused:553 {"model":"test-model","prompt":"why?","template":"{{ .Prompt }}","stream":false} Expected: is <400> but: was <200>“; mit dem Fix grün: `OpenAiCompatServerHttpTest` 77/77, `OllamaApiSupportTest` 54/54. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `b99a25b` ## Offene Verdachte ohne Fund-ID From 2c6e5c58caf6a8d7a03431b7b2488541ff4fb1dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:05:20 +0000 Subject: [PATCH 535/589] Serve upstream's token-count routes (F261) upstream counts the prompt tokens of a request without generating on three routes: /v1/messages/count_tokens ({"input_tokens": N}, which Claude Code asks before long requests), /v1/responses/input_tokens and /v1/chat/completions/input_tokens (with "object": "response.input_tokens"). All three answered 404 here since F112 stopped the first from running a generation through /v1/messages. Each route now translates its body as its generating route does and asks OpenAiBackend.countTokens, which LlamaModelBackend answers along the generation's own path: the mapper, the chat template (applyTemplate) and the tokenizer with special tokens added and parsed, as upstream's handle_count_tokens. A refused translation is 400, a backend that cannot count 501, in the route's error shape. A request with an image, audio or video is 501 too: upstream counts it with the projector, which this engine does not reach, and a count without the media would be too low. Red before the fix: "theTokenCountRoutesAnswerWithTheCountOfThe TranslatedRequest:345 /v1/messages/count_tokens Expected: is <200> but: was <404>"; green: OpenAiCompatServerHttpTest 79/79, and the model-backed count equals the usage of a generation on all three routes (OpenAiCompatServerIntegrationTest 15/15). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 14 +++ README.md | 4 +- .../llama/server/LlamaModelBackend.java | 40 +++++++ .../ladenthin/llama/server/OpenAiBackend.java | 15 +++ .../llama/server/OpenAiCompatServer.java | 94 +++++++++++++++ .../llama/server/LlamaModelBackendTest.java | 21 ++++ .../server/OpenAiCompatServerHttpTest.java | 112 +++++++++++++++++- .../OpenAiCompatServerIntegrationTest.java | 41 +++++++ 8 files changed, 336 insertions(+), 5 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 238f080ee..080f185c8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -183,6 +183,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F258](#f258) — mittel — *gefixt* - [F259](#f259) — niedrig bis mittel — *gefixt* - [F260](#f260) — niedrig — *gefixt* +- [F261](#f261) — niedrig bis mittel — *gefixt* ## Funde @@ -1112,6 +1113,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Reproduktion:** `OpenAiCompatServerHttpTest#aSubPathOfARouteIsNotHandledByTheRoute`; `#upstreamsRerankingAliasStillAnswers` schützt den Alias und den abschließenden Schrägstrich (ohne Fix grün) - **Rot-Grün-Nachweis:** ohne Fix rot („Expected: is <404> but: was <200>“), mit Fix grün (49/49 der Klasse, skipped 0); Gegenprobe per `git stash` rot (1, Bericht vor dem Lauf gelöscht), nach `pop` grün. Alle 25 Testklassen des Pakets `server` grün, `LlamaArchitectureTest` 13/13, `spotless:apply` und `spotbugs:check` grün. Der Agent (`llama-atmosphere-agent`) ruft keine Unterpfade auf. - **Offen (nicht geändert):** upstream bietet `/v1/messages/count_tokens` und die `input_tokens`-Routen tatsächlich an; sie hier nachzubauen bräuchte eine Tokenzählung über das Backend (`OpenAiBackend` hat keine) – ein API-Zusatz. +- **Nachtrag (Branch-Review 2026-10-21, Abschluss vor 6.0.0):** Die Zählrouten upstreams sind mit F261 bedient (`/v1/messages/count_tokens`, `/v1/responses/input_tokens`, `/v1/chat/completions/input_tokens`); `#aSubPathOfARouteIsNotHandledByTheRoute` prüft das Präfix-Matching seitdem an `/v1/messages/batches` und `/v1/chat/completions/other`. - **Status:** gefixt - **Commit:** java-llama.cpp `909edc5` @@ -2377,6 +2379,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `b99a25b` +### F261 – java-llama.cpp: `POST /v1/messages/count_tokens` und die `input_tokens`-Routen antworten 404; upstream zählt dort die Prompt-Token +- **Zeit:** 2026-10-21 16:05 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (neu `handleCountTokens` mit den drei Routen und ihren Kurzformen, `PATH_MESSAGES_COUNT_TOKENS`, `PATH_RESPONSES_INPUT_TOKENS`, `PATH_CHAT_COMPLETIONS_INPUT_TOKENS`), `OpenAiBackend.java` (neu `countTokens`), `LlamaModelBackend.java` (`countTokens`, `hasMediaContent`), `README.md` +- **Schwere:** niedrig bis mittel (Claude Code fragt `count_tokens` vor langen Anfragen; ein 404 lässt es schätzen bzw. die Funktion ausfallen) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Open points from the bug hunt“; Brief JR-19), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F112 hatte verhindert, dass `/v1/messages/count_tokens` über das Präfix-Matching eine ganze Generierung durch `/v1/messages` lief; seitdem antwortete die Route 404. +- **Beschreibung:** upstream bedient drei Zählrouten (`tools/server/server.cpp:181-185`, `handle_count_tokens` in `server-context.cpp`, b11639): `/v1/messages/count_tokens` (Anthropic, `{"input_tokens": N}`), `/v1/responses/input_tokens` und `/v1/chat/completions/input_tokens` (dazu `"object": "response.input_tokens"`). Jede übersetzt den Körper wie ihre generierende Route, rendert das Chat-Template (`oaicompat_chat_params_parse`) und tokenisiert den Prompt mit `add_special` und `parse_special`. Hier gab es keine davon. +- **Fix:** `OpenAiBackend#countTokens(JsonNode)` (Standard: `UnsupportedOperationException` → 501, wie upstream für eine fehlende Fähigkeit), in `LlamaModelBackend` über denselben Weg wie die Generierung: `mapper.toInferenceParameters` → `LlamaModel#applyTemplate` → `handleTokenize(prompt, true, false)`. Die drei Routen (plus `/messages/count_tokens`, `/responses/input_tokens`, `/chat/completions/input_tokens` nach der Kurzform-Regel des Servers) übersetzen mit `AnthropicApiSupport`/`ResponsesApiSupport` wie ihre Elternrouten; eine Übersetzung oder Zählung, die ablehnt, ist 400, eine fehlende Fähigkeit 501 (`not_supported_error` bzw. Anthropics `api_error`), jeweils in der Fehlerform der Route. Abweichung von upstream: eine Anfrage mit Bild, Audio oder Video zählt upstream mit dem Projektor (`process_mtmd_prompt`), den diese Engine nicht erreicht; statt zu wenig zu zählen, antwortet die Route 501 „counting the tokens of image, audio or video input is not supported“. Abweichung vom Brief: die dritte upstream-Route (`/v1/chat/completions/input_tokens`) mit aufgenommen; `OpenAiCompatServerHttpTest#aSubPathOfARouteIsNotHandledByTheRoute` (F112) prüft jetzt `/v1/messages/batches` und `/v1/chat/completions/other`, da die alte Beispielroute bedient wird. +- **Tests:** `OpenAiCompatServerHttpTest#theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest` (alle sechs Pfade, Antwortform, die übersetzte Anfrage erreicht das Backend, nichts generiert), `#theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape` (400 bei abgelehnter Übersetzung, 501 ohne Zählfähigkeit in Anthropic- und OpenAI-Form), `#aSubPathOfARouteIsNotHandledByTheRoute` (angepasst); `LlamaModelBackendTest#aRequestWithAnImageAudioOrVideoPartCountsAsMedia`; `OpenAiCompatServerIntegrationTest#theTokenCountRoutesCountThePromptAGenerationEvaluates` (modellgestützt: die Zählung jeder Route gleicht dem `usage` einer Generierung derselben Anfrage). +- **Rot-Grün-Nachweis:** Gegen den unveränderten Server (mit `countTokens` als Stummel) rot: „OpenAiCompatServerHttpTest.theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest:345 /v1/messages/count_tokens Expected: is <200> but: was <404>“, „theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape:386 {"error":{"message":"Not found: /v1/messages/count_tokens",…}} Expected: is <400> but: was <404>“, „OpenAiCompatServerIntegrationTest.theTokenCountRoutesCountThePromptAGenerationEvaluates:92 … Not found: /v1/chat/completions/input_tokens … Expected: is <200> but: was <404>“; mit dem Fix grün: `OpenAiCompatServerHttpTest` 79/79, `LlamaModelBackendTest` 5/5, `OpenAiCompatServerIntegrationTest` 15/15 (Winzmodell als Reasoning-Modell), `AnthropicApiSupportTest` 46/46, `ResponsesApiSupportTest` 70/70. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/README.md b/README.md index c5f864030..b354be0b4 100644 --- a/README.md +++ b/README.md @@ -1007,8 +1007,8 @@ path), all supporting tools and streaming: | Surface | Routes | For | |---|---|---| | **Ollama-native** | `GET /api/version`, `GET /api/tags`, `POST /api/show`, `POST /api/chat` (NDJSON streaming), `POST /api/generate` (through the chat template with `system`, as Ollama does -- a request's own `template` and a token `context` are refused there, since the model's chat template renders the prompt and no answer hands out a context; the bare prompt with `raw: true`, where `format` constrains the answer and each image goes where its `[img-N]` tag stands, as in Ollama -- an image no tag places is refused; FIM with `suffix`, which takes `format` too but no images; a `format` other than `"json"` or a schema object is refused; without a prompt and without a suffix Ollama's "model loaded" answer; unlike Ollama, a request with only a suffix stays fill-in-the-middle at the start of the file, and `keep_alive: 0` does not unload and is answered "load") | Copilot's built-in **Ollama** provider; Ollama-hardcoded tools | -| **Anthropic Messages** | `POST /v1/messages` (SSE event stream) | Claude-shaped clients (Claude Code); Copilot `messages` apiType | -| **OpenAI Responses** | `POST /v1/responses` (SSE event stream) | Copilot `responses` apiType; Responses-API clients | +| **Anthropic Messages** | `POST /v1/messages` (SSE event stream), `POST /v1/messages/count_tokens` (`{"input_tokens": N}`, the prompt `/v1/messages` would evaluate; text only, a request with an image is answered 501) | Claude-shaped clients (Claude Code); Copilot `messages` apiType | +| **OpenAI Responses** | `POST /v1/responses` (SSE event stream), `POST /v1/responses/input_tokens` (upstream's token count, `{"input_tokens": N, "object": "response.input_tokens"}`; the same for chat at `POST /v1/chat/completions/input_tokens`) | Copilot `responses` apiType; Responses-API clients | `/api/show` advertises the model's capabilities (`tools`, `insert`, and `vision` when `--mmproj` is set) and context length, which Copilot's Ollama provider reads to enable agent mode. The llama.cpp-native diff --git a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java index 3fad8465d..5680baced 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java @@ -5,6 +5,7 @@ package net.ladenthin.llama.server; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.io.UncheckedIOException; @@ -35,6 +36,8 @@ */ final class LlamaModelBackend implements OpenAiBackend { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final LlamaModel model; private final OpenAiRequestMapper mapper; @@ -116,6 +119,43 @@ static void relay(Consumer> modelCall, ChunkSink sink) throws I } } + @Override + public int countTokens(JsonNode request) throws IOException { + if (hasMediaContent(request)) { + // upstream counts an image's embeddings with the projector, which only its worker reaches + throw new UnsupportedOperationException( + "counting the tokens of image, audio or video input is not supported"); + } + // the prompt a generation evaluates: the chat template rendered over the request, tokenized with + // the special tokens added and parsed, as upstream's handle_count_tokens does + String prompt = model.applyTemplate(mapper.toInferenceParameters(request)); + JsonNode tokens = OBJECT_MAPPER + .readTree(model.handleTokenize(prompt, true, false)) + .path("tokens"); + if (!tokens.isArray()) { + throw new IOException("the tokenizer answered without a token array"); + } + return tokens.size(); + } + + /** + * Whether a chat request carries a content part that is not text (an image, audio or a video). + * + * @param request the OpenAI chat request + * @return {@code true} if any message's {@code content} array has a part whose {@code type} is not + * {@code "text"} + */ + static boolean hasMediaContent(JsonNode request) { + for (JsonNode message : request.path("messages")) { + for (JsonNode part : message.path("content")) { + if (part.isObject() && !"text".equals(part.path("type").asText(""))) { + return true; + } + } + } + return false; + } + @Override public String mediaMarker() { return model.getModelMeta().getMediaMarker(); diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java index 1ff4e8166..8d4356deb 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiBackend.java @@ -89,6 +89,21 @@ default void streamCompletions(JsonNode request, ChunkSink sink) throws IOExcept throw new UnsupportedOperationException("streaming /v1/completions is not supported by this backend"); } + /** + * Count the prompt tokens of a chat request without generating -- the prompt the chat template + * renders from it, tokenized as a generation tokenizes it ({@code POST /v1/messages/count_tokens}, + * {@code /v1/responses/input_tokens} and {@code /v1/chat/completions/input_tokens}, each translated + * to an OpenAI chat request first). The default throws {@link UnsupportedOperationException}, which + * the server answers {@code 501}; backends that can count override it. + * + * @param request the OpenAI {@code /v1/chat/completions} request + * @return the number of prompt tokens + * @throws IOException if the count fails in a way the caller should surface as a server error + */ + default int countTokens(JsonNode request) throws IOException { + throw new UnsupportedOperationException("token counting is not supported by this backend"); + } + /** * The placeholder a raw multimodal prompt puts where each image goes (llama.cpp's * {@code {"prompt_string": ..., "multimodal_data": [...]}} prompt object), which the server draws at diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 9c87e4a92..667cf3231 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; import net.ladenthin.llama.LlamaModel; import net.ladenthin.llama.exception.InvalidRequestException; import net.ladenthin.llama.value.SlotMetrics; @@ -52,6 +53,9 @@ * on the last) when {@code stream:true} and non-streaming otherwise.

  • *
  • {@code POST /v1/embeddings} — embeddings (requires the model to be loaded in embedding * mode).
  • + *
  • {@code POST /v1/messages/count_tokens}, {@code POST /v1/responses/input_tokens} and + * {@code POST /v1/chat/completions/input_tokens} — the prompt tokens of the request of the route + * before them, counted without generating ({@link OpenAiBackend#countTokens}).
  • *
  • {@code GET /v1/models} — advertises the single configured model.
  • *
  • {@code GET /metrics} — server and per-slot token/cache counters as JSON.
  • *
  • {@code GET /slots} — the per-slot metrics array as JSON.
  • @@ -101,6 +105,15 @@ public final class OpenAiCompatServer implements AutoCloseable { */ public static final String PATH_INFILL = "/infill"; + /** Anthropic's token count of a {@code /v1/messages} request, without generating. */ + public static final String PATH_MESSAGES_COUNT_TOKENS = "/v1/messages/count_tokens"; + + /** The token count of a {@code /v1/responses} request, without generating (upstream's route). */ + public static final String PATH_RESPONSES_INPUT_TOKENS = "/v1/responses/input_tokens"; + + /** The token count of a {@code /v1/chat/completions} request, without generating (upstream's route). */ + public static final String PATH_CHAT_COMPLETIONS_INPUT_TOKENS = "/v1/chat/completions/input_tokens"; + /** The model-list route. */ public static final String PATH_MODELS = "/v1/models"; @@ -139,6 +152,7 @@ public final class OpenAiCompatServer implements AutoCloseable { private static final int HTTP_NOT_FOUND = 404; private static final int HTTP_METHOD_NOT_ALLOWED = 405; private static final int HTTP_SERVER_ERROR = 500; + private static final int HTTP_NOT_IMPLEMENTED = 501; private static final int HTTP_PAYLOAD_TOO_LARGE = 413; private static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8"; @@ -154,6 +168,8 @@ public final class OpenAiCompatServer implements AutoCloseable { private static final String ERROR_TYPE_REQUEST = "invalid_request_error"; private static final String ERROR_TYPE_SERVER = "server_error"; + /** upstream's error type for a capability the server lacks (HTTP 501). */ + private static final String ERROR_TYPE_NOT_SUPPORTED = "not_supported_error"; /** Anthropic's error type for a request that is the client's fault (HTTP 400). */ private static final String ANTHROPIC_ERROR_REQUEST = "invalid_request_error"; /** Anthropic's error type for an unexpected server-side failure (HTTP 500). */ @@ -224,6 +240,13 @@ public OpenAiCompatServer(OpenAiBackend backend, OpenAiServerConfig config) thro register("/messages", this::handleAnthropicMessages); register(PATH_RESPONSES, this::handleResponses); register("/responses", this::handleResponses); + // upstream's token counts: the request of the route above, counted without generating + register(PATH_MESSAGES_COUNT_TOKENS, this::handleAnthropicCountTokens); + register("/messages/count_tokens", this::handleAnthropicCountTokens); + register(PATH_RESPONSES_INPUT_TOKENS, this::handleResponsesInputTokens); + register("/responses/input_tokens", this::handleResponsesInputTokens); + register(PATH_CHAT_COMPLETIONS_INPUT_TOKENS, this::handleChatInputTokens); + register("/chat/completions/input_tokens", this::handleChatInputTokens); // Ollama-native surface (Copilot's built-in Ollama provider + Ollama-hardcoded tools). register(PATH_OLLAMA_VERSION, this::handleOllamaVersion); register(PATH_OLLAMA_TAGS, this::handleOllamaTags); @@ -853,6 +876,77 @@ private void handleResponses(HttpExchange exchange) throws IOException { } } + // ----- token counts (upstream's handle_count_tokens) ----- + + private void handleAnthropicCountTokens(HttpExchange exchange) throws IOException { + handleCountTokens(exchange, true, AnthropicApiSupport::toOpenAiChatRequest); + } + + private void handleResponsesInputTokens(HttpExchange exchange) throws IOException { + handleCountTokens(exchange, false, ResponsesApiSupport::toOpenAiChatRequest); + } + + private void handleChatInputTokens(HttpExchange exchange) throws IOException { + handleCountTokens(exchange, false, request -> request); + } + + /** + * Count the prompt tokens of a request without generating, as upstream's {@code handle_count_tokens}: + * the body is translated as its generating route translates it, and the answer is + * {@code {"input_tokens": N}}, with {@code "object": "response.input_tokens"} on the OpenAI routes. + * A request the translation or the count refuses is {@code 400}, a backend that cannot count + * {@code 501}, both in the route's error shape. + */ + private void handleCountTokens(HttpExchange exchange, boolean anthropic, UnaryOperator toChatRequest) + throws IOException { + try { + JsonNode request = requirePostJson(exchange); + if (request == null) { + return; + } + final int count; + try { + count = backend.countTokens(toChatRequest.apply(request)); + } catch (IllegalArgumentException | InvalidRequestException e) { + sendCountError(exchange, anthropic, HTTP_BAD_REQUEST, message(e)); + return; + } catch (UnsupportedOperationException e) { + sendCountError(exchange, anthropic, HTTP_NOT_IMPLEMENTED, message(e)); + return; + } catch (IOException | RuntimeException e) { + LOG.warn("token count failed", e); + sendCountError(exchange, anthropic, HTTP_SERVER_ERROR, message(e)); + return; + } + ObjectNode body = OBJECT_MAPPER.createObjectNode().put("input_tokens", count); + if (!anthropic) { + body.put("object", "response.input_tokens"); + } + sendJson(exchange, HTTP_OK, body.toString()); + } finally { + exchange.close(); + } + } + + private void sendCountError(HttpExchange exchange, boolean anthropic, int status, String message) + throws IOException { + if (anthropic) { + sendJson( + exchange, + status, + anthropicError( + message, status == HTTP_BAD_REQUEST ? ANTHROPIC_ERROR_REQUEST : ANTHROPIC_ERROR_SERVER)); + } else if (status == HTTP_BAD_REQUEST) { + sendError(exchange, status, ERROR_TYPE_REQUEST, message); + } else { + sendError( + exchange, + status, + status == HTTP_NOT_IMPLEMENTED ? ERROR_TYPE_NOT_SUPPORTED : ERROR_TYPE_SERVER, + message); + } + } + /** Stream a Responses {@code /v1/responses} reply as the Responses SSE event sequence. */ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, String model, String responseId) throws IOException { diff --git a/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java b/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java index aa331e3e1..263e3a8bc 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java @@ -10,6 +10,8 @@ import static org.hamcrest.Matchers.sameInstance; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.UncheckedIOException; import java.util.ArrayList; @@ -23,6 +25,25 @@ */ public class LlamaModelBackendTest { + /** + * The token count renders the chat template and tokenizes the text; an image's embeddings are counted + * only by the projector, so a request with media is refused (501) rather than undercounted. + */ + @Test + public void aRequestWithAnImageAudioOrVideoPartCountsAsMedia() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + String[] media = {"image_url", "input_audio", "video_url"}; + for (String type : media) { + JsonNode request = mapper.readTree("{\"messages\":[{\"role\":\"system\",\"content\":\"s\"}," + + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"t\"},{\"type\":\"" + + type + "\"}]}]}"); + assertThat(type, LlamaModelBackend.hasMediaContent(request), is(true)); + } + JsonNode text = mapper.readTree("{\"messages\":[{\"role\":\"user\",\"content\":\"t\"}," + + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"t\"}]}]}"); + assertThat(LlamaModelBackend.hasMediaContent(text), is(false)); + } + @Test public void everyChunkReachesTheSinkInOrder() throws IOException { List written = new ArrayList<>(); diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java index c25fe8549..9213ade79 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java @@ -316,10 +316,87 @@ public void ollamaGenerateWithSuffixReadsTheInfillStopType() throws IOException @Test public void aSubPathOfARouteIsNotHandledByTheRoute() throws IOException { // The JDK server matches contexts by prefix, so /v1/messages/count_tokens (Claude Code counts - // tokens there) ran a whole generation through the /v1/messages handler instead of a 404. - try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { + // tokens there) ran a whole generation through the /v1/messages handler instead of a 404. That + // route is served now; any other sub-path still is no part of its parent. + RecordingBackend backend = new RecordingBackend(); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { String body = "{\"model\":\"m\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; - assertThat(post(server.getPort(), "/v1/messages/count_tokens", body, "").code, is(404)); + assertThat(post(server.getPort(), "/v1/messages/batches", body, "").code, is(404)); + assertThat(post(server.getPort(), "/v1/chat/completions/other", CHAT_BODY, "").code, is(404)); + assertThat("nothing was generated", backend.called, is("")); + } + } + + /** + * upstream counts the prompt tokens of a request without generating on three routes: + * {@code /v1/messages/count_tokens} (Anthropic, {@code {"input_tokens": N}}), + * {@code /v1/responses/input_tokens} and {@code /v1/chat/completions/input_tokens} (with + * {@code "object":"response.input_tokens"}). All three answered 404 here (F112 kept them from running + * a generation through the parent route). Each translates its body as its generating route does. + */ + @Test + public void theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest() throws IOException { + CountingBackend backend = new CountingBackend(42); + try (OpenAiCompatServer server = new OpenAiCompatServer(backend, config()).start()) { + String anthropic = "{\"model\":\"m\",\"system\":\"be brief\"," + + "\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + for (String path : new String[] {"/v1/messages/count_tokens", "/messages/count_tokens"}) { + Response response = post(server.getPort(), path, anthropic, ""); + assertThat(path, response.code, is(200)); + assertThat(path, READER.readTree(response.body), is(READER.readTree("{\"input_tokens\":42}"))); + JsonNode messages = backend.request.path("messages"); + assertThat(path, messages.path(0).path("role").asText(), is("system")); + assertThat(path, messages.path(1).path("content").asText(), is("hi")); + } + String responses = "{\"model\":\"m\",\"instructions\":\"be brief\",\"input\":\"hi\"}"; + for (String path : new String[] {"/v1/responses/input_tokens", "/responses/input_tokens"}) { + Response response = post(server.getPort(), path, responses, ""); + assertThat(path, response.code, is(200)); + assertThat( + path, + READER.readTree(response.body), + is(READER.readTree("{\"input_tokens\":42,\"object\":\"response.input_tokens\"}"))); + assertThat( + path, + backend.request.path("messages").path(1).path("content").asText(), + is("hi")); + } + for (String path : new String[] {"/v1/chat/completions/input_tokens", "/chat/completions/input_tokens"}) { + Response response = post(server.getPort(), path, CHAT_BODY, ""); + assertThat(path, response.code, is(200)); + assertThat( + path, + READER.readTree(response.body), + is(READER.readTree("{\"input_tokens\":42,\"object\":\"response.input_tokens\"}"))); + assertThat(path, backend.request, is(READER.readTree(CHAT_BODY))); + } + assertThat("nothing was generated", backend.generated, is(false)); + } + } + + /** + * A request the translation or the count refuses is the client's fault, a backend without a count + * answers 501 (upstream's answer for a capability the server lacks), as do images, whose tokens the + * count cannot see. + */ + @Test + public void theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape() throws IOException { + try (OpenAiCompatServer server = new OpenAiCompatServer(new CountingBackend(1), config()).start()) { + Response bad = post(server.getPort(), "/v1/messages/count_tokens", "{\"max_tokens\":\"x\"}", ""); + assertThat(bad.body, bad.code, is(400)); + assertThat(READER.readTree(bad.body).path("error").path("type").asText(), is("invalid_request_error")); + } + try (OpenAiCompatServer server = new OpenAiCompatServer(new FakeBackend(), config()).start()) { + Response anthropic = post( + server.getPort(), + "/v1/messages/count_tokens", + "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}", + ""); + assertThat(anthropic.body, anthropic.code, is(501)); + assertThat(READER.readTree(anthropic.body).path("type").asText(), is("error")); + Response chat = post(server.getPort(), "/v1/chat/completions/input_tokens", CHAT_BODY, ""); + assertThat(chat.body, chat.code, is(501)); + assertThat(READER.readTree(chat.body).path("error").path("type").asText(), is("not_supported_error")); } } @@ -1405,6 +1482,35 @@ public void stream(JsonNode request, ChunkSink sink) throws IOException { } } + /** Backend that counts every request as {@code count} tokens and records it; generation is flagged. */ + static final class CountingBackend extends FakeBackend { + private final int count; + volatile JsonNode request = READER.createObjectNode(); + volatile boolean generated; + + CountingBackend(int count) { + this.count = count; + } + + @Override + public int countTokens(JsonNode request) { + this.request = request; + return count; + } + + @Override + public String complete(JsonNode request) { + generated = true; + return super.complete(request); + } + + @Override + public void stream(JsonNode request, ChunkSink sink) throws IOException { + generated = true; + super.stream(request, sink); + } + } + /** * Backend that maps every chat request with the real {@link OpenAiRequestMapper} before answering, as * {@link LlamaModelBackend} does, so a request the mapper refuses never reaches generation. diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java index 1e6a39811..56e33ac29 100644 --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerIntegrationTest.java @@ -72,6 +72,47 @@ public static void tearDown() { } } + /** + * The token-count routes count the prompt a generation of the same request evaluates: the chat + * template rendered over the request, tokenized as the generation tokenizes it. upstream serves all + * three; they answered 404 here. + */ + @Test + public void theTokenCountRoutesCountThePromptAGenerationEvaluates() throws IOException { + String messages = "[{\"role\":\"user\",\"content\":\"Count the tokens of this sentence, please.\"}]"; + String chat = + "{\"model\":\"" + MODEL_ID + "\",\"max_tokens\":1,\"temperature\":0,\"messages\":" + messages + "}"; + int promptTokens = MAPPER.readTree(post(port, "/v1/chat/completions", chat, "").body) + .path("usage") + .path("prompt_tokens") + .asInt(-1); + assertThat(promptTokens, greaterThan(0)); + + Response chatCount = post(port, "/v1/chat/completions/input_tokens", chat, ""); + assertThat(chatCount.body, chatCount.code, is(200)); + assertThat(MAPPER.readTree(chatCount.body).path("input_tokens").asInt(-1), is(promptTokens)); + + String anthropic = + "{\"model\":\"" + MODEL_ID + "\",\"max_tokens\":1,\"temperature\":0,\"messages\":" + messages + "}"; + JsonNode usage = + MAPPER.readTree(post(port, "/v1/messages", anthropic, "").body).path("usage"); + int anthropicPrompt = usage.path("input_tokens").asInt(-1) + + usage.path("cache_read_input_tokens").asInt(0); + Response anthropicCount = post(port, "/v1/messages/count_tokens", anthropic, ""); + assertThat(anthropicCount.body, anthropicCount.code, is(200)); + assertThat(MAPPER.readTree(anthropicCount.body).path("input_tokens").asInt(-1), is(anthropicPrompt)); + + String responses = "{\"model\":\"" + MODEL_ID + "\",\"max_output_tokens\":1,\"temperature\":0," + + "\"input\":\"Count the tokens of this sentence, please.\"}"; + int responsesPrompt = MAPPER.readTree(post(port, "/v1/responses", responses, "").body) + .path("usage") + .path("input_tokens") + .asInt(-1); + Response responsesCount = post(port, "/v1/responses/input_tokens", responses, ""); + assertThat(responsesCount.body, responsesCount.code, is(200)); + assertThat(MAPPER.readTree(responsesCount.body).path("input_tokens").asInt(-1), is(responsesPrompt)); + } + @Test public void nonStreamingChatReturnsValidCompletion() throws IOException { String body = "{\"model\":\"" + MODEL_ID + "\",\"max_tokens\":16," From c242d70f88073a0f9c847e6c384ad7b19d203f2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:05:20 +0000 Subject: [PATCH 536/589] Record the commit of F261 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 080f185c8..f2ba49606 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2389,7 +2389,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `OpenAiCompatServerHttpTest#theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest` (alle sechs Pfade, Antwortform, die übersetzte Anfrage erreicht das Backend, nichts generiert), `#theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape` (400 bei abgelehnter Übersetzung, 501 ohne Zählfähigkeit in Anthropic- und OpenAI-Form), `#aSubPathOfARouteIsNotHandledByTheRoute` (angepasst); `LlamaModelBackendTest#aRequestWithAnImageAudioOrVideoPartCountsAsMedia`; `OpenAiCompatServerIntegrationTest#theTokenCountRoutesCountThePromptAGenerationEvaluates` (modellgestützt: die Zählung jeder Route gleicht dem `usage` einer Generierung derselben Anfrage). - **Rot-Grün-Nachweis:** Gegen den unveränderten Server (mit `countTokens` als Stummel) rot: „OpenAiCompatServerHttpTest.theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest:345 /v1/messages/count_tokens Expected: is <200> but: was <404>“, „theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape:386 {"error":{"message":"Not found: /v1/messages/count_tokens",…}} Expected: is <400> but: was <404>“, „OpenAiCompatServerIntegrationTest.theTokenCountRoutesCountThePromptAGenerationEvaluates:92 … Not found: /v1/chat/completions/input_tokens … Expected: is <200> but: was <404>“; mit dem Fix grün: `OpenAiCompatServerHttpTest` 79/79, `LlamaModelBackendTest` 5/5, `OpenAiCompatServerIntegrationTest` 15/15 (Winzmodell als Reasoning-Modell), `AnthropicApiSupportTest` 46/46, `ResponsesApiSupportTest` 70/70. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `2c6e5c5` ## Offene Verdachte ohne Fund-ID From f13394f778e6407c22cc3a398b91a9ee5b8b40bd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:10:51 +0000 Subject: [PATCH 537/589] Default OpenAiCompatServer to llama.cpp's port 9931 The two servers of the library defaulted to different ports: NativeServer follows llama-server's default, 9931 since b11521 (upstream #30159), while OpenAiServerConfig.DEFAULT_PORT stayed 8080. For 6.0.0 both use 9931 -- one port in the documentation, llama.cpp's own, and clear of the 8080 other local services take. A breaking change for a caller that relied on the default; the README, the CLI help, the javadoc examples, the agent's documentation and the test values follow. The history (CHANGELOG, ledger, docs/history) keeps 8080 where it describes the past, and the smoke scripts keep their explicit 18080. Red before the change: "OpenAiServerConfigTest.theDefaultPortIsLlama CppsDefaultPort Expected: is <9931> but: was <8080>" and the CLI help without "(default: 9931)"; green: OpenAiServerConfigTest 10/10, OpenAiServerCliTest 54/54, RouterClientTest, ServerLauncherTest, NativeServerSmokeTest, and the agent's AgentOptionsTest/ServerPropsTest. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- README.md | 42 +++++++++---------- ...feature-investigation-ide-agent-backend.md | 20 ++++----- llama-atmosphere-agent/README.md | 8 ++-- .../llama/atmosphere/AgentOptions.java | 2 +- .../llama/atmosphere/AgentRunner.java | 2 +- .../llama/atmosphere/LocalAgent.java | 2 +- .../llama/atmosphere/ModelEndpoint.java | 2 +- .../llama/atmosphere/ServerProps.java | 2 +- .../llama/atmosphere/AgentOptionsTest.java | 4 +- .../llama/atmosphere/ServerPropsTest.java | 8 ++-- llama-ffm/README.md | 2 +- .../ladenthin/llama/server/NativeServer.java | 10 ++--- .../llama/server/OpenAiCompatServer.java | 2 +- .../llama/server/OpenAiServerConfig.java | 7 +++- .../ladenthin/llama/server/RouterClient.java | 4 +- .../java/examples/OpenAiServerExample.java | 2 +- .../llama/server/NativeServerSmokeTest.java | 2 +- .../llama/server/OpenAiServerCliTest.java | 1 + .../llama/server/OpenAiServerConfigTest.java | 10 +++++ .../llama/server/RouterClientTest.java | 10 ++--- .../llama/server/ServerLauncherTest.java | 8 ++-- 21 files changed, 82 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index b354be0b4..390ba23ec 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Inference of Meta's LLaMA model (and others) in pure C/C++. - **Infilling** (fill-in-the-middle) for code models. - **Tokenize / detokenize** and **JSON-schema → grammar** conversion. - **Raw JSON endpoint handlers** mirroring the upstream llama.cpp HTTP server (`/completions`, `/v1/completions`, `/embeddings`, `/infill`, `/tokenize`, `/detokenize`). -- **Two runnable HTTP server modes, one entry point.** The classes jar's `Main-Class` is `ServerLauncher`, which dispatches on the `--jllama-openai-compat` flag. Without it, `jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 -m model.gguf --port 8080` runs the full upstream llama.cpp server (embedded **WebUI**, every llama-server flag forwarded) hosted inside `libjllama` over JNI — no separate `llama-server.exe`. With it, the same line plus `--jllama-openai-compat --model model.gguf --port 8080` runs the Java-transport, zero-extra-dependency **OpenAI-compatible** server (`OpenAiCompatServer`, streaming SSE) instead. Both are also runnable directly by class name via `java -cp … net.ladenthin.llama.server.{NativeServer,OpenAiCompatServer}`; see [Running the server from Maven Central](#running-the-server-from-maven-central). +- **Two runnable HTTP server modes, one entry point.** The classes jar's `Main-Class` is `ServerLauncher`, which dispatches on the `--jllama-openai-compat` flag. Without it, `jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 -m model.gguf --port 9931` runs the full upstream llama.cpp server (embedded **WebUI**, every llama-server flag forwarded) hosted inside `libjllama` over JNI — no separate `llama-server.exe`. With it, the same line plus `--jllama-openai-compat --model model.gguf --port 9931` runs the Java-transport, zero-extra-dependency **OpenAI-compatible** server (`OpenAiCompatServer`, streaming SSE) instead. Both are also runnable directly by class name via `java -cp … net.ladenthin.llama.server.{NativeServer,OpenAiCompatServer}`; see [Running the server from Maven Central](#running-the-server-from-maven-central). - **Model metadata** access (`getModelMeta()`) and **server management** (metrics, slot save/restore, runtime thread reconfiguration). - **Conversation checkpoints** — `Session.checkpoint(...)` / `rewind(...)` / `fork(...)` branch and roll back a chat (KV-cache slot save/restore + transcript snapshot) without re-prefilling. - **GGUF metadata inspection** without loading the model (`GgufInspector` — pure Java, reads header + key/value table only, big-endian aware). @@ -343,10 +343,10 @@ module jars, as `--deps`: ```bash # CPU only (Linux x86-64; cpu-windows-x86-64, metal-macos-aarch64, ... for the others) -jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 -m model.gguf --port 8080 +jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 -m model.gguf --port 9931 # with a GPU backend -- several may be named, ggml uses the ones whose runtime is installed jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64,net.ladenthin:llama:6.0.0:vulkan-linux-x86-64 \ - net.ladenthin:llama:6.0.0 -m model.gguf --port 8080 -ngl 99 + net.ladenthin:llama:6.0.0 -m model.gguf --port 9931 -ngl 99 ``` Every llama-server flag works, the WebUI included; add `--jllama-openai-compat` to run the Java @@ -355,7 +355,7 @@ Every llama-server flag works, the WebUI included; add `--jllama-openai-compat` profile per GPU module jar, named after its classifier: ```bash -mvn -f examples/server/pom.xml exec:java -Dexec.args="-m model.gguf --port 8080" # CPU +mvn -f examples/server/pom.xml exec:java -Dexec.args="-m model.gguf --port 9931" # CPU mvn -f examples/server/pom.xml -P cuda13-linux-x86-64 exec:java -Dexec.args="-m model.gguf -ngl 99" # + CUDA # a local jar-with-dependencies of exactly that combination, for a machine without Maven mvn -f examples/server/pom.xml -P assembly,cuda13-linux-x86-64 package @@ -1049,7 +1049,7 @@ Embed it in your app: // the alias is the name llama.cpp puts into every answer; set it to the id /v1/models advertises ModelParameters modelParams = new ModelParameters().setModel("models/model.gguf").setParallel(2).setAlias("local-model"); -OpenAiServerConfig config = OpenAiServerConfig.builder().port(8080).modelId("local-model").build(); +OpenAiServerConfig config = OpenAiServerConfig.builder().port(9931).modelId("local-model").build(); try (LlamaModel model = new LlamaModel(modelParams); OpenAiCompatServer server = new OpenAiCompatServer(model, config).start()) { Thread.currentThread().join(); // serve until interrupted @@ -1063,11 +1063,11 @@ or name the class explicitly via `-cp`: ```bash # from Maven Central (JBang resolves the classes jar, the natives jar named and the Java deps) jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 --jllama-openai-compat \ - --model models/Qwen3-0.6B-Q4_K_M.gguf --host 0.0.0.0 --port 8080 --n-gpu-layers 99 + --model models/Qwen3-0.6B-Q4_K_M.gguf --host 0.0.0.0 --port 9931 --n-gpu-layers 99 # or name the class explicitly, with the jars on the classpath java -cp 'target/llama-.jar:target/llama--cpu-linux-x86-64.jar:' \ - net.ladenthin.llama.server.OpenAiCompatServer --model models/model.gguf --port 8080 --model-id local-model + net.ladenthin.llama.server.OpenAiCompatServer --model models/model.gguf --port 9931 --model-id local-model ``` Run with `--help` for the full option list (`-m/--model`, `--host`, `-p/--port`, `-c/--ctx-size`, @@ -1081,7 +1081,7 @@ works directly. Verify with curl (streaming chat): ```bash -curl -N http://127.0.0.1:8080/v1/chat/completions \ +curl -N http://127.0.0.1:9931/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{"model":"local-model","stream":true,"messages":[{"role":"user","content":"hi"}]}' ``` @@ -1102,7 +1102,7 @@ endpoint (the host/port go here, not in the form): { "id": "local-model", "name": "Local model", - "url": "http://127.0.0.1:8080/v1/chat/completions", + "url": "http://127.0.0.1:9931/v1/chat/completions", "toolCalling": true, "vision": false, "maxInputTokens": 6144, @@ -1132,8 +1132,8 @@ the native server (pass `--help` for the full llama-server option list): ```bash jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 \ - -m models/model.gguf --host 127.0.0.1 --port 8080 -c 65536 --jinja -# then open http://127.0.0.1:8080/ for the WebUI + -m models/model.gguf --host 127.0.0.1 --port 9931 -c 65536 --jinja +# then open http://127.0.0.1:9931/ for the WebUI ``` Or embed it: @@ -1141,13 +1141,13 @@ Or embed it: ```java try (NativeServer server = new NativeServer( "-m", "gpt-oss-20b-UD-Q4_K_XL.gguf", - "--host", "127.0.0.1", "--port", "8080", + "--host", "127.0.0.1", "--port", "9931", "-c", "65536", "-b", "4096", "-ub", "2048", "--jinja", "-ngl", "0", "-t", "8", "-tb", "16", "-ctk", "q8_0", "-ctv", "q8_0", "--chat-template-kwargs", "{\"reasoning_effort\":\"low\"}", "--parallel", "1").start()) { - // Open http://127.0.0.1:8080/ in a browser for the WebUI; the OpenAI API is at /v1/... too. + // Open http://127.0.0.1:9931/ in a browser for the WebUI; the OpenAI API is at /v1/... too. Thread.currentThread().join(); } ``` @@ -1171,7 +1171,7 @@ JNI calls and HTTP: ```java try (LlamaModel model = new LlamaModel(new ModelParameters().setModel("models/model.gguf")); - NativeServer server = new NativeServer(model, "--host", "127.0.0.1", "--port", "8080").start()) { + NativeServer server = new NativeServer(model, "--host", "127.0.0.1", "--port", "9931").start()) { // HTTP (incl. WebUI in released jars) and direct Java calls share the same loaded model. String direct = model.complete(new InferenceParameters("2+2=").withNPredict(4)); Thread.currentThread().join(); @@ -1200,7 +1200,7 @@ String javaBin = System.getProperty("java.home") + File.separator + "bin" + File NativeServer.setWorkerCommand(javaBin, "-cp", System.getProperty("java.class.path"), "net.ladenthin.llama.server.NativeServer"); try (NativeServer router = new NativeServer( - "--host", "127.0.0.1", "--port", "8080", "--models-dir", "models").start()) { + "--host", "127.0.0.1", "--port", "9931", "--models-dir", "models").start()) { Thread.currentThread().join(); // each loaded model runs as a fresh worker JVM } ``` @@ -1212,7 +1212,7 @@ management endpoints, use `server.RouterClient` — a plain-HTTP typed client (w embedded router above or any external `llama-server` router): ```java -RouterClient client = new RouterClient(8080); +RouterClient client = new RouterClient(9931); List models = client.listModels(); // GET /models, typed status per entry client.loadModel("Qwen3-0.6B-Q4_K_M"); // POST /models/load (non-blocking) client.awaitModelLoaded("Qwen3-0.6B-Q4_K_M", 240_000L); // poll until LOADED (or SLEEPING); fails fast if the @@ -1233,8 +1233,8 @@ Against a router started with `--api-key`, pass the key — it is sent as `/models/unload` were always gated, and since llama.cpp b10519 the listing endpoints are too. ```java -RouterClient client = new RouterClient(8080, System.getenv("LLAMA_API_KEY")); -// or, for a remote router: new RouterClient("router.internal", 8080, key) +RouterClient client = new RouterClient(9931, System.getenv("LLAMA_API_KEY")); +// or, for a remote router: new RouterClient("router.internal", 9931, key) ``` > [!NOTE] @@ -1448,15 +1448,15 @@ backend): ```bash # 1. the server, from Maven Central jbang --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 \ - -m models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --jinja --port 8080 + -m models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --jinja --port 9931 # 2. the agent, from the llama-atmosphere-agent/ folder — a you> prompt appears (/clear, /exit) mvn -q compile exec:java \ - -Dexec.args="--base-url http://127.0.0.1:8080/v1 --workspace /path/to/project --allow-shell" + -Dexec.args="--base-url http://127.0.0.1:9931/v1 --workspace /path/to/project --allow-shell" # a single turn instead of the prompt loop mvn -q compile exec:java \ - -Dexec.args="--base-url http://127.0.0.1:8080/v1 --workspace /path/to/project --prompt 'Read the README and summarize it'" + -Dexec.args="--base-url http://127.0.0.1:9931/v1 --workspace /path/to/project --prompt 'Read the README and summarize it'" # or without a separate server: load the GGUF in-process mvn -q compile exec:java \ diff --git a/docs/feature-investigation-ide-agent-backend.md b/docs/feature-investigation-ide-agent-backend.md index af12ba582..4333d472b 100644 --- a/docs/feature-investigation-ide-agent-backend.md +++ b/docs/feature-investigation-ide-agent-backend.md @@ -73,7 +73,7 @@ SPDX-License-Identifier: MIT "github.copilot.chat.customOAIModels": { "my-model": { "name": "My Model", - "url": "http://127.0.0.1:8080/v1/chat/completions", + "url": "http://127.0.0.1:9931/v1/chat/completions", "toolCalling": true, "vision": false, "thinking": false, "maxInputTokens": 128000, "maxOutputTokens": 16000, "requiresAPIKey": false @@ -84,7 +84,7 @@ SPDX-License-Identifier: MIT Copilot reads capability flags `toolCalling`, `vision`, `thinking`, `maxInputTokens`/`maxOutputTokens`. It sends standard OpenAI chat bodies with `messages`, `tools`, `tool_choice`, `stream:true`. A documented crash — microsoft/vscode issue #273482 ("OpenAI Compatible models return TypeError: Cannot read properties of undefined (reading 'cached_tokens')"), shows `TypeError: Cannot read properties of undefined (reading 'cached_tokens') at SX.push (…github.copilot-chat-0.33.2025102701…)` reproduced with LM Studio models in agent and ask mode — occurs when the streamed `usage` lacks `prompt_tokens_details.cached_tokens`. [GitHub](https://github.com/microsoft/vscode/issues/273482) -**Continue.dev** (`config.yaml`, schema v1): `provider: openai` + `apiBase: http://127.0.0.1:8080/v1` + `model` + `roles: [chat, edit, apply]`. For OpenAI-compatible non-chat completion: `useLegacyCompletionsEndpoint: true` forces `/v1/completions`. [Continue](https://docs.continue.dev/customize/model-providers/top-level/openai) Continue's native `llama.cpp` provider posts to `/completion` (singular), not `/completions` (issue #4991). `requestOptions.headers` carries auth; `capabilities: [tool_use, image_input]` can be declared. +**Continue.dev** (`config.yaml`, schema v1): `provider: openai` + `apiBase: http://127.0.0.1:9931/v1` + `model` + `roles: [chat, edit, apply]`. For OpenAI-compatible non-chat completion: `useLegacyCompletionsEndpoint: true` forces `/v1/completions`. [Continue](https://docs.continue.dev/customize/model-providers/top-level/openai) Continue's native `llama.cpp` provider posts to `/completion` (singular), not `/completions` (issue #4991). `requestOptions.headers` carries auth; `capabilities: [tool_use, image_input]` can be declared. **Cline / Roo Code:** Settings → "OpenAI Compatible" → Base URL (must include `/v1`), API key, model ID. Roo internally uses Anthropic message format then transforms to OpenAI `ChatCompletionTool`; it accumulates streamed fragments by `index`; finalizes on `finish_reason:"tool_calls"`. `parallelToolCalls:true` is the default. @@ -178,12 +178,12 @@ OpenAI shape required: `tools:[{type:"function",function:{name,description,param ## Exact config snippets -**GitHub Copilot (VS Code ≥1.122) — Custom Endpoint (preferred):** *Chat: Manage Language Models* → Add Models → **Custom Endpoint** → display name, API key (any string if none), Base URL `http://127.0.0.1:8080/v1`, model ID, apiType `chat-completions`. For older stable builds, use the legacy object in `settings.json`: +**GitHub Copilot (VS Code ≥1.122) — Custom Endpoint (preferred):** *Chat: Manage Language Models* → Add Models → **Custom Endpoint** → display name, API key (any string if none), Base URL `http://127.0.0.1:9931/v1`, model ID, apiType `chat-completions`. For older stable builds, use the legacy object in `settings.json`: ```json "github.copilot.chat.customOAIModels": { "local-qwen": { "name": "Local Qwen (llama.cpp)", - "url": "http://127.0.0.1:8080/v1/chat/completions", + "url": "http://127.0.0.1:9931/v1/chat/completions", "toolCalling": true, "vision": false, "thinking": false, @@ -204,7 +204,7 @@ models: - name: local-fim provider: llama.cpp model: your-model.gguf - apiBase: http://127.0.0.1:8080 + apiBase: http://127.0.0.1:9931 roles: [autocomplete] autocompleteOptions: debounceDelay: 250 @@ -212,21 +212,21 @@ models: - name: local-chat provider: openai model: your-model - apiBase: http://127.0.0.1:8080/v1 + apiBase: http://127.0.0.1:9931/v1 apiKey: sk-local roles: [chat, edit, apply] ``` **Cline / Roo Code (VS Code settings UI):** - API Provider: **OpenAI Compatible** -- Base URL: `http://127.0.0.1:8080/v1` +- Base URL: `http://127.0.0.1:9931/v1` - API Key: `sk-local` (any string if no auth) - Model ID: `your-model` - (Roo Code ≥3.37: model must support native tool calling, or roll back to 3.36.16 for the XML option.) **Twinny (Providers → Code/FIM provider):** - Provider: `llamacpp` -- Hostname/port: `127.0.0.1` / `8080` +- Hostname/port: `127.0.0.1` / `9931` - FIM endpoint path: `/infill` - FIM Template: match the model (e.g. Qwen2.5-Coder / DeepSeek / CodeLlama) @@ -234,12 +234,12 @@ models: ```toml [model.completion.http] kind = "llama.cpp/completion" -api_endpoint = "http://127.0.0.1:8080" # no /v1 +api_endpoint = "http://127.0.0.1:9931" # no /v1 prompt_template = "<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>" [model.chat.http] kind = "openai/chat" -api_endpoint = "http://127.0.0.1:8080/v1" +api_endpoint = "http://127.0.0.1:9931/v1" ``` ## Caveats / open risks (2026 moving targets) diff --git a/llama-atmosphere-agent/README.md b/llama-atmosphere-agent/README.md index f7140daad..d577de206 100644 --- a/llama-atmosphere-agent/README.md +++ b/llama-atmosphere-agent/README.md @@ -126,7 +126,7 @@ natives table lists every backend. ```bash jbang --main net.ladenthin.llama.server.ServerLauncher \ --deps net.ladenthin:llama:6.0.0:cpu-linux-x86-64 net.ladenthin:llama:6.0.0 \ - -m models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --jinja --port 8080 + -m models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf --jinja --port 9931 ``` The classes jar `net.ladenthin:llama` plus the CPU natives jar of the platform (`cpu--`, @@ -142,7 +142,7 @@ natives table lists every backend. ```bash mvn -q compile exec:java \ - -Dexec.args="--base-url http://127.0.0.1:8080/v1 --workspace /path/to/project --allow-shell" + -Dexec.args="--base-url http://127.0.0.1:9931/v1 --workspace /path/to/project --allow-shell" ``` A `you>` prompt appears, with a status line pinned to the bottom of the window. The answer streams as it is generated, and every @@ -153,7 +153,7 @@ A single turn without the REPL: ```bash mvn -q compile exec:java \ - -Dexec.args="--base-url http://127.0.0.1:8080/v1 --workspace /path/to/project --prompt 'Read the README and summarize it'" + -Dexec.args="--base-url http://127.0.0.1:9931/v1 --workspace /path/to/project --prompt 'Read the README and summarize it'" ``` If the server was started with `--api-key `, add `--api-key ` to the agent's arguments. @@ -235,7 +235,7 @@ pinned block and no spinner, and nothing on screen is ever rewritten. That makes when it is piped, logged, recorded, or carried by anything that forwards lines rather than a screen: ```bash -mvn -q compile exec:java -Dexec.args="--base-url http://127.0.0.1:8080/v1 --plain" | tee session.log +mvn -q compile exec:java -Dexec.args="--base-url http://127.0.0.1:9931/v1 --plain" | tee session.log ``` The same console is what the agent falls back to on its own when there is no usable terminal — a pipe, diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java index fe04de975..8d72fbb39 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java @@ -308,7 +308,7 @@ public static String usage() { "", "Endpoint (exactly one):", " --base-url OpenAI-compatible base URL of a running server,", - " e.g. http://127.0.0.1:8080/v1 (java-llama.cpp NativeServer", + " e.g. http://127.0.0.1:9931/v1 (java-llama.cpp NativeServer", " started with --jinja, OpenAiCompatServer, or llama-server)", " --model load this GGUF in-process and serve it to the agent", " --ngl GPU layers for --model (default 0 = CPU only)", diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java index 574b420f0..49a4b8dd6 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentRunner.java @@ -46,7 +46,7 @@ public final class AgentRunner { /** * Configure the runtime for one endpoint. * - * @param baseUrl the OpenAI-compatible base URL, e.g. {@code http://127.0.0.1:8080/v1} + * @param baseUrl the OpenAI-compatible base URL, e.g. {@code http://127.0.0.1:9931/v1} * @param apiKey the bearer token (sent as {@code Authorization: Bearer ...}) * @param modelId the model id carried in every request * @param tools the tools offered to the model on every turn diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java index 96f4c3414..b86c110c2 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/LocalAgent.java @@ -27,7 +27,7 @@ *

    Two ways to reach a model: * *

      - *
    • {@code --base-url http://127.0.0.1:8080/v1} — a server you started yourself (java-llama.cpp's fat + *
    • {@code --base-url http://127.0.0.1:9931/v1} — a server you started yourself (java-llama.cpp's fat * jar {@code NativeServer} with {@code --jinja}, its {@code OpenAiCompatServer}, or upstream * {@code llama-server}), so you keep full control over model parameters. *
    • {@code --model model.gguf} — loads the GGUF in this JVM and serves it to the agent over a loopback diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java index 1d8797560..e27be5016 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ModelEndpoint.java @@ -106,7 +106,7 @@ static ModelParameters modelParameters(AgentOptions options) { /** * The OpenAI-compatible base URL. * - * @return e.g. {@code http://127.0.0.1:8080/v1} + * @return e.g. {@code http://127.0.0.1:9931/v1} */ public String baseUrl() { return baseUrl; diff --git a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ServerProps.java b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ServerProps.java index 32e21bf5b..174078020 100644 --- a/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ServerProps.java +++ b/llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/ServerProps.java @@ -34,7 +34,7 @@ private ServerProps() {} /** * Look the context size up. * - * @param baseUrl the OpenAI-compatible base URL, e.g. {@code http://127.0.0.1:8080/v1} + * @param baseUrl the OpenAI-compatible base URL, e.g. {@code http://127.0.0.1:9931/v1} * @param apiKey the bearer token to send * @return the context size in tokens, or {@link StatusLine#UNKNOWN_CONTEXT} when it cannot be read */ diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java index aa348b5ed..9eba97ed9 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java @@ -37,9 +37,9 @@ void plainChoosesTheLineOrientedConsoleEvenWithATerminal() { @Test void baseUrlModeWithDefaults() { - AgentOptions options = AgentOptions.parse(new String[] {"--base-url", "http://127.0.0.1:8080/v1/"}); + AgentOptions options = AgentOptions.parse(new String[] {"--base-url", "http://127.0.0.1:9931/v1/"}); - assertThat(options.getBaseUrl(), is("http://127.0.0.1:8080/v1")); + assertThat(options.getBaseUrl(), is("http://127.0.0.1:9931/v1")); assertThat(options.getModelPath(), is(nullValue())); assertThat(options.getApiKey(), is(AgentOptions.DEFAULT_API_KEY)); assertThat(options.getModelId(), is(AgentOptions.DEFAULT_MODEL_ID)); diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ServerPropsTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ServerPropsTest.java index 169df860c..c6e055033 100644 --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ServerPropsTest.java +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/ServerPropsTest.java @@ -16,11 +16,11 @@ class ServerPropsTest { void propsIsTriedNextToTheV1RoutesFirst() { // llama-server serves /props at the root; this project's server serves it under both. assertThat( - ServerProps.candidates("http://127.0.0.1:8080/v1"), - is(List.of("http://127.0.0.1:8080/props", "http://127.0.0.1:8080/v1/props"))); + ServerProps.candidates("http://127.0.0.1:9931/v1"), + is(List.of("http://127.0.0.1:9931/props", "http://127.0.0.1:9931/v1/props"))); assertThat( - ServerProps.candidates("http://127.0.0.1:8080/v1/"), - is(List.of("http://127.0.0.1:8080/props", "http://127.0.0.1:8080/v1/props"))); + ServerProps.candidates("http://127.0.0.1:9931/v1/"), + is(List.of("http://127.0.0.1:9931/props", "http://127.0.0.1:9931/v1/props"))); assertThat(ServerProps.candidates("http://host/api"), is(List.of("http://host/api/props"))); } diff --git a/llama-ffm/README.md b/llama-ffm/README.md index d545365ed..3ccd1d148 100644 --- a/llama-ffm/README.md +++ b/llama-ffm/README.md @@ -58,7 +58,7 @@ try (FfmLlamaModel model = FfmLlamaModel.load(new ModelParameters().setModel("mo String metrics = model.metrics(); // The embedded llama-server over the loaded model, WebUI included: - try (FfmNativeServer server = FfmNativeServer.startAttached(model, "--port", "8080")) { + try (FfmNativeServer server = FfmNativeServer.startAttached(model, "--port", "9931")) { // ... serve ... } } diff --git a/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java b/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java index 5895cc8a3..022111741 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/NativeServer.java @@ -62,8 +62,8 @@ *

      Typical use:

      *
      {@code
        * try (NativeServer server = new NativeServer(
      - *         "-m", "models/model.gguf", "--host", "127.0.0.1", "--port", "8080", "-c", "65536").start()) {
      - *     // Server (and WebUI at http://127.0.0.1:8080/) runs on a native worker thread.
      + *         "-m", "models/model.gguf", "--host", "127.0.0.1", "--port", "9931", "-c", "65536").start()) {
      + *     // Server (and WebUI at http://127.0.0.1:9931/) runs on a native worker thread.
        *     // Readiness: poll GET /health until it returns {"status":"ok"}.
        *     Thread.currentThread().join();
        * }
      @@ -184,7 +184,7 @@ public boolean isRunning(long handle) {
            * Call {@link #start()} to launch the server.

      * * @param args the llama-server command-line arguments (e.g. {@code "-m", "model.gguf", - * "--port", "8080"}); must not be {@code null} and must not contain {@code null} + * "--port", "9931"}); must not be {@code null} and must not contain {@code null} * elements */ public NativeServer(String... args) { @@ -235,7 +235,7 @@ public NativeServer(String... args) { * @param model the loaded model whose native server context this server should serve; must * not be {@code null} and must not be closed while the server runs * @param args the HTTP-side llama-server arguments (e.g. {@code "--host", "127.0.0.1", - * "--port", "8080"}); must not be {@code null} or contain {@code null} elements + * "--port", "9931"}); must not be {@code null} or contain {@code null} elements */ public NativeServer(LlamaModel model, String... args) { Objects.requireNonNull(model, "model"); @@ -385,7 +385,7 @@ public void close() { * {@code java -cp net.ladenthin.llama.server.OpenAiCompatServer …}.

      * * @param args the llama-server command-line arguments, forwarded verbatim (e.g. {@code -m - * model.gguf --host 127.0.0.1 --port 8080}); pass {@code --help} for the full + * model.gguf --host 127.0.0.1 --port 9931}); pass {@code --help} for the full * llama-server option list * @throws InterruptedException if interrupted while waiting for the server to exit */ diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java index 667cf3231..fdd163743 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java @@ -71,7 +71,7 @@ *
      {@code
        * try (LlamaModel model = new LlamaModel(new ModelParameters().setModel("models/model.gguf"));
        *      OpenAiCompatServer server = new OpenAiCompatServer(
      - *              model, OpenAiServerConfig.builder().port(8080).modelId("local").build()).start()) {
      + *              model, OpenAiServerConfig.builder().port(9931).modelId("local").build()).start()) {
        *     Thread.currentThread().join();
        * }
        * }
      diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java index 3401489d0..420944092 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiServerConfig.java @@ -20,8 +20,11 @@ public final class OpenAiServerConfig { /** Default bind address: loopback only, so the endpoint is not exposed off-host. */ public static final String DEFAULT_HOST = "127.0.0.1"; - /** Default TCP port. */ - public static final int DEFAULT_PORT = 8080; + /** + * Default TCP port: llama.cpp's own (9931 since b11521, upstream #30159), so that both servers of + * this library -- this one and {@link NativeServer} -- listen on the same port unless told otherwise. + */ + public static final int DEFAULT_PORT = 9931; /** Default advertised model id (the {@code id} echoed by {@code GET /v1/models}). */ public static final String DEFAULT_MODEL_ID = "local-model"; diff --git a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java index 02a6154de..eab2ab156 100644 --- a/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java +++ b/llama/src/main/java/net/ladenthin/llama/server/RouterClient.java @@ -36,8 +36,8 @@ * *
      {@code
        * NativeServer.setWorkerCommand(javaBin, "-cp", classpath, "net.ladenthin.llama.server.NativeServer");
      - * try (NativeServer router = new NativeServer("--models-dir", dir, "--port", "8080").start()) {
      - *     RouterClient client = new RouterClient(8080);
      + * try (NativeServer router = new NativeServer("--models-dir", dir, "--port", "9931").start()) {
      + *     RouterClient client = new RouterClient(9931);
        *     List models = client.listModels();
        *     client.loadModel(models.get(0).getId());
        *     client.awaitModelLoaded(models.get(0).getId(), 240_000L);
      diff --git a/llama/src/test/java/examples/OpenAiServerExample.java b/llama/src/test/java/examples/OpenAiServerExample.java
      index 74550c116..57349baf8 100644
      --- a/llama/src/test/java/examples/OpenAiServerExample.java
      +++ b/llama/src/test/java/examples/OpenAiServerExample.java
      @@ -21,7 +21,7 @@ public class OpenAiServerExample {
           public static void main(String... args) throws IOException, InterruptedException {
               String modelPath =
                       TestConstants.resolveModelProperty("net.ladenthin.llama.server.model", TestConstants.MODEL_PATH);
      -        int port = Integer.getInteger("net.ladenthin.llama.server.port", 8080);
      +        int port = Integer.getInteger("net.ladenthin.llama.server.port", 9931);
       
               // Two parallel slots let the editor's chat and its background title/summary requests run
               // concurrently instead of serializing behind one another.
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/NativeServerSmokeTest.java b/llama/src/test/java/net/ladenthin/llama/server/NativeServerSmokeTest.java
      index c0267c3c1..e9bf9fdf1 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/NativeServerSmokeTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/NativeServerSmokeTest.java
      @@ -84,6 +84,6 @@ public void nullArgElementRejected() {
           public void attachMode_nullModelRejected() {
               assertThrows(
                       NullPointerException.class,
      -                () -> new NativeServer((net.ladenthin.llama.LlamaModel) null, "--port", "8080"));
      +                () -> new NativeServer((net.ladenthin.llama.LlamaModel) null, "--port", "9931"));
           }
       }
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java
      index cd9616ad2..83bf54484 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCliTest.java
      @@ -322,6 +322,7 @@ public void usageMentionsEndpointsAndRequiredFlag() {
               assertThat(usage, containsString("/v1/chat/completions"));
               assertThat(usage, containsString("/v1/embeddings"));
               assertThat(usage, containsString("/health"));
      +        assertThat(usage, containsString("(default: 9931)"));
           }
       
           @Test
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java
      index e7feaa144..13df6c854 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerConfigTest.java
      @@ -35,6 +35,16 @@ public void builderAppliesLocalhostDefaults() {
               assertThat(config.isAuthenticationEnabled(), is(false));
           }
       
      +    /**
      +     * Both servers listen on llama.cpp's default port: {@code NativeServer} has since b11521 (upstream
      +     * #30159), and this one said 8080, so the two transports of one library defaulted differently.
      +     */
      +    @Test
      +    public void theDefaultPortIsLlamaCppsDefaultPort() {
      +        assertThat(OpenAiServerConfig.DEFAULT_PORT, is(9931));
      +        assertThat(OpenAiServerConfig.builder().build().getPort(), is(9931));
      +    }
      +
           @Test
           public void modelFtypeIsConfigurableAndNullBecomesEmpty() {
               assertThat(
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java
      index ebadfa669..32169828d 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/RouterClientTest.java
      @@ -348,15 +348,15 @@ public void clientsDifferingOnlyInApiKeyAreNotEqual() {
           @Test
           public void toStringNeverLeaksTheApiKey() {
               assertThat(
      -                new RouterClient("127.0.0.1", 8080, "super-secret").toString(),
      -                is("RouterClient(http://127.0.0.1:8080)"));
      +                new RouterClient("127.0.0.1", 9931, "super-secret").toString(),
      +                is("RouterClient(http://127.0.0.1:9931)"));
           }
       
           @Test
           public void toStringBracketsAnIpv6HostAsTheRequestUrlDoes() {
               // The rendered target URL used the raw host, so "::1" printed the invalid authority
      -        // "http://::1:8080" while the request went to http://[::1]:8080.
      -        assertThat(new RouterClient("::1", 8080).toString(), is("RouterClient(http://[::1]:8080)"));
      -        assertThat(new RouterClient("[::1]", 8080).toString(), is("RouterClient(http://[::1]:8080)"));
      +        // "http://::1:9931" while the request went to http://[::1]:9931.
      +        assertThat(new RouterClient("::1", 9931).toString(), is("RouterClient(http://[::1]:9931)"));
      +        assertThat(new RouterClient("[::1]", 9931).toString(), is("RouterClient(http://[::1]:9931)"));
           }
       }
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/ServerLauncherTest.java b/llama/src/test/java/net/ladenthin/llama/server/ServerLauncherTest.java
      index 1c3fbadac..937876ed0 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/ServerLauncherTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/ServerLauncherTest.java
      @@ -25,13 +25,13 @@ public class ServerLauncherTest {
       
           @Test
           public void resultIsShorterWhenFlagPresent() {
      -        String[] in = {FLAG, "-m", "m.gguf", "--port", "8080"};
      +        String[] in = {FLAG, "-m", "m.gguf", "--port", "9931"};
               assertThat(ServerLauncher.withoutFlag(in, FLAG).length < in.length, is(true));
           }
       
           @Test
           public void resultKeepsLengthWhenFlagAbsent() {
      -        String[] in = {"-m", "m.gguf", "--port", "8080"};
      +        String[] in = {"-m", "m.gguf", "--port", "9931"};
               assertThat(ServerLauncher.withoutFlag(in, FLAG).length == in.length, is(true));
           }
       
      @@ -45,8 +45,8 @@ public void flagPositionDoesNotMatter() {
       
           @Test
           public void stripsTheSelectorAndPreservesTheRest() {
      -        String[] out = ServerLauncher.withoutFlag(new String[] {FLAG, "-m", "m.gguf", "--port", "8080"}, FLAG);
      -        assertThat(out, arrayContaining("-m", "m.gguf", "--port", "8080"));
      +        String[] out = ServerLauncher.withoutFlag(new String[] {FLAG, "-m", "m.gguf", "--port", "9931"}, FLAG);
      +        assertThat(out, arrayContaining("-m", "m.gguf", "--port", "9931"));
           }
       
           @Test
      
      From 70485019757b0b2370106194a2fb1bb256b1f90c Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 16:15:44 +0000
      Subject: [PATCH 538/589] Name the refused value in the new route and count
       messages
      
      SpotBugs over the batch of F259-F261 flagged the new refusals with a
      fixed message (WEM) and two style points. The messages now carry what
      was refused: the template's length and the context's token count on
      /api/generate, the image count beside a suffix, the type of the content
      part a token count cannot see; the tokenizer's answer when it carries
      no token array. stripDataUrl tests the comma before the prefix and the
      chat count route passes UnaryOperator.identity(). The ledger entries of
      F260 and F261 name the new messages.
      
      spotbugs:check clean; OllamaApiSupportTest 54/54,
      OpenAiCompatServerHttpTest 79/79, LlamaModelBackendTest 5/5,
      LlamaArchitectureTest 13/13.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                              |  4 +--
       .../llama/server/LlamaModelBackend.java       | 30 ++++++++++---------
       .../llama/server/OllamaApiSupport.java        | 14 +++++----
       .../llama/server/OpenAiCompatServer.java      |  2 +-
       .../llama/server/LlamaModelBackendTest.java   |  4 +--
       .../llama/server/OllamaApiSupportTest.java    |  2 +-
       .../server/OpenAiCompatServerHttpTest.java    |  6 ++--
       7 files changed, 34 insertions(+), 28 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index f2ba49606..adfbb05e3 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2373,7 +2373,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Schwere:** niedrig (der Client bekommt eine plausible Antwort, die sein Template bzw. seinen Gesprächskontext nie gesehen hat)
       - **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Ollama routes“, zweiter Punkt; Brief JR-3), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F215 weist dieselben Felder neben `raw` ab (Ollamas eigene Regel); ohne `raw` blieb ihr Verwerfen offen.
       - **Beschreibung:** Ohne `raw` rendert Ollama den Prompt mit dem `template` der Anfrage und setzt den Token-`context` einer früheren Antwort fort. Hier rendert das Chat-Template des Modells den Prompt (`toOpenAiChatRequestFromGenerate`), und keine Antwort gibt einen `context` heraus; beide Felder wurden übergangen und die Anfrage als gewöhnlicher Chat-Zug beantwortet.
      -- **Fix:** Entscheidung (klarer Fehler statt stiller Abweichung, Form wie F215): ohne `raw` beantwortet `handleOllamaGenerate` ein nicht leeres `template` mit 400 „template is not supported: the model's chat template renders the prompt“ und einen nicht leeren `context` mit 400 „context is not supported: token contexts cannot be replayed; send the conversation as messages to /api/chat“, auch auf dem FIM-Weg (`suffix`); `""`, `[]` und `null` gelten wie bei Ollama als nicht gesetzt. Die Ladeantwort (kein Prompt) bleibt davor. README-Abschnitt der Ollama-Routen nennt die Abweichung.
      +- **Fix:** Entscheidung (klarer Fehler statt stiller Abweichung, Form wie F215): ohne `raw` beantwortet `handleOllamaGenerate` ein nicht leeres `template` mit 400 „template is not supported: the model's chat template renders the prompt (got N characters)“ und einen nicht leeren `context` mit 400 „context is not supported: token contexts cannot be replayed; send the conversation as messages to /api/chat (got N tokens)“ (die Länge bzw. der JSON-Typ in Klammern nach der WEM-Regel von SpotBugs, vgl. F227), auch auf dem FIM-Weg (`suffix`); `""`, `[]` und `null` gelten wie bei Ollama als nicht gesetzt. Die Ladeantwort (kein Prompt) bleibt davor. README-Abschnitt der Ollama-Routen nennt die Abweichung.
       - **Tests:** `OpenAiCompatServerHttpTest#ollamaGenerateWithATemplateOrAContextIsRefused` (`template`, `context: [1,2,3]`, `template` neben `suffix` → 400 mit der Meldung, das Backend wird nicht gerufen; leeres `template` und `context: []` → 200 über das Chat-Template).
       - **Rot-Grün-Nachweis:** Gegen den unveränderten Handler (mit den Meldungskonstanten als Stummel) rot: „OpenAiCompatServerHttpTest.ollamaGenerateWithATemplateOrAContextIsRefused:553 {"model":"test-model","prompt":"why?","template":"{{ .Prompt }}","stream":false} Expected: is <400> but: was <200>“; mit dem Fix grün: `OpenAiCompatServerHttpTest` 77/77, `OllamaApiSupportTest` 54/54.
       - **Status:** gefixt
      @@ -2385,7 +2385,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Schwere:** niedrig bis mittel (Claude Code fragt `count_tokens` vor langen Anfragen; ein 404 lässt es schätzen bzw. die Funktion ausfallen)
       - **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Open points from the bug hunt“; Brief JR-19), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F112 hatte verhindert, dass `/v1/messages/count_tokens` über das Präfix-Matching eine ganze Generierung durch `/v1/messages` lief; seitdem antwortete die Route 404.
       - **Beschreibung:** upstream bedient drei Zählrouten (`tools/server/server.cpp:181-185`, `handle_count_tokens` in `server-context.cpp`, b11639): `/v1/messages/count_tokens` (Anthropic, `{"input_tokens": N}`), `/v1/responses/input_tokens` und `/v1/chat/completions/input_tokens` (dazu `"object": "response.input_tokens"`). Jede übersetzt den Körper wie ihre generierende Route, rendert das Chat-Template (`oaicompat_chat_params_parse`) und tokenisiert den Prompt mit `add_special` und `parse_special`. Hier gab es keine davon.
      -- **Fix:** `OpenAiBackend#countTokens(JsonNode)` (Standard: `UnsupportedOperationException` → 501, wie upstream für eine fehlende Fähigkeit), in `LlamaModelBackend` über denselben Weg wie die Generierung: `mapper.toInferenceParameters` → `LlamaModel#applyTemplate` → `handleTokenize(prompt, true, false)`. Die drei Routen (plus `/messages/count_tokens`, `/responses/input_tokens`, `/chat/completions/input_tokens` nach der Kurzform-Regel des Servers) übersetzen mit `AnthropicApiSupport`/`ResponsesApiSupport` wie ihre Elternrouten; eine Übersetzung oder Zählung, die ablehnt, ist 400, eine fehlende Fähigkeit 501 (`not_supported_error` bzw. Anthropics `api_error`), jeweils in der Fehlerform der Route. Abweichung von upstream: eine Anfrage mit Bild, Audio oder Video zählt upstream mit dem Projektor (`process_mtmd_prompt`), den diese Engine nicht erreicht; statt zu wenig zu zählen, antwortet die Route 501 „counting the tokens of image, audio or video input is not supported“. Abweichung vom Brief: die dritte upstream-Route (`/v1/chat/completions/input_tokens`) mit aufgenommen; `OpenAiCompatServerHttpTest#aSubPathOfARouteIsNotHandledByTheRoute` (F112) prüft jetzt `/v1/messages/batches` und `/v1/chat/completions/other`, da die alte Beispielroute bedient wird.
      +- **Fix:** `OpenAiBackend#countTokens(JsonNode)` (Standard: `UnsupportedOperationException` → 501, wie upstream für eine fehlende Fähigkeit), in `LlamaModelBackend` über denselben Weg wie die Generierung: `mapper.toInferenceParameters` → `LlamaModel#applyTemplate` → `handleTokenize(prompt, true, false)`. Die drei Routen (plus `/messages/count_tokens`, `/responses/input_tokens`, `/chat/completions/input_tokens` nach der Kurzform-Regel des Servers) übersetzen mit `AnthropicApiSupport`/`ResponsesApiSupport` wie ihre Elternrouten; eine Übersetzung oder Zählung, die ablehnt, ist 400, eine fehlende Fähigkeit 501 (`not_supported_error` bzw. Anthropics `api_error`), jeweils in der Fehlerform der Route. Abweichung von upstream: eine Anfrage mit Bild, Audio oder Video zählt upstream mit dem Projektor (`process_mtmd_prompt`), den diese Engine nicht erreicht; statt zu wenig zu zählen, antwortet die Route 501 „counting the tokens of a '' content part is not supported; image, audio and video input is counted by the projector“. Abweichung vom Brief: die dritte upstream-Route (`/v1/chat/completions/input_tokens`) mit aufgenommen; `OpenAiCompatServerHttpTest#aSubPathOfARouteIsNotHandledByTheRoute` (F112) prüft jetzt `/v1/messages/batches` und `/v1/chat/completions/other`, da die alte Beispielroute bedient wird.
       - **Tests:** `OpenAiCompatServerHttpTest#theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest` (alle sechs Pfade, Antwortform, die übersetzte Anfrage erreicht das Backend, nichts generiert), `#theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape` (400 bei abgelehnter Übersetzung, 501 ohne Zählfähigkeit in Anthropic- und OpenAI-Form), `#aSubPathOfARouteIsNotHandledByTheRoute` (angepasst); `LlamaModelBackendTest#aRequestWithAnImageAudioOrVideoPartCountsAsMedia`; `OpenAiCompatServerIntegrationTest#theTokenCountRoutesCountThePromptAGenerationEvaluates` (modellgestützt: die Zählung jeder Route gleicht dem `usage` einer Generierung derselben Anfrage).
       - **Rot-Grün-Nachweis:** Gegen den unveränderten Server (mit `countTokens` als Stummel) rot: „OpenAiCompatServerHttpTest.theTokenCountRoutesAnswerWithTheCountOfTheTranslatedRequest:345 /v1/messages/count_tokens Expected: is <200> but: was <404>“, „theTokenCountRoutesAnswerRefusalsInTheirRoutesErrorShape:386 {"error":{"message":"Not found: /v1/messages/count_tokens",…}} Expected: is <400> but: was <404>“, „OpenAiCompatServerIntegrationTest.theTokenCountRoutesCountThePromptAGenerationEvaluates:92 … Not found: /v1/chat/completions/input_tokens … Expected: is <200> but: was <404>“; mit dem Fix grün: `OpenAiCompatServerHttpTest` 79/79, `LlamaModelBackendTest` 5/5, `OpenAiCompatServerIntegrationTest` 15/15 (Winzmodell als Reasoning-Modell), `AnthropicApiSupportTest` 46/46, `ResponsesApiSupportTest` 70/70.
       - **Status:** gefixt
      diff --git a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java
      index 5680baced..902272b5b 100644
      --- a/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java
      +++ b/llama/src/main/java/net/ladenthin/llama/server/LlamaModelBackend.java
      @@ -12,6 +12,7 @@
       import java.util.function.Consumer;
       import net.ladenthin.llama.LlamaModel;
       import net.ladenthin.llama.parameters.InferenceParameters;
      +import org.jspecify.annotations.Nullable;
       
       /**
        * Production {@link OpenAiBackend} that runs requests against a loaded {@link LlamaModel}.
      @@ -121,39 +122,40 @@ static void relay(Consumer> modelCall, ChunkSink sink) throws I
       
           @Override
           public int countTokens(JsonNode request) throws IOException {
      -        if (hasMediaContent(request)) {
      +        String mediaType = mediaPartType(request);
      +        if (mediaType != null) {
                   // upstream counts an image's embeddings with the projector, which only its worker reaches
      -            throw new UnsupportedOperationException(
      -                    "counting the tokens of image, audio or video input is not supported");
      +            throw new UnsupportedOperationException("counting the tokens of a '" + mediaType
      +                    + "' content part is not supported; image, audio and video input is counted by the projector");
               }
               // the prompt a generation evaluates: the chat template rendered over the request, tokenized with
               // the special tokens added and parsed, as upstream's handle_count_tokens does
               String prompt = model.applyTemplate(mapper.toInferenceParameters(request));
      -        JsonNode tokens = OBJECT_MAPPER
      -                .readTree(model.handleTokenize(prompt, true, false))
      -                .path("tokens");
      +        String answer = model.handleTokenize(prompt, true, false);
      +        JsonNode tokens = OBJECT_MAPPER.readTree(answer).path("tokens");
               if (!tokens.isArray()) {
      -            throw new IOException("the tokenizer answered without a token array");
      +            throw new IOException("the tokenizer answered without a token array: " + answer);
               }
               return tokens.size();
           }
       
           /**
      -     * Whether a chat request carries a content part that is not text (an image, audio or a video).
      +     * The type of the first content part of a chat request that is not text (an image, audio or a
      +     * video), which the token count cannot see.
            *
            * @param request the OpenAI chat request
      -     * @return {@code true} if any message's {@code content} array has a part whose {@code type} is not
      -     *     {@code "text"}
      +     * @return the part's {@code type}, or {@code null} if every message's content is text
            */
      -    static boolean hasMediaContent(JsonNode request) {
      +    static @Nullable String mediaPartType(JsonNode request) {
               for (JsonNode message : request.path("messages")) {
                   for (JsonNode part : message.path("content")) {
      -                if (part.isObject() && !"text".equals(part.path("type").asText(""))) {
      -                    return true;
      +                String type = part.path("type").asText("");
      +                if (part.isObject() && !"text".equals(type)) {
      +                    return type;
                       }
                   }
               }
      -        return false;
      +        return null;
           }
       
           @Override
      diff --git a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java
      index ce93e9f29..402e2851c 100644
      --- a/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java
      +++ b/llama/src/main/java/net/ladenthin/llama/server/OllamaApiSupport.java
      @@ -726,11 +726,13 @@ static void refuseTemplateAndContext(JsonNode request) {
               if (!template.isMissingNode()
                       && !template.isNull()
                       && !(template.isTextual() && template.asText().isEmpty())) {
      -            throw new IllegalArgumentException(TEMPLATE_UNSUPPORTED_MESSAGE);
      +            throw new IllegalArgumentException(
      +                    TEMPLATE_UNSUPPORTED_MESSAGE + " (got " + template.asText().length() + " characters)");
               }
               JsonNode context = request.path("context");
               if (!context.isMissingNode() && !context.isNull() && !(context.isArray() && context.size() == 0)) {
      -            throw new IllegalArgumentException(CONTEXT_UNSUPPORTED_MESSAGE);
      +            throw new IllegalArgumentException(CONTEXT_UNSUPPORTED_MESSAGE + " (got "
      +                    + (context.isArray() ? context.size() + " tokens" : "a " + context.getNodeType()) + ")");
               }
           }
       
      @@ -793,7 +795,8 @@ static ObjectNode toOpenAiCompletionRequest(JsonNode request, @Nullable String m
               String prompt = request.path("prompt").asText("");
               if (hasImages(request)) {
                   if (mediaMarker == null) {
      -                throw new IllegalStateException("a raw prompt with images needs the server's media marker");
      +                throw new IllegalStateException("a raw prompt with "
      +                        + request.path("images").size() + " images needs the server's media marker");
                   }
                   openAi.set("prompt", toRawMultimodalPrompt(prompt, request.path("images"), mediaMarker));
               } else {
      @@ -887,7 +890,7 @@ private static int imageIndex(String digits, int images) {
           /** The base64 payload of a {@code data:} URL; any other string is taken as bare base64, as Ollama sends it. */
           private static String stripDataUrl(String image) {
               int comma = image.indexOf(',');
      -        return image.startsWith("data:") && comma >= 0 ? image.substring(comma + 1) : image;
      +        return comma >= 0 && image.startsWith("data:") ? image.substring(comma + 1) : image;
           }
       
           /**
      @@ -899,7 +902,8 @@ private static String stripDataUrl(String image) {
            */
           static ObjectNode toInfillRequest(JsonNode request) {
               if (hasImages(request)) {
      -            throw new IllegalArgumentException("images are not supported with a suffix (fill-in-the-middle)");
      +            throw new IllegalArgumentException("images are not supported with a suffix (fill-in-the-middle); got "
      +                    + request.path("images").size());
               }
               ObjectNode infill = OBJECT_MAPPER.createObjectNode();
               copyFormatAsJsonSchema(request, infill);
      diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      index fdd163743..288dd4380 100644
      --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      @@ -887,7 +887,7 @@ private void handleResponsesInputTokens(HttpExchange exchange) throws IOExceptio
           }
       
           private void handleChatInputTokens(HttpExchange exchange) throws IOException {
      -        handleCountTokens(exchange, false, request -> request);
      +        handleCountTokens(exchange, false, UnaryOperator.identity());
           }
       
           /**
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java b/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java
      index 263e3a8bc..664870e57 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/LlamaModelBackendTest.java
      @@ -37,11 +37,11 @@ public void aRequestWithAnImageAudioOrVideoPartCountsAsMedia() throws IOExceptio
                   JsonNode request = mapper.readTree("{\"messages\":[{\"role\":\"system\",\"content\":\"s\"},"
                           + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"t\"},{\"type\":\""
                           + type + "\"}]}]}");
      -            assertThat(type, LlamaModelBackend.hasMediaContent(request), is(true));
      +            assertThat(type, LlamaModelBackend.mediaPartType(request), is(type));
               }
               JsonNode text = mapper.readTree("{\"messages\":[{\"role\":\"user\",\"content\":\"t\"},"
                       + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"t\"}]}]}");
      -        assertThat(LlamaModelBackend.hasMediaContent(text), is(false));
      +        assertThat(LlamaModelBackend.mediaPartType(text), is((String) null));
           }
       
           @Test
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java
      index 55cc4dc88..41e12dea7 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/OllamaApiSupportTest.java
      @@ -357,7 +357,7 @@ public void anInfillGenerationTakesItsFormatAndRefusesImages() throws IOExceptio
                       IllegalArgumentException.class,
                       () -> OllamaApiSupport.toInfillRequest(
                               read("{\"prompt\":\"def f(\",\"suffix\":\")\",\"images\":[\"AAAA\"]}")));
      -        assertThat(images.getMessage(), is("images are not supported with a suffix (fill-in-the-middle)"));
      +        assertThat(images.getMessage(), is("images are not supported with a suffix (fill-in-the-middle); got 1"));
           }
       
           @Test
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java
      index 9213ade79..2b74dbb65 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java
      @@ -614,15 +614,15 @@ public void ollamaGenerateWithATemplateOrAContextIsRefused() throws IOException
                   String[][] cases = {
                       {
                           "{\"model\":\"test-model\",\"prompt\":\"why?\",\"template\":\"{{ .Prompt }}\",\"stream\":false}",
      -                    OllamaApiSupport.TEMPLATE_UNSUPPORTED_MESSAGE
      +                    OllamaApiSupport.TEMPLATE_UNSUPPORTED_MESSAGE + " (got 13 characters)"
                       },
                       {
                           "{\"model\":\"test-model\",\"prompt\":\"why?\",\"context\":[1,2,3],\"stream\":false}",
      -                    OllamaApiSupport.CONTEXT_UNSUPPORTED_MESSAGE
      +                    OllamaApiSupport.CONTEXT_UNSUPPORTED_MESSAGE + " (got 3 tokens)"
                       },
                       {
                           "{\"model\":\"test-model\",\"prompt\":\"def f(\",\"suffix\":\")\",\"template\":\"t\"}",
      -                    OllamaApiSupport.TEMPLATE_UNSUPPORTED_MESSAGE
      +                    OllamaApiSupport.TEMPLATE_UNSUPPORTED_MESSAGE + " (got 1 characters)"
                       }
                   };
                   for (String[] c : cases) {
      
      From 266636d1404e15fd6bc87dba11719259df9013e9 Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 16:55:24 +0000
      Subject: [PATCH 539/589] Name the renamed media check in the ledger entry of
       F261
      
      The follow-up commit after SpotBugs renamed LlamaModelBackend.hasMediaContent
      to mediaPartType, which now returns the refused part type for the message;
      the file list of F261 still named the old method.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index adfbb05e3..7508af013 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2381,7 +2381,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       
       ### F261 – java-llama.cpp: `POST /v1/messages/count_tokens` und die `input_tokens`-Routen antworten 404; upstream zählt dort die Prompt-Token
       - **Zeit:** 2026-10-21 16:05 UTC
      -- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (neu `handleCountTokens` mit den drei Routen und ihren Kurzformen, `PATH_MESSAGES_COUNT_TOKENS`, `PATH_RESPONSES_INPUT_TOKENS`, `PATH_CHAT_COMPLETIONS_INPUT_TOKENS`), `OpenAiBackend.java` (neu `countTokens`), `LlamaModelBackend.java` (`countTokens`, `hasMediaContent`), `README.md`
      +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (neu `handleCountTokens` mit den drei Routen und ihren Kurzformen, `PATH_MESSAGES_COUNT_TOKENS`, `PATH_RESPONSES_INPUT_TOKENS`, `PATH_CHAT_COMPLETIONS_INPUT_TOKENS`), `OpenAiBackend.java` (neu `countTokens`), `LlamaModelBackend.java` (`countTokens`, `mediaPartType`), `README.md`
       - **Schwere:** niedrig bis mittel (Claude Code fragt `count_tokens` vor langen Anfragen; ein 404 lässt es schätzen bzw. die Funktion ausfallen)
       - **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Open points from the bug hunt“; Brief JR-19), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F112 hatte verhindert, dass `/v1/messages/count_tokens` über das Präfix-Matching eine ganze Generierung durch `/v1/messages` lief; seitdem antwortete die Route 404.
       - **Beschreibung:** upstream bedient drei Zählrouten (`tools/server/server.cpp:181-185`, `handle_count_tokens` in `server-context.cpp`, b11639): `/v1/messages/count_tokens` (Anthropic, `{"input_tokens": N}`), `/v1/responses/input_tokens` und `/v1/chat/completions/input_tokens` (dazu `"object": "response.input_tokens"`). Jede übersetzt den Körper wie ihre generierende Route, rendert das Chat-Template (`oaicompat_chat_params_parse`) und tokenisiert den Prompt mit `add_special` und `parse_special`. Hier gab es keine davon.
      
      From 359b6d065e5f0de241e81b660ca3181bf96cfa0e Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 17:06:09 +0000
      Subject: [PATCH 540/589] Report a backend failure after the stream started as
       an event (F262)
      
      After a stream had started, the five streaming routes of
      OpenAiCompatServer took every IOException for a client that had gone
      away: a DEBUG line, no error event, no [DONE]. OpenAiBackend allows an
      IOException for a failed generation too, so a client that was still
      connected saw its stream end as if the answer were complete.
      
      A disconnect is now recognised by identity: ResponseStream.writeStrict
      records the exception it throws and isOwnWriteFailure finds it again,
      also wrapped. Only that is a DEBUG disconnect; every other failure goes
      through failStream: a WARN naming the exception, a 500 before the
      stream started and the route's error event (SSE data, Ollama NDJSON,
      Anthropic error event, Responses response.failed) once it has.
      
      The new HTTP test over all five routes was red against the unchanged
      server ("/v1/chat/completions Expected: a string containing
      \"type\":\"server_error\" but: was data: {...chunk...}") and is green
      with the fix.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                              |  16 ++
       TODO.md                                       |   4 -
       .../llama/server/OpenAiCompatServer.java      | 157 ++++++++++--------
       .../server/OpenAiCompatServerHttpTest.java    |  49 ++++++
       .../llama/server/ResponseStreamTest.java      |  27 +++
       5 files changed, 180 insertions(+), 73 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 7508af013..045340f05 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -184,6 +184,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - [F259](#f259) — niedrig bis mittel — *gefixt*
       - [F260](#f260) — niedrig — *gefixt*
       - [F261](#f261) — niedrig bis mittel — *gefixt*
      +- [F262](#f262) — niedrig — *gefixt*
       
       ## Funde
       
      @@ -1778,6 +1779,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Rot-Grün-Nachweis:** Ohne den Fix rot: der HTTP-Test mit `java.net.SocketException: Unexpected end of file from server` aus `getResponseCode()`, der `ResponseStream`-Test mit „Expected: is  but: was “ (`answerIfNotStarted` nach gescheitertem Kopfzeilen-Schreiben); mit dem Fix grün (`ResponseStreamTest` 5, `OpenAiCompatServerHttpTest` 66, `OllamaApiSupportTest` 39, `LlamaArchitectureTest` 13). Modellgestützt mit dem Winzmodell `OpenAiCompatServerIntegrationTest` 13/13 grün; in `OpenAiServerCompletionIntegrationTest` scheitern nur die beiden Infill-Fälle (500, das Winzmodell hat keine FIM-Token), die keine Streaming-Route berühren. `spotbugs:check` grün.
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `d00936b`
      +- **Nachtrag (Branch-Review 2026-10-20/21):** die Hälfte nach dem Start des Streams (eine Backend-`IOException` nach dem ersten Chunk galt weiter als Abbruch, ohne Fehler-Event) ist als [F262](#f262) behoben: nur die Ausnahme, die ein Schreiben des Streams selbst warf, ist noch ein DEBUG-Abbruch.
       
       
       ### F215 – java-llama.cpp: Ollama `/api/generate` mit `raw: true` verwirft `system`, `template` und `context` still — Ollama antwortet 400
      @@ -2379,6 +2381,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `b99a25b`
       
      +
       ### F261 – java-llama.cpp: `POST /v1/messages/count_tokens` und die `input_tokens`-Routen antworten 404; upstream zählt dort die Prompt-Token
       - **Zeit:** 2026-10-21 16:05 UTC
       - **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (neu `handleCountTokens` mit den drei Routen und ihren Kurzformen, `PATH_MESSAGES_COUNT_TOKENS`, `PATH_RESPONSES_INPUT_TOKENS`, `PATH_CHAT_COMPLETIONS_INPUT_TOKENS`), `OpenAiBackend.java` (neu `countTokens`), `LlamaModelBackend.java` (`countTokens`, `mediaPartType`), `README.md`
      @@ -2391,6 +2394,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `2c6e5c5`
       
      +
      +### F262 – java-llama.cpp: nach begonnenem Stream gilt eine Backend-`IOException` als Verbindungsabbruch — kein Fehler-Event, nur DEBUG
      +- **Zeit:** 2026-10-21 17:05 UTC
      +- **Datei:** `llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java` (neu `failStream`; `ResponseStream.writeStrict` merkt sich seine Ausnahme, neu `ResponseStream.isOwnWriteFailure`; die Fehlerzweige von `streamCompletions`, `streamChat`, `streamOllamaChat`, `streamAnthropic`, `streamResponses`)
      +- **Schwere:** niedrig (nur über die öffentliche Schnittstelle `OpenAiBackend` erreichbar; `LlamaModelBackend` reicht nur die `IOException` der Senke weiter)
      +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Server transport“; Brief JR-6), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F214 hatte die Hälfte vor dem ersten Chunk behoben (500 statt keiner Antwort) und die Hälfte danach bewusst offen gelassen.
      +- **Beschreibung:** Nach dem Start des Streams nahmen die fünf Streaming-Routen jede `IOException` für einen gegangenen Client: `LOG.debug("client disconnected during stream", e)`, kein Fehler-Event, kein `[DONE]`. `OpenAiBackend.stream`/`streamCompletions` dürfen eine `IOException` aber auch für eine gescheiterte Generierung werfen; ein noch verbundener Client sah seinen Stream dann enden, als sei die Antwort vollständig, und im Log stand nichts über WARN.
      +- **Fix:** Der Abbruch wird nicht mehr am Typ erkannt, sondern an der Identität: `ResponseStream.writeStrict` merkt sich die Ausnahme, die es wirft, und `isOwnWriteFailure` erkennt sie wieder, auch eingewickelt (Ursachenkette, begrenzt auf 16 Glieder; `LlamaModelBackend.relay` reicht genau diese Ausnahme zurück). Nur sie ist ein DEBUG-Abbruch. Jeder andere Fehler — eine `RuntimeException` oder eine eigene `IOException` des Backends — geht durch den neuen Helfer `failStream`: WARN mit Klassenname und Meldung, vor dem Start 500 mit dem Fehlerkörper der Route, danach das Fehler-Event der Route über `writeQuietly` (SSE-`data:`-Fehlerobjekt für Chat und Completions, Ollama-NDJSON-Zeile `{"error":…}`, Anthropic `event: error`, Responses `response.failed` plus `event: error`). Abweichung vom Brief: der Brief schlug vor, jede `IOException` nach dem Start mit WARN und Event zu behandeln („ein echter Disconnect kostet nichts“); das hätte jeden gewöhnlichen Verbindungsabbruch als WARN geloggt. Die Identitätsprüfung trennt die beiden Fälle genau.
      +- **Tests:** `OpenAiCompatServerHttpTest#aBackendIOExceptionAfterTheFirstChunkIsAnErrorEventOnEveryStreamingRoute` (alle fünf Streaming-Routen, `IoFailingAfterFirstChunkBackend`: ein Chunk, dann `IOException("generation failed")`; Status 200, der Chunk, das Fehler-Event der Route mit der Meldung, WARN mit `IOException` per LogCaptor), `ResponseStreamTest#theFailureOfAStrictWriteIsRecognisedAsTheStreamsOwn` (auch als `UncheckedIOException` eingewickelt; eine andere `IOException` nicht), `#noFailureIsTheStreamsOwnBeforeAStrictWriteFailed`.
      +- **Rot-Grün-Nachweis:** Gegen den unveränderten Server rot: „OpenAiCompatServerHttpTest.aBackendIOExceptionAfterTheFirstChunkIsAnErrorEventOnEveryStreamingRoute:944 /v1/chat/completions Expected: a string containing "\"type\":\"server_error\"" but: was "data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}“ (der Stream endete nach dem Chunk, ohne Fehler-Event und ohne `[DONE]`); mit dem Fix grün: `OpenAiCompatServerHttpTest` 80/80, `ResponseStreamTest` 7/7.
      +- **Status:** gefixt
      +- **Commit:** (folgt)
      +
       ## Offene Verdachte ohne Fund-ID
       
       - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt.
      diff --git a/TODO.md b/TODO.md
      index 21dc34ff9..607ca75b9 100644
      --- a/TODO.md
      +++ b/TODO.md
      @@ -544,10 +544,6 @@ scope, is listed here; the ledger entry in brackets holds the analysis. Delete a
         honest options are a `400` or a documented deviation.
       
       **Server transport**
      -- **After a stream started, a backend `IOException` is taken for a disconnect** (F214): it is logged
      -  at DEBUG and the stream ends without an error event. A third-party `OpenAiBackend` may report a
      -  generation failure that way; a quiet error event (`writeQuietly` of the route's error event) would
      -  cost nothing when the client really is gone. Optional, outside what F182 promised.
       - **Decision open: should a malformed stream envelope fail instead of ending the stream?**
         `ChatStreamChunkParser.feed` treats an unparseable or empty envelope as the end of the stream, and
         `StreamingChunkAssembler` follows it, so a broken envelope looks like a short answer. One reviewer:
      diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      index 288dd4380..f5f14d356 100644
      --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      @@ -29,6 +29,7 @@
       import java.util.concurrent.TimeUnit;
       import java.util.concurrent.atomic.AtomicInteger;
       import java.util.concurrent.atomic.AtomicReference;
      +import java.util.function.Supplier;
       import java.util.function.UnaryOperator;
       import net.ladenthin.llama.LlamaModel;
       import net.ladenthin.llama.exception.InvalidRequestException;
      @@ -403,20 +404,9 @@ private void streamCompletions(HttpExchange exchange, JsonNode request) throws I
                           out.writeQuietly(OpenAiSseFormatter.sseData(
                                   OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null)));
                       }
      -            } catch (IOException e) {
      -                if (out.answerIfNotStarted(
      -                        HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) {
      -                    LOG.warn("streaming completion failed before the first chunk", e);
      -                } else {
      -                    LOG.debug("client disconnected during stream", e);
      -                }
      -            } catch (RuntimeException e) {
      -                LOG.warn("streaming completion failed", e);
      -                if (!out.answerIfNotStarted(
      -                        HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) {
      -                    out.writeQuietly(OpenAiSseFormatter.sseData(
      -                            OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null)));
      -                }
      +            } catch (IOException | RuntimeException e) {
      +                String error = OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null);
      +                failStream(out, e, "streaming completion", error, () -> OpenAiSseFormatter.sseData(error));
                   } finally {
                       if (heartbeat != null) {
                           heartbeat.cancel(false);
      @@ -502,20 +492,9 @@ private void streamChat(HttpExchange exchange, JsonNode request) throws IOExcept
                           out.writeQuietly(OpenAiSseFormatter.sseData(
                                   OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_REQUEST, null)));
                       }
      -            } catch (IOException e) {
      -                if (out.answerIfNotStarted(
      -                        HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) {
      -                    LOG.warn("streaming chat completion failed before the first chunk", e);
      -                } else {
      -                    LOG.debug("client disconnected during stream", e);
      -                }
      -            } catch (RuntimeException e) {
      -                LOG.warn("streaming chat completion failed", e);
      -                if (!out.answerIfNotStarted(
      -                        HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) {
      -                    out.writeQuietly(OpenAiSseFormatter.sseData(
      -                            OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null)));
      -                }
      +            } catch (IOException | RuntimeException e) {
      +                String error = OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null);
      +                failStream(out, e, "streaming chat completion", error, () -> OpenAiSseFormatter.sseData(error));
                   } finally {
                       // try-with-resources closes the stream (under its lock) after the heartbeat is cancelled,
                       // so the close never races a still-in-flight heartbeat write.
      @@ -644,17 +623,9 @@ private void streamOllamaChat(HttpExchange exchange, JsonNode openAiRequest, Str
                       if (!out.answerIfNotStarted(HTTP_BAD_REQUEST, ollamaError(message(e)))) {
                           out.writeQuietly(ollamaError(message(e)) + "\n");
                       }
      -            } catch (IOException e) {
      -                if (out.answerIfNotStarted(HTTP_SERVER_ERROR, ollamaError(message(e)))) {
      -                    LOG.warn("ollama streaming chat failed before the first chunk", e);
      -                } else {
      -                    LOG.debug("ollama client disconnected during stream", e);
      -                }
      -            } catch (RuntimeException e) {
      -                LOG.warn("ollama streaming chat failed", e);
      -                if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, ollamaError(message(e)))) {
      -                    out.writeQuietly(ollamaError(message(e)) + "\n");
      -                }
      +            } catch (IOException | RuntimeException e) {
      +                String error = ollamaError(message(e));
      +                failStream(out, e, "ollama streaming chat", error, () -> error + "\n");
                   }
               }
           }
      @@ -799,18 +770,9 @@ private void streamAnthropic(HttpExchange exchange, JsonNode openAiRequest, Stri
                       if (!out.answerIfNotStarted(HTTP_BAD_REQUEST, error)) {
                           out.writeQuietly(AnthropicApiSupport.sseEvent("error", error));
                       }
      -            } catch (IOException e) {
      -                if (out.answerIfNotStarted(HTTP_SERVER_ERROR, anthropicError(message(e), ANTHROPIC_ERROR_SERVER))) {
      -                    LOG.warn("anthropic streaming failed before the first event", e);
      -                } else {
      -                    LOG.debug("anthropic client disconnected during stream", e);
      -                }
      -            } catch (RuntimeException e) {
      -                LOG.warn("anthropic streaming failed", e);
      +            } catch (IOException | RuntimeException e) {
                       String error = anthropicError(message(e), ANTHROPIC_ERROR_SERVER);
      -                if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, error)) {
      -                    out.writeQuietly(AnthropicApiSupport.sseEvent("error", error));
      -                }
      +                failStream(out, e, "anthropic streaming", error, () -> AnthropicApiSupport.sseEvent("error", error));
                   } finally {
                       if (heartbeat != null) {
                           heartbeat.cancel(false);
      @@ -975,20 +937,15 @@ private void streamResponses(HttpExchange exchange, JsonNode openAiRequest, Stri
                           out.writeQuietly(translator.failed(message(e), ERROR_TYPE_REQUEST) + "event: error\ndata: " + error
                                   + "\n\n");
                       }
      -            } catch (IOException e) {
      -                if (out.answerIfNotStarted(
      -                        HTTP_SERVER_ERROR, OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null))) {
      -                    LOG.warn("responses streaming failed before the first event", e);
      -                } else {
      -                    LOG.debug("responses client disconnected during stream", e);
      -                }
      -            } catch (RuntimeException e) {
      -                LOG.warn("responses streaming failed", e);
      +            } catch (IOException | RuntimeException e) {
                       String error = OpenAiSseFormatter.errorJson(message(e), ERROR_TYPE_SERVER, null);
      -                if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, error)) {
      -                    out.writeQuietly(
      -                            translator.failed(message(e), ERROR_TYPE_SERVER) + "event: error\ndata: " + error + "\n\n");
      -                }
      +                failStream(
      +                        out,
      +                        e,
      +                        "responses streaming",
      +                        error,
      +                        () -> translator.failed(message(e), ERROR_TYPE_SERVER) + "event: error\ndata: " + error
      +                                + "\n\n");
                   } finally {
                       if (heartbeat != null) {
                           heartbeat.cancel(false);
      @@ -1248,6 +1205,12 @@ static final class ResponseStream implements AutoCloseable {
       
               private boolean answered;
       
      +        /** How many causes {@link #isOwnWriteFailure(Throwable)} follows. */
      +        private static final int MAX_CAUSE_DEPTH = 16;
      +
      +        /** The last exception a strict write threw: the stream's own failure, i.e. a client that went away. */
      +        private @Nullable IOException writeFailure;
      +
               ResponseStream(HttpExchange exchange, String contentType) {
                   this.exchange = exchange;
                   this.contentType = contentType;
      @@ -1298,13 +1261,42 @@ void startWith(String text) {
               /** Write under the lock, propagating failures so a streaming generation can be cancelled. */
               void writeStrict(String text) throws IOException {
                   synchronized (lock) {
      -                OutputStream out = body();
      -                if (out == null) {
      -                    throw new IOException("the request was already answered without a stream; " + text.length()
      -                            + " characters dropped");
      +                try {
      +                    OutputStream out = body();
      +                    if (out == null) {
      +                        throw new IOException("the request was already answered without a stream; " + text.length()
      +                                + " characters dropped");
      +                    }
      +                    out.write(text.getBytes(StandardCharsets.UTF_8));
      +                    out.flush();
      +                } catch (IOException e) {
      +                    writeFailure = e;
      +                    throw e;
      +                }
      +            }
      +        }
      +
      +        /**
      +         * Whether {@code failure} is, or was caused by, the exception a strict write of this stream threw
      +         * -- the client going away rather than the backend failing.
      +         *
      +         * @param failure the exception a route caught
      +         * @return {@code true} if a strict write threw it, also when it arrives wrapped
      +         */
      +        // identity is the point: the very exception a write threw, not an equal one
      +        @SuppressWarnings("ReferenceEquality")
      +        boolean isOwnWriteFailure(Throwable failure) {
      +            synchronized (lock) {
      +                IOException own = writeFailure;
      +                Throwable t = failure;
      +                // bounded, so a cause chain with a cycle cannot loop
      +                for (int depth = 0; own != null && t != null && depth < MAX_CAUSE_DEPTH; depth++) {
      +                    if (t == own) {
      +                        return true;
      +                    }
      +                    t = t.getCause();
                       }
      -                out.write(text.getBytes(StandardCharsets.UTF_8));
      -                out.flush();
      +                return false;
                   }
               }
       
      @@ -1367,6 +1359,33 @@ public void close() {
               }
           }
       
      +    /**
      +     * Report a failure of a streaming route that is not the request's fault. A failed write of the
      +     * stream itself is the client going away: nothing can reach it any more, so it is a DEBUG line. Any
      +     * other failure -- a {@link RuntimeException}, or an {@link IOException} of the backend's own, which
      +     * {@link OpenAiBackend} allows -- is a WARN naming the exception, answered with {@code 500} and
      +     * {@code errorBody} while the stream has not started, and with {@code errorEvent} on the stream once
      +     * it has. A backend {@link IOException} after the start used to be taken for a disconnect too, so a
      +     * client that was still connected saw its stream end as if the answer were complete.
      +     *
      +     * @param out the route's response stream
      +     * @param e the failure
      +     * @param what the route, for the log line
      +     * @param errorBody the route's error body for a {@code 500}
      +     * @param errorEvent the route's error event, written best-effort into the started stream
      +     */
      +    private static void failStream(
      +            ResponseStream out, Exception e, String what, String errorBody, Supplier errorEvent) {
      +        if (out.isOwnWriteFailure(e)) {
      +            LOG.debug("{}: the client went away", what, e);
      +            return;
      +        }
      +        LOG.warn("{} failed: {}: {}", what, e.getClass().getSimpleName(), message(e), e);
      +        if (!out.answerIfNotStarted(HTTP_SERVER_ERROR, errorBody)) {
      +            out.writeQuietly(errorEvent.get());
      +        }
      +    }
      +
           private static String message(Throwable t) {
               String m = t.getMessage();
               return m != null ? m : t.getClass().getSimpleName();
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java
      index 2b74dbb65..7acd49c3d 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiCompatServerHttpTest.java
      @@ -5,7 +5,9 @@
       package net.ladenthin.llama.server;
       
       import static org.hamcrest.MatcherAssert.assertThat;
      +import static org.hamcrest.Matchers.allOf;
       import static org.hamcrest.Matchers.containsString;
      +import static org.hamcrest.Matchers.hasItem;
       import static org.hamcrest.Matchers.is;
       import static org.hamcrest.Matchers.startsWith;
       
      @@ -23,6 +25,7 @@
       import java.util.concurrent.TimeUnit;
       import net.ladenthin.llama.exception.InvalidRequestException;
       import net.ladenthin.llama.exception.LlamaException;
      +import nl.altindag.log.LogCaptor;
       import org.junit.jupiter.api.Test;
       
       /**
      @@ -921,6 +924,35 @@ public void aBackendIOExceptionBeforeTheFirstChunkIsA500OnEveryStreamingRoute()
               }
           }
       
      +    /**
      +     * A backend {@link IOException} after the stream has started was taken for a client that had gone
      +     * away: no error event, and a DEBUG line only. A client that is still connected then saw the stream
      +     * end as if the answer were complete. It is the route's server-error event now, and a WARN naming
      +     * the exception; a write that failed because the client did go away stays a DEBUG line (see
      +     * {@code ResponseStreamTest}).
      +     */
      +    @Test
      +    public void aBackendIOExceptionAfterTheFirstChunkIsAnErrorEventOnEveryStreamingRoute() throws IOException {
      +        try (LogCaptor captor = LogCaptor.forClass(OpenAiCompatServer.class);
      +                OpenAiCompatServer server =
      +                        new OpenAiCompatServer(new IoFailingAfterFirstChunkBackend(), config()).start()) {
      +            for (String[] route : STREAMING_ROUTES) {
      +                captor.clearLogs();
      +                Response response = post(server.getPort(), route[0], route[1], "");
      +                assertThat(route[0], response.code, is(200));
      +                assertThat(route[0], response.body, containsString("he"));
      +                assertThat(route[0], response.body, containsString(route[3]));
      +                assertThat(route[0], response.body, containsString(IoFailingAfterFirstChunkBackend.REASON));
      +                assertThat(
      +                        route[0],
      +                        captor.getWarnLogs(),
      +                        hasItem(allOf(
      +                                containsString("IOException"),
      +                                containsString(IoFailingAfterFirstChunkBackend.REASON))));
      +            }
      +        }
      +    }
      +
           @Test
           public void aRejectionAfterTheFirstChunkIsAnInvalidRequestEvent() throws IOException {
               ScriptedStreamBackend backend =
      @@ -1645,6 +1677,23 @@ public void streamCompletions(JsonNode request, ChunkSink sink) throws IOExcepti
               }
           }
       
      +    /** Backend whose streams emit one chunk and then fail with an {@link IOException} of their own. */
      +    static final class IoFailingAfterFirstChunkBackend extends FakeBackend {
      +        static final String REASON = "generation failed";
      +
      +        @Override
      +        public void stream(JsonNode request, ChunkSink sink) throws IOException {
      +            sink.accept("{\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"he\"}}]}");
      +            throw new IOException(REASON);
      +        }
      +
      +        @Override
      +        public void streamCompletions(JsonNode request, ChunkSink sink) throws IOException {
      +            sink.accept("{\"object\":\"text_completion\",\"choices\":[{\"text\":\"he\",\"finish_reason\":null}]}");
      +            throw new IOException(REASON);
      +        }
      +    }
      +
           /** How long a test backend waits for the client to release it before it gives up. */
           static final long BACKEND_RELEASE_TIMEOUT_SECONDS = 30L;
       
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java b/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java
      index 7705b29a2..2c863f66e 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/ResponseStreamTest.java
      @@ -207,4 +207,31 @@ void aStreamWhoseHeadersCouldNotBeWrittenHasStarted() throws Exception {
       
               assertThat("the headers were written once", exchange.headerWrites, is(1));
           }
      +    /**
      +     * The routes tell a client that went away from a backend that failed by the exception: the one a
      +     * strict write threw (also when a backend hands it back wrapped) is the client's, any other one the
      +     * backend's, which still deserves an error event on the stream.
      +     */
      +    @Test
      +    void theFailureOfAStrictWriteIsRecognisedAsTheStreamsOwn() throws Exception {
      +        RecordingExchange exchange = new RecordingExchange();
      +        exchange.headerFailure = new IOException("Broken pipe");
      +        try (OpenAiCompatServer.ResponseStream out =
      +                new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) {
      +            IOException own = assertThrows(IOException.class, () -> out.writeStrict("DATA\n"));
      +            assertThat(out.isOwnWriteFailure(own), is(true));
      +            assertThat(out.isOwnWriteFailure(new java.io.UncheckedIOException(own)), is(true));
      +            assertThat(out.isOwnWriteFailure(new IOException("generation failed")), is(false));
      +        }
      +    }
      +
      +    @Test
      +    void noFailureIsTheStreamsOwnBeforeAStrictWriteFailed() throws Exception {
      +        RecordingExchange exchange = new RecordingExchange();
      +        try (OpenAiCompatServer.ResponseStream out =
      +                new OpenAiCompatServer.ResponseStream(exchange, "text/event-stream")) {
      +            out.writeStrict("DATA\n");
      +            assertThat(out.isOwnWriteFailure(new IOException("generation failed")), is(false));
      +        }
      +    }
       }
      
      From 08893ed89afd1e7dd56dfa7b1ea24227224ca8ac Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 17:06:10 +0000
      Subject: [PATCH 541/589] Record the commit of F262 in the bug-hunt ledger
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 045340f05..507ff9a28 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2405,7 +2405,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Tests:** `OpenAiCompatServerHttpTest#aBackendIOExceptionAfterTheFirstChunkIsAnErrorEventOnEveryStreamingRoute` (alle fünf Streaming-Routen, `IoFailingAfterFirstChunkBackend`: ein Chunk, dann `IOException("generation failed")`; Status 200, der Chunk, das Fehler-Event der Route mit der Meldung, WARN mit `IOException` per LogCaptor), `ResponseStreamTest#theFailureOfAStrictWriteIsRecognisedAsTheStreamsOwn` (auch als `UncheckedIOException` eingewickelt; eine andere `IOException` nicht), `#noFailureIsTheStreamsOwnBeforeAStrictWriteFailed`.
       - **Rot-Grün-Nachweis:** Gegen den unveränderten Server rot: „OpenAiCompatServerHttpTest.aBackendIOExceptionAfterTheFirstChunkIsAnErrorEventOnEveryStreamingRoute:944 /v1/chat/completions Expected: a string containing "\"type\":\"server_error\"" but: was "data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}“ (der Stream endete nach dem Chunk, ohne Fehler-Event und ohne `[DONE]`); mit dem Fix grün: `OpenAiCompatServerHttpTest` 80/80, `ResponseStreamTest` 7/7.
       - **Status:** gefixt
      -- **Commit:** (folgt)
      +- **Commit:** java-llama.cpp `359b6d0`
       
       ## Offene Verdachte ohne Fund-ID
       
      
      From f06729f17b78b52aa8df42751f1cf53649c72be4 Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 17:13:04 +0000
      Subject: [PATCH 542/589] Keep the streams' disconnect log line stable
      
      F262 renamed the DEBUG line of a client that went away during a stream
      to ": the client went away". The model-backed
      OpenAiServerCompletionIntegrationTest waits for the fixed line "client
      disconnected during stream" and timed out after 30 s against the tiny
      model. The line is the old one again, the same for every route, which
      is also what operators grep for. The test now recognises the WARN line
      of a failed stream by its prefix, since that line names the exception
      since F262 and an exact list comparison would never match it again.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                                     |  1 +
       .../ladenthin/llama/server/OpenAiCompatServer.java   |  3 ++-
       .../OpenAiServerCompletionIntegrationTest.java       | 12 ++++++++----
       3 files changed, 11 insertions(+), 5 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 507ff9a28..e6a698681 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2406,6 +2406,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Rot-Grün-Nachweis:** Gegen den unveränderten Server rot: „OpenAiCompatServerHttpTest.aBackendIOExceptionAfterTheFirstChunkIsAnErrorEventOnEveryStreamingRoute:944 /v1/chat/completions Expected: a string containing "\"type\":\"server_error\"" but: was "data: {"object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}“ (der Stream endete nach dem Chunk, ohne Fehler-Event und ohne `[DONE]`); mit dem Fix grün: `OpenAiCompatServerHttpTest` 80/80, `ResponseStreamTest` 7/7.
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `359b6d0`
      +- **Nachtrag (2026-10-21):** Der erste Stand schrieb die DEBUG-Zeile des Abbruchs als „: the client went away“; `OpenAiServerCompletionIntegrationTest#aClientThatDisconnectsMidStreamIsADisconnectAndFreesTheSlot` (modellgestützt, Winzmodell) wartet auf die feste Zeile „client disconnected during stream“ und lief dadurch nach 30 s rot („the server logged no end of the stream after 30000 ms; debug: [streaming completion: the client went away]“). Die Zeile ist wieder die alte, für alle Routen gleiche; der Test erkennt die WARN-Zeile eines gescheiterten Streams jetzt am Präfix „streaming completion failed“, weil sie seit F262 die Ausnahme nennt (mit dem exakten Listenvergleich hätte seine Negativprüfung nie mehr angeschlagen). Danach grün: `OpenAiServerCompletionIntegrationTest` 12 (2 übersprungen, FIM), `OpenAiCompatServerHttpTest` 80, `ResponseStreamTest` 7.
       
       ## Offene Verdachte ohne Fund-ID
       
      diff --git a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      index f5f14d356..3c850daa2 100644
      --- a/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      +++ b/llama/src/main/java/net/ladenthin/llama/server/OpenAiCompatServer.java
      @@ -1377,7 +1377,8 @@ public void close() {
           private static void failStream(
                   ResponseStream out, Exception e, String what, String errorBody, Supplier errorEvent) {
               if (out.isOwnWriteFailure(e)) {
      -            LOG.debug("{}: the client went away", what, e);
      +            // the same line for every route, which operators and the integration tests look for
      +            LOG.debug("client disconnected during stream", e);
                   return;
               }
               LOG.warn("{} failed: {}: {}", what, e.getClass().getSimpleName(), message(e), e);
      diff --git a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java
      index 92a4a197b..db1f0d769 100644
      --- a/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/server/OpenAiServerCompletionIntegrationTest.java
      @@ -47,7 +47,7 @@ public class OpenAiServerCompletionIntegrationTest extends OpenAiServerTestSuppo
           private static final long IDLE_TIMEOUT_MILLIS = 30_000L;
           /** The server's log line for a client that went away during a stream. */
           private static final String DISCONNECT_LOG = "client disconnected during stream";
      -    /** The server's log line for a stream that failed for any other reason. */
      +    /** The start of the server's log line for a stream that failed for any other reason. */
           private static final String FAILURE_LOG = "streaming completion failed";
       
           private static LlamaModel model;
      @@ -229,7 +229,7 @@ public void aClientThatDisconnectsMidStreamIsADisconnectAndFreesTheSlot() throws
                   }
                   List disconnects = awaitStreamOutcome(captor);
                   assertThat(disconnects.toString(), disconnects.size(), is(1));
      -            assertThat(captor.getWarnLogs().toString(), captor.getWarnLogs().contains(FAILURE_LOG), is(false));
      +            assertThat(captor.getWarnLogs().toString(), anyFailure(captor), is(false));
               }
               awaitIdleSlots();
           }
      @@ -247,8 +247,7 @@ private static String cancelledStreamBody() {
            */
           private static List awaitStreamOutcome(LogCaptor captor) throws InterruptedException {
               long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(IDLE_TIMEOUT_MILLIS);
      -        while (!captor.getDebugLogs().contains(DISCONNECT_LOG)
      -                && !captor.getWarnLogs().contains(FAILURE_LOG)) {
      +        while (!captor.getDebugLogs().contains(DISCONNECT_LOG) && !anyFailure(captor)) {
                   if (System.nanoTime() > deadline) {
                       throw new AssertionError("the server logged no end of the stream after " + IDLE_TIMEOUT_MILLIS
                               + " ms; debug: " + captor.getDebugLogs() + ", warn: " + captor.getWarnLogs());
      @@ -260,6 +259,11 @@ private static List awaitStreamOutcome(LogCaptor captor) throws Interrup
               return disconnects;
           }
       
      +    /** Whether the server logged a failed stream (the line names the exception after the prefix). */
      +    private static boolean anyFailure(LogCaptor captor) {
      +        return captor.getWarnLogs().stream().anyMatch(message -> message.startsWith(FAILURE_LOG));
      +    }
      +
           /** Polls the metrics until no slot is processing, for at most {@link #IDLE_TIMEOUT_MILLIS}. */
           private static void awaitIdleSlots() throws InterruptedException {
               long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(IDLE_TIMEOUT_MILLIS);
      
      From 877171700c8e6977e7d9b5533c4507b2bbded1b0 Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 17:18:38 +0000
      Subject: [PATCH 543/589] Reject a malformed stream envelope instead of ending
       the stream (F263)
      
      ChatStreamChunkParser.feed took an unparseable, empty or blank
      envelope for the end of the stream, an envelope without "stop" for
      "continue" and a "data" that is neither object nor array for nothing.
      The native side writes exactly {"data": , "stop":
      } (wrap_stream_chunk; stream_next skips a null payload), so
      any other shape is a broken transport, and the silent end made it look
      like a short answer.
      
      feed now reads with the strict reader and throws
      ChatStreamChunkParser.MalformedEnvelopeException (an
      IllegalArgumentException) naming the envelope by an 80-character
      excerpt, before the sink is called. LlamaModel.feedStreamEnvelope turns
      it into a LlamaException: for the caller it is a failure of the native
      layer, which a server answers with 500, not the request's fault. An
      exception of the caller's sink passes through unchanged.
      
      The new parser tests were red against the old parser ("this is not
      json ==> Expected java.lang.IllegalArgumentException to be thrown, but
      nothing was thrown.") and are green; the model-backed stream tests show
      the native side meets the strict contract.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                              | 14 ++++
       TODO.md                                       |  9 ---
       .../java/net/ladenthin/llama/LlamaModel.java  | 34 +++++++--
       .../llama/json/ChatStreamChunkParser.java     | 69 ++++++++++++++++---
       .../llama/LlamaModelStreamEnvelopeTest.java   | 59 ++++++++++++++++
       .../llama/json/ChatStreamChunkParserTest.java | 65 +++++++++--------
       6 files changed, 196 insertions(+), 54 deletions(-)
       create mode 100644 llama/src/test/java/net/ladenthin/llama/LlamaModelStreamEnvelopeTest.java
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index e6a698681..961bb4fc7 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -185,6 +185,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - [F260](#f260) — niedrig — *gefixt*
       - [F261](#f261) — niedrig bis mittel — *gefixt*
       - [F262](#f262) — niedrig — *gefixt*
      +- [F263](#f263) — niedrig — *gefixt*
       
       ## Funde
       
      @@ -2408,6 +2409,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Commit:** java-llama.cpp `359b6d0`
       - **Nachtrag (2026-10-21):** Der erste Stand schrieb die DEBUG-Zeile des Abbruchs als „: the client went away“; `OpenAiServerCompletionIntegrationTest#aClientThatDisconnectsMidStreamIsADisconnectAndFreesTheSlot` (modellgestützt, Winzmodell) wartet auf die feste Zeile „client disconnected during stream“ und lief dadurch nach 30 s rot („the server logged no end of the stream after 30000 ms; debug: [streaming completion: the client went away]“). Die Zeile ist wieder die alte, für alle Routen gleiche; der Test erkennt die WARN-Zeile eines gescheiterten Streams jetzt am Präfix „streaming completion failed“, weil sie seit F262 die Ausnahme nennt (mit dem exakten Listenvergleich hätte seine Negativprüfung nie mehr angeschlagen). Danach grün: `OpenAiServerCompletionIntegrationTest` 12 (2 übersprungen, FIM), `OpenAiCompatServerHttpTest` 80, `ResponseStreamTest` 7.
       
      +
      +### F263 – java-llama.cpp: ein fehlerhafter Stream-Umschlag beendet den Stream still, als sei die Antwort vollständig
      +- **Zeit:** 2026-10-21 17:18 UTC
      +- **Datei:** `llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java` (`feed`, neu `MalformedEnvelopeException`), `llama/src/main/java/net/ladenthin/llama/LlamaModel.java` (neu `feedStreamEnvelope`, genutzt von `streamChatCompletion` und `streamCompletionsOai`)
      +- **Schwere:** niedrig (über `LlamaModel` nicht erreichbar, solange die native Seite ihren Vertrag hält; ein Vertragsbruch sähe aus wie eine kurze Antwort)
      +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Server transport“, „Decision open: should a malformed stream envelope fail instead of ending the stream?“; Brief JR-7), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen.
      +- **Beschreibung:** `feed` nahm einen unparsbaren, leeren oder blanken Umschlag als Ende des Streams (`true`), ein Umschlag ohne `stop` als „weiter“ (`false`), ein `data` ohne Objekt/Array als „nichts“. Die native Seite schreibt aber genau `{"data": , "stop": }` (`wrap_stream_chunk` in `json_helpers.hpp`; `stream_next` in `jllama_engine.cpp` überspringt eine `null`-Nutzlast, ein leerer String wird nie gesendet). Jede andere Form ist ein Transportfehler; das stille Ende ließ ihn wie eine kurze Antwort aussehen (auch über `StreamingChunkAssembler` im langchain4j-Modul, das dem Stream folgt), und ein Umschlag ohne `stop` hätte die Abfrageschleife nie beendet.
      +- **Fix:** Vertrag geklärt und festgeschrieben: `feed` liest mit dem strikten Reader (`FAIL_ON_TRAILING_TOKENS`) und verlangt ein JSON-Objekt, `stop` als Boolean und `data` als Objekt oder Array (ein leeres Array liefert keinen Chunk); sonst `ChatStreamChunkParser.MalformedEnvelopeException` (eine `IllegalArgumentException`, das `json`-Paket nutzt keine Exception-Schicht) mit Grund und dem Umschlag als Ausschnitt (höchstens 80 Zeichen wie F232/F233), vor jedem Aufruf der Senke. `LlamaModel.feedStreamEnvelope` macht daraus eine `LlamaException` mit derselben Meldung und der Ursache: aus Sicht des Aufrufers ist das ein Fehler der nativen Schicht, nicht seines Arguments — als `IllegalArgumentException` hätte `OpenAiCompatServer` ihn als 400 `invalid_request_error` beantwortet, jetzt ist er 500. Eine `IllegalArgumentException` der Senke des Aufrufers geht unverändert durch. Der langchain4j-Adapter (`JllamaStreamingChatModel`) reicht die Ausnahme schon über sein `catch (Exception e) → handler.onError(e)` weiter; `StreamingChunkAssembler` brauchte keine Änderung (er bekommt einzelne Chunks und wirft für einen unparsbaren Chunk bereits selbst). Abweichung vom Brief: die zusätzliche Umwandlung in `LlamaException` und der eigene Ausnahmetyp (der Brief nannte nur die `IllegalArgumentException` des Parsers); kein Test in `StreamingChunkAssemblerTest`, weil der Umschlag ihn nie erreicht.
      +- **Tests:** `ChatStreamChunkParserTest#feed_anEnvelopeTheNativeSideDoesNotWrite_isRejectedNamingIt` (kein JSON, leer, blank, Array, Boolean, ohne `stop`, `stop` als String, ohne `data`, `data` `null`, `data` Zahl, nachgestellter Text; je Meldung mit dem Umschlag, Senke unberührt), `#feed_aLongBrokenEnvelopeIsNamedByABoundedExcerpt`, `#feed_emptyArrayData_emitsNothing`; die fünf Tests des alten Vertrags (`feed_missingData_emitsNothing`, `feed_stopDefaultsFalse_whenAbsent`, `feed_malformedEnvelope_reportsStop_andEmitsNothing`, `feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing`, `feed_nullData_emitsNothing`) sind ersetzt. Neu `LlamaModelStreamEnvelopeTest` (wohlgeformt erreicht die Senke; fehlerhaft ist `LlamaException` mit Umschlag und Ursache; die `IllegalArgumentException` der Senke bleibt dieselbe Instanz).
      +- **Rot-Grün-Nachweis:** Gegen den unveränderten Parser rot: „ChatStreamChunkParserTest.feed_anEnvelopeTheNativeSideDoesNotWrite_isRejectedNamingIt:78 this is not json ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „feed_aLongBrokenEnvelopeIsNamedByABoundedExcerpt:88 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix grün: `ChatStreamChunkParserTest` 5/5, `LlamaModelStreamEnvelopeTest` 3/3 (vor `feedStreamEnvelope` nicht kompilierbar), `LlamaArchitectureTest` 13/13. Dass die native Seite den strikten Vertrag erfüllt, zeigen die modellgestützten Stream-Tests mit dem Winzmodell: `OpenAiCompatServerIntegrationTest` 15/15, `OpenAiServerCompletionIntegrationTest` 12 (2 übersprungen, FIM).
      +- **Status:** gefixt
      +- **Commit:** (folgt)
      +
       ## Offene Verdachte ohne Fund-ID
       
       - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt.
      diff --git a/TODO.md b/TODO.md
      index 607ca75b9..5fbefe1aa 100644
      --- a/TODO.md
      +++ b/TODO.md
      @@ -543,15 +543,6 @@ scope, is listed here; the ledger entry in brackets holds the analysis. Delete a
         applies them. The chat-template path has no slot for a caller's template or a token context, so the
         honest options are a `400` or a documented deviation.
       
      -**Server transport**
      -- **Decision open: should a malformed stream envelope fail instead of ending the stream?**
      -  `ChatStreamChunkParser.feed` treats an unparseable or empty envelope as the end of the stream, and
      -  `StreamingChunkAssembler` follows it, so a broken envelope looks like a short answer. One reviewer:
      -  the path is not reachable from `LlamaModel`, whose native side always writes a well-formed envelope,
      -  so hardening buys nothing. The other: throw an `IllegalArgumentException` naming the envelope
      -  instead of a silent end, as the rerank and tool-schema readers now do (F232, F233). Decide, then either pin the
      -  current contract or change it with a red/green test.
      -
       **Anthropic Messages**
       - **Upstream candidate:** `stop_reason` `stop_sequence` for a matched stop word (F221); recorded in
         the upstream inventory ("Upstream submissions", last bullet) for the day after the release.
      diff --git a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java
      index c5ba89241..497bbb2e7 100644
      --- a/llama/src/main/java/net/ladenthin/llama/LlamaModel.java
      +++ b/llama/src/main/java/net/ladenthin/llama/LlamaModel.java
      @@ -773,7 +773,8 @@ public LlamaIterable generateChat(InferenceParameters parameters) {
            * @throws net.ladenthin.llama.exception.InvalidRequestException if the request is rejected (see
            *     {@link #handleChatCompletions(String)}); a prompt over the context size is reported this way too, on
            *     the first chunk
      -     * @throws net.ladenthin.llama.exception.LlamaException if inference fails
      +     * @throws net.ladenthin.llama.exception.LlamaException if inference fails, or the native layer delivers a
      +     *     malformed stream envelope
            */
           public void streamChatCompletion(InferenceParameters parameters, Consumer chunkSink) {
               InferenceParameters streaming = parameters.withStream(true);
      @@ -781,8 +782,7 @@ public void streamChatCompletion(InferenceParameters parameters, Consumer chunkSink) {
      +        try {
      +            return parser.feed(envelope, chunkSink);
      +        } catch (ChatStreamChunkParser.MalformedEnvelopeException e) {
      +            throw new LlamaException(String.valueOf(e.getMessage()), e);
      +        }
      +    }
      +
           /**
            * Stream an OpenAI {@code /v1/completions} request as {@code text_completion} chunk JSON objects,
            * feeding each chunk's JSON string to {@code chunkSink} as it is produced.
      @@ -812,15 +834,15 @@ public void streamChatCompletion(InferenceParameters parameters, Consumer chunkSink) {
               int taskId = requestCompletionOaiStream(requestJson);
               boolean stopped = false;
               try {
                   while (!stopped) {
      -                String envelope = receiveChatCompletionChunk(taskId);
      -                stopped = chatStreamParser.feed(envelope, chunkSink);
      +                stopped = feedStreamEnvelope(chatStreamParser, receiveChatCompletionChunk(taskId), chunkSink);
                   }
               } finally {
                   // as in streamChatCompletion: an early exit (chunkSink threw) releases the native task
      diff --git a/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java b/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java
      index 2cd32b36e..8fefd4f27 100644
      --- a/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java
      +++ b/llama/src/main/java/net/ladenthin/llama/json/ChatStreamChunkParser.java
      @@ -4,10 +4,13 @@
       
       package net.ladenthin.llama.json;
       
      +import com.fasterxml.jackson.databind.DeserializationFeature;
       import com.fasterxml.jackson.databind.JsonNode;
       import com.fasterxml.jackson.databind.ObjectMapper;
      +import com.fasterxml.jackson.databind.ObjectReader;
       import java.io.IOException;
       import java.util.function.Consumer;
      +import org.jspecify.annotations.Nullable;
       
       /**
        * Pure JSON transform for the streaming-chat envelope produced by the native
      @@ -38,29 +41,43 @@ public ChatStreamChunkParser() {}
           /** Shared Jackson mapper; thread-safe and reused across all instances. */
           public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
       
      +    /** Reads exactly one JSON value: {@code FAIL_ON_TRAILING_TOKENS} refuses text after it. */
      +    private static final ObjectReader STRICT_READER =
      +            OBJECT_MAPPER.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
      +
      +    /** How much of a rejected envelope its message quotes. */
      +    private static final int ENVELOPE_EXCERPT = 80;
      +
           /**
            * Parse one streaming envelope and feed each contained {@code chat.completion.chunk}
            * JSON string to {@code chunkSink}, in order.
            *
      -     * 

      A {@code data} array yields one {@code chunkSink} call per element; a {@code data} - * object yields a single call; any other shape (absent/null) yields no calls. An - * unparseable envelope is treated as end-of-stream (returns {@code true}) so a polling - * loop cannot spin forever on malformed input. + *

      A {@code data} array yields one {@code chunkSink} call per element (none for an empty array); + * a {@code data} object yields a single call. The envelope must be exactly what the native side + * writes -- one JSON object with {@code data} an object or an array and {@code stop} a boolean; + * anything else is a broken transport and is rejected rather than taken for the end of the stream, + * which would make a broken envelope look like a short answer. * * @param envelopeJson the raw {@code {"data":…,"stop":…}} string from the native layer * @param chunkSink receiver for each chunk's JSON string (one OpenAI SSE event each) * @return {@code true} if this envelope marks the end of the stream, else {@code false} + * @throws MalformedEnvelopeException if the envelope is not of that form; the message quotes it (at + * most 80 characters), nothing is passed to {@code chunkSink} */ public boolean feed(String envelopeJson, Consumer chunkSink) { final JsonNode root; try { - root = OBJECT_MAPPER.readTree(envelopeJson); + root = STRICT_READER.readTree(envelopeJson); } catch (IOException e) { - return true; + throw malformed(envelopeJson, "it is not one well-formed JSON value", e); + } + // Jackson returns MissingNode for an empty or blank text + if (!root.isObject()) { + throw malformed(envelopeJson, "it is not a JSON object", null); } - // An empty or blank envelope is unparseable too, but Jackson returns MissingNode for it. - if (root.isMissingNode()) { - return true; + JsonNode stop = root.path("stop"); + if (!stop.isBoolean()) { + throw malformed(envelopeJson, "'stop' is not a boolean", null); } JsonNode data = root.path("data"); if (data.isArray()) { @@ -69,7 +86,39 @@ public boolean feed(String envelopeJson, Consumer chunkSink) { } } else if (data.isObject()) { chunkSink.accept(data.toString()); + } else { + throw malformed(envelopeJson, "'data' is neither an object nor an array", null); + } + return stop.booleanValue(); + } + + private static MalformedEnvelopeException malformed(String envelopeJson, String why, @Nullable Throwable cause) { + String excerpt = envelopeJson.length() > ENVELOPE_EXCERPT + ? envelopeJson.substring(0, ENVELOPE_EXCERPT) + "..." + : envelopeJson; + return new MalformedEnvelopeException( + "malformed streaming envelope from the native layer (" + why + "), expected " + + "{\"data\": , \"stop\": } but got: '" + excerpt + "'", + cause); + } + + /** + * An envelope that is not what the native layer writes. An {@link IllegalArgumentException}, since + * the envelope is this parser's argument; its own type lets {@code LlamaModel} tell it from an + * exception of the caller's chunk sink and report it as the failure of the native layer it is. + */ + public static final class MalformedEnvelopeException extends IllegalArgumentException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the exception. + * + * @param message what is wrong, with an excerpt of the envelope + * @param cause the parser's exception, or {@code null} + */ + public MalformedEnvelopeException(String message, @Nullable Throwable cause) { + super(message, cause); } - return root.path("stop").asBoolean(false); } } diff --git a/llama/src/test/java/net/ladenthin/llama/LlamaModelStreamEnvelopeTest.java b/llama/src/test/java/net/ladenthin/llama/LlamaModelStreamEnvelopeTest.java new file mode 100644 index 000000000..97eea047e --- /dev/null +++ b/llama/src/test/java/net/ladenthin/llama/LlamaModelStreamEnvelopeTest.java @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: MIT + +package net.ladenthin.llama; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import net.ladenthin.llama.exception.LlamaException; +import net.ladenthin.llama.json.ChatStreamChunkParser; +import org.junit.jupiter.api.Test; + +/** + * How {@link LlamaModel}'s streaming loops report the envelopes of {@code receiveChatCompletionChunk}: + * no model is loaded, the envelope is a literal. + */ +public class LlamaModelStreamEnvelopeTest { + + private final ChatStreamChunkParser parser = new ChatStreamChunkParser(); + + @Test + public void aWellFormedEnvelopeReachesTheSink() { + List chunks = new ArrayList<>(); + boolean stop = LlamaModel.feedStreamEnvelope(parser, "{\"data\":{\"x\":1},\"stop\":true}", chunks::add); + assertThat(stop, is(true)); + assertThat(chunks.size(), is(1)); + } + + /** + * A malformed envelope is the native layer's failure: a {@link LlamaException}, which a server + * answers with 500, not the {@link IllegalArgumentException} the parser throws for its argument, + * which a server would answer as the request's fault. + */ + @Test + public void aMalformedEnvelopeIsALlamaExceptionNamingIt() { + LlamaException e = assertThrows( + LlamaException.class, () -> LlamaModel.feedStreamEnvelope(parser, "not json", chunk -> {})); + assertThat(e.getMessage(), containsString("'not json'")); + assertThat(e.getCause(), instanceOf(ChatStreamChunkParser.MalformedEnvelopeException.class)); + } + + @Test + public void anIllegalArgumentExceptionOfTheSinkPassesThroughUnchanged() { + IllegalArgumentException own = new IllegalArgumentException("the sink's own"); + IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> LlamaModel.feedStreamEnvelope(parser, "{\"data\":{\"x\":1},\"stop\":false}", chunk -> { + throw own; + })); + assertThat(e, sameInstance(own)); + } +} diff --git a/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java b/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java index 01836eb04..043433d80 100644 --- a/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java +++ b/llama/src/test/java/net/ladenthin/llama/json/ChatStreamChunkParserTest.java @@ -5,9 +5,12 @@ package net.ladenthin.llama.json; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.ArrayList; import java.util.List; @@ -41,45 +44,49 @@ public void feed_arrayData_emitsEachElementInOrder_andReportsStopped() { } @Test - public void feed_missingData_emitsNothing() { + public void feed_emptyArrayData_emitsNothing() { List chunks = new ArrayList<>(); - boolean stop = parser.feed("{\"stop\":true}", chunks::add); + boolean stop = parser.feed("{\"data\":[],\"stop\":true}", chunks::add); assertThat(stop, is(true)); assertThat(chunks, is(empty())); } + /** + * The native side writes exactly {@code {"data": , "stop": }} + * ({@code wrap_stream_chunk}; {@code stream_next} never returns a null payload). Anything else is a + * broken transport, not the end of an answer: it used to end the stream as if the answer were + * complete, so a broken envelope looked like a short answer. + */ @Test - public void feed_stopDefaultsFalse_whenAbsent() { - List chunks = new ArrayList<>(); - boolean stop = parser.feed("{\"data\":{\"x\":1}}", chunks::add); - assertThat(stop, is(false)); - assertThat(chunks, hasSize(1)); - } - - @Test - public void feed_malformedEnvelope_reportsStop_andEmitsNothing() { - List chunks = new ArrayList<>(); - boolean stop = parser.feed("this is not json", chunks::add); - assertThat(stop, is(true)); - assertThat(chunks, is(empty())); - } - - /** Jackson parses an empty or blank text to MissingNode instead of throwing; it is still unparseable. */ - @Test - public void feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing() { - for (String envelope : new String[] {"", " ", "\n"}) { + public void feed_anEnvelopeTheNativeSideDoesNotWrite_isRejectedNamingIt() { + String[] envelopes = { + "this is not json", + "", + " ", + "[1]", + "true", + "{\"data\":{\"x\":1}}", + "{\"data\":{\"x\":1},\"stop\":\"yes\"}", + "{\"stop\":true}", + "{\"data\":null,\"stop\":false}", + "{\"data\":3,\"stop\":false}", + "{\"data\":{},\"stop\":true} trailing", + }; + for (String envelope : envelopes) { List chunks = new ArrayList<>(); - boolean stopped = parser.feed(envelope, chunks::add); - assertThat("envelope '" + envelope + "'", stopped, is(true)); - assertThat(chunks, is(empty())); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> parser.feed(envelope, chunks::add), envelope); + assertThat(envelope, e.getMessage(), containsString("'" + envelope + "'")); + assertThat(envelope, chunks, is(empty())); } } @Test - public void feed_nullData_emitsNothing() { - List chunks = new ArrayList<>(); - boolean stop = parser.feed("{\"data\":null,\"stop\":false}", chunks::add); - assertThat(stop, is(false)); - assertThat(chunks, is(empty())); + public void feed_aLongBrokenEnvelopeIsNamedByABoundedExcerpt() { + String envelope = "x" + String.join("", java.util.Collections.nCopies(200, "y")); + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> parser.feed(envelope, chunk -> {})); + assertThat(e.getMessage(), containsString("'" + envelope.substring(0, 80) + "...'")); + assertThat(e.getMessage(), not(containsString(envelope.substring(0, 81)))); } } From 9d820bdcbdf9916084911fd72e89bfd5e4ca4c19 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:18:38 +0000 Subject: [PATCH 544/589] Record the commit of F263 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 961bb4fc7..297f4c586 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2420,7 +2420,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `ChatStreamChunkParserTest#feed_anEnvelopeTheNativeSideDoesNotWrite_isRejectedNamingIt` (kein JSON, leer, blank, Array, Boolean, ohne `stop`, `stop` als String, ohne `data`, `data` `null`, `data` Zahl, nachgestellter Text; je Meldung mit dem Umschlag, Senke unberührt), `#feed_aLongBrokenEnvelopeIsNamedByABoundedExcerpt`, `#feed_emptyArrayData_emitsNothing`; die fünf Tests des alten Vertrags (`feed_missingData_emitsNothing`, `feed_stopDefaultsFalse_whenAbsent`, `feed_malformedEnvelope_reportsStop_andEmitsNothing`, `feed_emptyOrBlankEnvelope_reportsStop_andEmitsNothing`, `feed_nullData_emitsNothing`) sind ersetzt. Neu `LlamaModelStreamEnvelopeTest` (wohlgeformt erreicht die Senke; fehlerhaft ist `LlamaException` mit Umschlag und Ursache; die `IllegalArgumentException` der Senke bleibt dieselbe Instanz). - **Rot-Grün-Nachweis:** Gegen den unveränderten Parser rot: „ChatStreamChunkParserTest.feed_anEnvelopeTheNativeSideDoesNotWrite_isRejectedNamingIt:78 this is not json ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „feed_aLongBrokenEnvelopeIsNamedByABoundedExcerpt:88 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix grün: `ChatStreamChunkParserTest` 5/5, `LlamaModelStreamEnvelopeTest` 3/3 (vor `feedStreamEnvelope` nicht kompilierbar), `LlamaArchitectureTest` 13/13. Dass die native Seite den strikten Vertrag erfüllt, zeigen die modellgestützten Stream-Tests mit dem Winzmodell: `OpenAiCompatServerIntegrationTest` 15/15, `OpenAiServerCompletionIntegrationTest` 12 (2 übersprungen, FIM). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `8771717` ## Offene Verdachte ohne Fund-ID From 93be4df238788039ffd82c9bc8bd921e00351967 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:25:11 +0000 Subject: [PATCH 545/589] Make LlamaLoader.initialize() idempotent after a load (F264) Every call of initialize() ran the whole search again (only the nested call from JNI_OnLoad returned early). With net.ladenthin.llama.lib.path or the backend filter changed in between it loaded a second copy of the library, or extracted another module set, and replaced the build info, while the JVM keeps using the first library it can never unload. After the first success initialize() now does nothing; a lib.path or backend property changed since the load is named once on stderr, since it can only take effect in a new JVM. A failed load is still retried. The package-private resetForTesting() forgets the load for the loader tests and returns what restores it; BackendLoadTest resets before and restores after every test. The new BackendLoadTest case was red against the old loader ("a second start extracted again ==> expected: <[...-3aa8]> but was: <[...-3aa8, ...-3aa8-me7c14cbc]>") and is green; the loader classes also pass in a shared fork in both run orders. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 +++ CLAUDE.md | 6 +- TODO.md | 6 - .../ladenthin/llama/loader/LlamaLoader.java | 113 ++++++++++++++++-- .../llama/loader/BackendLoadTest.java | 49 +++++++- 5 files changed, 170 insertions(+), 19 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 297f4c586..35fc13435 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -186,6 +186,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F261](#f261) — niedrig bis mittel — *gefixt* - [F262](#f262) — niedrig — *gefixt* - [F263](#f263) — niedrig — *gefixt* +- [F264](#f264) — niedrig — *gefixt* ## Funde @@ -2194,6 +2195,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Offen:** Ein zweiter erfolgreicher `initialize()` lädt in der Produktion weiter erneut und überschreibt die Build-Info, wenn sich dazwischen `net.ladenthin.llama.lib.path` oder der Backend-Filter ändert (dann käme eine zweite Kopie der Bibliothek in den Prozess). Ob `initialize()` nach dem ersten Erfolg nichts mehr tun soll, ist eine Entwurfsfrage (`BackendLoadTest` braucht das wiederholte Laden), nicht Teil dieses Funds. - **Status:** gefixt - **Commit:** java-llama.cpp `3e49d39` +- **Nachtrag (Branch-Review 2026-10-20/21):** Der offene Punkt ist als [F264](#f264) entschieden: `initialize()` ist nach dem ersten Erfolg idempotent; `BackendLoadTest` setzt den Loader über `LlamaLoader.resetForTesting()` vor jedem Test zurück und stellt Ladezustand und Build-Info danach wieder her. ### F246 – java-llama.cpp: `complete()`/`generate()` mit `withInputPrefix`/`withInputSuffix` verwerfen Präfix und Suffix still und vervollständigen den leeren Prompt - **Zeit:** 2026-10-21 10:57 UTC @@ -2422,6 +2424,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `8771717` + +### F264 – java-llama.cpp: ein zweites erfolgreiches `LlamaLoader.initialize()` durchläuft die Suche erneut — eine zweite Bibliothekskopie, eine ersetzte Build-Info +- **Zeit:** 2026-10-21 17:24 UTC +- **Datei:** `llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java` (`initialize`, neu `loadOnce`, `reportIgnoredChange`, `resetForTesting`; das Feld `extracted` ist `loaded`), `llama/src/test/java/net/ladenthin/llama/loader/BackendLoadTest.java`, `CLAUDE.md` (RPC-Abschnitt) +- **Schwere:** niedrig (braucht eine zwischen zwei Aufrufen geänderte `net.ladenthin.llama.lib.path` bzw. einen geänderten Backend-Filter) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Java API“; Brief JR-13), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. F245 hatte die Test-Isolation der Build-Info behoben und das Verhalten in der Produktion als Entwurfsfrage offen gelassen. +- **Beschreibung:** `initialize()` lief bei jedem Aufruf (nur der verschachtelte Aufruf aus `JNI_OnLoad` kehrte früh zurück) die ganze Suche erneut: mit einer inzwischen geänderten `lib.path` oder einem anderen Backend-Filter lud er eine zweite Kopie der Bibliothek (bzw. extrahierte eine zweite Modulkombination in ein eigenes Verzeichnis) und ersetzte die Build-Info, obwohl die JVM die erste Bibliothek nie entladen kann und die native Seite weiter sie benutzt. +- **Fix:** `initialize()` ist nach dem ersten Erfolg idempotent: `loadOnce` merkt sich den Erfolg (`loaded`, unter dem Initialisierungs-Lock) samt `lib.path` und Backend-Filter zum Ladezeitpunkt; ein weiterer Aufruf lädt nichts und nennt eine inzwischen geänderte Eigenschaft einmal auf stderr („[jllama] the native library is already loaded in this JVM, so net.ladenthin.llama.backend=cuda13,vulkan (cpu at the load) takes effect only in a new JVM“), ein unveränderter Aufruf bleibt still. Ein gescheiterter Versuch wird beim nächsten Aufruf wiederholt. Für die Loader-Tests ein paketprivater Hook `resetForTesting()`, der Erfolgsmerker und Build-Info vergisst (die geladene Bibliothek bleibt geladen) und einen `Runnable` zurückgibt, der den Zustand wiederherstellt; `BackendLoadTest` setzt vor jedem Test zurück und stellt danach wieder her (statt bisher nur die Build-Info). Javadoc von `initialize()` beschreibt den Vertrag. Abweichung vom Brief: statt eines DEBUG-Logs „already loaded“ bei jedem Aufruf meldet der Loader nur eine Änderung, die nicht mehr wirken kann — der Loader schreibt bewusst nur auf stderr (kein SLF4J; ein Router-Worker wertet stdout aus), und `initialize()` wird von jeder Einstiegsklasse aufgerufen, ein Hinweis je Aufruf wäre Rauschen. +- **Tests:** `BackendLoadTest#aSecondInitializeAfterASuccessfulLoadLoadsNothingAndSaysWhatItIgnores` (Linux x86-64: erster Aufruf mit Filter `cpu` lädt die Dummy-Bibliothek, zweiter mit `cuda13,vulkan` extrahiert nichts und nennt die Änderung auf stderr); `#theDummyLoadsLeaveTheBuildInfoOfThisJvmAsTheyFoundIt` prüft den wiederhergestellten Zustand. +- **Rot-Grün-Nachweis:** Gegen den unveränderten Loader rot: „BackendLoadTest.aSecondInitializeAfterASuccessfulLoadLoadsNothingAndSaysWhatItIgnores:284 a second start extracted again ==> expected: <[…/jllama-backend-cpu-1a146e4d916-3aa8]> but was: <[…/jllama-backend-cpu-1a146e4d916-3aa8, …/jllama-backend-cpu-1a146e4d916-3aa8-me7c14cbc]>“; mit dem Fix grün: `BackendLoadTest` 10/10, `LlamaLoaderTest` 67/67, `NativeLibraryLoadSmokeTest` 7/7, `LlamaArchitectureTest` 13/13, dazu `BackendLoadTest,LlamaLoggerTest,NativeLibraryLoadSmokeTest` in einer gemeinsamen Fork in beiden Reihenfolgen (`-Dsurefire.runOrder=alphabetical` und `reversealphabetical`) grün — die echte Bibliothek wird nach den Fixture-Läufen weiter geladen bzw. bleibt geladen. +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/CLAUDE.md b/CLAUDE.md index 8cae1b905..d08d0703b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1663,8 +1663,10 @@ loading thread — the lock is reentrant, so a second complete load ran while th `System.load`, clearing the temp files and, with every GPU jar on the classpath, extracting every backend again over the library being loaded. The RPC smoke timed out on it (the doubled extraction of the CUDA/ROCm/SYCL libraries, then still complete libraries); it now waits 300 s like the other -smoke and fails if the backend is selected more than once. Only the *nested* call is skipped — later calls still run the body, -which `BackendLoadTest` relies on. +smoke and fails if the backend is selected more than once. Since 6.0.0 `initialize()` is also idempotent +after its first success (a changed `lib.path` or `backend` is named once on stderr and takes effect only +in a new JVM; F264); `BackendLoadTest` drives repeated loads through the package-private +`LlamaLoader.resetForTesting()`. **Single instance per process** (ggml keeps the server state in globals). `startLocal` binds loopback only; `startOnNetwork` is the explicit, warned opt-in, and binding needs an IPv4 literal (the server uses `inet_addr`). Endpoints on the client side are `value.RpcEndpoint` diff --git a/TODO.md b/TODO.md index 5fbefe1aa..5f018cbfe 100644 --- a/TODO.md +++ b/TODO.md @@ -529,12 +529,6 @@ The review of the bug-hunt branch confirmed its findings; the fixes are F213 … addenda in the ledger. What the fixes left open, or what the reviewers raised outside the findings' scope, is listed here; the ledger entry in brackets holds the analysis. Delete an item when it is done. -**Java API (`LlamaModel`, `LlamaLoader`)** -- **A second successful `LlamaLoader.initialize()` runs the search again** (F245): if - `net.ladenthin.llama.lib.path` or the backend filter changed in between, it loads a second copy of - the library and replaces the build info. Whether `initialize()` should do nothing after its first - success is a design decision; `BackendLoadTest` relies on repeated loads. - **Ollama routes** - **`/api/generate` with `raw: true` drops `format` and `images` without a word** (F215). Ollama keeps both. Either map them (`format` as `json_schema`, `images` as multimodal data, as the non-raw path diff --git a/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java b/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java index 0f9061919..f8316dccf 100644 --- a/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java +++ b/llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java @@ -31,6 +31,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.jar.JarFile; import java.util.regex.Matcher; @@ -83,7 +84,20 @@ public class LlamaLoader { */ private static final Object INITIALIZE_LOCK = new Object(); - private static boolean extracted = false; + /** + * Whether a library was loaded; from then on {@link #initialize()} does nothing. Only read and + * written under the lock. + */ + private static boolean loaded = false; + + /** {@code net.ladenthin.llama.lib.path} at the successful load, to name a later change. */ + private static @Nullable String loadedLibPath; + + /** {@code net.ladenthin.llama.backend} at the successful load, to name a later change. */ + private static @Nullable String loadedBackend; + + /** The last ignored change {@link #initialize()} reported, so a repeated call does not repeat it. */ + private static @Nullable String reportedChange; /** Whether {@link #initialize()} is running; only read and written under the lock. */ private static boolean initializing = false; @@ -242,7 +256,7 @@ public class LlamaLoader { * The parsed {@link #BACKEND_BUILD_FILE} of the library that was loaded, empty before a load and * for a library loaded from a directory that carries none. Set by the branch that loaded it -- * only after its {@code System.load} succeeded, so a refused or failed start leaves it as it was -- - * on every successful load (a later {@link #initialize()} runs the search again), read by {@link + * once per JVM (after a successful load {@link #initialize()} does nothing), read by {@link * #getBuildInfo()}. */ private static volatile Map loadedBuild = Collections.emptyMap(); @@ -287,10 +301,18 @@ public class LlamaLoader { private LlamaLoader() {} /** - * Loads the llama and jllama shared libraries + * Loads the native library: from {@code net.ladenthin.llama.lib.path}, {@code java.library.path} or + * the natives jars on the classpath (see the class description). + * + *

      Idempotent: after the first successful load every further call returns at once. A JVM cannot + * unload a library, so a {@code net.ladenthin.llama.lib.path} or {@code net.ladenthin.llama.backend} + * changed after the load can no longer take effect; such a change is named once on stderr instead + * of loading a second copy of the library. A load that failed is tried again by the next call. + * + * @throws UnsatisfiedLinkError if no library could be loaded; the message names every place tried */ public static void initialize() { - runOnceOnThisThread(LlamaLoader::load); + runOnceOnThisThread(LlamaLoader::loadOnce); } /** @@ -321,9 +343,83 @@ static void runOnceOnThisThread(Runnable body) { } } - private static void load() { + private static void loadOnce() { + if (loaded) { + reportIgnoredChange(); + return; + } + String libPath = systemProperties.getLibPath(); + String backend = systemProperties.getBackend(); loadNativeLibrary("jllama"); - extracted = true; + loaded = true; + loadedLibPath = libPath; + loadedBackend = backend; + reportedChange = null; + } + + /** + * Names a {@code lib.path} or {@code backend} property that changed after the library was loaded, + * once per distinct change: it can only take effect in a new JVM. + */ + private static void reportIgnoredChange() { + StringBuilder change = new StringBuilder(); + appendChange(change, LlamaSystemProperties.PREFIX + ".lib.path", loadedLibPath, systemProperties.getLibPath()); + appendChange(change, LlamaSystemProperties.PREFIX + ".backend", loadedBackend, systemProperties.getBackend()); + String text = change.toString(); + if (text.isEmpty() || text.equals(reportedChange)) { + return; + } + reportedChange = text; + System.err.println("[jllama] the native library is already loaded in this JVM, so " + text + + " takes effect only in a new JVM"); + } + + private static void appendChange( + StringBuilder change, String property, @Nullable String atLoad, @Nullable String now) { + if (Objects.equals(atLoad, now)) { + return; + } + if (change.length() > 0) { + change.append(" and "); + } + change.append(property) + .append('=') + .append(now == null ? "" : now) + .append(" (") + .append(atLoad == null ? "unset" : atLoad) + .append(" at the load)"); + } + + /** + * For the loader tests: forgets the successful load and its build info, so the next + * {@link #initialize()} searches again (the library already loaded stays loaded -- a JVM cannot + * unload it). The tests drive {@link #initialize()} against fixture trees in a fork that may + * already have loaded the real library. + * + * @return what puts the state back as it was, which a test runs when it is done + */ + static Runnable resetForTesting() { + synchronized (INITIALIZE_LOCK) { + final boolean wasLoaded = loaded; + final String libPath = loadedLibPath; + final String backend = loadedBackend; + final String reported = reportedChange; + final Map build = loadedBuild; + loaded = false; + loadedLibPath = null; + loadedBackend = null; + reportedChange = null; + loadedBuild = Collections.emptyMap(); + return () -> { + synchronized (INITIALIZE_LOCK) { + loaded = wasLoaded; + loadedLibPath = libPath; + loadedBackend = backend; + reportedChange = reported; + loadedBuild = build; + } + }; + } } /** @@ -622,9 +718,8 @@ private static void loadNativeLibrary(String name) { // The temp-directory cleanup runs here, before the first extraction, and nowhere earlier: // it needs the module selection (the backend filter), which must not be able to fail a // start that never reaches the jars -- a lib.path start with a stale filter property. - if (!extracted) { - cleanup(extractionDirectoriesOfThisClasspath()); - } + // (Only reached while nothing is loaded: initialize() does nothing after a successful load.) + cleanup(extractionDirectoriesOfThisClasspath()); String nativeResourcePath = getNativeResourcePath(); String library = null; for (String backend : LIBRARY_BACKENDS) { diff --git a/llama/src/test/java/net/ladenthin/llama/loader/BackendLoadTest.java b/llama/src/test/java/net/ladenthin/llama/loader/BackendLoadTest.java index 818bea7ee..256e54fac 100644 --- a/llama/src/test/java/net/ladenthin/llama/loader/BackendLoadTest.java +++ b/llama/src/test/java/net/ladenthin/llama/loader/BackendLoadTest.java @@ -55,9 +55,12 @@ public class BackendLoadTest { /** What {@link LlamaLoader#getBuildInfo()} reported before the first test of this class. */ private static Map buildInfoBeforeThisClass; - /** What {@link LlamaLoader#getBuildInfo()} reported before the current test. */ + /** What {@link LlamaLoader#getBuildInfo()} reported at the start of the current test (after the reset). */ private Map buildInfoBeforeThisTest; + /** Puts back the loader state the reset before the current test found. */ + private Runnable restoreLoaderState; + @TempDir Path tempDir; @@ -68,6 +71,9 @@ public static void rememberTheBuildInfoOfThisJvm() { @BeforeEach public void redirectLoaderToFixtures() { + // initialize() does nothing once a library is loaded, and an earlier test class of this fork + // may have loaded the real one: every test starts from "nothing loaded" + restoreLoaderState = LlamaLoader.resetForTesting(); buildInfoBeforeThisTest = LlamaLoader.getBuildInfo(); previousArch = System.getProperty(ARCH_PROP); previousTmpDir = System.getProperty(TMPDIR_PROP); @@ -79,7 +85,7 @@ public void redirectLoaderToFixtures() { @AfterEach public void restoreProperties() { - LlamaLoader.restoreBuildInfo(buildInfoBeforeThisTest); + restoreLoaderState.run(); restore(ARCH_PROP, previousArch); restore(TMPDIR_PROP, previousTmpDir); restore(BACKEND_PROP, previousBackend); @@ -264,6 +270,28 @@ public void loadsTheLibraryAloneWhenCpuIsForced() throws IOException { assertEquals(java.util.Collections.singleton("libjllama.so"), names(dir)); } + /** + * Once a library is loaded, {@link LlamaLoader#initialize()} does nothing: a changed property can + * no longer take effect (the JVM cannot unload a library), and a second search used to load a + * second copy and replace the build info. Here the module filter changes between the two calls, + * which would select another set of modules and therefore another extraction directory. + */ + @Test + @Order(1) + public void aSecondInitializeAfterASuccessfulLoadLoadsNothingAndSaysWhatItIgnores() throws IOException { + assumeLinuxFixtureTree(); + assumeX86_64(); + System.setProperty(ARCH_PROP, "backendtest-ok"); + System.setProperty(BACKEND_PROP, "cpu"); + LlamaLoader.initialize(); + Path first = theExtractionDir(); + System.setProperty(BACKEND_PROP, "cuda13,vulkan"); + String stderr = captureStderr(LlamaLoader::initialize); + assertEquals(java.util.Collections.singletonList(first), extractionDirs(), "a second start extracted again"); + assertTrue(stderr.contains(BACKEND_PROP + "=cuda13,vulkan"), stderr); + assertTrue(stderr.contains("already loaded"), stderr); + } + /** * Runs after the two dummy loads above: they really load a library and publish the fixture's * build file ({@code bTEST}), which must not outlive them. In a shared fork a real library loaded @@ -273,10 +301,27 @@ public void loadsTheLibraryAloneWhenCpuIsForced() throws IOException { @Test @Order(2) public void theDummyLoadsLeaveTheBuildInfoOfThisJvmAsTheyFoundIt() { + // the reset before this test forgot the state the earlier tests put back: look at that state + restoreLoaderState.run(); assertEquals( buildInfoBeforeThisClass, LlamaLoader.getBuildInfo(), "build info after the dummy loads: " + LlamaLoader.getBuildInfo() + ", before: " + buildInfoBeforeThisClass); } + + private static String captureStderr(Runnable body) { + java.io.PrintStream previous = System.err; + java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + try (java.io.PrintStream capture = + new java.io.PrintStream(buffer, true, java.nio.charset.StandardCharsets.UTF_8.name())) { + System.setErr(capture); + body.run(); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } finally { + System.setErr(previous); + } + return new String(buffer.toByteArray(), java.nio.charset.StandardCharsets.UTF_8); + } } From 7c285ee30c7c7a17065c1925e2b98d32a0c79e7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:25:11 +0000 Subject: [PATCH 546/589] Record the commit of F264 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 35fc13435..3425a98f0 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2435,7 +2435,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `BackendLoadTest#aSecondInitializeAfterASuccessfulLoadLoadsNothingAndSaysWhatItIgnores` (Linux x86-64: erster Aufruf mit Filter `cpu` lädt die Dummy-Bibliothek, zweiter mit `cuda13,vulkan` extrahiert nichts und nennt die Änderung auf stderr); `#theDummyLoadsLeaveTheBuildInfoOfThisJvmAsTheyFoundIt` prüft den wiederhergestellten Zustand. - **Rot-Grün-Nachweis:** Gegen den unveränderten Loader rot: „BackendLoadTest.aSecondInitializeAfterASuccessfulLoadLoadsNothingAndSaysWhatItIgnores:284 a second start extracted again ==> expected: <[…/jllama-backend-cpu-1a146e4d916-3aa8]> but was: <[…/jllama-backend-cpu-1a146e4d916-3aa8, …/jllama-backend-cpu-1a146e4d916-3aa8-me7c14cbc]>“; mit dem Fix grün: `BackendLoadTest` 10/10, `LlamaLoaderTest` 67/67, `NativeLibraryLoadSmokeTest` 7/7, `LlamaArchitectureTest` 13/13, dazu `BackendLoadTest,LlamaLoggerTest,NativeLibraryLoadSmokeTest` in einer gemeinsamen Fork in beiden Reihenfolgen (`-Dsurefire.runOrder=alphabetical` und `reversealphabetical`) grün — die echte Bibliothek wird nach den Fixture-Läufen weiter geladen bzw. bleibt geladen. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `93be4df` ## Offene Verdachte ohne Fund-ID From b528b4e735f3a819a1b25a8ef9e229e62a42dafd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:27:13 +0000 Subject: [PATCH 547/589] Report the arguments that arrive before a tool call's name (F265) StreamingChunkAssembler collected argument text that arrived before a streamed tool call had a name into the assembled call, but afterwards reported only the current fragment as a PartialToolCall. The partials of such a call did not make up its arguments, which is langchain4j's contract ("ty\":1}" instead of "{\"city\":1}"). Not reachable at b11639, whose parser sends id and name with a call's first fragment; compute_diffs allows the other order. Each call now counts how much of its arguments was reported; once it has a name, everything after that is reported as one partial, also by the fragment that only brings the name. A call whose name arrives after the next call started reports its held-back prefix before its own completion. The contract assertion in toolCallDeltaBeforeItsName_doesNotFailTheStream was red against the old assembler (Expected: is "{\"city\":1}" but: was "ty\":1}") and is green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 15 +++++++++++++ TODO.md | 4 ---- .../langchain4j/StreamingChunkAssembler.java | 22 ++++++++++++------- .../StreamingChunkAssemblerTest.java | 18 +++++++-------- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 3425a98f0..65e9ae3fe 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -187,6 +187,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - [F262](#f262) — niedrig — *gefixt* - [F263](#f263) — niedrig — *gefixt* - [F264](#f264) — niedrig — *gefixt* +- [F265](#f265) — niedrig — *gefixt* ## Funde @@ -1231,6 +1232,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `f7f3f0f` - **Nachtrag (Branch-Review 2026-10-20, Fund J14-4):** Die Klassen-Javadoc von `StreamingChunkAssembler` sagte noch, jedes `tool_calls`-Fragment gehe als `onPartialToolCall` hinaus, und dass id und Name am ersten Fragment kämen. Seit dem Fix (`f7f3f0f`) wird ein Fragment nur weitergereicht, wenn es Argumenttext trägt und sein Aufruf schon einen Namen hat; andere (ein Kopf nur mit id/Name, Argumente vor dem Namen) speisen nur den zusammengesetzten Aufruf. Javadoc und der Satz im `llama-langchain4j/README.md` sagen das jetzt, „id und Name“ kommen „usually“ am ersten Fragment. Nur Doku. +- **Nachtrag (Branch-Review 2026-10-20/21):** Das Präfix vor dem Namen (J14-1) ist als [F265](#f265) behoben: `#toolCallDeltaBeforeItsName_doesNotFailTheStream` prüft jetzt `assertPartialsMakeUpTheCompleteCall`. ### F133 – java-llama.cpp: `setPoolingType(UNSPECIFIED)` lässt ein vorher gesetztes `--pooling` stehen @@ -2437,6 +2439,19 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Status:** gefixt - **Commit:** java-llama.cpp `93be4df` + +### F265 – java-llama.cpp: das Argument-Präfix vor dem Namen eines gestreamten Werkzeugaufrufs erreicht `onPartialToolCall` nie +- **Zeit:** 2026-10-21 17:26 UTC +- **Datei:** `llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java` (`PartialTool.reportedLength`, `acceptToolCallFragment`, Klassen-Javadoc) +- **Schwere:** niedrig (bei b11639 nicht erreichbar: llama.cpp sendet Id und Namen mit dem ersten Fragment eines Aufrufs; Härtung gegen eine künftige Parser-Änderung, die `compute_diffs` erlaubt) +- **Historie:** Offener Punkt aus dem Branch-Review 2026-10-20/21 (TODO.md „Tests“, erster Punkt; Fund J14-1, Brief JR-11), umgesetzt am 2026-10-21 nach der Entscheidung des Eigentümers, alle offenen Punkte vor 6.0.0 zu schließen. Folge des Fixes von F128, der Fragmente ohne Namen nur noch in den Aufruf sammelte, statt am `PartialToolCall` zu scheitern; der Nachtrag zu F128 hatte die Vertragsprüfung `assertPartialsMakeUpTheCompleteCall` für diesen Fall als rot dokumentiert. +- **Beschreibung:** Argumenttext, der vor dem Namen eines Aufrufs ankam, ging nur in den gesammelten Aufruf; gemeldet wurde danach nur das jeweils aktuelle Fragment. Die Teilstücke eines Aufrufs ergaben daher nicht seine vollständigen Argumente (langchain4js Vertrag), im Beispiel kam `ty":1}` statt `{"city":1}` an. +- **Fix:** Je Aufruf zählt `reportedLength`, wie viele Zeichen der Argumente schon gemeldet sind. Sobald ein Aufruf einen Namen hat und ungemeldeter Text vorliegt, wird alles ab `reportedLength` als ein `PartialToolCall` gemeldet — auch durch das Fragment, das nur den Namen bringt. So ergeben die Teilstücke stets die vollständigen Argumente, jedes mit endgültiger Id und Namen; das Verhalten für den gewöhnlichen Fall (Kopf-Fragment mit Name, dann Argumente) ist unverändert. Folge für einen Aufruf, dessen Name erst nach dem Beginn des nächsten kommt: sein Präfix wird beim Eintreffen des Namens gemeldet, vor seinem `CompleteToolCall` (Reihenfolge `partial:1, partial:0, complete:0, complete:1`). Klassen-Javadoc angepasst (der Satz, Argumente vor dem Namen „only feed the assembled call“, fällt). +- **Tests:** `StreamingChunkAssemblerTest#toolCallDeltaBeforeItsName_doesNotFailTheStream` prüft jetzt `assertPartialsMakeUpTheCompleteCall`; `#defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts` erwartet das nachgereichte Teilstück von Aufruf 0 (`{}`, Name `first`) vor dessen Abschluss. +- **Rot-Grün-Nachweis:** Gegen den unveränderten Assembler rot: „StreamingChunkAssemblerTest.toolCallDeltaBeforeItsName_doesNotFailTheStream:197->assertPartialsMakeUpTheCompleteCall:90 Expected: is "{\"city\":1}" but: was "ty\":1}"“; mit dem Fix grün, `StreamingChunkAssemblerTest` 13/13 (die geänderte Erwartung in `defersCompletion…` war mit dem Fix zunächst rot: „Expected: iterable containing ["partial:1", "complete:0", "complete:1"] but: item 1: was "partial:0"“ — genau das nachgereichte Präfix). +- **Status:** gefixt +- **Commit:** (folgt) + ## Offene Verdachte ohne Fund-ID - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt. diff --git a/TODO.md b/TODO.md index 5f018cbfe..849fb7c2d 100644 --- a/TODO.md +++ b/TODO.md @@ -552,10 +552,6 @@ the serving thread has entered `jllama_rpc_server_serve` cannot be told from "no stays a no-op -- callers repeat the stop until the serve returns, as `RpcServer` does (F241). **Tests** -- **The argument prefix that arrives before a tool call's name never reaches `onPartialToolCall`** - (`StreamingChunkAssembler`; documented in its Javadoc since this pass). If it is fixed, the contract - assertion `assertPartialsMakeUpTheCompleteCall` in `toolCallDeltaBeforeItsName_doesNotFailTheStream` - lands in the same commit; today it is red (`Expected: is "{\"city\":1}" but: was "ty\":1}"`). - **`RpcClient.AStaleServerIsKeptOutOfALaterLoadThatDidNotAskForIt` fails under `--gtest_repeat=2`** in one process (also filtered alone); it passes in a normal `ctest` run. The test drives ggml's process-wide registry directly; find out which state the first run leaves. diff --git a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java index 42fa7fd4c..a93e3f606 100644 --- a/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java +++ b/llama-langchain4j/src/main/java/net/ladenthin/llama/langchain4j/StreamingChunkAssembler.java @@ -31,11 +31,12 @@ *

    • {@code delta.reasoning_content} → * {@link StreamingChatResponseHandler#onPartialThinking(PartialThinking)} *
    • {@code delta.tool_calls[*]} fragments → accumulated per OpenAI {@code index} (id and - * name usually arrive on the first fragment, arguments split across fragments). A fragment is - * forwarded as {@link StreamingChatResponseHandler#onPartialToolCall(PartialToolCall)} only if - * it carries argument text and its call already has a name ({@code PartialToolCall} refuses - * empty arguments and a blank name); other fragments, such as an id/name-only header or - * arguments that arrive before the name, only feed the assembled call. Every call is + * name usually arrive on the first fragment, arguments split across fragments). The argument + * text is forwarded as {@link StreamingChatResponseHandler#onPartialToolCall(PartialToolCall)} + * once its call has a name ({@code PartialToolCall} refuses empty arguments and a blank name): + * an id/name-only header is reported with the first argument fragment, and arguments that + * arrive before the name are reported together with the fragment that brings the name, so the + * partials of a call always make up its complete arguments. Every call is * completed as {@link StreamingChatResponseHandler#onCompleteToolCall(CompleteToolCall)} * as soon as a call with a higher index starts (llama.cpp only ever extends the last call of * a message), the last one, and any call that had no name yet at that point, by @@ -57,6 +58,8 @@ private static final class PartialTool { String id = ""; String name = ""; final StringBuilder arguments = new StringBuilder(); + /** How many characters of {@link #arguments} were already reported as partials. */ + int reportedLength; /** Whether {@code onCompleteToolCall} was already sent for this call. */ boolean completed; @@ -166,15 +169,18 @@ private void acceptToolCallFragment(JsonNode fragment) { tool.arguments.append(argumentsFragment); // PartialToolCall refuses empty arguments and a blank name, and llama.cpp sends both: the // first delta of a call usually carries id and name only, and its incremental parser may - // report a call before its name. Such a delta still feeds the assembled call. - if (argumentsFragment.isEmpty() || tool.name.isEmpty()) { + // report a call before its name. Such a delta only feeds the assembled call; what it held + // back is reported with the first delta that can be, so no argument text is lost. + if (tool.name.isEmpty() || tool.arguments.length() == tool.reportedLength) { return; } + String unreported = tool.arguments.substring(tool.reportedLength); + tool.reportedLength = tool.arguments.length(); handler.onPartialToolCall(PartialToolCall.builder() .index(index) .id(tool.id) .name(tool.name) - .partialArguments(argumentsFragment) + .partialArguments(unreported) .build()); } diff --git a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java index b805fa6ba..6c06b060e 100644 --- a/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java +++ b/llama-langchain4j/src/test/java/net/ladenthin/llama/langchain4j/StreamingChunkAssemblerTest.java @@ -190,16 +190,11 @@ void toolCallDeltaBeforeItsName_doesNotFailTheStream() { assertThat(response.aiMessage().toolExecutionRequests().get(0).name(), is("get_weather")); assertThat(response.aiMessage().toolExecutionRequests().get(0).arguments(), is("{\"city\":1}")); - // Every partial that is reported carries the final id and name. Which fragments are reported - // here is not pinned: the prefix that arrives before the name is not reported at all, so the - // partials do not make up the arguments yet (assertPartialsMakeUpTheCompleteCall is red). - // At least one is reported, so the loop below cannot pass on an empty list. + // The prefix that arrived before the name is reported as soon as the name is known, so the + // partials make up the arguments, each with the final id and name. assertThat(handler.completeToolCalls.size(), is(1)); assertThat(handler.partialToolCalls, is(not(empty()))); - for (PartialToolCall partial : handler.partialToolCalls) { - assertThat(partial.id(), is("call_1")); - assertThat(partial.name(), is("get_weather")); - } + assertPartialsMakeUpTheCompleteCall(handler); } @Test @@ -252,7 +247,8 @@ void completesEachToolCallAsSoonAsTheNextOneStarts() { /** * A call that has no name yet when the next one starts is not completed early: a * {@link CompleteToolCall} with a blank name would reach the handler. It is completed by - * {@code complete()}, with the name that arrived in the meantime. + * {@code complete()}, with the name that arrived in the meantime; the arguments it held back are + * reported as its partial when the name arrives, before its completion. */ @Test void defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts() { @@ -268,7 +264,9 @@ void defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts() { + "\"id\":\"a\",\"function\":{\"name\":\"first\"}}]},\"finish_reason\":\"tool_calls\"}]}"); ChatResponse response = assembler.complete(); - assertThat(handler.toolCallEvents, contains("partial:1", "complete:0", "complete:1")); + assertThat(handler.toolCallEvents, contains("partial:1", "partial:0", "complete:0", "complete:1")); + assertThat(handler.partialToolCalls.get(1).partialArguments(), is("{}")); + assertThat(handler.partialToolCalls.get(1).name(), is("first")); assertThat(handler.completeToolCalls.get(0).toolExecutionRequest().name(), is("first")); assertThat(response.aiMessage().toolExecutionRequests().get(0).name(), is("first")); assertThat(response.aiMessage().toolExecutionRequests().get(1).name(), is("second")); From 9377ec72858732bbaa04eeaf1f42b066fda68ada Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:27:13 +0000 Subject: [PATCH 548/589] Record the commit of F265 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 65e9ae3fe..accbe06a8 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2450,7 +2450,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** `StreamingChunkAssemblerTest#toolCallDeltaBeforeItsName_doesNotFailTheStream` prüft jetzt `assertPartialsMakeUpTheCompleteCall`; `#defersCompletionOfAToolCallThatHasNoNameYetWhenTheNextOneStarts` erwartet das nachgereichte Teilstück von Aufruf 0 (`{}`, Name `first`) vor dessen Abschluss. - **Rot-Grün-Nachweis:** Gegen den unveränderten Assembler rot: „StreamingChunkAssemblerTest.toolCallDeltaBeforeItsName_doesNotFailTheStream:197->assertPartialsMakeUpTheCompleteCall:90 Expected: is "{\"city\":1}" but: was "ty\":1}"“; mit dem Fix grün, `StreamingChunkAssemblerTest` 13/13 (die geänderte Erwartung in `defersCompletion…` war mit dem Fix zunächst rot: „Expected: iterable containing ["partial:1", "complete:0", "complete:1"] but: item 1: was "partial:0"“ — genau das nachgereichte Präfix). - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `b528b4e` ## Offene Verdachte ohne Fund-ID From a0a95aaa5805a6f10b72246bd64d07663599ce07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:28:38 +0000 Subject: [PATCH 549/589] Pin that AgentOptions.withWorkspace keeps every other option The review found withWorkspace copying only baseUrl, modelPath and workspace; at HEAD it already copies all 26 fields explicitly, so the editor session of AcpServer gets every command-line setting. What was left is that a field added later and forgotten in the hand-written copy would go unnoticed. The new reflective test sets every instance field to a value no parse produces, calls withWorkspace and requires every field but the workspace to be equal; an unknown field type fails it with a hint. A copy without "b.acp = acp;" makes it red ("acp Expected: is but: was "); at HEAD it is green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-CHECKED.md | 1 + .../llama/atmosphere/AgentOptionsTest.java | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/BUGHUNT-CHECKED.md b/BUGHUNT-CHECKED.md index 04c3155bd..79773195c 100644 --- a/BUGHUNT-CHECKED.md +++ b/BUGHUNT-CHECKED.md @@ -179,3 +179,4 @@ Datei; ein angegebener Hash ist der Stand der Hauptdatei zur Zeit der Durchsicht - Die FIM-Tests `ChatScenarioTest#testHandleInfillDirect`, `ErrorHandlingTest#testHandleInfillMissingInputSuffix`, `OpenAiServerCompletionIntegrationTest#infillReturnsContent` und `#ollamaGenerateWithSuffixUsesInfill` ↔ `jllama_engine.cpp` `infill` (die FIM-Prüfung vor der Feldprüfung) · – · 2026-10-21 · kein Fund (Test-Nachtrag, Branch-Review 2026-10-20, Fund JF-9; Verdacht ergänzt) — Neue Annahme `FimAssumptions.assumeFimTokens(model)` am Anfang jedes der vier Tests: ein Infill ohne `input_prefix` scheitert bei einem Modell mit FIM-Tokens an dem fehlenden Feld (`InvalidRequestException`, nichts wird erzeugt) und bei einem ohne mit genau `Model does not support fill-in-the-middle infill` (nur diese Meldung wird zum Abbruch, jede andere Antwort ist ein Fehler). Die GGUF-Metadaten taugen nicht als Quelle: CodeLlamas GGUF nennt keinen `tokenizer.ggml.fim_*`-Schlüssel, upstream findet die Tokens über ihren Text (`"▁
      "` usw., `llama-vocab.cpp:2867`ff., b11639); deshalb fragt die Annahme die native Prüfung selbst. Rot-Grün per Konfiguration: mit dem Winzmodell vorher `Tests run: 1, Failures: 0, Errors: 1` (ChatScenarioTest), `Tests run: 1, Failures: 0, Errors: 1` (ErrorHandlingTest), `Tests run: 2, Failures: 2, Errors: 0` (OpenAiServerCompletionIntegrationTest), nachher `Tests run: 1, Failures: 0, Errors: 0, Skipped: 1`, `Tests run: 1, Failures: 0, Errors: 0, Skipped: 1`, `Tests run: 2, Failures: 0, Errors: 0, Skipped: 2`; mit einer Variante des Winzmodells, deren Metadaten drei FIM-Token-Ids nennen, laufen alle vier und sind grün (`Skipped: 0`), die Annahme springt bei FIM-Tokens also nicht an. `LlamaModelTest#testCompleteInfillCustom`, den der Brief als fünften nennt, erreicht die FIM-Prüfung nicht und bekommt die Annahme nicht: er scheitert aus einem anderen Grund (Verdacht unter „Offene Verdachte ohne Fund-ID“).
       - `LlamaModelTest#testCompleteWithCancellationToken` und `#testCompleteAsyncCancelPropagates` ↔ `LlamaModel.complete(InferenceParameters, CancellationToken)` / `completeAsync(InferenceParameters, CancellationToken)` · – · 2026-10-21 · kein Fund (Test-Nachtrag, Branch-Review 2026-10-20, Fund JF-10) — Beide hingen vom Tempo des Modells ab (200 ms Schlaf, dann der Abbruch; mit dem Winzmodell war die Generierung bis dahin oft schon fertig: „future should report cancelled ==> expected:  but was: “ bzw. „token should be reset after call returns ==> expected:  but was: “). Jetzt laufen sie gegen eine eigene Instanz des Textmodells mit `enableContextShift()` (Kontext 128) und einer Anfrage ohne Token-Limit mit `ignore_eos` (Muster von J2-7), so dass die Generierung nur durch den Abbruch endet; abgebrochen wird, sobald ein Slot erzeugt (Abfrage der Metriken statt Schlaf), und geprüft wird, dass `complete` binnen 30 s mit einem Ergebnis zurückkehrt und das Token zurückgesetzt hat bzw. die Future abgebrochen meldet, und in beiden Fällen, dass danach kein Slot mehr arbeitet. Das deckt die Javadoc beider Methoden: Rückkehr mit dem bis dahin erzeugten Text, Zurücksetzen des Tokens, `future.cancel(true)` ruft über `whenComplete` `cancel()` des Tokens auf und beendet die Schleife. Rot-Grün: ohne `token.cancel()` bzw. `future.cancel(true)` im Test rot (`testCompleteWithCancellationToken:328 » Timeout`, `testCompleteAsyncCancelPropagates:420 future should report cancelled ==> expected:  but was: `); mit vorübergehend abgeschaltetem Abbruch in `LlamaModel` (kein `token.cancel()` im `whenComplete`, keine Prüfung des Tokens in der Schleife) rot (`» Timeout` bzw. `awaitProcessingSlots:466 a slot is still processing within 30000 ms`); unverändert grün in sechs Läufen hintereinander mit dem Winzmodell (je `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0`).
       - `LlamaModelTest#testCompleteGrammar` / `#testGenerateGrammar` und `ChatAdvancedTest#testMessagesInGenerate` ↔ upstream `server-context.cpp:3564` (leerer Prompt, b11639) · – · 2026-10-21 · kein Fund in den Grammar-Tests (Test-Nachtrag, Branch-Review 2026-10-20, Fund JF-11); Verdacht ergänzt für `testMessagesInGenerate` — Gemessen statt angenommen: nicht frühes EOS. Der Prompt `""` ist auf einem Modell ohne BOS kein einziges Token; upstream gibt den Slot dann frei und antwortet leer (`"empty prompt - releasing slot"`: `content` `""`, `tokens_evaluated` 0, `tokens_predicted` 0, `stop_type` `none`), ohne zu sampeln, die Grammatik wird also nie berührt; mit dem Prompt `"x"` liefert dasselbe Winzmodell unter der Grammatik `aababbaabb` (`stop_type` `limit`). Nur CodeLlama, das ein BOS setzt, erzeugte etwas. Die Grammar-Tests nutzen jetzt den Prompt `GRAMMAR_PROMPT` und `ignore_eos` (die Grammatik lässt EOS zu, sobald ein Buchstabe stand, und das Winzmodell hängt den Text seines EOS an, weil es dort ein gewöhnliches Byte-Token ist: `abbba>`); die Zusicherung (`[ab]+`, 1 … `nPredict + 1` Tokens) ist unverändert. Rot-Grün per Konfiguration: mit dem Winzmodell vorher `testCompleteGrammar:228  doesn't match [ab]+ ==> expected:  but was: ` und `testGenerateGrammar:185 expected:  but was: `, danach dreimal `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0`; ohne die Grammatik rot (`NYq:_EVhP; doesn't match [ab]+`), sie wird also geprüft. `testMessagesInGenerate` bleibt unverändert rot (`generate() with messages must produce output`): die Ursache ist ein Produktionsverdacht, kein Testproblem (unter „Offene Verdachte ohne Fund-ID“).
      +- `llama-atmosphere-agent/src/main/java/net/ladenthin/llama/atmosphere/AgentOptions.java` (`withWorkspace`) · 8d72fbb · 2026-10-21 · kein Fund (Test-Nachtrag, Branch-Review 2026-10-20/21, Brief JR-15) — Der Befund des Briefs (`withWorkspace` kopiere nur `baseUrl`, `modelPath` und `workspace`) gilt am HEAD nicht mehr: die Methode kopiert alle 26 Felder ausdrücklich (seit dem Merge von #494), `AcpServer` bekommt also alle Einstellungen der Kommandozeile. Offen war nur, dass ein später hinzugefügtes Feld, das die Kopie vergisst, unbemerkt bliebe. Neu `AgentOptionsTest#withWorkspaceKeepsEveryOtherOption`: setzt reflektiv jedes Instanzfeld auf einen Wert, den kein Parse erzeugt (je Typ; ein unbekannter Typ lässt den Test mit einem Hinweis scheitern), ruft `withWorkspace` und verlangt jedes Feld außer `workspace` gleich. Gegenprobe rot mit einer Kopie ohne `b.acp = acp;`: „AgentOptionsTest.withWorkspaceKeepsEveryOtherOption:315 acp Expected: is  but: was “; am HEAD grün, `AgentOptionsTest` 18/18. Keine Fund-Id, da keine Produktionsänderung nötig war.
      diff --git a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java
      index 9eba97ed9..91cf2a9cf 100644
      --- a/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java
      +++ b/llama-atmosphere-agent/src/test/java/net/ladenthin/llama/atmosphere/AgentOptionsTest.java
      @@ -286,4 +286,56 @@ void modelIdForARemoteEndpointIsSentUnchanged() {
                               .getModelId(),
                       is("a,b"));
           }
      +    /**
      +     * {@code withWorkspace} copies every option and replaces only the workspace -- an editor session
      +     * ({@code --acp}) runs with all of the command line's settings in the project the editor names. The
      +     * copy is written out field by field, so the check is reflective: every field is set to a value no
      +     * parse produces, and a field added later that the copy forgets fails here.
      +     */
      +    @Test
      +    void withWorkspaceKeepsEveryOtherOption() throws ReflectiveOperationException {
      +        AgentOptions options = AgentOptions.parse(new String[] {"--base-url", "http://localhost:1/v1"});
      +        java.util.List fields = new java.util.ArrayList<>();
      +        int n = 0;
      +        for (java.lang.reflect.Field field : AgentOptions.class.getDeclaredFields()) {
      +            if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {
      +                continue;
      +            }
      +            field.setAccessible(true);
      +            field.set(options, unusualValue(field, n++));
      +            fields.add(field);
      +        }
      +        Path elsewhere = Paths.get("elsewhere").toAbsolutePath().normalize();
      +
      +        AgentOptions copy = options.withWorkspace(elsewhere);
      +
      +        assertThat(copy.getWorkspace(), is(elsewhere));
      +        for (java.lang.reflect.Field field : fields) {
      +            if (!field.getName().equals("workspace")) {
      +                assertThat(field.getName(), field.get(copy), is(field.get(options)));
      +            }
      +        }
      +    }
      +
      +    /** A value of the field's type that no parse produces, distinct per field. */
      +    private static Object unusualValue(java.lang.reflect.Field field, int n) {
      +        Class type = field.getType();
      +        if (type == String.class) {
      +            return "unusual-" + field.getName();
      +        }
      +        if (type == Path.class) {
      +            return Paths.get("/unusual-" + field.getName());
      +        }
      +        if (type == int.class) {
      +            return 7001 + n;
      +        }
      +        if (type == double.class) {
      +            return 0.5 + n;
      +        }
      +        if (type == boolean.class) {
      +            // every boolean option defaults to false except autoCompact; flip whatever it is
      +            return !field.getName().equals("autoCompact");
      +        }
      +        throw new AssertionError("no unusual value for " + field + "; add one for its type");
      +    }
       }
      
      From 967864a015a91f601243b1064cd3b5b0b26fe08c Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Wed, 21 Oct 2026 17:32:41 +0000
      Subject: [PATCH 550/589] Satisfy SpotBugs for the stream and loader fixes
      
      The end-of-batch spotbugs:check flagged four things in F262 to F264:
      the IOException of readTree that ChatStreamChunkParser.feed turns into
      its MalformedEnvelopeException (suppressed with the reason, like
      ChatRequest.parseSchemaOrThrow), angle brackets in that message, which
      read as XML to the injection detector (now "an object or an array"),
      the static field of ResponseStream declared after its instance fields,
      and the loader's restore lambda assigning the captured build info
      directly (it goes through restoreBuildInfo now, which copies it into an
      unmodifiable map). spotbugs:check and javadoc:javadoc of the core are
      clean; the affected test classes are green.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                                   |  1 +
       llama/spotbugs-exclude.xml                         | 14 ++++++++++++++
       .../llama/json/ChatStreamChunkParser.java          |  2 +-
       .../net/ladenthin/llama/loader/LlamaLoader.java    |  2 +-
       .../ladenthin/llama/server/OpenAiCompatServer.java |  6 +++---
       5 files changed, 20 insertions(+), 5 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index accbe06a8..67e96994a 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2425,6 +2425,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Rot-Grün-Nachweis:** Gegen den unveränderten Parser rot: „ChatStreamChunkParserTest.feed_anEnvelopeTheNativeSideDoesNotWrite_isRejectedNamingIt:78 this is not json ==> Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“ und „feed_aLongBrokenEnvelopeIsNamedByABoundedExcerpt:88 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.“; mit dem Fix grün: `ChatStreamChunkParserTest` 5/5, `LlamaModelStreamEnvelopeTest` 3/3 (vor `feedStreamEnvelope` nicht kompilierbar), `LlamaArchitectureTest` 13/13. Dass die native Seite den strikten Vertrag erfüllt, zeigen die modellgestützten Stream-Tests mit dem Winzmodell: `OpenAiCompatServerIntegrationTest` 15/15, `OpenAiServerCompletionIntegrationTest` 12 (2 übersprungen, FIM).
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `8771717`
      +- **Nachtrag (2026-10-21):** `spotbugs:check` am Ende des Durchgangs: die Umwandlung der `IOException` von `readTree` in `feed` (`EXS_EXCEPTION_SOFTENING_NO_CONSTRAINTS`) ist mit Begründung unterdrückt wie bei `ChatRequest.parseSchemaOrThrow`; die Meldung nennt die erwartete Form ohne spitze Klammern (`{"data": an object or an array, "stop": a boolean}`), die `POTENTIAL_XML_INJECTION` auslösten. In demselben Commit für F262 das statische Feld von `ResponseStream` vor die Instanzfelder (`IMC_IMMATURE_CLASS_WRONG_FIELD_ORDER`) und für F264 die Wiederherstellung der Build-Info über `restoreBuildInfo` (`MS_EXPOSE_REP`). Danach `spotbugs:check` und `javadoc:javadoc` des Kerns grün.
       
       
       ### F264 – java-llama.cpp: ein zweites erfolgreiches `LlamaLoader.initialize()` durchläuft die Suche erneut — eine zweite Bibliothekskopie, eine ersetzte Build-Info
      diff --git a/llama/spotbugs-exclude.xml b/llama/spotbugs-exclude.xml
      index 1fd776b48..7fbc3714b 100644
      --- a/llama/spotbugs-exclude.xml
      +++ b/llama/spotbugs-exclude.xml
      @@ -151,6 +151,20 @@ SPDX-License-Identifier: MIT
               
           
       
      +    
      +    
      +        
      +        
      +        
      +    
      +
           
      +		     section "jqwik Policy", which points to the cross-repo policy
      +		     workspace/policies/jqwik-prompt-injection.md, for full context. -->
       		1.9.3
       		1.5.1
       		4.10.5.0
      @@ -585,6 +586,28 @@ SPDX-License-Identifier: MIT
       			
       				maven-resources-plugin
       			
      +			
      +				
      +				org.apache.maven.plugins
      +				maven-jar-plugin
      +				
      +					
      +						default-jar
      +						
      +							
      +								
      +									net.ladenthin.llama.server.ServerLauncher
      +								
      +							
      +						
      +					
      +				
      +			
       			
       				org.apache.maven.plugins
       				maven-source-plugin
      
      From 834ae19f4748268a8c6d5f8d123d9fd5641b2a3f Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Thu, 22 Oct 2026 00:32:43 +0000
      Subject: [PATCH 576/589] Record the commit of F269 in the bug-hunt ledger
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 9d77b658c..e8943e0ce 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2512,7 +2512,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Tests:** `.github/smoke-natives-jars.sh` (Job `package`, lädt die echten Jars) liest `Main-Class` aus dem Klassen-Jar und bricht ab, wenn es nicht `net.ladenthin.llama.server.ServerLauncher` ist – aus dem Jar selbst, nicht aus dem POM, damit ein erneuter Verlust (etwa durch ein Profil, das das Manifest überschreibt) im selben Job auffällt wie damals der Verlust der Fat-Jars.
       - **Rot-Grün-Nachweis:** Manifest vor dem Fix ohne `Main-Class` (gemessen am Jar aus `mvn package`), der neue Smoke-Check liefe damit rot („names '' as Main-Class“); nach dem Fix enthält das Manifest den Eintrag, `bash -n` über das Skript, `check-natives.py` 0 Abweichungen. Kein Unit-Test: ein Manifest-Eintrag ist erst nach `package` beobachtbar, die Prüfung gehört in den Job, der die Jars lädt.
       - **Status:** gefixt
      -- **Commit:** (folgt)
      +- **Commit:** java-llama.cpp `68ab75d`
       
       ## Offene Verdachte ohne Fund-ID
       
      
      From 9daae5b5fe98eee87214ace07dfd444aadb6811e Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Thu, 22 Oct 2026 00:56:40 +0000
      Subject: [PATCH 577/589] Count the grammar tests' tokens from the stream and
       the result, not by re-tokenising the text (F270)
      
      testGenerateGrammar and testCompleteGrammar read the number of generated
      tokens as model.encode(output).length, but the grammar admits multi-letter
      pieces and the encoder splits their concatenation differently from how it
      was sampled: on the Windows x86-64 job a text of at most 10 sampled pieces
      re-encoded to more than 11 tokens, and which pieces fall is random (no seed,
      temperature sampling). The stream test counts the chunks with text (a lower
      bound that never exceeds n_predict), the blocking test reads the server's own
      predicted_n through completeWithStats; both name text and count on failure.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                              | 26 +++++++++++++++++++
       .../net/ladenthin/llama/LlamaModelTest.java   | 23 +++++++++++-----
       2 files changed, 43 insertions(+), 6 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index e8943e0ce..7eb8660bc 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -191,6 +191,8 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - [F266](#f266) — mittel — *gefixt*
       - [F267](#f267) — niedrig — *gefixt*
       - [F269](#f269) — mittel — *gefixt*
      +- [F270](#f270) — niedrig (Testfehler) — *gefixt*
      +- [F271](#f271) — niedrig bis mittel — *gefixt*
       
       ## Funde
       
      @@ -2514,6 +2516,30 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `68ab75d`
       
      +
      +### F270 – java-llama.cpp: `LlamaModelTest.testGenerateGrammar` / `testCompleteGrammar` zählen die erzeugten Token über eine Re-Tokenisierung des Textes
      +- **Zeit:** 2026-10-22 01:10 UTC
      +- **Datei:** `llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java` (`testGenerateGrammar`, `testCompleteGrammar`)
      +- **Schwere:** niedrig (Testfehler; der Windows-x86-64-Job des Laufs 39692853697 rot, `testGenerateGrammar:201 expected:  but was: `)
      +- **Historie:** Die Tests prüfen die Grammatik `root ::= ("a" | "b")+` mit `n_predict` 10 und ohne festen Seed; die zweite Zusicherung las die Zahl der erzeugten Token als `model.encode(output).length <= nPredict + 1`. Die Grammatik lässt mehrbuchstabige Stücke zu (`ab`, `aab`, …), und der Encoder zerlegt deren Verkettung anders, als sie gesampelt wurde: ein Text aus höchstens 10 Stücken re-tokenisierte auf dem Windows-Läufer zu mehr als 11 Token. Welche Stücke fallen, ist Zufall (kein Seed, Temperatur-Sampling) und hängt am CPU-Modul des Läufers; die Zusicherung war damit eine Lotterie, keine Messung. Die erste Zusicherung (`[ab]+`) hielt.
      +- **Fix:** Der Stream-Test zählt die Chunks mit Text (jeder trägt mindestens ein gesampeltes Token, also eine untere Schranke, die `n_predict` nie überschreitet), der Blocking-Test liest `completeWithStats(...).getTimings().getPredictedN()`, die Zählung des Servers. Beide Zusicherungen nennen den Text und die Zahl in ihrer Meldung.
      +- **Tests:** dieselben zwei Tests; modellgebunden (CodeLlama 7B Q2_K), lokal nicht ausführbar (kein Modell im Sandkasten).
      +- **Rot-Grün-Nachweis:** rot im CI-Lauf 39692853697 (Windows 2025 x86-64, clang), grün erwartet im Lauf auf dem nächsten Head; `test-compile` und Spotless lokal.
      +- **Status:** gefixt
      +- **Commit:** (folgt)
      +
      +
      +### F271 – java-llama.cpp: `jllama_rpc_server_stop()` beendet auch den Serve, der startet, während der Stop auf den gemeinten wartet
      +- **Zeit:** 2026-10-22 01:20 UTC
      +- **Datei:** `llama/src/main/cpp/jllama_api.cpp` (`rpc_serve_state`, `rpc_serving_scope`, `jllama_rpc_server_stop`), `llama/src/main/cpp/jllama_api.h`, `llama/src/main/cpp/rpc_support.hpp` (Test-Naht `set_stop_hook_for_testing`), `llama-ffm/.../FfmRpcServer.java` (Javadoc)
      +- **Schwere:** niedrig bis mittel (nur C- und FFM-Aufrufer, die aus einem anderen Thread neu starten; das Java-`RpcServer` hält seinen Slot bis `close()` zurück ist)
      +- **Historie:** Gefunden vom Opus-Review des PR #554 am 2026-10-22. Seit F241 wiederholt der Stop ggmls `stop_server` alle 50 ms, bis `serving == 0`, und `stop_requested` wurde erst bei `serving == 0` zurückgesetzt. Nimmt ein neuer `jllama_rpc_server_serve()` den Mutex, bevor der wartende Stop `serving == 0` sieht, zählt der Neue mit: der Stop wartet auf ihn (bis zum 30-s-Timeout), er liest `stop_requested` noch als gesetzt und kehrt zurück, ohne je zu binden – oder, falls er gebunden hat, trifft ihn der wiederholte ggml-Stop. Header und FFM-Javadoc versprachen das Gegenteil („does not carry over to the next one“).
      +- **Fix:** Jeder Serve-Aufruf bekommt eine Id (`active`, älteste zuerst); der Stop merkt sich die jüngste als Ziel (`stop_up_to`), ein Serve fragt `id <= stop_up_to`, und der Stop wartet nur, solange ein Serve mit Id bis zum Ziel aktiv ist. ggmls Stop wird unter dem Mutex ausgelöst, den die Registrierung eines neuen Serve zuerst nimmt; der Neue bindet danach, und ggml löscht sein Flag beim Binden. Header-Kommentar, FFM-Javadoc, CLAUDE.md und CHANGELOG angepasst.
      +- **Tests:** `test_c_api.cpp` `CApiRpc.AStopDoesNotReachTheServeStartedAfterIt`: der erste Serve hängt in der Serve-Naht, der Stop läuft auf dem Testthread; die neue Stop-Naht (nach dem Vermerk des Ziels) startet den zweiten Serve, hält ihn in der Serve-Naht und gibt den ersten frei. Der Stop muss mit OK zurückkehren, sobald der erste beendet ist; der zweite muss nach der Freigabe binden und lauschen, ein zweiter Stop beendet ihn.
      +- **Rot-Grün-Nachweis:** mit der alten Implementierung (nur die Naht eingezogen) rot: „the RPC server did not stop within 30 s of the stop request“ und „the stop of the first serve ended the second one“ (30 002 ms); mit dem Fix grün (12 ms), dreimal einzeln und zusammen mit den vier anderen `CApiRpc`-Fällen; ganze C++-Suite 686/686; clang-format 23.1.3.
      +- **Status:** gefixt
      +- **Commit:** (folgt)
      +
       ## Offene Verdachte ohne Fund-ID
       
       - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt.
      diff --git a/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java b/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java
      index f1ba8faf5..411b8fc2c 100644
      --- a/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java
      @@ -191,14 +191,22 @@ public void testGenerateGrammar() {
                       .withIgnoreEos(true)
                       .withNPredict(nPredict);
               StringBuilder sb = new StringBuilder();
      +        int chunks = 0;
               for (LlamaOutput output : model.generate(params)) {
                   sb.append(output);
      +            if (!output.text.isEmpty()) {
      +                chunks++;
      +            }
               }
               String output = sb.toString();
       
      -        assertTrue(output.matches("[ab]+"));
      -        int generated = model.encode(output).length;
      -        assertTrue(generated > 0 && generated <= nPredict + 1);
      +        assertTrue(output.matches("[ab]+"), output + " doesn't match [ab]+");
      +        // Every non-empty chunk carries at least one sampled token, so their number is a lower bound
      +        // of the tokens generated and never exceeds n_predict. Re-tokenising the text is no measure of
      +        // it: the grammar admits multi-letter pieces ("ab", "aab", ...), and the encoder splits their
      +        // concatenation differently from how it was sampled (measured on the Windows job: a text of
      +        // at most 10 sampled pieces re-encoded to more than 11 tokens).
      +        assertTrue(chunks > 0 && chunks <= nPredict, "chunks with text: " + chunks + " for " + output);
           }
       
           @Test
      @@ -263,10 +271,13 @@ public void testCompleteGrammar() {
                       .withGrammar("root ::= (\"a\" | \"b\")+")
                       .withIgnoreEos(true)
                       .withNPredict(nPredict);
      -        String output = model.complete(params);
      +        CompletionResult result = model.completeWithStats(params);
      +        String output = result.getText();
               assertTrue(output.matches("[ab]+"), output + " doesn't match [ab]+");
      -        int generated = model.encode(output).length;
      -        assertTrue(generated > 0 && generated <= nPredict + 1, "generated count is: " + generated);
      +        // The server's own count of the sampled tokens (see testGenerateGrammar for why the text
      +        // is not re-tokenised to get it).
      +        long generated = result.getTimings().getPredictedN();
      +        assertTrue(generated > 0 && generated <= nPredict, "generated count is: " + generated + " for " + output);
           }
       
           @Test
      
      From c59569a41cb4249afd7d44a29b3066da0722469b Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Thu, 22 Oct 2026 00:56:40 +0000
      Subject: [PATCH 578/589] Let an RPC stop be for the serves in progress when it
       is made, not for the one that starts meanwhile (F271)
      
      jllama_rpc_server_stop() waited for the count of serve calls to reach zero
      and reset its request only then, so a serve that took over right after the
      targeted one had ended (a restart from another thread, which FfmRpcServer
      allows) was ended too: it returned without binding, or lost its listener once
      bound. Every serve call gets an id; the stop records the newest as its target,
      waits for the serves up to it, and issues ggml's stop under the mutex a new
      serve's registration takes first, so the newcomer binds afterwards and ggml
      clears its flag there. The header, the FFM Javadoc and CLAUDE.md say so.
      
      Red/green with a new CApiRpc case over a stop seam (set_stop_hook_for_testing,
      next to the serve seam): the next serve starts once the stop is recorded,
      the stop must return as soon as the first serve has ended, and the second
      must come up. Before: 30 s timeout and the second serve never listened; now
      12 ms. C++ suite 686/686, clang-format 23.1.3.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                              |  2 +-
       CHANGELOG.md                                  |  7 ++
       CLAUDE.md                                     |  7 +-
       .../net/ladenthin/llama/ffm/FfmRpcServer.java |  9 ++-
       llama/src/main/cpp/jllama_api.cpp             | 71 +++++++++++------
       llama/src/main/cpp/jllama_api.h               |  9 ++-
       llama/src/main/cpp/rpc_support.hpp            |  5 ++
       llama/src/test/cpp/test_c_api.cpp             | 77 +++++++++++++++++++
       8 files changed, 154 insertions(+), 33 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 7eb8660bc..49445ceef 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2526,7 +2526,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Tests:** dieselben zwei Tests; modellgebunden (CodeLlama 7B Q2_K), lokal nicht ausführbar (kein Modell im Sandkasten).
       - **Rot-Grün-Nachweis:** rot im CI-Lauf 39692853697 (Windows 2025 x86-64, clang), grün erwartet im Lauf auf dem nächsten Head; `test-compile` und Spotless lokal.
       - **Status:** gefixt
      -- **Commit:** (folgt)
      +- **Commit:** java-llama.cpp `9daae5b`
       
       
       ### F271 – java-llama.cpp: `jllama_rpc_server_stop()` beendet auch den Serve, der startet, während der Stop auf den gemeinten wartet
      diff --git a/CHANGELOG.md b/CHANGELOG.md
      index 6631fb580..93cf918b3 100644
      --- a/CHANGELOG.md
      +++ b/CHANGELOG.md
      @@ -903,6 +903,13 @@ the first two items break every existing consumer at upgrade.
         changed meaning accordingly (see Added).
       
       ### Fixed
      +- **`jllama_rpc_server_stop()` (C API, `llama-ffm`) is for the serve calls in progress when it is made.**
      +  It waited for the count of serves to reach zero and reset the stop request only then, so a serve
      +  that took over right after the one it targeted had ended (a restart from another thread, which
      +  `FfmRpcServer` allows) was ended too: it returned without binding, or lost its listener once bound.
      +  Every serve call has an id now; the stop records the newest as its target, waits for the serves up
      +  to it, and issues ggml's stop under the mutex a new serve's registration takes first (F271). The
      +  Java `RpcServer` was not affected: its single-instance slot stays taken until `close()` has returned.
       - **The classes jar carries `Main-Class: net.ladenthin.llama.server.ServerLauncher` again.** The entry
         had gone with the fat-jar assembly, so `java -jar llama-.jar` and the README's
         `jbang --deps  net.ladenthin:llama:` lines, which run a GAV through its manifest,
      diff --git a/CLAUDE.md b/CLAUDE.md
      index 6d304b2fc..8e2bca94d 100644
      --- a/CLAUDE.md
      +++ b/CLAUDE.md
      @@ -1656,7 +1656,10 @@ new lifecycle branch cannot hide behind "needs natives". The in-JVM `rpc-server`
       `ggml_backend_rpc_stop_server()` (patch `0015`) through `jllama_rpc_server_stop()` -- which keeps a
       stop issued during the serve call but before ggml binds the socket (ggml clears its own flag there)
       and returns once the serve has, so the FFM and C callers get what `RpcServer`'s Java retry gave the
      -JNI path alone (F241) -- and `start` waits for
      +JNI path alone (F241); every serve call has an id, a stop is for the serves in progress when it is
      +made and waits for those only, so a serve started while the stop still waits (a restart from
      +another thread) is neither told to stop nor reached by the repeated ggml stop, which is issued under
      +the registration mutex (F271) -- and `start` waits for
       `ggml_backend_rpc_server_listening()` so a bind failure is a `LlamaException` rather than a silently
       dead thread. It serves every accelerator, else the CPU — **never an RPC device**
       (`jllama::rpc::server_devices()`), which in a JVM that is also a client would forward to itself.
      @@ -2641,7 +2644,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson"
       | `src/test/cpp/test_model_flags.cpp` | 4 | **The contract between the Java CLI-flag registries and llama.cpp's server argument parser.** CMake reads `ModelFlag.java` + `ModelOption.java` (`cmake/extract-java-wire-names.cmake` → a generated header of `{name, contract}` pairs), and this file asserts every `SERVER_PARSER` name is in `common_params_parser_init(params, LLAMA_EXAMPLE_SERVER).options`. It exists because **no Java test can catch this class**: `ModelFlagTest`/`ModelParametersExtendedTest` pin the *string mapping* (`hasKey("--mlock")`), never that llama.cpp still accepts the string, so they stay green forever while the flag is dead — and `common_params_parse` treats an unregistered option as a hard error, so the affected builder method makes the model **unloadable**, not merely ineffective. **A grep over `arg.cpp` is not a substitute**: `--grp-attn-n`/`-w` are present there at every pinned tag but `set_examples()`-scoped to `LLAMA_EXAMPLE_COMPLETION`/`PASSKEY`, so the server parser rejects them exactly like a deleted flag — only the real option table sees that. `--vocab-only` is the one exemption, and it declares itself `CliContract.PROJECT_PSEUDO` on its own constant rather than appearing in a list inside this file; the test asserts such a name is **still unknown** to the parser (an exemption upstream later registers would be hiding a real check) and that the exempt set is non-empty. |
       | `src/test/cpp/test_wire_contracts.cpp` | 6 | **The same contract for the two quieter surfaces.** `RequestField` against `server_schema::make_llama_cmpl_schema(...)` (5 tests) and `TrainingField` against `jllama_train::config_keys()` (1 test). Both receivers *silently ignore* an unknown key — the schema skips it, `train_engine.cpp` reads with `j.value(key, default)` and falls back — so a dead field produces no error anywhere and every string-mapping test keeps passing. `OAI_LAYER`-declared keys (consumed by `oaicompat_*_params_parse` before the schema) are exempt from the schema check, and are checked **both** ways: still unknown to the schema (the inverted check), and read by at least one upstream reader-shaped site (the configure-time sweep — this is what caught `chat_template`, a key a public builder wrote and nothing read). See [`docs/history/parameter-wire-surface.md`](docs/history/parameter-wire-surface.md). |
       | `src/test/cpp/test_rpc.cpp` | 28 | **The runnable guard for `patches/0015` and for `rpc_support.hpp`.** The device-selection rules (pure, literal inputs: `--rpc` endpoints accumulate across repeated options, an explicit `--device`/`-dev` is never overridden, stale RPC devices are replaced by the local GPUs chosen the way llama.cpp's default does — duplicates by device id dropped, iGPUs only without a discrete GPU, `none` on a CPU-only host). Then the **real** ggml-rpc client and server over loopback, no model: a `mul_mat` graph computed on the RPC backend equals the local CPU result; `ggml_backend_rpc_stop_server()` ends the server, frees the port and disconnects a client that is still connected; an unreachable or malformed endpoint is `nullptr`/`std::invalid_argument` instead of an abort; a registered server that went away is re-checked on the next registration; its device reports 0/0 memory instead of aborting; and a stale server is kept out of a later load that did not ask for it. The loopback server picks a free port from a range so parallel jobs do not collide, and serves the **CPU** by name — a served GPU that lacks an op aborts the server (see the RPC section). Also: server devices chosen by name (case-insensitive, deduplicated), an unknown name listing the available devices, and a registered RPC device refused; the mmproj device pinned the way clip would choose it minus the stale server; and `exclude_stale_devices()`, the params-level guard for TTS and the trainer. |
      -| `src/test/cpp/test_c_api.cpp` | 51 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. `CApiSlotFiles` loads the random model with `--slot-save-path`: a saved slot file lands under that directory by its name and restores, and a name with a directory part, `..`, an absolute path or nothing is `JLLAMA_STATUS_INVALID_REQUEST` with nothing written (red before F251: the name was the path, relative to the working directory); a model without the directory refuses save and restore with `JLLAMA_STATUS_ERROR`. Three `CApiRpc` cases pin `jllama_rpc_server_stop()` around the bind: a stop issued before ggml binds the socket -- from the serving thread through the test seam `jllama::rpc::set_serve_hook_for_testing()` (`rpc_support.hpp`, not exported), or from another thread while the serve is held in that seam, where the stop must not return before the serve has -- ends the serve instead of being lost (patches/0015 clears ggml's stop flag at the bind; red before F241), and a stop while nothing serves does not stop the next serve. Two `CApiFim` cases load the same random model with FIM tokens (`write_tiny_llama_gguf(prefix, true)`): a raw completion with `input_prefix`/`input_suffix`, blocking and streamed, evaluates exactly the prompt `jllama_infill` builds from the same fields, and one of the two fields alone is refused naming the other (red before F246: the completion path completed the empty prompt and dropped both). Two more `CApi` cases pin `messages` on the raw paths: blocking and streamed, the request evaluates exactly the tokens of a completion of the prompt `jllama_apply_template` renders from them (an empty `prompt` beside them counts as none), and a non-empty `prompt` or the infill fields beside them are refused (red before F247: the field was ignored). Two more: the chat fields the raw paths cannot apply beside `messages` (`response_format`, `tools`, `tool_choice`, `parallel_tool_calls`, `top_logprobs`, `thinking_budget_tokens`) are refused by name on both raw paths, and `CApiTemplateStops` loads the random model with an end-of-generation byte of its own (`write_tiny_llama_gguf(prefix, false, 4)`) and a template upstream adds the stop string `` to, which a grammar forces: the raw paths stop there as the chat path does, also beside a `stop` of the request (red before F248: dropped, `stop_type` `eos` and the string in `content`). And `CApiReasoningBudget` loads it with a template that opens a `` block in its generation prompt: a raw completion with `messages` and `reasoning_budget_tokens` 1 completes the forced `` and then the chat path's answer, blocking and streamed, while an output-format grammar beside it still runs (the generation prompt the budget is primed with is left out there); a template without thinking tags leaves the request unchanged (red before F249: the tags were dropped and the budget did nothing). A third case loads the random model with ``, `` and `` as control tokens (`write_tiny_llama_gguf(prefix, false, 4, special)`): the forced end tag reads as text in the raw content, blocking and streamed, and a request's own `preserved_tokens` stays in force beside the template's (red before F250: the session's preserved tokens were not taken over, and the special end tag was an empty piece). A fourth, without `messages`, names the tags in the request with a logit bias that makes the model write the start tag: a budget of zero forces the end tag after each one, and a `reasoning_budget_message` before it (red before F252: without the message the budget forced nothing). Four cases pin the raw multimodal prompt: `CApiVision` loads the random model with a random llava-style vision projector the shared fixture writes too (`write_tiny_mmproj_gguf()`, `--mmproj`) and finds the server's `media_marker` in the model meta; a raw completion whose `prompt` is upstream's `{"prompt_string": "......", "multimodal_data": [""]}` evaluates the text tokens plus the image's four patch embeddings on the blocking, streamed and OpenAI paths (red before F258: refused as if the model had no projector), while markers that do not match the images, data that is not base64, no data and an image at the very end (an abort of the process in `update_slots`) are refused, and the same object on the model without a projector is the caller's fault. `CApiVocabOnly` loads the random model with `--vocab-only`: the meta, tokenize and detokenize calls answer, and a parameterized case per other C function on a model (22 instances: every completion, chat and stream path, the template, embeddings, rerank, infill, metrics, slots, LoRA, configure, systemone, the attached server) is `JLLAMA_STATUS_ERROR` with "`jllama_` is not available in vocab-only mode" (red before F266: a `SIGSEGV` in `llama_vocab::token_bos()` or a wait that never ended). |
      +| `src/test/cpp/test_c_api.cpp` | 51 | **The C API (`jllama_api.h`, layer B) without a JVM.** Writes a tiny random two-layer llama GGUF with the GPT-2 byte tokenizer (the public `gguf` API, like `test_kolibri1.cpp`), loads it through `jllama_model_load` and drives the shipped contract: version and build info, null-argument refusal with a message, a missing model failing to load, the load-progress callback (reported, and a `false` return aborts the load), a chat completion that is an OpenAI `chat.completion` object, an invalid request answered as the caller's fault (`JLLAMA_STATUS_INVALID_REQUEST`), streaming until `stop` with the task released afterwards ("Task not found" on the next receive), a released task gone, and the log callback seeing the server's lines with a null set flushing synchronously. Hermetic, so it runs in every `C++ Tests` job without `restore-models`; the output of a random model is noise, the contract is what is asserted. Two things the fixture does on purpose: the output rows of every non-ASCII and special token are scaled to almost nothing, because a byte above 0x7F is an incomplete UTF-8 piece the server holds back rather than streams (a random model that emits only those sends no partial chunk at all), and the task id is checked `>= 0` -- the server numbers its tasks from zero. Then every other `LlamaModel` operation in turn (the meta, raw and OAI completions, raw streaming, the streamed OAI completion (`text_completion` chunks with one id, probabilities, `usage` on the last), the template, tokenize/detokenize, metrics, slots, LoRA, configure, the refusals of a model loaded without `--embedding`/`--reranking`/FIM tokens, the grammar conversion, a Q8_0 quantisation that loads again, several prompts in one request and several completions of one (`"n"` > 1) refused on the blocking, raw and streamed paths while a token array stays one prompt, and every OpenAI answer and chunk carrying a `chatcmpl-` id and the model name, one id per stream), then the other engines: the embedded server in router mode and attached to the model (which still answers after the server stopped), the RPC server over loopback, text to speech refusing missing models, and a fine-tuning run over the random F32 model that writes a GGUF -- which is why `tts_engine.cpp`, `train_engine.cpp` and `native_server_engine.cpp` are in `jllama_test`'s source list. Four model-free cases (`CApiStatic`) run before any load. `CApiSlotFiles` loads the random model with `--slot-save-path`: a saved slot file lands under that directory by its name and restores, and a name with a directory part, `..`, an absolute path or nothing is `JLLAMA_STATUS_INVALID_REQUEST` with nothing written (red before F251: the name was the path, relative to the working directory); a model without the directory refuses save and restore with `JLLAMA_STATUS_ERROR`. Four `CApiRpc` cases pin `jllama_rpc_server_stop()` around the bind and the next serve: a stop issued before ggml binds the socket -- from the serving thread through the test seam `jllama::rpc::set_serve_hook_for_testing()` (`rpc_support.hpp`, not exported), or from another thread while the serve is held in that seam, where the stop must not return before the serve has -- ends the serve instead of being lost (patches/0015 clears ggml's stop flag at the bind; red before F241), a stop while nothing serves does not stop the next serve, and a stop does not reach a serve started while it waits for the one it is for (the stop seam `set_stop_hook_for_testing()` starts the next serve once the stop is recorded; red before F271: the stop waited 30 s for the newcomer and ended it too). Two `CApiFim` cases load the same random model with FIM tokens (`write_tiny_llama_gguf(prefix, true)`): a raw completion with `input_prefix`/`input_suffix`, blocking and streamed, evaluates exactly the prompt `jllama_infill` builds from the same fields, and one of the two fields alone is refused naming the other (red before F246: the completion path completed the empty prompt and dropped both). Two more `CApi` cases pin `messages` on the raw paths: blocking and streamed, the request evaluates exactly the tokens of a completion of the prompt `jllama_apply_template` renders from them (an empty `prompt` beside them counts as none), and a non-empty `prompt` or the infill fields beside them are refused (red before F247: the field was ignored). Two more: the chat fields the raw paths cannot apply beside `messages` (`response_format`, `tools`, `tool_choice`, `parallel_tool_calls`, `top_logprobs`, `thinking_budget_tokens`) are refused by name on both raw paths, and `CApiTemplateStops` loads the random model with an end-of-generation byte of its own (`write_tiny_llama_gguf(prefix, false, 4)`) and a template upstream adds the stop string `` to, which a grammar forces: the raw paths stop there as the chat path does, also beside a `stop` of the request (red before F248: dropped, `stop_type` `eos` and the string in `content`). And `CApiReasoningBudget` loads it with a template that opens a `` block in its generation prompt: a raw completion with `messages` and `reasoning_budget_tokens` 1 completes the forced `` and then the chat path's answer, blocking and streamed, while an output-format grammar beside it still runs (the generation prompt the budget is primed with is left out there); a template without thinking tags leaves the request unchanged (red before F249: the tags were dropped and the budget did nothing). A third case loads the random model with ``, `` and `` as control tokens (`write_tiny_llama_gguf(prefix, false, 4, special)`): the forced end tag reads as text in the raw content, blocking and streamed, and a request's own `preserved_tokens` stays in force beside the template's (red before F250: the session's preserved tokens were not taken over, and the special end tag was an empty piece). A fourth, without `messages`, names the tags in the request with a logit bias that makes the model write the start tag: a budget of zero forces the end tag after each one, and a `reasoning_budget_message` before it (red before F252: without the message the budget forced nothing). Four cases pin the raw multimodal prompt: `CApiVision` loads the random model with a random llava-style vision projector the shared fixture writes too (`write_tiny_mmproj_gguf()`, `--mmproj`) and finds the server's `media_marker` in the model meta; a raw completion whose `prompt` is upstream's `{"prompt_string": "......", "multimodal_data": [""]}` evaluates the text tokens plus the image's four patch embeddings on the blocking, streamed and OpenAI paths (red before F258: refused as if the model had no projector), while markers that do not match the images, data that is not base64, no data and an image at the very end (an abort of the process in `update_slots`) are refused, and the same object on the model without a projector is the caller's fault. `CApiVocabOnly` loads the random model with `--vocab-only`: the meta, tokenize and detokenize calls answer, and a parameterized case per other C function on a model (22 instances: every completion, chat and stream path, the template, embeddings, rerank, infill, metrics, slots, LoRA, configure, systemone, the attached server) is `JLLAMA_STATUS_ERROR` with "`jllama_` is not available in vocab-only mode" (red before F266: a `SIGSEGV` in `llama_vocab::token_bos()` or a wait that never ended). |
       | `src/test/cpp/test_native_server_shutdown.cpp` | 3 | **The runnable guard for the shutdown-handler guard in `patches/0006`/`0007`.** Runs the real `llama_server()` in router mode over an empty `--models-dir` on an ephemeral loopback port (no model, no worker) and stops it the way `native_server.cpp` does. Pins a clean stop (exit code 0), that `llama_server_request_shutdown()` **after** the server returned is a no-op (the deterministic form of the CI `SIGSEGV` — it crashed every run before the fix), and that requests hammered from another thread while the server tears down are safe (5 rounds). Compiled on non-Android only, like `server.cpp` itself. |
       | `src/test/cpp/test_gallocr_leafs.cpp` | 1 | **The runnable guard for `patches/0020`.** Two graphs with the same two nodes in the same order (`n1 = add(x, x)`, `n2 = mul(w, n1)`) and only the leafs moved: one expands `n1` before `n2` and meets the input first, the other expands only `n2`, whose first source is the weight -- the shape of the llama.cpp case, where every node and node source still matches its slot, so the old check passed it. Reserved with the first, the second must get an allocation for the input inside the allocator's buffer and compute the right product on it. No model, every platform. **Verified both ways:** with the fix removed from the source tree the test dies with SIGSEGV (rc 139); a first version that swapped the operands of one `add` passed without the fix, because a swapped *node source* was already caught -- a guard has to move only the leafs. |
       | `src/test/cpp/test_lora_cache.cpp` | 5 | **The runnable guard for `patches/0021`.** Writes a random llama model (the shared `tiny_llama_gguf.hpp`, also used by `test_c_api.cpp`) and a random LoRA adapter on K, V and the FFN output of every layer, loads them with one slot and `--cache-ram 0`, and answers the same prompt greedily. After `jllama_set_lora_adapters` (scale 1 -> 0 and back) and after a request with its own adapters, the warm slot must answer exactly like an emptied one. One case guards the others against passing vacuously: the adapter changes the answer, and a repeated request reuses the cache (`timings.cache_n > 0`; not `tokens_cached`, which counts every token the slot holds and is positive after any request). After each adapter change the next request must take nothing from the cache (`timings.cache_n == 0`), so those three cases are red without the patch whether or not the random model happens to answer differently. **Verified both ways:** without the patch 3 of 4 red (the warm slot answered from the old scales), with it 4 of 4 green in three runs; no model, every platform. A second fixture (`LoraCacheFreshSlot`: two slots, `--slot-save-path`) saves slot 0 (by its file name inside that directory) after a request and restores it into the never-used slot 1, whose request must take all of the prompt but at most two tokens from it and answer like slot 0 -- red before the `init()` hunk (`Expected: (prompt_n) <= (2), actual: 77 vs 2`), green with it (F240). |
      diff --git a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java
      index 3e17643a3..baee1dc0f 100644
      --- a/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java
      +++ b/llama-ffm/src/main/java/net/ladenthin/llama/ffm/FfmRpcServer.java
      @@ -49,10 +49,11 @@ public static void serve(String host, int port, int threads, String cacheDir, St
       
           /**
            * Stops the server and returns once {@link #serve} has returned, also for a stop that arrives before
      -     * the socket is bound. A no-op when no {@link #serve} call is in progress, and it does not carry over
      -     * to the next one: a stop issued before the serving thread has entered {@link #serve} is lost, so
      -     * stopping a server that may not have come up yet means repeating this until {@link #serve} has
      -     * returned.
      +     * the socket is bound. A no-op when no {@link #serve} call is in progress, and it is for the
      +     * {@link #serve} calls in progress when it is made: one started while the stop still waits (a
      +     * restart from another thread) is not stopped. A stop issued before the serving thread has entered
      +     * {@link #serve} is lost, so stopping a server that may not have come up yet means repeating this
      +     * until {@link #serve} has returned.
            *
            * @throws LlamaException when {@link #serve} has not returned 30 seconds after the request
            */
      diff --git a/llama/src/main/cpp/jllama_api.cpp b/llama/src/main/cpp/jllama_api.cpp
      index f1b9a7c13..bb6fc1fdf 100644
      --- a/llama/src/main/cpp/jllama_api.cpp
      +++ b/llama/src/main/cpp/jllama_api.cpp
      @@ -23,6 +23,7 @@
       #endif
       
       #include 
      +#include 
       #include 
       #include 
       #include 
      @@ -122,21 +123,34 @@ void log_adapter(enum ggml_log_level level, const char *text, void * /*user_data
       
       // jllama::rpc::set_serve_hook_for_testing().
       std::atomic g_rpc_serve_hook{nullptr};
      +// jllama::rpc::set_stop_hook_for_testing().
      +std::atomic g_rpc_stop_hook{nullptr};
       
       // The serves in progress, for jllama_rpc_server_stop(). ggml clears its stop flag when it binds the
       // socket (patches/0015), so a stop of ggml's alone is lost when it lands before the bind (device
       // initialization, transport start) and the server goes on listening. A stop while a serve is in
       // progress is therefore kept here: serve does not start ggml's server once it is set, and the
       // stopping thread repeats ggml's stop until serve has returned. A stop while none is in progress
      -// stays a no-op, and the request ends with the last serve, so it never reaches the next one -- the
      -// reason ggml clears its flag in the first place.
      +// stays a no-op, and a stop is for the serves in progress when it is called: every serve call gets
      +// an id, the stop records the newest one as its target and waits for the serves up to it, so a
      +// serve that starts while the stop still waits (a restart from another thread) is neither told to
      +// stop nor reached by the repeated ggml stop -- that one is issued under the mutex the new serve's
      +// registration takes first, and ggml clears its flag when the new serve binds. Before, the stop
      +// waited for the count of serves to reach zero and the request was reset only then, so a serve
      +// that took over right after the targeted one had ended was ended too.
       struct rpc_serve_state {
           std::mutex mutex;
           std::condition_variable ended;
      -    int serving = 0;
      -    bool stop_requested = false;
      +    std::uint64_t next_id = 1;
      +    std::vector active; // the ids of the serves in progress, oldest first
      +    std::uint64_t stop_up_to = 0;      // the serves with an id up to this one were asked to stop
       };
       
      +// Whether a serve with an id up to `target` is still in progress.
      +bool serving_up_to(const rpc_serve_state &state, std::uint64_t target) {
      +    return !state.active.empty() && state.active.front() <= target;
      +}
      +
       rpc_serve_state &rpc_serve() {
           static rpc_serve_state state;
           return state;
      @@ -148,39 +162,41 @@ thread_local bool t_in_rpc_serve = false;
       constexpr auto RPC_STOP_RETRY = std::chrono::milliseconds(50);
       constexpr auto RPC_STOP_TIMEOUT = std::chrono::seconds(30);
       
      -// Marks one jllama_rpc_server_serve() call in progress, for its whole duration.
      +// Marks one jllama_rpc_server_serve() call in progress, for its whole duration, under an id of its own.
       class rpc_serving_scope {
         public:
           rpc_serving_scope() {
               rpc_serve_state &state = rpc_serve();
               std::lock_guard lock(state.mutex);
      -        if (state.serving++ == 0) {
      -            state.stop_requested = false;
      -        }
      +        id_ = state.next_id++;
      +        state.active.push_back(id_);
               t_in_rpc_serve = true;
           }
           ~rpc_serving_scope() {
               t_in_rpc_serve = false;
               rpc_serve_state &state = rpc_serve();
               std::lock_guard lock(state.mutex);
      -        if (--state.serving == 0) {
      -            state.stop_requested = false;
      -        }
      +        state.active.erase(std::remove(state.active.begin(), state.active.end(), id_), state.active.end());
               state.ended.notify_all();
           }
           rpc_serving_scope(const rpc_serving_scope &) = delete;
           rpc_serving_scope &operator=(const rpc_serving_scope &) = delete;
       
      -    static bool stop_requested() {
      +    // Whether a stop was issued for this serve (not for one that started after the stop).
      +    bool stop_requested() const {
               rpc_serve_state &state = rpc_serve();
               std::lock_guard lock(state.mutex);
      -        return state.stop_requested;
      +        return id_ <= state.stop_up_to;
           }
      +
      +  private:
      +    std::uint64_t id_ = 0;
       };
       
       } // namespace
       
       void jllama::rpc::set_serve_hook_for_testing(void (*hook)()) { g_rpc_serve_hook.store(hook); }
      +void jllama::rpc::set_stop_hook_for_testing(void (*hook)()) { g_rpc_stop_hook.store(hook); }
       
       extern "C" {
       
      @@ -651,7 +667,7 @@ jllama_status jllama_rpc_server_serve(const char *host, int port, int threads, c
               if (auto *hook = g_rpc_serve_hook.load()) {
                   hook();
               }
      -        if (rpc_serving_scope::stop_requested()) {
      +        if (serving.stop_requested()) {
                   t_last_error.clear();
                   return JLLAMA_STATUS_OK; // stopped before the server started
               }
      @@ -674,24 +690,35 @@ jllama_status jllama_rpc_server_stop(void) {
                   jllama::rpc::rpc_proc("ggml_backend_rpc_stop_server");
               rpc_serve_state &state = rpc_serve();
               std::unique_lock lock(state.mutex);
      -        if (state.serving > 0) {
      -            state.stop_requested = true;
      +        // The serves in progress now; one that starts later gets a greater id and is not meant.
      +        const std::uint64_t target = state.next_id - 1;
      +        if (!state.active.empty()) {
      +            state.stop_up_to = std::max(state.stop_up_to, target);
               }
      -        if (state.serving == 0 || t_in_rpc_serve) {
      +        if (state.active.empty() || t_in_rpc_serve) {
                   // Nothing to wait for: none runs, or this is the serving thread itself, before ggml's
                   // server started (serve sees the request and does not start it).
      -            lock.unlock();
                   stop_server();
                   t_last_error.clear();
                   return JLLAMA_STATUS_OK;
               }
      -        const auto deadline = std::chrono::steady_clock::now() + RPC_STOP_TIMEOUT;
      -        for (;;) {
      +        if (auto *hook = g_rpc_stop_hook.load()) {
                   lock.unlock();
      -            stop_server(); // lost while the socket is not bound yet, hence the repetition
      +            hook();
                   lock.lock();
      +        }
      +        const auto deadline = std::chrono::steady_clock::now() + RPC_STOP_TIMEOUT;
      +        for (;;) {
      +            if (!serving_up_to(state, target)) {
      +                t_last_error.clear();
      +                return JLLAMA_STATUS_OK;
      +            }
      +            // Under the mutex, so a serve that starts now registers only afterwards and then binds,
      +            // which clears ggml's flag: the stop reaches the serves it is for. Repeated, because it is
      +            // lost while their socket is not bound yet.
      +            stop_server();
                   const auto next = std::min(std::chrono::steady_clock::now() + RPC_STOP_RETRY, deadline);
      -            if (state.ended.wait_until(lock, next, [&state]() { return state.serving == 0; })) {
      +            if (state.ended.wait_until(lock, next, [&state, target]() { return !serving_up_to(state, target); })) {
                       t_last_error.clear();
                       return JLLAMA_STATUS_OK;
                   }
      diff --git a/llama/src/main/cpp/jllama_api.h b/llama/src/main/cpp/jllama_api.h
      index 8508af485..a7849f26b 100644
      --- a/llama/src/main/cpp/jllama_api.h
      +++ b/llama/src/main/cpp/jllama_api.h
      @@ -400,10 +400,11 @@ JLLAMA_API jllama_status jllama_rpc_server_serve(const char *host, int port, int
        * thread itself (from a callback that runs there before the server starts), it returns at once and
        * serve returns without starting the server.
        *
      - * A no-op when no serve call is in progress, and it does not carry over to the next one: a stop
      - * issued before the serving thread has entered jllama_rpc_server_serve() is lost, so a caller that
      - * stops a server that may not have come up yet repeats the stop until serve has returned, the way
      - * the Java RpcServer does.
      + * A no-op when no serve call is in progress, and it is for the serve calls in progress when it is
      + * made: one that starts while the stop still waits (a restart from another thread) is not stopped.
      + * A stop issued before the serving thread has entered jllama_rpc_server_serve() is lost, so a
      + * caller that stops a server that may not have come up yet repeats the stop until serve has
      + * returned, the way the Java RpcServer does.
        */
       JLLAMA_API jllama_status jllama_rpc_server_stop(void);
       
      diff --git a/llama/src/main/cpp/rpc_support.hpp b/llama/src/main/cpp/rpc_support.hpp
      index da55c136c..015c04524 100644
      --- a/llama/src/main/cpp/rpc_support.hpp
      +++ b/llama/src/main/cpp/rpc_support.hpp
      @@ -385,4 +385,9 @@ inline std::optional exclude_stale_devices(std::vector g_next_run;
      +std::atomic g_serve_hook_calls{0};
      +
      +void hold_in(held_serve &held) {
      +    std::unique_lock lock(held.mutex);
      +    held.entered = true;
      +    held.changed.notify_all();
      +    held.changed.wait(lock, [&held]() { return held.released; });
      +}
      +
      +bool wait_entered(held_serve &held) {
      +    std::unique_lock lock(held.mutex);
      +    return held.changed.wait_for(lock, std::chrono::seconds(10), [&held]() { return held.entered; });
      +}
      +
      +void release(held_serve &held) {
      +    std::lock_guard lock(held.mutex);
      +    held.released = true;
      +    held.changed.notify_all();
      +}
      +
      +struct stop_hook_scope {
      +    explicit stop_hook_scope(void (*hook)()) { jllama::rpc::set_stop_hook_for_testing(hook); }
      +    ~stop_hook_scope() { jllama::rpc::set_stop_hook_for_testing(nullptr); }
      +    stop_hook_scope(const stop_hook_scope &) = delete;
      +    stop_hook_scope &operator=(const stop_hook_scope &) = delete;
      +};
      +
      +} // namespace
      +
      +// A stop is for the serve in progress when it is called, not for one started afterwards. Here the
      +// next serve starts while the stop still waits for the first (the stop seam runs once the request
      +// is recorded): the stop must return as soon as the first serve has ended, and the second must come
      +// up and listen. Before, the stop repeated ggml's stop until no serve ran at all, so a serve that
      +// took over right after the first had ended was ended too -- it returned without binding (the
      +// request was still set, it is reset only when no serve runs), or lost its listener once bound.
      +TEST(CApiRpc, AStopDoesNotReachTheServeStartedAfterIt) {
      +    g_held.entered = g_held.released = false;
      +    g_held_next.entered = g_held_next.released = false;
      +    g_serve_hook_calls.store(0);
      +    g_next_run.reset();
      +    serve_hook_scope serve_hook([]() { hold_in(g_serve_hook_calls.fetch_add(1) == 0 ? g_held : g_held_next); });
      +    stop_hook_scope stop_hook([]() {
      +        // Once: the stop of the second serve below must not start a third.
      +        jllama::rpc::set_stop_hook_for_testing(nullptr);
      +        // The stop is recorded: start the next serve, let it enter its seam, then end the first.
      +        g_next_run = std::make_unique(rpc_test_port(11));
      +        EXPECT_TRUE(wait_entered(g_held_next)) << "the second serve did not start";
      +        release(g_held);
      +    });
      +    rpc_serve_run first(rpc_test_port(10));
      +    ASSERT_TRUE(wait_entered(g_held)) << "the first serve did not start";
      +
      +    const jllama_status stop_status = jllama_rpc_server_stop();
      +    EXPECT_EQ(stop_status, JLLAMA_STATUS_OK) << jllama_last_error();
      +    EXPECT_TRUE(first.returned_within(std::chrono::seconds(10))) << "the first serve went on after the stop";
      +    EXPECT_EQ(first.status(), JLLAMA_STATUS_OK) << jllama_last_error();
      +    ASSERT_NE(g_next_run, nullptr);
      +
      +    // The second serve was not meant: released, it binds and listens.
      +    release(g_held_next);
      +    for (int i = 0;
      +         i < 500 && !jllama_rpc_server_listening() && !g_next_run->returned_within(std::chrono::milliseconds(10));
      +         ++i) {
      +    }
      +    EXPECT_TRUE(jllama_rpc_server_listening()) << "the stop of the first serve ended the second one";
      +    EXPECT_EQ(jllama_rpc_server_stop(), JLLAMA_STATUS_OK) << jllama_last_error();
      +    EXPECT_TRUE(g_next_run->returned_within(std::chrono::seconds(10)));
      +    EXPECT_EQ(g_next_run->status(), JLLAMA_STATUS_OK) << jllama_last_error();
      +    g_next_run.reset();
      +    EXPECT_FALSE(jllama_rpc_server_listening());
      +}
      +
       // A stop while nothing serves stays a no-op: it must not end the next serve as well.
       TEST(CApiRpc, AStopWhileNoServeRunsDoesNotStopTheNextOne) {
           ASSERT_EQ(jllama_rpc_server_stop(), JLLAMA_STATUS_OK);
      
      From 272c8fce5db096e28de8617a9d7cdcf4255d5b82 Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Thu, 22 Oct 2026 00:56:41 +0000
      Subject: [PATCH 579/589] Record the commit of F271 in the bug-hunt ledger
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md | 2 +-
       1 file changed, 1 insertion(+), 1 deletion(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 49445ceef..9edea1aae 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -2538,7 +2538,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Tests:** `test_c_api.cpp` `CApiRpc.AStopDoesNotReachTheServeStartedAfterIt`: der erste Serve hängt in der Serve-Naht, der Stop läuft auf dem Testthread; die neue Stop-Naht (nach dem Vermerk des Ziels) startet den zweiten Serve, hält ihn in der Serve-Naht und gibt den ersten frei. Der Stop muss mit OK zurückkehren, sobald der erste beendet ist; der zweite muss nach der Freigabe binden und lauschen, ein zweiter Stop beendet ihn.
       - **Rot-Grün-Nachweis:** mit der alten Implementierung (nur die Naht eingezogen) rot: „the RPC server did not stop within 30 s of the stop request“ und „the stop of the first serve ended the second one“ (30 002 ms); mit dem Fix grün (12 ms), dreimal einzeln und zusammen mit den vier anderen `CApiRpc`-Fällen; ganze C++-Suite 686/686; clang-format 23.1.3.
       - **Status:** gefixt
      -- **Commit:** (folgt)
      +- **Commit:** java-llama.cpp `c59569a`
       
       ## Offene Verdachte ohne Fund-ID
       
      
      From 85ebbfdd0ed68330f94448e2ec747c97413b918e Mon Sep 17 00:00:00 2001
      From: Claude 
      Date: Thu, 22 Oct 2026 09:33:36 +0000
      Subject: [PATCH 580/589] Compare generations only on the same prompt-cache
       path in the Java tests (F272)
      
      ChatScenarioTest#testChatCompleteTextMatchesChatCompleteContent,
      ChatAdvancedTest#testCachePromptConsistentOutput and
      MemoryManagementTest#testPromptCacheStableAcrossMultipleCalls set the
      texts of two identical greedy requests equal, with a prompt-cache hit
      between them: the second request takes every prompt token but the last
      from the slot and re-evaluates that one token in a batch of its own
      (upstream [TAG_PROMPT_LOGITS]), whose logits are not bit-identical to
      the batched prompt evaluation of the first request. On a near tie the
      greedy argmax flips, which the MSVC Windows job of run 39702748614 saw:
      "2 plus 2 is 4.<|im" against "4.<|im_end|>".
      
      Measured on the random test model over 4000 prompts: the first token's
      logprob differs between the first and the second request in 3999 pairs
      and the text in 4; the second and the third request, both over the
      cached prefix, agree in all 4000; two requests with cache_prompt=false
      are bit-identical (150 of 150).
      
      ChatScenarioTest sets cache_prompt=false on both requests and proves it
      through the second response's timings (cache_n 0, prompt_n equal to the
      prompt tokens). The other two tests make three requests, assert that
      the second and the third reuse the cache (cache_n == prompt_tokens - 1,
      prompt_n == 1) and compare those two with each other; the first is only
      checked for output. CLAUDE.md records the rule, the ledger carries F272.
      Test and documentation change only.
      
      Co-Authored-By: Claude Fable 5.1 
      Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2
      ---
       BUGHUNT-FOUND.md                              | 13 ++++++
       CLAUDE.md                                     | 14 ++++++
       .../net/ladenthin/llama/ChatAdvancedTest.java | 43 +++++++++++++++----
       .../net/ladenthin/llama/ChatScenarioTest.java | 25 +++++++++--
       .../ladenthin/llama/MemoryManagementTest.java | 34 +++++++++++----
       5 files changed, 107 insertions(+), 22 deletions(-)
      
      diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md
      index 9edea1aae..498443c29 100644
      --- a/BUGHUNT-FOUND.md
      +++ b/BUGHUNT-FOUND.md
      @@ -193,6 +193,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - [F269](#f269) — mittel — *gefixt*
       - [F270](#f270) — niedrig (Testfehler) — *gefixt*
       - [F271](#f271) — niedrig bis mittel — *gefixt*
      +- [F272](#f272) — niedrig (Testfehler) — *gefixt*
       
       ## Funde
       
      @@ -2540,6 +2541,18 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU
       - **Status:** gefixt
       - **Commit:** java-llama.cpp `c59569a`
       
      +
      +### F272 – java-llama.cpp: `ChatScenarioTest`, `ChatAdvancedTest` und `MemoryManagementTest` vergleichen zwei Generierungen über einen Prompt-Cache-Treffer hinweg
      +- **Zeit:** 2026-10-22 09:31 UTC
      +- **Datei:** `llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java` (`testChatCompleteTextMatchesChatCompleteContent`), `ChatAdvancedTest.java` (`testCachePromptConsistentOutput`), `MemoryManagementTest.java` (`testPromptCacheStableAcrossMultipleCalls`); `CLAUDE.md` (Regel unter „Java tests“)
      +- **Schwere:** niedrig (Testfehler; der MSVC-Windows-Job des Laufs 39702748614 rot: `ChatScenarioTest.testChatCompleteTextMatchesChatCompleteContent:163 … expected: <4.<|im_end|>\n> but was: <2 plus 2 is 4.<|im>`)
      +- **Historie:** Die drei Tests stellen zwei bzw. drei identische Greedy-Anfragen (Temperatur 0, fester Seed) und setzen die Texte gleich. Zwischen der ersten und der zweiten Anfrage liegt ein Prompt-Cache-Treffer: mit `cache_prompt` (Vorgabe) übernimmt die zweite alle Prompt-Token bis auf das letzte aus dem Slot und wertet nur dieses eine neu aus (upstream `server-context.cpp`, `[TAG_PROMPT_LOGITS]`: „need to evaluate at least 1 token for each active slot“, „n_past was set to 158“). Die Logits dieser Ein-Token-Auswertung sind nicht bitgleich mit denen der gebündelten Prompt-Auswertung der ersten Anfrage (andere Batch-Form, andere Kernel und Summationsreihenfolge), und bei einem knappen Gleichstand kippt der Greedy-Argmax: CodeLlama 7B Q2_K antwortete auf „What is 2 plus 2?“ einmal „2 plus 2 is 4.<|im“ und einmal „4.<|im_end|>“. Gemessen am Zufallsmodell `tiny2k.gguf` (2 Schichten, 4000 Zufallsprompts, je drei Anfragen, `n_probs` 3): erste gegen zweite Anfrage – Logprob des ersten erzeugten Tokens verschieden in 3999 von 4000 Paaren (höchstens 5,6e-3), Text verschieden in 4 (etwa `'1Q]RNQ'` gegen `'Q]RNQ]'` bei `cache_n` 112, `prompt_n` 1); ohne Cache (150 Paare) 0 Unterschiede, bitgleich. Zweite gegen dritte Anfrage (beide über demselben Cache-Präfix, je ein Token neu): 0 Text- und 0 Logprob-Unterschiede in 4000 Paaren; in allen 8000 Cache-Anfragen galt `cache_n == prompt_tokens - 1` und `prompt_n == 1`. Der Vergleich erste gegen zweite war damit eine Lotterie mit kleiner Verlustwahrscheinlichkeit, kein Nachweis – dieselbe Klasse wie F270.
      +- **Fix:** `ChatScenarioTest`: beide Anfragen mit `withCachePrompt(false)`; die zweite (`chatComplete`, die Rohantwort) belegt die Vorbedingung über `timings` (`cache_n` 0, `prompt_n` gleich `usage.prompt_tokens`), sodass ein später entferntes Flag den Test deterministisch rot macht statt ihn zur Lotterie zurückzubauen. `ChatAdvancedTest` und `MemoryManagementTest`: drei Anfragen über `completeWithStats`; die zweite und die dritte müssen den Cache nutzen (`cache_n == prompt_tokens - 1`, `prompt_n == 1`) und einander gleichen; die erste wird nur auf nicht-leer geprüft und absichtlich nicht mit ihnen verglichen (die Javadoc nennt den Grund). `CLAUDE.md` hält die Regel fest. Kein Produktionscode: das Verhalten ist upstreams und kein Fehler, der Server muss für die Logits mindestens ein Token auswerten.
      +- **Tests:** dieselben drei Tests. Messprogramm im Sandkasten (nicht eingecheckt): je Prompt zwei bzw. drei `completeWithStats` mit `n_probs`, Vergleich von Text, erstem Logprob und `timings`.
      +- **Rot-Grün-Nachweis:** rot im CI-Lauf 39702748614 (Windows 2025 x86-64, MSVC) mit dem alten Vergleich; lokal mit dem Zufallsmodell der alte Vergleich (erste gegen zweite Anfrage) rot in 4 von 4000 Prompts, der neue (zweite gegen dritte) grün in 4000 von 4000; die drei umgebauten Tests grün mit `tiny2k.gguf` (je 1 Test, 0 übersprungen); Spotless. Modellgebunden (CodeLlama 7B Q2_K): grün erwartet im nächsten Lauf.
      +- **Status:** gefixt
      +- **Commit:** (folgt)
      +
       ## Offene Verdachte ohne Fund-ID
       
       - *(geschlossen 2026-10-19, ohne Fund-ID, Entscheidung des Nutzers: bewusst nicht ändern)* Continue entscheidet die FIM-Fähigkeit eines Ollama-Modells an `{{ .Suffix }}` im `template` von `/api/show`; wir liefern `""`. Ein Platzhalter-Template wäre erfunden (der Server kennt das Prompt-Format des Modells nicht) und für die meisten Modelle falsch. Continue nutzt dann eigene Vorlagen; für die FIM-Token des Modells gibt es `POST /infill` (Continues `llama.cpp`-Provider). README-Hinweis ergänzt.
      diff --git a/CLAUDE.md b/CLAUDE.md
      index 8e2bca94d..427eaa15a 100644
      --- a/CLAUDE.md
      +++ b/CLAUDE.md
      @@ -2601,6 +2601,20 @@ Locally the model tests self-skip when a GGUF is absent (`Assume.assumeTrue`), s
       checkout is fine.
       
       
      +**Never compare two generations across a prompt-cache hit.** A second request with the same (or a
      +prefix-equal) prompt and `cache_prompt` (the default) takes every prompt token but the last from the
      +slot and re-evaluates that one token in a batch of its own (upstream `[TAG_PROMPT_LOGITS]`); its
      +logits are not bit-identical to the batched prompt evaluation of the first request, and a greedy near
      +tie flips -- measured on the random test model over 4000 prompts: the first token's logprob differs
      +in 3999 first-vs-second pairs and the text in 4, while the second and the third request (both over
      +the cached prefix) agree in all 4000 and two requests with `cache_prompt=false` are bit-identical.
      +`ChatScenarioTest#testChatCompleteTextMatchesChatCompleteContent` was that lottery and lost once on
      +the MSVC Windows job (run 39702748614, F272). So a test that needs two generations to agree either
      +sets `withCachePrompt(false)` on both and proves it through the response's `timings` (`cache_n` 0,
      +`prompt_n` equal to the prompt tokens), or compares the second with the third cached request and
      +asserts the reuse (`cache_n == prompt_tokens - 1`, `prompt_n == 1`) -- never the first with the second.
      +
      +
       Test files are in `src/test/java/net/ladenthin/llama/` and `src/test/java/examples/`.
       
       ### C++ unit tests
      diff --git a/llama/src/test/java/net/ladenthin/llama/ChatAdvancedTest.java b/llama/src/test/java/net/ladenthin/llama/ChatAdvancedTest.java
      index d3d134b4f..ef34002d0 100644
      --- a/llama/src/test/java/net/ladenthin/llama/ChatAdvancedTest.java
      +++ b/llama/src/test/java/net/ladenthin/llama/ChatAdvancedTest.java
      @@ -31,7 +31,7 @@
        * Advanced inference parameter scenarios covering code paths untested by
        * {@link LlamaModelTest} and {@link ChatScenarioTest}:
        * 
        - *
      • setCachePrompt — repeated call produces consistent output
      • + *
      • setCachePrompt — the second and third call reuse the cached prefix and agree
      • *
      • nPredict=-1 with stop string — unbounded generation terminates
      • *
      • setNProbs — streaming JSON contains probability data
      • *
      • setChatTemplate — custom Jinja template applied by applyTemplate
      • @@ -78,13 +78,21 @@ public static void tearDown() { } // ------------------------------------------------------------------ - // 1. setCachePrompt — repeated call with same prompt is consistent + // 1. setCachePrompt — repeated calls over the cached prefix are consistent // ------------------------------------------------------------------ /** - * Two identical calls with {@code setCachePrompt(true)} and a fixed seed - * must produce the same output. This verifies that prompt caching does not - * corrupt the KV cache or alter sampling state between requests. + * Three identical greedy calls with {@code cache_prompt=true}: the second and the third reuse the + * slot's prefix (every prompt token but the last, which upstream re-evaluates so that the request + * has logits) and must produce the same output, because they compute the same thing over the same + * cache -- which verifies that prompt caching does not corrupt the KV cache or alter the sampling + * state between requests. The first call is deliberately not compared with them: it evaluated the + * prompt in one batch, and the one-token re-evaluation does not reproduce its logits bit for bit + * (measured on the random test model over 4000 prompts: the first token's logprob differs in 3999 + * first-vs-second pairs and the greedy text in 4 of them, against 0 of 4000 between the second and + * the third call and 0 of 150 without the cache), so a greedy near tie can flip between a batched + * and a cached generation -- what the MSVC Windows job of run 39702748614 saw in + * {@code ChatScenarioTest}. */ @Test public void testCachePromptConsistentOutput() { @@ -94,11 +102,28 @@ public void testCachePromptConsistentOutput() { .withTemperature(0.0f) .withCachePrompt(true); - String first = model.complete(params); - String second = model.complete(params); + CompletionResult first = model.completeWithStats(params); + CompletionResult second = model.completeWithStats(params); + CompletionResult third = model.completeWithStats(params); + + assertFalse(first.getText().isEmpty(), "First cached call must produce output"); + assertCachedPrefixReused(second); + assertCachedPrefixReused(third); + assertEquals( + second.getText(), + third.getText(), + "Two generations over the same cached prefix must produce identical output"); + } - assertFalse(first.isEmpty(), "First cached call must produce output"); - assertEquals(first, second, "Same prompt with cache_prompt must produce identical output"); + private static void assertCachedPrefixReused(CompletionResult result) { + assertEquals( + result.getUsage().getPromptTokens() - 1, + result.getTimings().getCacheN(), + "every prompt token but the last must come from the cache: " + result.getRawJson()); + assertEquals( + 1, + result.getTimings().getPromptN(), + "only the last prompt token must be evaluated: " + result.getRawJson()); } // ------------------------------------------------------------------ diff --git a/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java b/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java index d2b28548b..6768ac4a6 100644 --- a/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java +++ b/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java @@ -20,6 +20,7 @@ import net.ladenthin.llama.json.CompletionResponseParser; import net.ladenthin.llama.parameters.InferenceParameters; import net.ladenthin.llama.parameters.ModelParameters; +import net.ladenthin.llama.value.ChatResponse; import net.ladenthin.llama.value.LlamaOutput; import net.ladenthin.llama.value.Pair; import org.junit.jupiter.api.AfterAll; @@ -142,8 +143,14 @@ public void testChatCompleteTextReturnsPlainString() { } /** - * chatCompleteText() must return the same content as extracting choices[0].message.content - * from the raw chatComplete() JSON. + * {@code chatCompleteText} must return exactly {@code choices[0].message.content} of the response + * {@code chatComplete} gives for the same request. The two calls are two generations, so they agree + * only when both compute the same thing: {@code cache_prompt=false} makes each of them evaluate the + * whole prompt in one batch. With the cache the second request reuses the slot's prefix and + * re-evaluates one token in a batch of its own (upstream {@code [TAG_PROMPT_LOGITS]}), whose logits + * are not bit-identical to the batched ones, and a greedy near tie then flips -- the MSVC Windows + * job of run 39702748614 answered "2 plus 2 is 4.<|im" once and "4.<|im_end|>" once. The raw + * response of the second call proves the precondition: nothing was taken from the cache. */ @Test public void testChatCompleteTextMatchesChatCompleteContent() { @@ -154,11 +161,21 @@ public void testChatCompleteTextMatchesChatCompleteContent() { .withMessages("You are a helpful assistant.", messages) .withNPredict(N_PREDICT) .withSeed(42) - .withTemperature(0.0f); + .withTemperature(0.0f) + .withCachePrompt(false); - String rawJson = model.chatComplete(params); String text = model.chatCompleteText(params); + String rawJson = model.chatComplete(params); + ChatResponse response = chatParser.parseResponse(rawJson); + assertEquals( + 0, + response.getTimings().getCacheN(), + "the second generation must take nothing from the prompt cache: " + rawJson); + assertEquals( + response.getUsage().getPromptTokens(), + response.getTimings().getPromptN(), + "the second generation must evaluate the whole prompt: " + rawJson); String expected = chatParser.extractChoiceContent(rawJson); assertEquals(expected, text, "chatCompleteText must match choices[0].message.content"); } diff --git a/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java b/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java index 829083e23..b30624898 100644 --- a/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java +++ b/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java @@ -265,9 +265,13 @@ public void testPromptCachePrefixReuseSucceeds() { } /** - * Three consecutive calls with the same prompt, seed, and {@code cache_prompt=true} must all - * produce identical output. The third call reuses the cache path that was already exercised - * by the second call, verifying that the cache state remains stable across multiple reuses. + * Three consecutive calls with the same prompt, seed and {@code cache_prompt=true}: the second and + * the third both reuse the cached prefix (every prompt token but the last, which upstream + * re-evaluates) and must produce identical output, which verifies that the cache state stays + * stable across repeated reuses. The first call is not compared with them on purpose: it + * evaluated the prompt in one batch, and the one-token re-evaluation does not reproduce those + * logits bit for bit, so a greedy near tie can flip between the two paths (see + * {@code ChatAdvancedTest#testCachePromptConsistentOutput}). */ @Test public void testPromptCacheStableAcrossMultipleCalls() { @@ -277,13 +281,25 @@ public void testPromptCacheStableAcrossMultipleCalls() { .withTemperature(0f) .withSeed(77); - String first = model.complete(params); - String second = model.complete(params); - String third = model.complete(params); + CompletionResult first = model.completeWithStats(params); + CompletionResult second = model.completeWithStats(params); + CompletionResult third = model.completeWithStats(params); - assertFalse(first.isEmpty(), "First call must produce output"); - assertEquals(first, second, "Second call must match first"); - assertEquals(first, third, "Third call must match first"); + assertFalse(first.getText().isEmpty(), "First call must produce output"); + for (CompletionResult cached : new CompletionResult[] {second, third}) { + assertEquals( + cached.getUsage().getPromptTokens() - 1, + cached.getTimings().getCacheN(), + "every prompt token but the last must come from the cache: " + cached.getRawJson()); + assertEquals( + 1, + cached.getTimings().getPromptN(), + "only the last prompt token must be evaluated: " + cached.getRawJson()); + } + assertEquals( + second.getText(), + third.getText(), + "Third call must match the second: both generate over the same cached prefix"); } // ------------------------------------------------------------------ From e2c165775d3bc5e1496cfc48d3a05fc71fc40a6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 09:33:36 +0000 Subject: [PATCH 581/589] Record the commit of F272 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 498443c29..c9cca75e9 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2551,7 +2551,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Tests:** dieselben drei Tests. Messprogramm im Sandkasten (nicht eingecheckt): je Prompt zwei bzw. drei `completeWithStats` mit `n_probs`, Vergleich von Text, erstem Logprob und `timings`. - **Rot-Grün-Nachweis:** rot im CI-Lauf 39702748614 (Windows 2025 x86-64, MSVC) mit dem alten Vergleich; lokal mit dem Zufallsmodell der alte Vergleich (erste gegen zweite Anfrage) rot in 4 von 4000 Prompts, der neue (zweite gegen dritte) grün in 4000 von 4000; die drei umgebauten Tests grün mit `tiny2k.gguf` (je 1 Test, 0 übersprungen); Spotless. Modellgebunden (CodeLlama 7B Q2_K): grün erwartet im nächsten Lauf. - **Status:** gefixt -- **Commit:** (folgt) +- **Commit:** java-llama.cpp `85ebbfd` ## Offene Verdachte ohne Fund-ID From ff3ee6b5cb8159590d5bf416eb3e344b789a60f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 09:57:04 +0000 Subject: [PATCH 582/589] Upgrade llama.cpp from b11803 to b11809 Six upstream commits, the first runbook chunk on the way to b11815: LoRA adapters on prism.hadamard folded matmul weights (#30910, the delta reads the primal input; only a latent lookup table is still refused), dflash's optional fc.input_scale (#30945), an OpenCL F32-to-I32 ARGMAX kernel (#30897, embedded by the two OpenCL module builds), WebGPU Q2_0 (#30914) and two WebUI commits. Nothing in common/, include/, tools/server/, tools/mtmd/, the root CMakeLists.txt or upstream's .github/, so no source change here, the server contract and the toolchain pins are unchanged, and all 12 patches apply as they are (fresh configure at 0b1281aee, check-patches.py 12/88/0). Built from the pin in a fresh tree, ctest 686/686. CHANGELOG, TODO.md and the history rows updated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- CHANGELOG.md | 8 ++++++-- CLAUDE.md | 10 +++++----- README.md | 2 +- TODO.md | 5 +++-- docs/history/llama-cpp-breaking-changes.md | 2 ++ llama/CMakeLists.txt | 4 ++-- .../net/ladenthin/llama/value/LlamaCppVersion.java | 8 ++++---- 7 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93cf918b3..d656f8126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,7 +136,7 @@ the first two items break every existing consumer at upgrade. fails the stream through `onError`. ### Added -- **llama.cpp b11803** (from b11639, in five steps on the branch: b11760, b11761, b11799, b11800, b11803). What the +- **llama.cpp b11809** (from b11639, in six steps on the branch: b11760, b11761, b11799, b11800, b11803, b11809). What the range brings to a consumer: `POST /tts` (llama.cpp b11781, #26603, speech from a TTS-capable model through the embedded server) and the realtime transcription WebSocket `/v1/realtime` (b11780, #30636), both served by `NativeServer` -- classic and attach mode -- and not by the Java transport or `LlamaModel` yet; a @@ -146,7 +146,11 @@ the first two items break every existing consumer at upgrade. (b11761, this project's #30505); two architectures from the last step, Flint (`FlintForCausalLM`, b11803, #30928) and SenseNova U1 (text plus its vision projector in mtmd, #28919), and a jinja fix (#30941: a `namespace` attribute is no longer shadowed by a dict method of the same name, so `ns.items` is the - attribute again, as in jinja2). Two patches dropped (`0016`, `0019`), `0007` refreshed. + attribute again, as in jinja2). The b11809 step (2026-10-22): LoRA adapters on prism.hadamard + folded matmul weights load now (#30910; an adapter on a latent lookup table such as `token_embd` is + still refused), dflash reads an optional `fc.input_scale`, OpenCL gets an F32-to-I32 ARGMAX kernel + (#30897, embedded in the two OpenCL module jars) and WebGPU Q2_0 -- nothing here changed. Two + patches dropped (`0016`, `0019`), `0007` refreshed. - **`RouterModel.Status.INCOMPLETE`** (llama.cpp b11714): a router download that never finished is listed as `"incomplete"` without a worker; it was `UNKNOWN` here. - **`/v1/messages` passes `id_slot` through and merges `extra_params`** like upstream (llama.cpp b11756 and diff --git a/CLAUDE.md b/CLAUDE.md index 427eaa15a..fda7e4da1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Java bindings for [llama.cpp](https://github.com/ggerganov/llama.cpp) via JNI, providing a high-level API for LLM inference in Java. The Java layer communicates with a native C++ library through JNI. -Current llama.cpp pinned version: **b11803** +Current llama.cpp pinned version: **b11809** ## Natives jars: library jars and GPU module jars (`.github/natives.csv`) @@ -213,7 +213,7 @@ measurements behind the Windows step are in `docs/handover/local-agent-report-b1 a `llama-common`/`cpp-httplib` that came out shared (one more file to ship). On the static platforms (macOS, s390x) all of it stays inside `libjllama`. **Every ggml default llama.cpp's own CMakeLists changes before its `add_subdirectory(ggml)` is repeated** -- `GGML_LLAMAFILE_DEFAULT` - and `GGML_CUDA_GRAPHS_DEFAULT` at b11803 (unchanged since b11639) -- because adding ggml first skips that block; re-check with + and `GGML_CUDA_GRAPHS_DEFAULT` at b11809 (unchanged since b11639) -- because adding ggml first skips that block; re-check with `grep -n _DEFAULT CMakeLists.txt` at the new tag on every bump. The second one was missing once: ggml's own default for CUDA graphs is OFF, and a CUDA module built without it lost two thirds of its token generation (265.6 -> 88.8 tg128 on an RTX 3070, prompt processing -20%; Vulkan, which @@ -1183,7 +1183,7 @@ needs no extra step here, `build-webui` re-reads the tag and rebuilds the matchi ships no UI): ```bash # needs node/npm + network for the asset build; the embed step is plain cmake -P -git clone --depth 1 --branch b11803 https://github.com/ggml-org/llama.cpp /tmp/lc +git clone --depth 1 --branch b11809 https://github.com/ggml-org/llama.cpp /tmp/lc ( cd /tmp/lc/tools/ui && npm ci && npm run build ) mkdir -p webui-generated /tmp/ui-gen cmake -DUI_SOURCE_DIR=/tmp/lc/tools/ui -DUI_BINARY_DIR=/tmp/ui-gen \ @@ -1223,7 +1223,7 @@ cache lives in **Depot Cache** over sccache's **WebDAV** backend: - `SCCACHE_WEBDAV_TOKEN: ${{ secrets.DEPOT_TOKEN }}` — a Depot **organization** token, stored as the repo secret **`DEPOT_TOKEN`**. -Because `sccache` is **content-addressed** and llama.cpp is pinned (`JLLAMA_GIT_TAG b11803`), the +Because `sccache` is **content-addressed** and llama.cpp is pinned (`JLLAMA_GIT_TAG b11809`), the ~280 upstream object files are byte-identical every run, so a warm cache recompiles only the *changed* files. Depot's cache is **shared across all branches** (unlike GitHub's per-branch `actions/cache`), so every branch builds incrementally; a `b` version bump @@ -2667,7 +2667,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" #### Upstream source location (in CMake build tree) -llama.cpp is fetched via CMake FetchContent, pinned to `JLLAMA_GIT_TAG b11803`. +llama.cpp is fetched via CMake FetchContent, pinned to `JLLAMA_GIT_TAG b11809`. **GoogleTest** is a separate `BUILD_TESTING`-only FetchContent (`GIT_TAG v1.18.0`), used solely by the `jllama_test` C++ unit-test binary — not by the shipped library, and not coupled to the diff --git a/README.md b/README.md index 0ede02e07..a74462f40 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **Build:** ![Java 8+](https://img.shields.io/badge/Java-8%2B-informational) ![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows%20%7C%20Android-lightgrey) -[![llama.cpp b11803](https://img.shields.io/badge/llama.cpp-%23b11803-informational)](https://github.com/ggml-org/llama.cpp/releases/tag/b11803) +[![llama.cpp b11809](https://img.shields.io/badge/llama.cpp-%23b11809-informational)](https://github.com/ggml-org/llama.cpp/releases/tag/b11809) [![JPMS](https://img.shields.io/badge/JPMS-modular%20JAR-25A162)](https://openjdk.org/projects/jigsaw/) ![JUnit](https://img.shields.io/badge/tested%20with-JUnit6-25A162) [![JSpecify](https://img.shields.io/badge/JSpecify-1.0.1%20%40NullMarked-25A162)](https://jspecify.dev) diff --git a/TODO.md b/TODO.md index 10bd9c760..48ed6b453 100644 --- a/TODO.md +++ b/TODO.md @@ -568,8 +568,9 @@ stays a no-op -- callers repeat the stop until the serve returns, as `RpcServer` ### After the llama.cpp bump to b11800 (2026-10-21): what the range offers and is not wired yet -The bump itself is done (b11639 -> b11760 -> b11761 -> b11799 -> b11800, and b11803 on 2026-10-21: Flint, SenseNova -U1 and a jinja fix, nothing to wire; patches `0016` and `0019` dropped, `0007` refreshed; +The bump itself is done (b11639 -> b11760 -> b11761 -> b11799 -> b11800, b11803 on 2026-10-21: Flint, SenseNova +U1 and a jinja fix, nothing to wire; b11809 on 2026-10-22: LoRA on prism.hadamard folded weights, an OpenCL +ARGMAX kernel, nothing to wire; patches `0016` and `0019` dropped, `0007` refreshed; `docs/history/llama-cpp-breaking-changes.md` has the rows). Four things the range brings that this library does not expose, each a decision rather than an omission: diff --git a/docs/history/llama-cpp-breaking-changes.md b/docs/history/llama-cpp-breaking-changes.md index d018633af..c95300eda 100644 --- a/docs/history/llama-cpp-breaking-changes.md +++ b/docs/history/llama-cpp-breaking-changes.md @@ -905,3 +905,5 @@ Used during `llama.cpp` version bumps: when upgrading, scan this file from the r | b11799–b11800 | patches | **All 12 apply unchanged** (`git apply` in order on a pristine `d454a204e` worktree; `check-patches.py`: 12 patches, 88 hunks, 0 problems). The commit touches one patch-target file, `ggml-rpc.h` (the version defines above, outside `0015`'s hunk); the drop-checks of the b11799 row stand, every other patch-target file is untouched. | | b11800–b11803 | `src/`, `tools/mtmd/`, `common/jinja/value.cpp` (3 commits, `a37cc4610` = b11803, 2026-10-21 .. 2026-10-22) | **Two architectures and a jinja fix, no source change here.** Flint (`FlintForCausalLM`, #30928, b11803): `LLM_ARCH_FLINT`, `src/models/flint.cpp`, pre-tokenizer `flint` (`LLAMA_VOCAB_PRE_TYPE_FLINT` = 62, its own regex), and two shared edits -- `build_amax_src1_f16()` in `src/llama-graph.cpp` scales the row maximum by `1/1024` instead of `1/32768` ("x32 headroom for the F16 sums of 32 values in Q8 activation blocks", a numerical change for every architecture on that path, Mistral 4 / Mellum / Flint), and `src/llama-kv-cache.cpp` applies RoPE to the cache only when some layer has it (`hparams.has_rope(il)`), so an architecture without RoPE in any layer skips it. SenseNova U1 (#28919, b11801): `LLM_ARCH_SENSENOVA_U1`, `src/models/sensenova-u1.cpp`, and its vision projector in mtmd -- `PROJECTOR_TYPE_SENSENOVA_U1` (`tools/mtmd/models/sensenova-u1.cpp`, a new source `tools/mtmd/CMakeLists.txt` lists, so `mtmd` picks it up by itself), ``/`` markers, dynamic-size preprocessing, and `MTMD_POS_TYPE_SENSENOVA_U1` (M-RoPE with zero-based image H/W and one temporal position per image); `mtmd.h` is untouched. jinja (#30941, b11802): `namespace` objects no longer carry the dict built-ins, so an attribute named like a dict method (`ns.items`) resolves to the attribute, as in jinja2 -- a template-rendering change the `--jinja` path inherits. Nothing in `common/*.h`, `include/`, `ggml/`, `tools/server/` or the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged); the server request-field set, its bounds and the response keys are identical between the tags (the three greps of the runbook); upstream's `.github/` is untouched, so the OpenVINO, CCCL, ROCm and oneAPI pins stay. | | b11800–b11803 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `a37cc4610`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). No patch-target file is in the range; the drop-checks read at b11803 give the b11799 answers: `0014` -- `common/log.h` names only `common_log_default_callback`; `0015` -- `GGML_ABORT("Failed to connect` still at `ggml-rpc.cpp:650`, no `stop_server`; `0017` -- the four `_mm_prefetch` calls at `quants.c` 846/847/864/865; `0018` -- `free(buffer->context)` still at `amx.cpp:46`; `0020` -- `ggml_gallocr_needs_realloc()` still compares only `n_leafs`; `0021` -- the `else` branch of the per-request LoRA block still takes `params_base.lora_adapters` without the clear; `0001` -- `argv = utf8.ptrs.data()` still at `arg.cpp:1288`, no `common_params_parse_main` in `arg.h`; `0002` -- the server still installs its own `load_progress_callback` unconditionally; `0008` -- `get_server_exec_path()` still the worker binary. | +| b11803–b11809 | `src/llama-adapter.cpp`, `src/llama-graph.cpp`, `src/llama-model.h`, `src/models/dflash.cpp`, `ggml/src/ggml-opencl/`, `ggml/src/ggml-webgpu/`, `tools/ui/` (6 commits, `0b1281aee` = b11809, 2026-10-21 .. 2026-10-22) | **No source change here.** LoRA on prism.hadamard folded weights (#30910, b11806): `build_lora_mm` / `build_lora_mm_id` feed the folded weight the Hadamard-transformed input and the LoRA delta the primal `cur`, so an adapter on a folded matmul weight is accepted now; only a latent lookup table (`token_embd`, the tied head) is still refused ("targets a prism.hadamard latent lookup table") -- a message `test_lora_cache.cpp`'s random adapter never meets, its random model carrying no Hadamard blocks. dflash (#30945, b11808): an optional `fc.input_scale` tensor (`fc_in_s`, `TENSOR_NOT_REQUIRED`). OpenCL F32-to-I32 ARGMAX kernel (#30897, b11804; `argmax.cl` joins `GGML_OPENCL_KERNELS`, so the embedded-kernel builds of the two OpenCL module jars pick it up by themselves), WebGPU Q2_0 (#30914, b11809, not built here), two WebUI commits (#30681, #30919, auto-followed by `build-webui`). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). **Verified at b11809**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | +| b11803–b11809 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `0b1281aee`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). No patch-target file is in the range -- `git diff --name-only b11803..b11809` names no `common/arg.*`, `common/log.*`, `tools/server/*`, `ggml-rpc`, `ggml-alloc.c`, `amx.cpp` or `arch/x86/quants.c`, and no `tests/*` or `examples/*` file at all -- so the drop-check answers of the b11803 row stand unchanged. | diff --git a/llama/CMakeLists.txt b/llama/CMakeLists.txt index e057fc909..563c7b261 100644 --- a/llama/CMakeLists.txt +++ b/llama/CMakeLists.txt @@ -347,8 +347,8 @@ endif() # hash, every other consumer keeps the number, and the pin check after FetchContent_MakeAvailable # asserts that the fetched commit's count IS the number. Clear it once the tag exists; # `llama-next-version.sh` says when, and `llama-next-version.sh --resolve b` prints the hash. -set(JLLAMA_GIT_TAG b11803) -set(JLLAMA_GIT_COMMIT "a37cc46104eb50900d5ab4a9ba02a0abdac039db") # the commit b11803 names; kept after the tag exists +set(JLLAMA_GIT_TAG b11809) +set(JLLAMA_GIT_COMMIT "0b1281aeef1838bf5848de7848a17e8d12747770") # the commit b11809 names; kept after the tag exists if(NOT JLLAMA_GIT_TAG MATCHES "^b[0-9]+$") message(FATAL_ERROR "JLLAMA_GIT_TAG must be a llama.cpp release number b, got '${JLLAMA_GIT_TAG}'") endif() diff --git a/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java b/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java index c6e006629..ce97e1f26 100644 --- a/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java +++ b/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java @@ -9,13 +9,13 @@ * library was compiled against, exposed as a compile-time constant so callers can render a badge or * emit a startup log line without loading the native library. * - *

        {@link #LLAMA_CPP_VERSION} is a pure-Java string ({@code "b11803"}) that mirrors the + *

        {@link #LLAMA_CPP_VERSION} is a pure-Java string ({@code "b11809"}) that mirrors the * {@code GIT_TAG} in {@code llama/CMakeLists.txt}. It is available even when {@code libjllama} is * absent (pure-Java checkout, before {@code System.load}), which is what makes it suitable for a * lightweight version badge in Android or other UIs.

        * *

        For the authoritative value that is baked into the native binary — the build number - * plus the resolved upstream commit, e.g. {@code "b11803-"} — call + * plus the resolved upstream commit, e.g. {@code "b11809-"} — call * {@link net.ladenthin.llama.LlamaModel#getLlamaCppBuildInfo()} instead; that reads llama.cpp's own * {@code build-info} through JNI and therefore cannot drift from the compiled library (but requires * the native library to be loaded).

        @@ -23,14 +23,14 @@ public final class LlamaCppVersion { /** - * The pinned llama.cpp release tag this library was built against, e.g. {@code "b11803"}. + * The pinned llama.cpp release tag this library was built against, e.g. {@code "b11809"}. * *

        Kept in lockstep with {@code GIT_TAG} in {@code llama/CMakeLists.txt} — see the * "Upgrading/Downgrading llama.cpp Version" checklist in {@code CLAUDE.md}. This is the * compile-time pin; use {@link net.ladenthin.llama.LlamaModel#getLlamaCppBuildInfo()} for the * value actually linked into the native binary.

        */ - public static final String LLAMA_CPP_VERSION = "b11803"; + public static final String LLAMA_CPP_VERSION = "b11809"; // Constants holder — not instantiable. private LlamaCppVersion() {} From 8e0dedb0b7bdc5bf945a18d5788d0dd980d992da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:08:26 +0000 Subject: [PATCH 583/589] Upgrade llama.cpp from b11809 to b11814 Five upstream commits, the second runbook chunk on the way to b11815: tokenizer.ggml.clean_spaces as a GGUF key (#30940, with the pre-tokenizer defaults turning the space clean-up off for lfm2, glm4/glm5, dbrx, exaone4, mellum2, minicpm5, gemma-4 and chatglm-bpe), the Pixtral pre-tokenizer moved to Tekken (#30960), the granite graphs recording their layer inputs (#30957), KleidiAI v1.32.0 with SVE Q8_0 kernels (#30808, not built here) and the SYCL dequant switches merged (#30740). Nothing in common/, include/, tools/server/, tools/mtmd/, the root CMakeLists.txt or upstream's .github/, so no source change here, the server contract and the toolchain pins are unchanged, and all 12 patches apply as they are (fresh configure at d88040c70, check-patches.py 12/88/0). Built from the pin in a fresh tree, ctest 686/686. CHANGELOG, TODO.md and the history rows updated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- CHANGELOG.md | 11 ++++++++--- CLAUDE.md | 10 +++++----- README.md | 2 +- TODO.md | 3 ++- docs/history/llama-cpp-breaking-changes.md | 2 ++ llama/CMakeLists.txt | 4 ++-- .../net/ladenthin/llama/value/LlamaCppVersion.java | 8 ++++---- 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d656f8126..039ddfec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,7 +136,7 @@ the first two items break every existing consumer at upgrade. fails the stream through `onError`. ### Added -- **llama.cpp b11809** (from b11639, in six steps on the branch: b11760, b11761, b11799, b11800, b11803, b11809). What the +- **llama.cpp b11814** (from b11639, in seven steps on the branch: b11760, b11761, b11799, b11800, b11803, b11809, b11814). What the range brings to a consumer: `POST /tts` (llama.cpp b11781, #26603, speech from a TTS-capable model through the embedded server) and the realtime transcription WebSocket `/v1/realtime` (b11780, #30636), both served by `NativeServer` -- classic and attach mode -- and not by the Java transport or `LlamaModel` yet; a @@ -149,8 +149,13 @@ the first two items break every existing consumer at upgrade. attribute again, as in jinja2). The b11809 step (2026-10-22): LoRA adapters on prism.hadamard folded matmul weights load now (#30910; an adapter on a latent lookup table such as `token_embd` is still refused), dflash reads an optional `fc.input_scale`, OpenCL gets an F32-to-I32 ARGMAX kernel - (#30897, embedded in the two OpenCL module jars) and WebGPU Q2_0 -- nothing here changed. Two - patches dropped (`0016`, `0019`), `0007` refreshed. + (#30897, embedded in the two OpenCL module jars) and WebGPU Q2_0 -- nothing here changed. The b11814 + step (2026-10-22): `tokenizer.ggml.clean_spaces` is a GGUF key (#30940; the converter writes it from + `tokenizer_config.json`, and the pre-tokenizer defaults turn the clean-up of spaces before punctuation + off for `lfm2`, `glm4`/`glm5`, `dbrx`, `exaone4`, `mellum2`, `minicpm5`, `gemma-4` and `chatglm-bpe` + models, whose detokenized text keeps that space now), Pixtral GGUFs tokenize with the Tekken + pre-tokenizer (#30960), KleidiAI v1.32.0 with SVE Q8_0 kernels (#30808, not built here) -- nothing + here changed. Two patches dropped (`0016`, `0019`), `0007` refreshed. - **`RouterModel.Status.INCOMPLETE`** (llama.cpp b11714): a router download that never finished is listed as `"incomplete"` without a worker; it was `UNKNOWN` here. - **`/v1/messages` passes `id_slot` through and merges `extra_params`** like upstream (llama.cpp b11756 and diff --git a/CLAUDE.md b/CLAUDE.md index fda7e4da1..28b347bd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Java bindings for [llama.cpp](https://github.com/ggerganov/llama.cpp) via JNI, providing a high-level API for LLM inference in Java. The Java layer communicates with a native C++ library through JNI. -Current llama.cpp pinned version: **b11809** +Current llama.cpp pinned version: **b11814** ## Natives jars: library jars and GPU module jars (`.github/natives.csv`) @@ -213,7 +213,7 @@ measurements behind the Windows step are in `docs/handover/local-agent-report-b1 a `llama-common`/`cpp-httplib` that came out shared (one more file to ship). On the static platforms (macOS, s390x) all of it stays inside `libjllama`. **Every ggml default llama.cpp's own CMakeLists changes before its `add_subdirectory(ggml)` is repeated** -- `GGML_LLAMAFILE_DEFAULT` - and `GGML_CUDA_GRAPHS_DEFAULT` at b11809 (unchanged since b11639) -- because adding ggml first skips that block; re-check with + and `GGML_CUDA_GRAPHS_DEFAULT` at b11814 (unchanged since b11639) -- because adding ggml first skips that block; re-check with `grep -n _DEFAULT CMakeLists.txt` at the new tag on every bump. The second one was missing once: ggml's own default for CUDA graphs is OFF, and a CUDA module built without it lost two thirds of its token generation (265.6 -> 88.8 tg128 on an RTX 3070, prompt processing -20%; Vulkan, which @@ -1183,7 +1183,7 @@ needs no extra step here, `build-webui` re-reads the tag and rebuilds the matchi ships no UI): ```bash # needs node/npm + network for the asset build; the embed step is plain cmake -P -git clone --depth 1 --branch b11809 https://github.com/ggml-org/llama.cpp /tmp/lc +git clone --depth 1 --branch b11814 https://github.com/ggml-org/llama.cpp /tmp/lc ( cd /tmp/lc/tools/ui && npm ci && npm run build ) mkdir -p webui-generated /tmp/ui-gen cmake -DUI_SOURCE_DIR=/tmp/lc/tools/ui -DUI_BINARY_DIR=/tmp/ui-gen \ @@ -1223,7 +1223,7 @@ cache lives in **Depot Cache** over sccache's **WebDAV** backend: - `SCCACHE_WEBDAV_TOKEN: ${{ secrets.DEPOT_TOKEN }}` — a Depot **organization** token, stored as the repo secret **`DEPOT_TOKEN`**. -Because `sccache` is **content-addressed** and llama.cpp is pinned (`JLLAMA_GIT_TAG b11809`), the +Because `sccache` is **content-addressed** and llama.cpp is pinned (`JLLAMA_GIT_TAG b11814`), the ~280 upstream object files are byte-identical every run, so a warm cache recompiles only the *changed* files. Depot's cache is **shared across all branches** (unlike GitHub's per-branch `actions/cache`), so every branch builds incrementally; a `b` version bump @@ -2667,7 +2667,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" #### Upstream source location (in CMake build tree) -llama.cpp is fetched via CMake FetchContent, pinned to `JLLAMA_GIT_TAG b11809`. +llama.cpp is fetched via CMake FetchContent, pinned to `JLLAMA_GIT_TAG b11814`. **GoogleTest** is a separate `BUILD_TESTING`-only FetchContent (`GIT_TAG v1.18.0`), used solely by the `jllama_test` C++ unit-test binary — not by the shipped library, and not coupled to the diff --git a/README.md b/README.md index a74462f40..275ca9210 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **Build:** ![Java 8+](https://img.shields.io/badge/Java-8%2B-informational) ![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows%20%7C%20Android-lightgrey) -[![llama.cpp b11809](https://img.shields.io/badge/llama.cpp-%23b11809-informational)](https://github.com/ggml-org/llama.cpp/releases/tag/b11809) +[![llama.cpp b11814](https://img.shields.io/badge/llama.cpp-%23b11814-informational)](https://github.com/ggml-org/llama.cpp/releases/tag/b11814) [![JPMS](https://img.shields.io/badge/JPMS-modular%20JAR-25A162)](https://openjdk.org/projects/jigsaw/) ![JUnit](https://img.shields.io/badge/tested%20with-JUnit6-25A162) [![JSpecify](https://img.shields.io/badge/JSpecify-1.0.1%20%40NullMarked-25A162)](https://jspecify.dev) diff --git a/TODO.md b/TODO.md index 48ed6b453..4e2ec7fd7 100644 --- a/TODO.md +++ b/TODO.md @@ -570,7 +570,8 @@ stays a no-op -- callers repeat the stop until the serve returns, as `RpcServer` The bump itself is done (b11639 -> b11760 -> b11761 -> b11799 -> b11800, b11803 on 2026-10-21: Flint, SenseNova U1 and a jinja fix, nothing to wire; b11809 on 2026-10-22: LoRA on prism.hadamard folded weights, an OpenCL -ARGMAX kernel, nothing to wire; patches `0016` and `0019` dropped, `0007` refreshed; +ARGMAX kernel, nothing to wire; b11814 on 2026-10-22: the `tokenizer.ggml.clean_spaces` key and the Pixtral +pre-tokenizer change, nothing to wire; patches `0016` and `0019` dropped, `0007` refreshed; `docs/history/llama-cpp-breaking-changes.md` has the rows). Four things the range brings that this library does not expose, each a decision rather than an omission: diff --git a/docs/history/llama-cpp-breaking-changes.md b/docs/history/llama-cpp-breaking-changes.md index c95300eda..1f0803258 100644 --- a/docs/history/llama-cpp-breaking-changes.md +++ b/docs/history/llama-cpp-breaking-changes.md @@ -907,3 +907,5 @@ Used during `llama.cpp` version bumps: when upgrading, scan this file from the r | b11800–b11803 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `a37cc4610`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). No patch-target file is in the range; the drop-checks read at b11803 give the b11799 answers: `0014` -- `common/log.h` names only `common_log_default_callback`; `0015` -- `GGML_ABORT("Failed to connect` still at `ggml-rpc.cpp:650`, no `stop_server`; `0017` -- the four `_mm_prefetch` calls at `quants.c` 846/847/864/865; `0018` -- `free(buffer->context)` still at `amx.cpp:46`; `0020` -- `ggml_gallocr_needs_realloc()` still compares only `n_leafs`; `0021` -- the `else` branch of the per-request LoRA block still takes `params_base.lora_adapters` without the clear; `0001` -- `argv = utf8.ptrs.data()` still at `arg.cpp:1288`, no `common_params_parse_main` in `arg.h`; `0002` -- the server still installs its own `load_progress_callback` unconditionally; `0008` -- `get_server_exec_path()` still the worker binary. | | b11803–b11809 | `src/llama-adapter.cpp`, `src/llama-graph.cpp`, `src/llama-model.h`, `src/models/dflash.cpp`, `ggml/src/ggml-opencl/`, `ggml/src/ggml-webgpu/`, `tools/ui/` (6 commits, `0b1281aee` = b11809, 2026-10-21 .. 2026-10-22) | **No source change here.** LoRA on prism.hadamard folded weights (#30910, b11806): `build_lora_mm` / `build_lora_mm_id` feed the folded weight the Hadamard-transformed input and the LoRA delta the primal `cur`, so an adapter on a folded matmul weight is accepted now; only a latent lookup table (`token_embd`, the tied head) is still refused ("targets a prism.hadamard latent lookup table") -- a message `test_lora_cache.cpp`'s random adapter never meets, its random model carrying no Hadamard blocks. dflash (#30945, b11808): an optional `fc.input_scale` tensor (`fc_in_s`, `TENSOR_NOT_REQUIRED`). OpenCL F32-to-I32 ARGMAX kernel (#30897, b11804; `argmax.cl` joins `GGML_OPENCL_KERNELS`, so the embedded-kernel builds of the two OpenCL module jars pick it up by themselves), WebGPU Q2_0 (#30914, b11809, not built here), two WebUI commits (#30681, #30919, auto-followed by `build-webui`). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). **Verified at b11809**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | | b11803–b11809 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `0b1281aee`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). No patch-target file is in the range -- `git diff --name-only b11803..b11809` names no `common/arg.*`, `common/log.*`, `tools/server/*`, `ggml-rpc`, `ggml-alloc.c`, `amx.cpp` or `arch/x86/quants.c`, and no `tests/*` or `examples/*` file at all -- so the drop-check answers of the b11803 row stand unchanged. | +| b11809–b11814 | `src/llama-vocab.cpp`, `src/llama-arch.{cpp,h}`, `src/llama-model-saver.cpp`, `src/models/granite*.cpp`, `ggml/src/ggml-cpu/kleidiai/`, `ggml/src/ggml-feats.h`, `ggml/src/ggml-cpu/CMakeLists.txt`, `ggml/src/ggml-sycl/convert.cpp`, `gguf-py/`, `tests/` (5 commits, `d88040c70` = b11814, 2026-10-21 .. 2026-10-22) | **Two tokenizer behaviour changes, no source change here.** `tokenizer.ggml.clean_spaces` (#30940, b11814): the detokenizer's clean-up of spaces before punctuation (transformers' `clean_up_tokenization_spaces`) is a GGUF key now (`LLM_KV_TOKENIZER_CLEAN_SPACES`, written by the converter from `tokenizer_config.json`, read with `ml.get_key(..., false)` after the pre-tokenizer defaults, and written by the model saver, so a GGUF `LlamaTrainer` writes carries it), and the pre-tokenizer table turns it off where it was on before -- `minicpm5`, `lfm2`, `gemma-4` / `granite-embed-multi-311m`, `dbrx`, `chatglm-bpe`, `glm4` / `glm5`, `exaone4`, `mellum2` -- so the detokenized text of those models keeps the space before punctuation that the old clean-up removed. Pixtral (#30960, b11813): pre-tokenizer `pixtral` moved from the Llama-3 pre-type to Tekken (`LLAMA_VOCAB_PRE_TYPE_TEKKEN`, `clean_spaces = false`), a tokenization change for Pixtral GGUFs. Granite (#30957, b11812): the four granite graphs record each layer's input in `t_layer_inp` (the eagle3 / dflash feature-fusion hook), no output change. KleidiAI v1.32.0 with Q8_0 SVE dotprod GEMV and SVE i8mm GEMM kernels (#30808, b11811; `ggml-feats.h` learns `has_svei8mm`) -- `GGML_CPU_KLEIDIAI` is off in every build here and the aarch64 CPU variant list is unchanged (`ggml/src/ggml-cpu/CMakeLists.txt` differs only in the KleidiAI tag and archive hash). SYCL dequant switches merged (#30740, b11810, `convert.cpp` only). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). None of CI's models uses one of the pre-tokenizers above, and the random fixtures write no `clean_spaces` key and keep their pre-tokenizer's default. **Verified at b11814**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | +| b11809–b11814 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `d88040c70`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). The range touches two files under `tests/` (`tests/CMakeLists.txt`, `tests/test-tokenizer-0.cpp`), neither of them a `0001` target (its `tests/` hunks are `test-arg-parser.cpp`, `test-export-graph-ops.cpp` and `test-thread-safety.cpp`), and no other patch-target file, so the drop-check answers of the b11803 row stand unchanged. | diff --git a/llama/CMakeLists.txt b/llama/CMakeLists.txt index 563c7b261..a93c52906 100644 --- a/llama/CMakeLists.txt +++ b/llama/CMakeLists.txt @@ -347,8 +347,8 @@ endif() # hash, every other consumer keeps the number, and the pin check after FetchContent_MakeAvailable # asserts that the fetched commit's count IS the number. Clear it once the tag exists; # `llama-next-version.sh` says when, and `llama-next-version.sh --resolve b` prints the hash. -set(JLLAMA_GIT_TAG b11809) -set(JLLAMA_GIT_COMMIT "0b1281aeef1838bf5848de7848a17e8d12747770") # the commit b11809 names; kept after the tag exists +set(JLLAMA_GIT_TAG b11814) +set(JLLAMA_GIT_COMMIT "d88040c704c8855c497d46c20675a3d492f70f94") # the commit b11814 names; kept after the tag exists if(NOT JLLAMA_GIT_TAG MATCHES "^b[0-9]+$") message(FATAL_ERROR "JLLAMA_GIT_TAG must be a llama.cpp release number b, got '${JLLAMA_GIT_TAG}'") endif() diff --git a/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java b/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java index ce97e1f26..4d689b10f 100644 --- a/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java +++ b/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java @@ -9,13 +9,13 @@ * library was compiled against, exposed as a compile-time constant so callers can render a badge or * emit a startup log line without loading the native library. * - *

        {@link #LLAMA_CPP_VERSION} is a pure-Java string ({@code "b11809"}) that mirrors the + *

        {@link #LLAMA_CPP_VERSION} is a pure-Java string ({@code "b11814"}) that mirrors the * {@code GIT_TAG} in {@code llama/CMakeLists.txt}. It is available even when {@code libjllama} is * absent (pure-Java checkout, before {@code System.load}), which is what makes it suitable for a * lightweight version badge in Android or other UIs.

        * *

        For the authoritative value that is baked into the native binary — the build number - * plus the resolved upstream commit, e.g. {@code "b11809-"} — call + * plus the resolved upstream commit, e.g. {@code "b11814-"} — call * {@link net.ladenthin.llama.LlamaModel#getLlamaCppBuildInfo()} instead; that reads llama.cpp's own * {@code build-info} through JNI and therefore cannot drift from the compiled library (but requires * the native library to be loaded).

        @@ -23,14 +23,14 @@ public final class LlamaCppVersion { /** - * The pinned llama.cpp release tag this library was built against, e.g. {@code "b11809"}. + * The pinned llama.cpp release tag this library was built against, e.g. {@code "b11814"}. * *

        Kept in lockstep with {@code GIT_TAG} in {@code llama/CMakeLists.txt} — see the * "Upgrading/Downgrading llama.cpp Version" checklist in {@code CLAUDE.md}. This is the * compile-time pin; use {@link net.ladenthin.llama.LlamaModel#getLlamaCppBuildInfo()} for the * value actually linked into the native binary.

        */ - public static final String LLAMA_CPP_VERSION = "b11809"; + public static final String LLAMA_CPP_VERSION = "b11814"; // Constants holder — not instantiable. private LlamaCppVersion() {} From f64a42eabdbbb18333a104a4ff73590c6ce95024 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:08:27 +0000 Subject: [PATCH 584/589] Compare generations only on the same prompt-cache path in eight more tests (F272) A sweep of every test tree for the comparison 85ebbfd removed from three tests found eight more: two generations set equal with a prompt-cache hit between them, where the second re-evaluates one token over the cached prefix (upstream [TAG_PROMPT_LOGITS]) and does not reproduce the batched logits bit for bit. MemoryManagementTest (the deterministic-output test, the stable-across-calls test through a shared helper, and the complete-miss test, whose fresh model now gets the same warm-up), LlamaModelTest#testCompleteAsync and ChatScenarioTest's stop-string test (cache_prompt=false on both requests, proven through the timings; the stopped content is a prefix of the unconstrained one now), and in the C++ suite the two LoRA cache tests (a third cached request, and the restored slot against the saved one) and the two reasoning-budget tests (both requests without the cache, proven through the timings). Test and documentation change only; the ledger carries the Nachtrag and CLAUDE.md names the helpers. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 + CLAUDE.md | 4 + llama/src/test/cpp/test_c_api.cpp | 57 +++++-- llama/src/test/cpp/test_lora_cache.cpp | 34 +++- .../net/ladenthin/llama/ChatScenarioTest.java | 56 +++++-- .../net/ladenthin/llama/LlamaModelTest.java | 14 +- .../ladenthin/llama/MemoryManagementTest.java | 148 ++++++++++++------ 7 files changed, 230 insertions(+), 85 deletions(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index c9cca75e9..123fe117f 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2550,8 +2550,10 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Fix:** `ChatScenarioTest`: beide Anfragen mit `withCachePrompt(false)`; die zweite (`chatComplete`, die Rohantwort) belegt die Vorbedingung über `timings` (`cache_n` 0, `prompt_n` gleich `usage.prompt_tokens`), sodass ein später entferntes Flag den Test deterministisch rot macht statt ihn zur Lotterie zurückzubauen. `ChatAdvancedTest` und `MemoryManagementTest`: drei Anfragen über `completeWithStats`; die zweite und die dritte müssen den Cache nutzen (`cache_n == prompt_tokens - 1`, `prompt_n == 1`) und einander gleichen; die erste wird nur auf nicht-leer geprüft und absichtlich nicht mit ihnen verglichen (die Javadoc nennt den Grund). `CLAUDE.md` hält die Regel fest. Kein Produktionscode: das Verhalten ist upstreams und kein Fehler, der Server muss für die Logits mindestens ein Token auswerten. - **Tests:** dieselben drei Tests. Messprogramm im Sandkasten (nicht eingecheckt): je Prompt zwei bzw. drei `completeWithStats` mit `n_probs`, Vergleich von Text, erstem Logprob und `timings`. - **Rot-Grün-Nachweis:** rot im CI-Lauf 39702748614 (Windows 2025 x86-64, MSVC) mit dem alten Vergleich; lokal mit dem Zufallsmodell der alte Vergleich (erste gegen zweite Anfrage) rot in 4 von 4000 Prompts, der neue (zweite gegen dritte) grün in 4000 von 4000; die drei umgebauten Tests grün mit `tiny2k.gguf` (je 1 Test, 0 übersprungen); Spotless. Modellgebunden (CodeLlama 7B Q2_K): grün erwartet im nächsten Lauf. +- **Nachtrag (Sweep 2026-10-22):** Ein Lesedurchgang über alle Testbäume (Java, C++, FFM, langchain4j, Kotlin, Smokes) fand acht weitere Vergleiche derselben Form, alle umgebaut -- Form A: beide Anfragen `cache_prompt=false`, Vorbedingung über `timings` belegt; Form B: zweite gegen dritte Cache-Anfrage, Wiederverwendung über `timings` belegt: `MemoryManagementTest#testPromptCacheGivesDeterministicOutput` (B; Helfer `assertCachedPrefixReused`, auch in `#testPromptCacheStableAcrossMultipleCalls`), `#testPromptCacheCompleteMissAfterWarmup` (das frische Modell bekommt denselben Warm-up, beide Antworten mit `cache_n <= 1` und gleichem `cache_n` / `prompt_n`; der Warm-up selbst ohne Cache, damit ein Präfix eines früheren Tests im geteilten Slot nicht hineinspielt), `LlamaModelTest#testCompleteAsync` (A), `ChatScenarioTest#testChatCompleteWithStopString` (A; aus der Längenprüfung wurde `unContent.startsWith(stContent)`, Helfer `assertWholePromptEvaluated` auch im ersten Test der Klasse), C++ `LoraCache.TheAdapterChangesTheAnswerAndTheSlotReusesItsCache` (B: eine dritte Anfrage mit gleichem `cache_n` / `prompt_n`), `LoraCacheFreshSlot.AStateRestoredIntoANeverUsedSlotIsKept` (der wiederhergestellte Slot 1 gegen Slot 0 nach dem Speichern, beide mit je einem neu ausgewerteten Token), `CApiReasoningBudget.ARawCompletionWithMessagesKeepsTheReasoningBudgetOfTheChatTemplate` (A; Chat-, Roh- und Stream-Anfrage ohne Cache, Helfer `without_prompt_cache` / `expect_whole_prompt_evaluated`) und `CApi.ARawCompletionWithMessagesAndATemplateWithoutThinkingTagsIgnoresTheBudget` (A; hielt bisher nur durch die gtest-Reihenfolge, der Test davor ließ denselben Prompt im Slot). Als korrekt gelesen und unverändert: die Vergleiche `after_change` gegen `fresh_answer()` in `test_lora_cache.cpp` (beide `cache_n` 0, gleiche Batch-Form), Stream gegen Blocking in `test_c_api.cpp` (zwei Cache-Treffer über demselben Präfix), Fälle, in denen Bias oder Grammatik jedes Token erzwingen, Prompt-Token-Zählungen, zustandslose Embedding- und Rerank-Vergleiche mit Toleranz, `assertNotEquals`-Fälle. Grün: ctest 686/686 (b11814-Build mit den geänderten Tests), die sieben Java-Tests mit `tiny2k.gguf` (0 übersprungen); Spotless, clang-format. - **Status:** gefixt - **Commit:** java-llama.cpp `85ebbfd` +- **Nachtrag-Commit:** (folgt) ## Offene Verdachte ohne Fund-ID diff --git a/CLAUDE.md b/CLAUDE.md index 28b347bd9..84d2cfc90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2613,6 +2613,10 @@ the MSVC Windows job (run 39702748614, F272). So a test that needs two generatio sets `withCachePrompt(false)` on both and proves it through the response's `timings` (`cache_n` 0, `prompt_n` equal to the prompt tokens), or compares the second with the third cached request and asserts the reuse (`cache_n == prompt_tokens - 1`, `prompt_n == 1`) -- never the first with the second. +A sweep of every test tree the same day rewrote eight more (the ledger's F272 Nachtrag names them); +the helpers to reuse are `MemoryManagementTest#assertCachedPrefixReused`, +`ChatScenarioTest#assertWholePromptEvaluated` and `test_c_api.cpp`'s `without_prompt_cache` / +`expect_whole_prompt_evaluated`. Test files are in `src/test/java/net/ladenthin/llama/` and `src/test/java/examples/`. diff --git a/llama/src/test/cpp/test_c_api.cpp b/llama/src/test/cpp/test_c_api.cpp index bf19ffea7..5238a5739 100644 --- a/llama/src/test/cpp/test_c_api.cpp +++ b/llama/src/test/cpp/test_c_api.cpp @@ -1234,6 +1234,21 @@ json budget_request(int budget) { return request; } +// The request with the prompt cache off, so it evaluates its whole prompt in one batch. With the cache +// a request after one with the same prompt takes every prompt token but the last from the slot and +// re-evaluates that one in a batch of its own (upstream [TAG_PROMPT_LOGITS]), whose logits are not +// bit-identical to the batched ones, so a greedy near tie can flip between the two answers (F272). +json without_prompt_cache(json request) { + request["cache_prompt"] = false; + return request; +} + +// The result took nothing from the prompt cache and evaluated all prompt_tokens of its prompt. +void expect_whole_prompt_evaluated(const json &result, int prompt_tokens) { + EXPECT_EQ(result.at("timings").at("cache_n").get(), 0) << result.dump(); + EXPECT_EQ(result.at("timings").at("prompt_n").get(), prompt_tokens) << result.dump(); +} + } // namespace TEST(CApiReasoningBudget, ARawCompletionWithMessagesKeepsTheReasoningBudgetOfTheChatTemplate) { @@ -1241,11 +1256,15 @@ TEST(CApiReasoningBudget, ARawCompletionWithMessagesKeepsTheReasoningBudgetOfThe // declares, and the sampler forces the end tag once the budget is spent. The raw completion paths // rendered the messages and dropped the tags, so the same budget did nothing there. The random // model gets an end-of-generation byte of its own, so the '>' of the end tag can be generated. + // Every request below that is compared with another runs without the prompt cache and evaluates + // its whole prompt in one batch, which its timings prove: with the cache the raw request after the + // chat request re-evaluated only the last prompt token, which does not reproduce the chat path's + // batched logits bit for bit (F272). const std::string path = write_tiny_llama_gguf("jllama-c-api-budget", false, 4); ASSERT_FALSE(path.empty()); jllama_model *model = load_with_chat_template(path, THINKING_TEMPLATE); ASSERT_NE(model, nullptr); - const json request = budget_request(1); + const json request = without_prompt_cache(budget_request(1)); // The reference: on the chat path the budget closes the thinking block the generation prompt // opened (its "\n" after "" spends the one token), and the chat parser takes the end tag @@ -1253,32 +1272,39 @@ TEST(CApiReasoningBudget, ARawCompletionWithMessagesKeepsTheReasoningBudgetOfThe // then the chat path's answer. Owned chat; ASSERT_EQ(jllama_chat_completions(model, request.dump().c_str(), &chat.s), JLLAMA_STATUS_OK) << jllama_last_error(); - const std::string answer = - json::parse(chat.str()).at("choices").at(0).at("message").value("content", std::string()); + const json chat_result = json::parse(chat.str()); + expect_whole_prompt_evaluated(chat_result, chat_result.at("usage").at("prompt_tokens").get()); + const std::string answer = chat_result.at("choices").at(0).at("message").value("content", std::string()); EXPECT_EQ(answer.find(""), std::string::npos) << chat.str(); Owned completion; ASSERT_EQ(jllama_completions(model, request.dump().c_str(), &completion.s), JLLAMA_STATUS_OK) << jllama_last_error(); - const std::string blocking = json::parse(completion.str()).at("content").get(); + const json raw_result = json::parse(completion.str()); + expect_whole_prompt_evaluated(raw_result, raw_result.at("tokens_evaluated").get()); + const std::string blocking = raw_result.at("content").get(); EXPECT_EQ(blocking.rfind("", 0), 0u) << completion.str(); EXPECT_GE(blocking.size(), answer.size()) << completion.str(); EXPECT_EQ(blocking.substr(blocking.size() - std::min(blocking.size(), answer.size())), answer) << completion.str() << "\n" << chat.str(); + // The stream runs without the prompt cache too, so it is computed like the blocking request. int task = -1; ASSERT_EQ(jllama_completions_stream_request(model, request.dump().c_str(), &task), JLLAMA_STATUS_OK) << jllama_last_error(); std::string streamed; + json last_chunk; bool stop = false; for (int i = 0; i < 50 && !stop; ++i) { Owned chunk; ASSERT_EQ(jllama_completions_stream_receive(model, task, &chunk.s, &stop), JLLAMA_STATUS_OK) << jllama_last_error(); - streamed += json::parse(chunk.str()).value("content", std::string()); + last_chunk = json::parse(chunk.str()); + streamed += last_chunk.value("content", std::string()); } EXPECT_TRUE(stop); + expect_whole_prompt_evaluated(last_chunk, last_chunk.at("tokens_evaluated").get()); EXPECT_EQ(streamed, blocking); // An output-format grammar beside the budget cannot take the generation prompt the budget is @@ -1413,16 +1439,27 @@ TEST(CApiReasoningBudget, ARawCompletionWithoutMessagesForcesTheEndTagOfItsOwnTa TEST_F(CApi, ARawCompletionWithMessagesAndATemplateWithoutThinkingTagsIgnoresTheBudget) { // A template that declares no thinking tags gives the budget nothing to act on, on the chat path - // and on the raw paths alike: the request runs unchanged, without an error. + // and on the raw paths alike: the request runs unchanged, without an error. Both requests run + // without the prompt cache and evaluate their whole prompt in one batch, which their timings + // prove; with the cache the second took all but the last prompt token from the slot, and the + // first did so too whenever the test before it left the same prompt there, so the two answers + // agreed only by the order the tests run in (F272). Owned without; - ASSERT_EQ(jllama_completions(model_, budget_request(-1).dump().c_str(), &without.s), JLLAMA_STATUS_OK) + ASSERT_EQ(jllama_completions(model_, without_prompt_cache(budget_request(-1)).dump().c_str(), &without.s), + JLLAMA_STATUS_OK) << jllama_last_error(); Owned with; - ASSERT_EQ(jllama_completions(model_, budget_request(1).dump().c_str(), &with.s), JLLAMA_STATUS_OK) + ASSERT_EQ(jllama_completions(model_, without_prompt_cache(budget_request(1)).dump().c_str(), &with.s), + JLLAMA_STATUS_OK) << jllama_last_error(); - EXPECT_EQ(json::parse(with.str()).at("content"), json::parse(without.str()).at("content")) << with.str(); - EXPECT_EQ(json::parse(with.str()).at("content").get().find(""), std::string::npos) + const json without_result = json::parse(without.str()); + const json with_result = json::parse(with.str()); + expect_whole_prompt_evaluated(without_result, without_result.at("tokens_evaluated").get()); + expect_whole_prompt_evaluated(with_result, with_result.at("tokens_evaluated").get()); + EXPECT_EQ(with_result.at("tokens_evaluated").get(), without_result.at("tokens_evaluated").get()) << with.str(); + EXPECT_EQ(with_result.at("content"), without_result.at("content")) << with.str(); + EXPECT_EQ(with_result.at("content").get().find(""), std::string::npos) << with.str(); } TEST(CApiStatic, JsonSchemaToGrammarNeedsNoModel) { diff --git a/llama/src/test/cpp/test_lora_cache.cpp b/llama/src/test/cpp/test_lora_cache.cpp index cb6203c4f..c123a74e3 100644 --- a/llama/src/test/cpp/test_lora_cache.cpp +++ b/llama/src/test/cpp/test_lora_cache.cpp @@ -133,6 +133,8 @@ class LoraCache : public ::testing::Test { // How many prompt tokens the request took from the slot's cache (upstream's n_past). Not // tokens_cached: that is every token the slot holds afterwards, positive after any request. static int cache_n(const json &response) { return response.at("timings").at("cache_n").get(); } + // How many prompt tokens the request evaluated. + static int prompt_n(const json &response) { return response.at("timings").at("prompt_n").get(); } // The answer of the model with the current adapters, computed on an empty slot. static std::string fresh_answer() { erase_slot(); @@ -150,13 +152,22 @@ jllama_model *LoraCache::model_ = nullptr; } // namespace // Without these two, the tests below could pass for the wrong reason: an adapter that changes -// nothing, or a slot that never reuses its cache. +// nothing, or a slot that never reuses its cache. The reuse is compared between two requests over the +// cache, not with the fresh answer: a request over the cache re-evaluates only the last prompt token, +// in a batch of its own (upstream [TAG_PROMPT_LOGITS]), which does not reproduce the logits of the +// fresh answer's one batch bit for bit, so a greedy near tie could flip between the two (F272). The +// two requests over the cache take the same tokens from it and compute the same thing. TEST_F(LoraCache, TheAdapterChangesTheAnswerAndTheSlotReusesItsCache) { const std::string with_adapter = fresh_answer(); const json again = complete(); EXPECT_GT(cache_n(again), 0) << again.dump(); EXPECT_LT(again.at("timings").at("prompt_n").get(), again.at("tokens_evaluated").get()) << again.dump(); - EXPECT_EQ(again.at("content").get(), with_adapter); + const json once_more = complete(); + EXPECT_GT(cache_n(once_more), 0) << once_more.dump(); + EXPECT_LT(prompt_n(once_more), once_more.at("tokens_evaluated").get()) << once_more.dump(); + EXPECT_EQ(cache_n(once_more), cache_n(again)) << once_more.dump() << "\n" << again.dump(); + EXPECT_EQ(prompt_n(once_more), prompt_n(again)) << once_more.dump() << "\n" << again.dump(); + EXPECT_EQ(once_more.at("content").get(), again.at("content").get()); set_scale(0.0f); EXPECT_NE(fresh_answer(), with_adapter); @@ -255,7 +266,7 @@ jllama_model *LoraCacheFreshSlot::model_ = nullptr; TEST_F(LoraCacheFreshSlot, AStateRestoredIntoANeverUsedSlotIsKept) { ASSERT_NE(model_, nullptr); - const json first = complete_on(0); + complete_on(0); // the state to save: the prompt, evaluated in one batch, and an answer // The C API resolves the file name under --slot-save-path, as upstream's slots route does; // TearDownTestSuite's remove_all(save_dir_) takes the file away again. slot_action(JLLAMA_SLOT_SAVE, 0, "slot0.bin"); @@ -271,5 +282,20 @@ TEST_F(LoraCacheFreshSlot, AStateRestoredIntoANeverUsedSlotIsKept) { const int prompt_n = restored.at("timings").at("prompt_n").get(); const int evaluated = restored.at("tokens_evaluated").get(); EXPECT_LE(prompt_n, 2) << "of " << evaluated << " prompt tokens: " << restored.dump(); - EXPECT_EQ(restored.at("content").get(), first.at("content").get()); + + // The kept state answers like the slot it was saved from, asked again over its own cache. Not + // like slot 0's first answer: that one evaluated the prompt in one batch, while both requests here + // re-evaluate only the last prompt token in a batch of its own (upstream [TAG_PROMPT_LOGITS]), + // which does not reproduce the batched logits bit for bit, so a greedy near tie could flip (F272). + // Slot 0 still holds the saved state, and each slot has a KV stream of its own (-np 2 without a + // unified cache), so the two compute the same thing. + const json saved_slot = complete_on(0); + EXPECT_LE(saved_slot.at("timings").at("prompt_n").get(), 2) + << "of " << saved_slot.at("tokens_evaluated").get() << " prompt tokens: " << saved_slot.dump(); + EXPECT_EQ(saved_slot.at("timings").at("cache_n").get(), restored.at("timings").at("cache_n").get()) + << saved_slot.dump() << "\n" + << restored.dump(); + EXPECT_EQ(saved_slot.at("timings").at("prompt_n").get(), prompt_n) << saved_slot.dump() << "\n" + << restored.dump(); + EXPECT_EQ(restored.at("content").get(), saved_slot.at("content").get()); } diff --git a/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java b/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java index 6768ac4a6..2a68b76b7 100644 --- a/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java +++ b/llama/src/test/java/net/ladenthin/llama/ChatScenarioTest.java @@ -167,15 +167,7 @@ public void testChatCompleteTextMatchesChatCompleteContent() { String text = model.chatCompleteText(params); String rawJson = model.chatComplete(params); - ChatResponse response = chatParser.parseResponse(rawJson); - assertEquals( - 0, - response.getTimings().getCacheN(), - "the second generation must take nothing from the prompt cache: " + rawJson); - assertEquals( - response.getUsage().getPromptTokens(), - response.getTimings().getPromptN(), - "the second generation must evaluate the whole prompt: " + rawJson); + assertWholePromptEvaluated(rawJson); String expected = chatParser.extractChoiceContent(rawJson); assertEquals(expected, text, "chatCompleteText must match choices[0].message.content"); } @@ -288,8 +280,17 @@ public void testStreamingAndBlockingOutputBothNonEmpty() { // ------------------------------------------------------------------ /** - * A stop string set in the parameters must terminate generation in chat mode. - * The response content must be shorter than the unconstrained generation. + * A stop string set in the parameters must terminate generation in chat mode: the stopped + * content is the beginning of the unconstrained one, cut before the stop string. + * + *

        That holds only when both generations compute the same tokens, so both requests run with + * {@code cache_prompt=false} and evaluate the whole prompt in one batch, which their timings prove. + * With the cache the second request would take every prompt token but the last from the slot and + * re-evaluate that one in a batch of its own (upstream {@code [TAG_PROMPT_LOGITS]}), whose logits + * are not bit-identical to the batched ones, so a greedy near tie could flip between the two (see + * {@code ChatAdvancedTest#testCachePromptConsistentOutput}). Over the same tokens the server cuts + * the stopped text at the first occurrence of the stop string, and the test model's chat format + * takes the content from the text as it is, so the stopped content is a prefix of the other. */ @Test public void testChatCompleteWithStopString() { @@ -301,29 +302,50 @@ public void testChatCompleteWithStopString() { .withMessages(null, messages) .withNPredict(N_PREDICT) .withSeed(42) - .withTemperature(0.0f); + .withTemperature(0.0f) + .withCachePrompt(false); String unJson = model.chatComplete(unconstrained); + assertWholePromptEvaluated(unJson); String unContent = chatParser.extractChoiceContent(unJson); - // Stopped at "3" + // Stopped at "4" InferenceParameters stopped = new InferenceParameters("") .withMessages(null, messages) .withNPredict(N_PREDICT) .withSeed(42) .withTemperature(0.0f) + .withCachePrompt(false) .withStopStrings("4"); String stJson = model.chatComplete(stopped); + assertNotNull(stJson, "Stop-string response must not be null"); + assertWholePromptEvaluated(stJson); String stContent = chatParser.extractChoiceContent(stJson); - assertNotNull(stJson, "Stop-string response must not be null"); - // Content with stop should be shorter (or at most equal) + // The same generation, cut before the stop string assertTrue( - stContent.length() <= unContent.length(), - "Content with stop string must not exceed unconstrained content length"); + unContent.startsWith(stContent), + "Content with stop string must be the beginning of the unconstrained content: '" + stContent + "' / '" + + unContent + "'"); // The stopped content must not contain "4" (the stop string itself is excluded) assertFalse(stContent.contains("4"), "Content stopped at '4' must not contain '4'"); } + /** + * The chat response {@code rawJson} took nothing from the prompt cache and evaluated its whole + * prompt in one batch -- the precondition for comparing its text with another generation's. + */ + private void assertWholePromptEvaluated(String rawJson) { + ChatResponse response = chatParser.parseResponse(rawJson); + assertEquals( + 0, + response.getTimings().getCacheN(), + "the generation must take nothing from the prompt cache: " + rawJson); + assertEquals( + response.getUsage().getPromptTokens(), + response.getTimings().getPromptN(), + "the generation must evaluate the whole prompt: " + rawJson); + } + // ------------------------------------------------------------------ // 5. Chat with grammar constraint // ------------------------------------------------------------------ diff --git a/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java b/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java index 411b8fc2c..ceb4ac5de 100644 --- a/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java +++ b/llama/src/test/java/net/ladenthin/llama/LlamaModelTest.java @@ -438,13 +438,21 @@ public void testCloseDuringInference() throws Exception { * Regression: {@link LlamaModel#completeAsync(InferenceParameters)} * must complete with the same text {@link LlamaModel#complete(InferenceParameters)} * would have produced, on a background thread. + * + *

        Both requests run with {@code cache_prompt=false}, so each evaluates the whole prompt in one + * batch and the two compute the same thing. With the cache the second request would take every + * prompt token but the last from the slot and re-evaluate that one in a batch of its own (upstream + * {@code [TAG_PROMPT_LOGITS]}), whose logits are not bit-identical to the batched ones, so a near + * tie of the seeded sampling could flip between the two (see + * {@code ChatAdvancedTest#testCachePromptConsistentOutput}). {@code completeAsync} returns the text + * only, so the precondition is the request itself rather than its timings. */ @Test public void testCompleteAsync() throws Exception { InferenceParameters params = - new InferenceParameters(prefix).withNPredict(8).withSeed(42); - String sync = - model.complete(new InferenceParameters(prefix).withNPredict(8).withSeed(42)); + new InferenceParameters(prefix).withNPredict(8).withSeed(42).withCachePrompt(false); + String sync = model.complete( + new InferenceParameters(prefix).withNPredict(8).withSeed(42).withCachePrompt(false)); String async = model.completeAsync(params).get(30, java.util.concurrent.TimeUnit.SECONDS); assertEquals(sync, async); } diff --git a/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java b/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java index b30624898..2b2660f53 100644 --- a/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java +++ b/llama/src/test/java/net/ladenthin/llama/MemoryManagementTest.java @@ -184,13 +184,17 @@ public void testContextShiftFollowedByFreshGeneration() { // ------------------------------------------------------------------ /** - * With {@code cache_prompt=true} and a fixed seed the second identical call must produce - * exactly the same output as the first. + * With {@code cache_prompt=true} and greedy decoding, repeated identical calls over the cached + * prefix must produce the same output. * - *

        The first call populates {@code slot.cache_tokens}; the second call computes - * {@code get_common_prefix(prompt_tokens)} which returns the full prompt length, skipping - * all prompt re-evaluation and jumping straight to sampling. Any divergence between the - * cached and non-cached code paths would produce different tokens. + *

        Each call after the first computes {@code get_common_prefix(prompt_tokens)} over the slot's + * tokens, takes every prompt token but the last from the KV cache and re-evaluates that one in a + * batch of its own, so that the request has logits (upstream {@code [TAG_PROMPT_LOGITS]}). The + * second and the third call therefore compute the same thing over the same cache and are compared; + * the first is not: it evaluated the prompt in one batch, whose logits the one-token re-evaluation + * does not reproduce bit for bit, so a greedy near tie can flip between the two (see + * {@code ChatAdvancedTest#testCachePromptConsistentOutput}). Any divergence between two reuses of + * the cache would produce different tokens. */ @Test public void testPromptCacheGivesDeterministicOutput() { @@ -200,13 +204,18 @@ public void testPromptCacheGivesDeterministicOutput() { .withTemperature(0f) // greedy decoding: fully deterministic .withSeed(42); - String first = model.complete(params); - String second = model.complete(params); + CompletionResult first = model.completeWithStats(params); + CompletionResult second = model.completeWithStats(params); + CompletionResult third = model.completeWithStats(params); - assertFalse(first.isEmpty(), "First cached-prompt call must produce output"); - assertFalse(second.isEmpty(), "Second cached-prompt call must produce output"); + assertFalse(first.getText().isEmpty(), "First cached-prompt call must produce output"); + assertFalse(second.getText().isEmpty(), "Second cached-prompt call must produce output"); + assertCachedPrefixReused(second); + assertCachedPrefixReused(third); assertEquals( - first, second, "Both calls share the same prompt and seed; cache_prompt=true must not change output"); + second.getText(), + third.getText(), + "Both calls share the same prompt, seed and cached prefix; cache_prompt=true must not change output"); } /** @@ -286,16 +295,8 @@ public void testPromptCacheStableAcrossMultipleCalls() { CompletionResult third = model.completeWithStats(params); assertFalse(first.getText().isEmpty(), "First call must produce output"); - for (CompletionResult cached : new CompletionResult[] {second, third}) { - assertEquals( - cached.getUsage().getPromptTokens() - 1, - cached.getTimings().getCacheN(), - "every prompt token but the last must come from the cache: " + cached.getRawJson()); - assertEquals( - 1, - cached.getTimings().getPromptN(), - "only the last prompt token must be evaluated: " + cached.getRawJson()); - } + assertCachedPrefixReused(second); + assertCachedPrefixReused(third); assertEquals( second.getText(), third.getText(), @@ -352,36 +353,42 @@ public void testContextShiftWithNKeepPreservesGeneration() { /** * Exercises the {@code cache_prompt=true} complete-miss path: after populating the - * KV cache with prompt A, a second call with a completely unrelated prompt B causes - * {@code get_common_prefix()} to return 0 (utils.hpp ~1204). + * KV cache with prompt A, a second call with a completely unrelated prompt B makes + * {@code get_common_prefix()} return at most the BOS token both prompts start with. * - *

        The relevant branch in server.hpp ~3162–3164: + *

        The relevant branch ({@code update_slots()} in server-context.cpp): *

        -     *   slot.n_past = slot.cache_tokens.get_common_prefix(prompt_tokens);
        -     *   // → 0 because first tokens of A and B differ
        +     *   n_past = slot.prompt.tokens.get_common_prefix(input_tokens);
        +     *   // → 1 (the BOS token) on a model that adds one, else 0: the first tokens of A and B differ
              * 
        - * At this point {@code slot.cache_tokens} still holds all of prompt A's tokens and the KV - * memory still contains the evaluated key/value vectors for A. With {@code n_past = 0}, - * the server re-evaluates the entire prompt B, overwriting those positions. If the stale A - * data leaks into B's evaluation (wrong sequence ID, wrong position offset, residual KV - * entries beyond the new prompt length), generation produces wrong tokens or aborts. + * At this point the slot still holds all of prompt A's tokens and the KV memory still contains + * the evaluated key/value vectors for A. The server removes everything after {@code n_past} and + * evaluates the rest of prompt B in one batch, overwriting those positions. If the stale A data + * leaks into B's evaluation (wrong sequence ID, wrong position offset, residual KV entries beyond + * the new prompt length), generation produces wrong tokens or aborts. * - *

        The test also cross-checks output correctness: a fresh call (no prior cache) on the - * same disjoint prompt must produce the identical result, confirming that the stale-cache - * state had no observable effect on the logits. + *

        The test also cross-checks output correctness: a fresh model that answers the same prompt + * must produce the identical result, confirming that the stale-cache state had no observable + * effect on the logits. The fresh model gets the same warm-up first, so both answers are computed + * the same way -- the reused BOS entry and the rest of the prompt in one batch, which the timings + * of both prove; a model without the warm-up would evaluate the whole prompt in one batch, and that + * does not reproduce the logits bit for bit, so a greedy near tie could flip (see + * {@code ChatAdvancedTest#testCachePromptConsistentOutput}). The warm-up takes nothing from the + * cache ({@code cache_prompt=false}), so on the shared model, whose slot may still hold an earlier + * test's prompt with the same prefix, it too is evaluated from scratch in one batch. */ @Test public void testPromptCacheCompleteMissAfterWarmup() { - // Step 1: warm the cache with a distinct prompt so cache_tokens is fully populated. + // Step 1: warm the cache with a distinct prompt so the slot's tokens are fully populated. InferenceParameters warmup = new InferenceParameters(CACHE_PREFIX_PROMPT) - .withCachePrompt(true) + .withCachePrompt(false) .withNPredict(5) .withSeed(1); model.complete(warmup); // Step 2: call with a completely disjoint prompt. - // "x = " shares no leading tokens with CACHE_PREFIX_PROMPT ("def remove_non_ascii…"), - // so get_common_prefix() returns 0 and the stale KV data for CACHE_PREFIX_PROMPT must + // "x = " shares no token with CACHE_PREFIX_PROMPT ("def remove_non_ascii…") but the BOS, so + // get_common_prefix() returns at most 1 and the stale KV data for CACHE_PREFIX_PROMPT must // be silently discarded / overwritten. final String disjointPrompt = "x = "; InferenceParameters missParams = new InferenceParameters(disjointPrompt) @@ -389,12 +396,13 @@ public void testPromptCacheCompleteMissAfterWarmup() { .withNPredict(8) .withTemperature(0f) .withSeed(99); - String afterMiss = model.complete(missParams); + CompletionResult afterMiss = model.completeWithStats(missParams); - assertNotNull(afterMiss); - assertFalse(afterMiss.isEmpty(), "Cache-miss call must still produce output"); + assertNotNull(afterMiss.getText()); + assertFalse(afterMiss.getText().isEmpty(), "Cache-miss call must still produce output"); + assertPromptCacheMiss(afterMiss); - // Step 3: baseline — fresh model (no prior cache) on the same disjoint prompt. + // Step 3: baseline — a fresh model with the same warm-up, on the same disjoint prompt. // Output must be identical, proving that the stale A cache had no effect on B's logits. int gpuLayers = Integer.getInteger(TestConstants.PROP_TEST_NGL, TestConstants.DEFAULT_TEST_NGL); try (LlamaModel freshModel = new LlamaModel(new ModelParameters() @@ -402,17 +410,24 @@ public void testPromptCacheCompleteMissAfterWarmup() { .setCtxSize(128) .setGpuLayers(gpuLayers) .setFit(false))) { - InferenceParameters freshParams = new InferenceParameters(disjointPrompt) - .withCachePrompt(true) - .withNPredict(8) - .withTemperature(0f) - .withSeed(99); - String fresh = freshModel.complete(freshParams); + freshModel.complete(warmup); + CompletionResult fresh = freshModel.completeWithStats(missParams); + assertPromptCacheMiss(fresh); + assertEquals( + fresh.getTimings().getCacheN(), + afterMiss.getTimings().getCacheN(), + "both answers must take the same tokens from the cache: " + fresh.getRawJson() + " / " + + afterMiss.getRawJson()); assertEquals( - fresh, - afterMiss, - "A cache-miss call must produce the same output as a cold-start call on the same prompt"); + fresh.getTimings().getPromptN(), + afterMiss.getTimings().getPromptN(), + "both answers must evaluate the same tokens: " + fresh.getRawJson() + " / " + + afterMiss.getRawJson()); + assertEquals( + fresh.getText(), + afterMiss.getText(), + "A cache-miss call must produce the same output as a fresh model on the same prompt"); } } @@ -489,6 +504,37 @@ public void testOpenCloseWithoutGeneration() { } } + /** + * A request that repeats the slot's prompt reuses it: every prompt token but the last comes from + * the cache, and only the last is evaluated, in a batch of its own (upstream + * {@code [TAG_PROMPT_LOGITS]}). Two results that pass this computed the same thing over the same + * cache, which is what makes comparing their texts sound. + */ + private static void assertCachedPrefixReused(CompletionResult result) { + assertEquals( + result.getUsage().getPromptTokens() - 1, + result.getTimings().getCacheN(), + "every prompt token but the last must come from the cache: " + result.getRawJson()); + assertEquals( + 1, + result.getTimings().getPromptN(), + "only the last prompt token must be evaluated: " + result.getRawJson()); + } + + /** + * A prompt disjoint from the slot's reuses at most the BOS token both start with, and evaluates + * every other prompt token. + */ + private static void assertPromptCacheMiss(CompletionResult result) { + int cacheN = result.getTimings().getCacheN(); + assertTrue( + cacheN <= 1, "a disjoint prompt can share only the BOS token with the cache: " + result.getRawJson()); + assertEquals( + result.getUsage().getPromptTokens() - cacheN, + result.getTimings().getPromptN(), + "every prompt token not taken from the cache must be evaluated: " + result.getRawJson()); + } + /** * The shift tests are only worth something if the shift happened: every requested token was * generated although prompt and answer together do not fit into {@link #SMALL_CTX_SIZE}. Without From a38580ac2123068e7ba2f3b255729933811b8be9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:08:27 +0000 Subject: [PATCH 585/589] Record the commit of the F272 follow-up in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 123fe117f..1c3b83472 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2553,7 +2553,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Nachtrag (Sweep 2026-10-22):** Ein Lesedurchgang über alle Testbäume (Java, C++, FFM, langchain4j, Kotlin, Smokes) fand acht weitere Vergleiche derselben Form, alle umgebaut -- Form A: beide Anfragen `cache_prompt=false`, Vorbedingung über `timings` belegt; Form B: zweite gegen dritte Cache-Anfrage, Wiederverwendung über `timings` belegt: `MemoryManagementTest#testPromptCacheGivesDeterministicOutput` (B; Helfer `assertCachedPrefixReused`, auch in `#testPromptCacheStableAcrossMultipleCalls`), `#testPromptCacheCompleteMissAfterWarmup` (das frische Modell bekommt denselben Warm-up, beide Antworten mit `cache_n <= 1` und gleichem `cache_n` / `prompt_n`; der Warm-up selbst ohne Cache, damit ein Präfix eines früheren Tests im geteilten Slot nicht hineinspielt), `LlamaModelTest#testCompleteAsync` (A), `ChatScenarioTest#testChatCompleteWithStopString` (A; aus der Längenprüfung wurde `unContent.startsWith(stContent)`, Helfer `assertWholePromptEvaluated` auch im ersten Test der Klasse), C++ `LoraCache.TheAdapterChangesTheAnswerAndTheSlotReusesItsCache` (B: eine dritte Anfrage mit gleichem `cache_n` / `prompt_n`), `LoraCacheFreshSlot.AStateRestoredIntoANeverUsedSlotIsKept` (der wiederhergestellte Slot 1 gegen Slot 0 nach dem Speichern, beide mit je einem neu ausgewerteten Token), `CApiReasoningBudget.ARawCompletionWithMessagesKeepsTheReasoningBudgetOfTheChatTemplate` (A; Chat-, Roh- und Stream-Anfrage ohne Cache, Helfer `without_prompt_cache` / `expect_whole_prompt_evaluated`) und `CApi.ARawCompletionWithMessagesAndATemplateWithoutThinkingTagsIgnoresTheBudget` (A; hielt bisher nur durch die gtest-Reihenfolge, der Test davor ließ denselben Prompt im Slot). Als korrekt gelesen und unverändert: die Vergleiche `after_change` gegen `fresh_answer()` in `test_lora_cache.cpp` (beide `cache_n` 0, gleiche Batch-Form), Stream gegen Blocking in `test_c_api.cpp` (zwei Cache-Treffer über demselben Präfix), Fälle, in denen Bias oder Grammatik jedes Token erzwingen, Prompt-Token-Zählungen, zustandslose Embedding- und Rerank-Vergleiche mit Toleranz, `assertNotEquals`-Fälle. Grün: ctest 686/686 (b11814-Build mit den geänderten Tests), die sieben Java-Tests mit `tiny2k.gguf` (0 übersprungen); Spotless, clang-format. - **Status:** gefixt - **Commit:** java-llama.cpp `85ebbfd` -- **Nachtrag-Commit:** (folgt) +- **Nachtrag-Commit:** java-llama.cpp `f64a42e` ## Offene Verdachte ohne Fund-ID From ad2854241869d8803f0318ffb7bb24ad6bb6c950 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:18:35 +0000 Subject: [PATCH 586/589] Upgrade llama.cpp from b11814 to b11815 One upstream commit, the last runbook chunk: the SYCL CONV_2D kernel supports the channel-fastest CWHN layout instead of asserting on it (#30363), which reaches the two SYCL module jars; the regenerated docs/ops/SYCL.csv is what put this single commit over the 100 KiB threshold. Nothing in common/, include/, src/, tools/server/, tools/mtmd/, the root CMakeLists.txt or upstream's .github/, so no source change here, the server contract and the toolchain pins are unchanged, and all 12 patches apply as they are (fresh configure at 7170cad4d, git apply in order on a pristine worktree, check-patches.py 12/88/0; no patch-target file changed anywhere in b11803..b11815, the drop-checks re-read at b11815 give the b11803 answers). Built from the pin in a fresh tree, ctest 686/686, NativeLibraryLoadSmokeTest 7/7 against the b11815 library. CHANGELOG, TODO.md and the history rows updated, with one correction to the b11809 rows: three OpenCL module jars embed the new ARGMAX kernel, not two. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- CHANGELOG.md | 12 +++++++----- CLAUDE.md | 10 +++++----- README.md | 2 +- TODO.md | 2 +- docs/history/llama-cpp-breaking-changes.md | 6 ++++-- llama/CMakeLists.txt | 4 ++-- .../net/ladenthin/llama/value/LlamaCppVersion.java | 8 ++++---- 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039ddfec0..d8d5e0cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,7 +136,7 @@ the first two items break every existing consumer at upgrade. fails the stream through `onError`. ### Added -- **llama.cpp b11814** (from b11639, in seven steps on the branch: b11760, b11761, b11799, b11800, b11803, b11809, b11814). What the +- **llama.cpp b11815** (from b11639, in eight steps on the branch: b11760, b11761, b11799, b11800, b11803, b11809, b11814, b11815). What the range brings to a consumer: `POST /tts` (llama.cpp b11781, #26603, speech from a TTS-capable model through the embedded server) and the realtime transcription WebSocket `/v1/realtime` (b11780, #30636), both served by `NativeServer` -- classic and attach mode -- and not by the Java transport or `LlamaModel` yet; a @@ -149,13 +149,15 @@ the first two items break every existing consumer at upgrade. attribute again, as in jinja2). The b11809 step (2026-10-22): LoRA adapters on prism.hadamard folded matmul weights load now (#30910; an adapter on a latent lookup table such as `token_embd` is still refused), dflash reads an optional `fc.input_scale`, OpenCL gets an F32-to-I32 ARGMAX kernel - (#30897, embedded in the two OpenCL module jars) and WebGPU Q2_0 -- nothing here changed. The b11814 + (#30897, embedded in the three OpenCL module jars) and WebGPU Q2_0 -- nothing here changed. The b11814 step (2026-10-22): `tokenizer.ggml.clean_spaces` is a GGUF key (#30940; the converter writes it from `tokenizer_config.json`, and the pre-tokenizer defaults turn the clean-up of spaces before punctuation - off for `lfm2`, `glm4`/`glm5`, `dbrx`, `exaone4`, `mellum2`, `minicpm5`, `gemma-4` and `chatglm-bpe` - models, whose detokenized text keeps that space now), Pixtral GGUFs tokenize with the Tekken + off for `lfm2`, `glm4`/`glm5`, `dbrx`, `exaone4`, `mellum2`, `minicpm5`, `gemma-4`, + `granite-embed-multi-311m` and `chatglm-bpe` models, whose detokenized text keeps that space now), Pixtral GGUFs tokenize with the Tekken pre-tokenizer (#30960), KleidiAI v1.32.0 with SVE Q8_0 kernels (#30808, not built here) -- nothing - here changed. Two patches dropped (`0016`, `0019`), `0007` refreshed. + here changed. The b11815 step (2026-10-22, one commit): the SYCL `CONV_2D` kernel accepts the + channel-fastest CWHN layout instead of aborting (#30363), which reaches the two SYCL module jars. + Two patches dropped (`0016`, `0019`), `0007` refreshed. - **`RouterModel.Status.INCOMPLETE`** (llama.cpp b11714): a router download that never finished is listed as `"incomplete"` without a worker; it was `UNKNOWN` here. - **`/v1/messages` passes `id_slot` through and merges `extra_params`** like upstream (llama.cpp b11756 and diff --git a/CLAUDE.md b/CLAUDE.md index 84d2cfc90..47e6c7692 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Java bindings for [llama.cpp](https://github.com/ggerganov/llama.cpp) via JNI, providing a high-level API for LLM inference in Java. The Java layer communicates with a native C++ library through JNI. -Current llama.cpp pinned version: **b11814** +Current llama.cpp pinned version: **b11815** ## Natives jars: library jars and GPU module jars (`.github/natives.csv`) @@ -213,7 +213,7 @@ measurements behind the Windows step are in `docs/handover/local-agent-report-b1 a `llama-common`/`cpp-httplib` that came out shared (one more file to ship). On the static platforms (macOS, s390x) all of it stays inside `libjllama`. **Every ggml default llama.cpp's own CMakeLists changes before its `add_subdirectory(ggml)` is repeated** -- `GGML_LLAMAFILE_DEFAULT` - and `GGML_CUDA_GRAPHS_DEFAULT` at b11814 (unchanged since b11639) -- because adding ggml first skips that block; re-check with + and `GGML_CUDA_GRAPHS_DEFAULT` at b11815 (unchanged since b11639) -- because adding ggml first skips that block; re-check with `grep -n _DEFAULT CMakeLists.txt` at the new tag on every bump. The second one was missing once: ggml's own default for CUDA graphs is OFF, and a CUDA module built without it lost two thirds of its token generation (265.6 -> 88.8 tg128 on an RTX 3070, prompt processing -20%; Vulkan, which @@ -1183,7 +1183,7 @@ needs no extra step here, `build-webui` re-reads the tag and rebuilds the matchi ships no UI): ```bash # needs node/npm + network for the asset build; the embed step is plain cmake -P -git clone --depth 1 --branch b11814 https://github.com/ggml-org/llama.cpp /tmp/lc +git clone --depth 1 --branch b11815 https://github.com/ggml-org/llama.cpp /tmp/lc ( cd /tmp/lc/tools/ui && npm ci && npm run build ) mkdir -p webui-generated /tmp/ui-gen cmake -DUI_SOURCE_DIR=/tmp/lc/tools/ui -DUI_BINARY_DIR=/tmp/ui-gen \ @@ -1223,7 +1223,7 @@ cache lives in **Depot Cache** over sccache's **WebDAV** backend: - `SCCACHE_WEBDAV_TOKEN: ${{ secrets.DEPOT_TOKEN }}` — a Depot **organization** token, stored as the repo secret **`DEPOT_TOKEN`**. -Because `sccache` is **content-addressed** and llama.cpp is pinned (`JLLAMA_GIT_TAG b11814`), the +Because `sccache` is **content-addressed** and llama.cpp is pinned (`JLLAMA_GIT_TAG b11815`), the ~280 upstream object files are byte-identical every run, so a warm cache recompiles only the *changed* files. Depot's cache is **shared across all branches** (unlike GitHub's per-branch `actions/cache`), so every branch builds incrementally; a `b` version bump @@ -2671,7 +2671,7 @@ ctest --test-dir build --output-on-failure -R "ResultsToJson" #### Upstream source location (in CMake build tree) -llama.cpp is fetched via CMake FetchContent, pinned to `JLLAMA_GIT_TAG b11814`. +llama.cpp is fetched via CMake FetchContent, pinned to `JLLAMA_GIT_TAG b11815`. **GoogleTest** is a separate `BUILD_TESTING`-only FetchContent (`GIT_TAG v1.18.0`), used solely by the `jllama_test` C++ unit-test binary — not by the shipped library, and not coupled to the diff --git a/README.md b/README.md index 275ca9210..92a28d7d7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ **Build:** ![Java 8+](https://img.shields.io/badge/Java-8%2B-informational) ![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows%20%7C%20Android-lightgrey) -[![llama.cpp b11814](https://img.shields.io/badge/llama.cpp-%23b11814-informational)](https://github.com/ggml-org/llama.cpp/releases/tag/b11814) +[![llama.cpp b11815](https://img.shields.io/badge/llama.cpp-%23b11815-informational)](https://github.com/ggml-org/llama.cpp/releases/tag/b11815) [![JPMS](https://img.shields.io/badge/JPMS-modular%20JAR-25A162)](https://openjdk.org/projects/jigsaw/) ![JUnit](https://img.shields.io/badge/tested%20with-JUnit6-25A162) [![JSpecify](https://img.shields.io/badge/JSpecify-1.0.1%20%40NullMarked-25A162)](https://jspecify.dev) diff --git a/TODO.md b/TODO.md index 4e2ec7fd7..243bc1bcc 100644 --- a/TODO.md +++ b/TODO.md @@ -571,7 +571,7 @@ stays a no-op -- callers repeat the stop until the serve returns, as `RpcServer` The bump itself is done (b11639 -> b11760 -> b11761 -> b11799 -> b11800, b11803 on 2026-10-21: Flint, SenseNova U1 and a jinja fix, nothing to wire; b11809 on 2026-10-22: LoRA on prism.hadamard folded weights, an OpenCL ARGMAX kernel, nothing to wire; b11814 on 2026-10-22: the `tokenizer.ggml.clean_spaces` key and the Pixtral -pre-tokenizer change, nothing to wire; patches `0016` and `0019` dropped, `0007` refreshed; +pre-tokenizer change, nothing to wire; b11815 on 2026-10-22: a SYCL CONV_2D layout fix, nothing to wire; patches `0016` and `0019` dropped, `0007` refreshed; `docs/history/llama-cpp-breaking-changes.md` has the rows). Four things the range brings that this library does not expose, each a decision rather than an omission: diff --git a/docs/history/llama-cpp-breaking-changes.md b/docs/history/llama-cpp-breaking-changes.md index 1f0803258..341eaec53 100644 --- a/docs/history/llama-cpp-breaking-changes.md +++ b/docs/history/llama-cpp-breaking-changes.md @@ -905,7 +905,9 @@ Used during `llama.cpp` version bumps: when upgrading, scan this file from the r | b11799–b11800 | patches | **All 12 apply unchanged** (`git apply` in order on a pristine `d454a204e` worktree; `check-patches.py`: 12 patches, 88 hunks, 0 problems). The commit touches one patch-target file, `ggml-rpc.h` (the version defines above, outside `0015`'s hunk); the drop-checks of the b11799 row stand, every other patch-target file is untouched. | | b11800–b11803 | `src/`, `tools/mtmd/`, `common/jinja/value.cpp` (3 commits, `a37cc4610` = b11803, 2026-10-21 .. 2026-10-22) | **Two architectures and a jinja fix, no source change here.** Flint (`FlintForCausalLM`, #30928, b11803): `LLM_ARCH_FLINT`, `src/models/flint.cpp`, pre-tokenizer `flint` (`LLAMA_VOCAB_PRE_TYPE_FLINT` = 62, its own regex), and two shared edits -- `build_amax_src1_f16()` in `src/llama-graph.cpp` scales the row maximum by `1/1024` instead of `1/32768` ("x32 headroom for the F16 sums of 32 values in Q8 activation blocks", a numerical change for every architecture on that path, Mistral 4 / Mellum / Flint), and `src/llama-kv-cache.cpp` applies RoPE to the cache only when some layer has it (`hparams.has_rope(il)`), so an architecture without RoPE in any layer skips it. SenseNova U1 (#28919, b11801): `LLM_ARCH_SENSENOVA_U1`, `src/models/sensenova-u1.cpp`, and its vision projector in mtmd -- `PROJECTOR_TYPE_SENSENOVA_U1` (`tools/mtmd/models/sensenova-u1.cpp`, a new source `tools/mtmd/CMakeLists.txt` lists, so `mtmd` picks it up by itself), ``/`` markers, dynamic-size preprocessing, and `MTMD_POS_TYPE_SENSENOVA_U1` (M-RoPE with zero-based image H/W and one temporal position per image); `mtmd.h` is untouched. jinja (#30941, b11802): `namespace` objects no longer carry the dict built-ins, so an attribute named like a dict method (`ns.items`) resolves to the attribute, as in jinja2 -- a template-rendering change the `--jinja` path inherits. Nothing in `common/*.h`, `include/`, `ggml/`, `tools/server/` or the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged); the server request-field set, its bounds and the response keys are identical between the tags (the three greps of the runbook); upstream's `.github/` is untouched, so the OpenVINO, CCCL, ROCm and oneAPI pins stay. | | b11800–b11803 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `a37cc4610`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). No patch-target file is in the range; the drop-checks read at b11803 give the b11799 answers: `0014` -- `common/log.h` names only `common_log_default_callback`; `0015` -- `GGML_ABORT("Failed to connect` still at `ggml-rpc.cpp:650`, no `stop_server`; `0017` -- the four `_mm_prefetch` calls at `quants.c` 846/847/864/865; `0018` -- `free(buffer->context)` still at `amx.cpp:46`; `0020` -- `ggml_gallocr_needs_realloc()` still compares only `n_leafs`; `0021` -- the `else` branch of the per-request LoRA block still takes `params_base.lora_adapters` without the clear; `0001` -- `argv = utf8.ptrs.data()` still at `arg.cpp:1288`, no `common_params_parse_main` in `arg.h`; `0002` -- the server still installs its own `load_progress_callback` unconditionally; `0008` -- `get_server_exec_path()` still the worker binary. | -| b11803–b11809 | `src/llama-adapter.cpp`, `src/llama-graph.cpp`, `src/llama-model.h`, `src/models/dflash.cpp`, `ggml/src/ggml-opencl/`, `ggml/src/ggml-webgpu/`, `tools/ui/` (6 commits, `0b1281aee` = b11809, 2026-10-21 .. 2026-10-22) | **No source change here.** LoRA on prism.hadamard folded weights (#30910, b11806): `build_lora_mm` / `build_lora_mm_id` feed the folded weight the Hadamard-transformed input and the LoRA delta the primal `cur`, so an adapter on a folded matmul weight is accepted now; only a latent lookup table (`token_embd`, the tied head) is still refused ("targets a prism.hadamard latent lookup table") -- a message `test_lora_cache.cpp`'s random adapter never meets, its random model carrying no Hadamard blocks. dflash (#30945, b11808): an optional `fc.input_scale` tensor (`fc_in_s`, `TENSOR_NOT_REQUIRED`). OpenCL F32-to-I32 ARGMAX kernel (#30897, b11804; `argmax.cl` joins `GGML_OPENCL_KERNELS`, so the embedded-kernel builds of the two OpenCL module jars pick it up by themselves), WebGPU Q2_0 (#30914, b11809, not built here), two WebUI commits (#30681, #30919, auto-followed by `build-webui`). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). **Verified at b11809**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | +| b11803–b11809 | `src/llama-adapter.cpp`, `src/llama-graph.cpp`, `src/llama-model.h`, `src/models/dflash.cpp`, `ggml/src/ggml-opencl/`, `ggml/src/ggml-webgpu/`, `tools/ui/` (6 commits, `0b1281aee` = b11809, 2026-10-21 .. 2026-10-22) | **No source change here.** LoRA on prism.hadamard folded weights (#30910, b11806): `build_lora_mm` / `build_lora_mm_id` feed the folded weight the Hadamard-transformed input and the LoRA delta the primal `cur`, so an adapter on a folded matmul weight is accepted now; only a latent lookup table (`token_embd`, the tied head) is still refused ("targets a prism.hadamard latent lookup table") -- a message `test_lora_cache.cpp`'s random adapter never meets, its random model carrying no Hadamard blocks. dflash (#30945, b11808): an optional `fc.input_scale` tensor (`fc_in_s`, `TENSOR_NOT_REQUIRED`). OpenCL F32-to-I32 ARGMAX kernel (#30897, b11804; `argmax.cl` joins `GGML_OPENCL_KERNELS`, so the embedded-kernel builds of the three OpenCL module jars -- `opencl-android-aarch64`, `opencl-windows-x86-64`, `opencl-windows-aarch64` -- pick it up by themselves), WebGPU Q2_0 (#30914, b11809, not built here), two WebUI commits (#30681, #30919, auto-followed by `build-webui`). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). **Verified at b11809**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | | b11803–b11809 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `0b1281aee`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). No patch-target file is in the range -- `git diff --name-only b11803..b11809` names no `common/arg.*`, `common/log.*`, `tools/server/*`, `ggml-rpc`, `ggml-alloc.c`, `amx.cpp` or `arch/x86/quants.c`, and no `tests/*` or `examples/*` file at all -- so the drop-check answers of the b11803 row stand unchanged. | -| b11809–b11814 | `src/llama-vocab.cpp`, `src/llama-arch.{cpp,h}`, `src/llama-model-saver.cpp`, `src/models/granite*.cpp`, `ggml/src/ggml-cpu/kleidiai/`, `ggml/src/ggml-feats.h`, `ggml/src/ggml-cpu/CMakeLists.txt`, `ggml/src/ggml-sycl/convert.cpp`, `gguf-py/`, `tests/` (5 commits, `d88040c70` = b11814, 2026-10-21 .. 2026-10-22) | **Two tokenizer behaviour changes, no source change here.** `tokenizer.ggml.clean_spaces` (#30940, b11814): the detokenizer's clean-up of spaces before punctuation (transformers' `clean_up_tokenization_spaces`) is a GGUF key now (`LLM_KV_TOKENIZER_CLEAN_SPACES`, written by the converter from `tokenizer_config.json`, read with `ml.get_key(..., false)` after the pre-tokenizer defaults, and written by the model saver, so a GGUF `LlamaTrainer` writes carries it), and the pre-tokenizer table turns it off where it was on before -- `minicpm5`, `lfm2`, `gemma-4` / `granite-embed-multi-311m`, `dbrx`, `chatglm-bpe`, `glm4` / `glm5`, `exaone4`, `mellum2` -- so the detokenized text of those models keeps the space before punctuation that the old clean-up removed. Pixtral (#30960, b11813): pre-tokenizer `pixtral` moved from the Llama-3 pre-type to Tekken (`LLAMA_VOCAB_PRE_TYPE_TEKKEN`, `clean_spaces = false`), a tokenization change for Pixtral GGUFs. Granite (#30957, b11812): the four granite graphs record each layer's input in `t_layer_inp` (the eagle3 / dflash feature-fusion hook), no output change. KleidiAI v1.32.0 with Q8_0 SVE dotprod GEMV and SVE i8mm GEMM kernels (#30808, b11811; `ggml-feats.h` learns `has_svei8mm`) -- `GGML_CPU_KLEIDIAI` is off in every build here and the aarch64 CPU variant list is unchanged (`ggml/src/ggml-cpu/CMakeLists.txt` differs only in the KleidiAI tag and archive hash). SYCL dequant switches merged (#30740, b11810, `convert.cpp` only). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). None of CI's models uses one of the pre-tokenizers above, and the random fixtures write no `clean_spaces` key and keep their pre-tokenizer's default. **Verified at b11814**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | +| b11809–b11814 | `src/llama-vocab.cpp`, `src/llama-arch.{cpp,h}`, `src/llama-model-saver.cpp`, `src/models/granite*.cpp`, `ggml/src/ggml-cpu/kleidiai/`, `ggml/src/ggml-feats.h`, `ggml/src/ggml-cpu/CMakeLists.txt`, `ggml/src/ggml-sycl/convert.cpp`, `gguf-py/`, `tests/` (5 commits, `d88040c70` = b11814, 2026-10-21 .. 2026-10-22) | **Two tokenizer behaviour changes, no source change here.** `tokenizer.ggml.clean_spaces` (#30940, b11814): the detokenizer's clean-up of spaces before punctuation (transformers' `clean_up_tokenization_spaces`) is a GGUF key now (`LLM_KV_TOKENIZER_CLEAN_SPACES`, written by the converter from `tokenizer_config.json`, read with `ml.get_key(..., false)` after the per-type defaults -- SPM, UGM and RWKV `false`, WPM `true`, BPE `true` unless the pre-tokenizer branch says otherwise, all unchanged -- so it only overrides when present, and written by the model saver, so a GGUF `LlamaTrainer` writes carries it and `GgufInspector` shows it), and the pre-tokenizer table turns it off where it was on before -- `minicpm5`, `lfm2`, `gemma-4` / `granite-embed-multi-311m`, `dbrx`, `chatglm-bpe`, `glm4` / `glm5`, `exaone4`, `mellum2` -- so the detokenized text of those models keeps the space before punctuation that the old clean-up removed. Pixtral (#30960, b11813): pre-tokenizer `pixtral` moved from the Llama-3 pre-type to Tekken (`LLAMA_VOCAB_PRE_TYPE_TEKKEN`, `clean_spaces = false`), a tokenization change for Pixtral GGUFs. Granite (#30957, b11812): the four granite graphs record each layer's input in `t_layer_inp` (the eagle3 / dflash feature-fusion hook), no output change. KleidiAI v1.32.0 with Q8_0 SVE dotprod GEMV and SVE i8mm GEMM kernels (#30808, b11811; `ggml-feats.h` learns `has_svei8mm`) -- `GGML_CPU_KLEIDIAI` is off in every build here and the aarch64 CPU variant list is unchanged (`ggml/src/ggml-cpu/CMakeLists.txt` differs only in the KleidiAI tag and archive hash). SYCL dequant switches merged (#30740, b11810, `convert.cpp` only). Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). None of CI's models uses one of the pre-tokenizers above, and the random fixtures write no `clean_spaces` key and keep their pre-tokenizer's default. **Verified at b11814**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros). | | b11809–b11814 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `d88040c70`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems). The range touches two files under `tests/` (`tests/CMakeLists.txt`, `tests/test-tokenizer-0.cpp`), neither of them a `0001` target (its `tests/` hunks are `test-arg-parser.cpp`, `test-export-graph-ops.cpp` and `test-thread-safety.cpp`), and no other patch-target file, so the drop-check answers of the b11803 row stand unchanged. | +| b11814–b11815 | `ggml/src/ggml-sycl/conv2d.cpp`, `ggml/src/ggml-sycl/ggml-sycl.cpp`, `docs/ops.md`, `docs/ops/SYCL.csv` (one commit, `7170cad4d` = b11815, 2026-10-22) | **One SYCL kernel fix, no source change here.** `CONV_2D` on the SYCL backend (#30363) supports the channel-fastest CWHN layout (input `[N][H][W][C]`, kernel `[KH][KW][IC][OC]`) next to the contiguous WHCN one -- the kernel is templated on the layout, `supports_op` accepts a non-contiguous input or kernel whose channel stride is the element size, and the former `GGML_ASSERT(p[6] == 0)` ("no cwhn layout support") is gone; the regenerated `docs/ops/SYCL.csv` (7,793 insertions, 6,047 deletions, 2.3 MB of diff) is what pushed this one-commit chunk over the runbook's 100 KiB threshold on its own, so it is a chunk of its own. The SYCL module jars are built in CI only (`sycl-linux-x86-64`, `sycl-windows-x86-64`); the change reaches a consumer running a convolution (a vision projector, Whisper-style audio input) on an Intel GPU with a CWHN tensor, which used to abort. Nothing in `common/`, `include/`, `tools/server/`, `tools/mtmd/`, `src/`, the root `CMakeLists.txt` (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` unchanged) or upstream's `.github/`; the server request-field set, its bounds and the response keys are identical (`tools/server/` is not in the range). **Verified at b11815**: `jllama` and `jllama_test` built from the pin in a fresh tree, `ctest` **686/686** (665 `TEST` macros), `NativeLibraryLoadSmokeTest` against the b11815 library. | +| b11814–b11815 | patches | **All 12 apply unchanged** (the fail-loud applier on a fresh build directory at `7170cad4d`; `jllama-build-info` lists 0001, 0002, 0003, 0006, 0007, 0008, 0014, 0015, 0017, 0018, 0020, 0021; `check-patches.py`: 12 patches, 88 hunks, 0 problems; and `git apply --check` + `git apply` in filename order on a pristine b11815 worktree, every one clean, the 48 paths the patches touch equal to the `diff --git` headers). **Across the whole b11803..b11815 range no patch-target file changed**: the 48 touched paths are the same blobs at both tags (`git rev-parse :`), and the intersection with `git diff --name-only b11803..b11815` (59 files) is empty. The drop-checks, re-read at b11815, give the b11803 answers: `0001` -- `argv = utf8.ptrs.data()` at `common/arg.cpp:1288` inside the count guard, no `common_params_parse_main` anywhere in the tree (75 `common_params_parse(` call sites at both tags, none new); `0002` -- `params_base.load_progress_callback = load_progress_callback;` unguarded at `server-context.cpp:1283`; `0006`/`0007` -- `tools/server/server.cpp` is the same blob, the helper body still byte-identical to the route-table block it replaces; `0008` -- `get_server_exec_path()` at `server-models.cpp:575`, no `LLAMA_SERVER_WORKER_CMD`; `0014` -- `common/log.h` names only `common_log_default_callback`; `0015` -- `GGML_ABORT("Failed to connect` at `ggml-rpc.cpp:650`, no `stop_server`, `RPC_PROTO` 8/0/1 and `GGML_OP_COUNT` 102 at both tags; `0017` -- the four `_mm_prefetch` calls of `quants.c` with the cast still after the addition; `0018` -- `free(buffer->context)` still at `amx.cpp:46`; `0020` -- `ggml_gallocr_needs_realloc()` still compares only `n_leafs`; `0021` -- the `else` branch of the per-request LoRA block still takes `params_base.lora_adapters` without the clear, `init()` still starts a slot without the server's adapters. Root `CMakeLists.txt` the same blob (`GGML_LLAMAFILE_DEFAULT` / `GGML_CUDA_GRAPHS_DEFAULT` `ON` at lines 167 / 171); `.github/workflows/release.yml`, `vendor/cpp-httplib/CMakeLists.txt`, `ggml/CMakeLists.txt`, `ggml/src/CMakeLists.txt`, `tools/server/CMakeLists.txt` and `tools/mtmd/CMakeLists.txt` byte-identical, so every `KEEP IN SYNC WITH UPSTREAM` pin of `publish.yml` stays (ROCm 10.0.0, Vulkan 1.4.357.0, DLE 2026.1.4 + oneDNN 2026.0.2 + Level Zero 1.33.1, CCCL v3.4.3, OpenVINO 2026.4.1, BoringSSL 0.20261020.0). | diff --git a/llama/CMakeLists.txt b/llama/CMakeLists.txt index a93c52906..fd17d9568 100644 --- a/llama/CMakeLists.txt +++ b/llama/CMakeLists.txt @@ -347,8 +347,8 @@ endif() # hash, every other consumer keeps the number, and the pin check after FetchContent_MakeAvailable # asserts that the fetched commit's count IS the number. Clear it once the tag exists; # `llama-next-version.sh` says when, and `llama-next-version.sh --resolve b` prints the hash. -set(JLLAMA_GIT_TAG b11814) -set(JLLAMA_GIT_COMMIT "d88040c704c8855c497d46c20675a3d492f70f94") # the commit b11814 names; kept after the tag exists +set(JLLAMA_GIT_TAG b11815) +set(JLLAMA_GIT_COMMIT "7170cad4d02f7907517613931840cbb2367399a3") # the commit b11815 names; kept after the tag exists if(NOT JLLAMA_GIT_TAG MATCHES "^b[0-9]+$") message(FATAL_ERROR "JLLAMA_GIT_TAG must be a llama.cpp release number b, got '${JLLAMA_GIT_TAG}'") endif() diff --git a/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java b/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java index 4d689b10f..810775aea 100644 --- a/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java +++ b/llama/src/main/java/net/ladenthin/llama/value/LlamaCppVersion.java @@ -9,13 +9,13 @@ * library was compiled against, exposed as a compile-time constant so callers can render a badge or * emit a startup log line without loading the native library. * - *

        {@link #LLAMA_CPP_VERSION} is a pure-Java string ({@code "b11814"}) that mirrors the + *

        {@link #LLAMA_CPP_VERSION} is a pure-Java string ({@code "b11815"}) that mirrors the * {@code GIT_TAG} in {@code llama/CMakeLists.txt}. It is available even when {@code libjllama} is * absent (pure-Java checkout, before {@code System.load}), which is what makes it suitable for a * lightweight version badge in Android or other UIs.

        * *

        For the authoritative value that is baked into the native binary — the build number - * plus the resolved upstream commit, e.g. {@code "b11814-"} — call + * plus the resolved upstream commit, e.g. {@code "b11815-"} — call * {@link net.ladenthin.llama.LlamaModel#getLlamaCppBuildInfo()} instead; that reads llama.cpp's own * {@code build-info} through JNI and therefore cannot drift from the compiled library (but requires * the native library to be loaded).

        @@ -23,14 +23,14 @@ public final class LlamaCppVersion { /** - * The pinned llama.cpp release tag this library was built against, e.g. {@code "b11814"}. + * The pinned llama.cpp release tag this library was built against, e.g. {@code "b11815"}. * *

        Kept in lockstep with {@code GIT_TAG} in {@code llama/CMakeLists.txt} — see the * "Upgrading/Downgrading llama.cpp Version" checklist in {@code CLAUDE.md}. This is the * compile-time pin; use {@link net.ladenthin.llama.LlamaModel#getLlamaCppBuildInfo()} for the * value actually linked into the native binary.

        */ - public static final String LLAMA_CPP_VERSION = "b11814"; + public static final String LLAMA_CPP_VERSION = "b11815"; // Constants holder — not instantiable. private LlamaCppVersion() {} From d10f211adb0d23cdb179cb6e5242c15085c52950 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:31:55 +0000 Subject: [PATCH 587/589] Run the Windows sign job on every run instead of skipping it, and refuse a job-level if on it A skipped job skips everything behind it in the dependency chain, past any `if:` of the jobs in between (GitHub's documented rule). Measured in run 38870889665 attempt 2, a dispatch on main and the first run to reach package after the sign job existed: sign-windows-natives was skipped (no sign_windows switch), package tolerated the skip with !cancelled() and ran green, and the smoke sets, the report and both publish jobs behind package were skipped all the same, with the run concluding "success". Every run since did the same, so no smoke set and no publish job has run since 2026-10-16. The job now runs on every run and is never skipped: without the switch every step but the first is gated off, and the environment is an expression -- maven-central only when signing, otherwise the unprotected `unsigned`, which holds no secrets -- so the protected environment's rules still never apply to a pull request. package goes back to the default condition: a sign job that ran and failed still skips it. natives.check_signing refuses a job-level `if:` on the sign job (unit test for both forms; proven red with the old condition put back). The workflow header comment, CLAUDE.md, TODO.md item 11 and the CHANGELOG describe the always-running job. Ported as a fresh change from the unmerged branch claude/jolly-brahmagupta-0ydzby (commits 251bcab and 2ac4ea8 of 2026-10-16), which is left to be deleted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .github/buildcheck/natives.py | 15 +++++- .github/buildcheck/tests/test_natives.py | 12 +++++ .github/workflows/publish.yml | 62 +++++++++++++++++------- CHANGELOG.md | 8 ++- CLAUDE.md | 23 ++++++--- TODO.md | 10 ++-- 6 files changed, 101 insertions(+), 29 deletions(-) diff --git a/.github/buildcheck/natives.py b/.github/buildcheck/natives.py index 3baf0736e..fed6dc129 100644 --- a/.github/buildcheck/natives.py +++ b/.github/buildcheck/natives.py @@ -47,6 +47,8 @@ SMOKE_EXCLUDED_OSES = ("Linux-Android",) # The job that re-uploads the Windows natives artifacts Authenticode-signed (CLAUDE.md, "Code signing"). SIGN_JOB = "sign-windows-natives" +# A job-level key sits at the four-space indent of the job body (` if:`); a step's `if:` is deeper. +JOB_CONDITION = re.compile(r"^ if:") SMOKE_EXCLUDED_TREES = ("Linux/s390x",) # `package` uploads each smoke set alone, as llama-smoke-, for the smoke jobs; a matrix row @@ -245,7 +247,14 @@ def check_signing(natives, jobs, uploads): or reads one of them -- `overwrite` deletes the artifact for a moment, so a build or test job still reading it would race (the Windows Java test job reads natives-cpu-windows-x86-64 through the reusable workflow's input). A Windows row without its step would ship that jar unsigned on a - signed run, which `--require-signed` in `package` then reports late.""" + signed run, which `--require-signed` in `package` then reports late. + + And the job carries no job-level `if:`: it must run on every run (a no-op without the switch, + gated step by step) rather than be skipped, because GitHub applies a skip to every job in the + dependency chain behind the skipped one, past any condition of the jobs in between -- the first + form was `if:`-skipped without the switch, `package` tolerated that with `!cancelled()` and ran + green, and the smoke sets, the report and both publish jobs behind `package` were skipped with + the run concluding "success" (run 38870889665, attempt 2).""" windows = [r["classifier"] for r in natives if tree(r).split("/")[0] == "Windows"] sign = jobs.get(SIGN_JOB) if sign is None: @@ -253,6 +262,10 @@ def check_signing(natives, jobs, uploads): signed = {c for c, uploaders in uploads.items() if SIGN_JOB in uploaders} failures = [f"publish.yml {SIGN_JOB} re-uploads (one per Windows row): missing {c}" for c in sorted(set(windows) - signed)] + if any(JOB_CONDITION.match(line) for line in sign.lines): + failures.append(f"publish.yml {SIGN_JOB} carries a job-level `if:` -- the job must run on every run " + f"(a no-op without the switch), never be skipped: a skipped job skips every job behind " + f"`package` in the dependency chain, past any condition in between") failures += [f"publish.yml {SIGN_JOB} re-uploads natives-{c}, which is not a Windows row -- it would replace " f"the build artifact of another platform with an unsigned copy" for c in sorted(signed - set(windows))] diff --git a/.github/buildcheck/tests/test_natives.py b/.github/buildcheck/tests/test_natives.py index f2602f783..e442cc125 100644 --- a/.github/buildcheck/tests/test_natives.py +++ b/.github/buildcheck/tests/test_natives.py @@ -186,6 +186,18 @@ def test_the_sign_job_re_uploads_every_windows_row_with_overwrite(self): "error` would leave the unsigned build artifact in place when the download matched " "nothing"] * len(WINDOWS)) + def test_the_sign_job_carries_no_job_level_condition(self): + """A skipped sign job skips every job behind package (GitHub's dependency-chain rule), so the job + must run on every run; a step-level `if:` (the per-step gate on the switch) is what it uses.""" + skipped = [" if: github.event_name == 'workflow_dispatch' && inputs.sign_windows"] + sign_uploads() + self.assertEqual(natives.check_workflow(ROWS, jobs(sign=skipped)), + ["publish.yml sign-windows-natives carries a job-level `if:` -- the job must run on every " + "run (a no-op without the switch), never be skipped: a skipped job skips every job " + "behind `package` in the dependency chain, past any condition in between"]) + gated = [line for step in sign_uploads() for line in + ([step, " if: env.SIGN_WINDOWS == 'true'"] if step.startswith(" - ") else [step])] + self.assertEqual(natives.check_workflow(ROWS, jobs(sign=gated)), []) + def test_the_sign_job_waits_for_every_reader_of_a_windows_artifact(self): # a job reading the artifact between the build and the signed re-upload would race the overwrite self.assertEqual(natives.check_workflow(ROWS, jobs(consumer_needs="[build]")), diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 526e68ea0..5dd3d9808 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,8 +42,9 @@ env: # dispatch input, for snapshot and release alike -- the pipeline is the same at that point, and # a release is `publish_to_central` + `sign_windows` both on. Never on a push or a pull request, # as nothing publishes from those either. What the switch does: the sign-windows-natives job - # runs (the maven-central environment's eSigner secrets), and package / the publish jobs require - # every Windows DLL of the merged tree to be signed. + # signs (it runs on every run, but only with the switch does it take the maven-central + # environment and its eSigner secrets), and package / the publish jobs require every Windows DLL + # of the merged tree to be signed. SIGN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows }} SCCACHE_WEBDAV_ENDPOINT: https://cache.depot.dev # The CI model set is .github/models.csv (URLs, and the model cache key is its hash). The Java @@ -2982,16 +2983,28 @@ jobs: # --------------------------------------------------------------------------- # Windows code signature (CLAUDE.md, "Code signing of the Windows DLLs"; TODO.md, delivery - # roadmap, item 11). Only on a dispatch with `sign_windows`: Authenticode over every DLL of - # every Windows natives artifact with SSL.com eSigner through jsign, then the artifacts are - # re-uploaded under their own names (`overwrite: true`), so `package` and the publish jobs - # merge signed DLLs, the Windows smoke rows run the signed sets and the jars carry them. + # roadmap, item 11). Signing happens only on a dispatch with `sign_windows`: Authenticode over + # every DLL of every Windows natives artifact with SSL.com eSigner through jsign, then the + # artifacts are re-uploaded under their own names (`overwrite: true`), so `package` and the + # publish jobs merge signed DLLs, the Windows smoke rows run the signed sets and the jars carry + # them. # # A job of its own, not steps in `package`: the eSigner secrets live in the maven-central # environment (the GPG key's), and `package` runs on every pull request, where that # environment's rules must not apply. The TOTP secret lets whoever holds it sign in the owner's - # name, so it is handed to exactly one job, which runs only on the explicit switch and only - # where the environment allows. The job waits for every job that reads a Windows natives + # name, so it is handed to exactly one job, which signs only on the explicit switch and only + # where the environment allows. + # + # The job itself runs on EVERY run and is never skipped -- without the switch it does nothing + # (every step but the first is gated on SIGN_WINDOWS) and takes the unprotected environment + # `unsigned`, which holds no secrets. It cannot be `if:`-skipped: GitHub applies a skip "to all + # jobs in the dependency chain from the point of failure or skip onwards", through every job in + # between whatever its own condition says, so a skipped sign job skipped the smoke sets, the + # report and both publish jobs behind `package` even though `package` itself tolerated the + # skip and ran green (run 38870889665 attempt 2, the first run to reach `package` after the + # job existed). The environment name is an expression for that reason: `maven-central` only + # when signing, so its protection rules never apply to a pull request. + # The job waits for every job that reads a Windows natives # artifact (the Windows Java test job downloads natives-cpu-windows-x86-64): `overwrite` # deletes the artifact for a moment, and a download in that moment would fail. # check-natives.py holds the upload steps to the Windows rows of natives.csv and the `needs` @@ -3000,26 +3013,32 @@ jobs: sign-windows-natives: name: Sign the Windows DLLs (SSL.com eSigner) needs: [build-windows-x86_64, build-windows-arm64, build-windows-x86_64-cuda, build-windows-x86_64-vulkan, build-windows-x86_64-opencl, build-windows-x86_64-rocm, build-windows-x86_64-sycl, build-windows-arm64-opencl, build-windows-arm64-vulkan, build-windows-x86_64-openvino, test-java-windows-x86_64] - if: github.event_name == 'workflow_dispatch' && inputs.sign_windows runs-on: ubuntu-latest - environment: maven-central + environment: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && 'maven-central' || 'unsigned' }} permissions: contents: read steps: + - name: Nothing to sign (no sign_windows switch) -- the build artifacts stay as uploaded + if: env.SIGN_WINDOWS != 'true' + run: echo "sign_windows is off; the Windows natives artifacts are re-used unsigned (jllama-build.txt says signed=no)." - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: env.SIGN_WINDOWS == 'true' with: persist-credentials: false - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + if: env.SIGN_WINDOWS == 'true' with: distribution: 'temurin' java-version-file: .java-version # Unmerged, one directory per artifact (Windows/// inside), the layout the # build jobs uploaded and merge-native-artifacts.sh expects back. - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + if: env.SIGN_WINDOWS == 'true' with: pattern: "natives-*-windows-*" path: ${{ github.workspace }}/native-artifacts/ - name: Sign every Windows DLL and mark the directories signed=yes + if: env.SIGN_WINDOWS == 'true' env: ES_USERNAME: ${{ secrets.ES_USERNAME }} ES_PASSWORD: ${{ secrets.ES_PASSWORD }} @@ -3030,63 +3049,74 @@ jobs: # Every DLL of every directory carries a signature now, read from the file (the attribute # certificate table), before anything is re-uploaded. - name: Verify the signatures + if: env.SIGN_WINDOWS == 'true' run: python3 .github/verify-native-deps.py --require-signed "${{ github.workspace }}/native-artifacts" # One upload per artifact, same name, same layout; overwrite replaces the build job's. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-cpu-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-cpu-windows-x86-64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-cpu-windows-aarch64 path: ${{ github.workspace }}/native-artifacts/natives-cpu-windows-aarch64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-cuda13-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-cuda13-windows-x86-64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-vulkan-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-vulkan-windows-x86-64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-opencl-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-opencl-windows-x86-64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-rocm-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-rocm-windows-x86-64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-sycl-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-sycl-windows-x86-64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-opencl-windows-aarch64 path: ${{ github.workspace }}/native-artifacts/natives-opencl-windows-aarch64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-vulkan-windows-aarch64 path: ${{ github.workspace }}/native-artifacts/natives-vulkan-windows-aarch64/ overwrite: true if-no-files-found: error - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + if: env.SIGN_WINDOWS == 'true' with: name: natives-openvino-windows-x86-64 path: ${{ github.workspace }}/native-artifacts/natives-openvino-windows-x86-64/ @@ -3132,14 +3162,12 @@ jobs: - test-java-macos-arm64-metal-15 - test-java-windows-x86_64 - test-java-windows-x86_64-msvc + # sign-windows-natives runs on every run (a no-op without the `sign_windows` switch), so the + # default condition is right here: a sign job that ran and failed -- wrong secrets, the + # environment refusing the branch -- skips this job, because the signature was asked for. + # It must never be a skipped job: a skip propagates down the whole dependency chain past + # any `if:` in between (see the comment on that job). - sign-windows-natives - # sign-windows-natives is skipped on every run without the `sign_windows` switch, and a skipped - # `needs` entry skips the dependents unless the condition says otherwise: run on success of - # everything else, never after a failure or a cancellation (every other entry is an - # unconditional job, so "not failed, not cancelled" is "succeeded" for them). A sign job that - # ran and failed -- wrong secrets, the environment refusing the branch -- skips this job: the - # signature was asked for. - if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} runs-on: ubuntu-latest # id-token + attestations: the build-provenance attestation below (Sigstore) signs a statement # that these exact files came out of this workflow at this commit; contents stays read. diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d5e0cbd..8622e2bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -527,8 +527,12 @@ the first two items break every existing consumer at upgrade. `sign-windows-natives` (`.github/sign-windows-natives.sh`: jsign, the secrets of the `maven-central` environment, an RFC 3161 timestamp) re-uploads the Windows natives artifacts signed, `signed=yes` in each directory's `jllama-build.txt`, and `verify-native-deps.py` refuses an unsigned DLL on such a - run. Without the switch every build says `signed=no`, as before. (Azure Trusted Signing, the first - choice, takes no individual in Germany and was dropped before it ever ran.) + run. Without the switch every build says `signed=no`, as before, and the job runs as a no-op in + the unprotected environment `unsigned` rather than being skipped: a skipped job skips every job + behind `package` with it (GitHub's dependency-chain rule), which is what the first form did -- the + smoke sets, the report and both publish jobs of every run from 2026-10-16 to 2026-10-22 were + skipped behind a green `package`, with the run concluding "success". (Azure Trusted Signing, the + first choice, takes no individual in Germany and was dropped before it ever ran.) - **A `v*` tag release is built without the shared compiler cache.** `USE_CACHE` is `false` on every tag ref whatever the dispatch input says, so each natives build of a release compiles every object in the job that ships it; snapshots and PR builds keep the sccache/Depot cache. The cache provider diff --git a/CLAUDE.md b/CLAUDE.md index 47e6c7692..b97c95b78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3190,10 +3190,20 @@ certificate and the secrets exist. every pull request, where that environment's rules must not apply -- `verify-signing-key` on the same environment is "red by design" on foreign branches. The job waits for the Windows Java test job because it reads `natives-cpu-windows-x86-64`: `overwrite` deletes the artifact for a moment, - and a download in that moment fails. `package` lists the job in `needs` with an `if` that - tolerates its skip (`!cancelled() && !contains(needs.*.result, 'failure') && ...`): skipped means - no switch, run and failed (wrong secrets, the environment refusing the branch) means `package` - is skipped too, because the signature was asked for. + and a download in that moment fails. **The job runs on every run and is never skipped**: without + the switch its steps are gated off (`env.SIGN_WINDOWS`) and it takes the unprotected environment + `unsigned` -- the environment name is an expression, `maven-central` only when signing, so that + environment's rules never apply to a pull request -- and `package` lists it in `needs` with the + default condition: run and failed (wrong secrets, the environment refusing the branch) means + `package` is skipped, because the signature was asked for. The first form had the job itself + `if:`-skipped without the switch and `package` tolerating the skip (`!cancelled() && + !contains(needs.*.result, 'failure') && ...`), and that lost every job behind `package`: GitHub + applies a skip "to all jobs in the dependency chain from the point of failure or skip onwards", + past any `if:` in between, so run 38870889665 (attempt 2, the first run to reach `package` after + the job existed, a dispatch on main) ended "success" with `package` green and the smoke sets, the + report and both publish jobs skipped -- and so did every run since, until this was corrected on + 2026-10-22 (the fix had sat on the unmerged branch `claude/jolly-brahmagupta-0ydzby` since + 2026-10-16). A job that other jobs depend on must run or fail, never skip. - **How**: `.github/sign-windows-natives.sh` -- **jsign** (`net.jsign:jsign:7.6` from Maven Central, pinned by version and SHA-256; Java, runs on the Linux runner, no signtool) with `--storetype ESIGNER --keystore --storepass "|" --alias @@ -3223,8 +3233,9 @@ certificate and the secrets exist. is on -- so a signed run cannot let an unsigned DLL into a jar, and a run without the switch is not blocked. Unit tests in `test_nativedeps.py` (`SignatureTest`, a synthetic PE with and without the security directory). `check-natives.py` holds the job itself (`natives.check_signing`): one - `overwrite: true` upload per Windows row of `natives.csv`, and the job waits for every job that - uploads one of them. + `overwrite: true` upload per Windows row of `natives.csv`, the job waits for every job that + uploads one of them, and it carries no job-level `if:` (the chain rule above; a step-level gate + is what it uses). - **What the owner does, in order** (TODO.md item 11 and "Code-sign the Windows DLLs" carry the details and the prices): delete the Azure Trusted Signing account; SSL.com's IV validation; the IV code-signing certificate enrolled in eSigner; the TOTP secret and the credential id from the diff --git a/TODO.md b/TODO.md index 243bc1bcc..a175992be 100644 --- a/TODO.md +++ b/TODO.md @@ -218,9 +218,13 @@ only under that environment's rules -- never a step of `package`, which runs on *(The code side landed 2026-10-15: `.github/sign-windows-natives.sh` (jsign 7.2 pinned by SHA-256, `--storetype ESIGNER`, every `Windows/*/*/*.dll`, `signed=yes` afterwards; measured with a real DLL and dummy credentials: the options are accepted and the run fails at SSL.com's authentication), the -job `sign-windows-natives` (environment `maven-central`, re-uploads the ten Windows artifacts with -`overwrite: true`, `check-natives.py` holds the uploads to the Windows rows of `natives.csv`), -`package` waiting for it with a skip-tolerant `if`, `--require-signed` in `package` and both publish +job `sign-windows-natives` (environment `maven-central` only when signing, else the unprotected +`unsigned`; re-uploads the ten Windows artifacts with `overwrite: true`, `check-natives.py` holds +the uploads to the Windows rows of `natives.csv`), `package` waiting for it -- the job runs on every +run and is never `if:`-skipped, because GitHub applies a skip to the whole dependency chain behind +the skipped job past any condition in between, which is how run 38870889665 (attempt 2) ended +"success" with every job behind `package` skipped; `check-natives.py` refuses a job-level `if:` on +it now --, `--require-signed` in `package` and both publish jobs whenever `SIGN_WINDOWS` is on, `pe_signed()` + `signature_violations()` in `verify-native-deps.py` with unit tests; CLAUDE.md "Code signing of the Windows DLLs", README. Unexercised until the certificate exists.)* From 9c835e8efcceaea0972da28c8158c3ee0540aec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:19:32 +0000 Subject: [PATCH 588/589] Restore the classpath line of the natives-jar load smoke lost with F269 The package job's "Load the natives jars together" step died on its first run since the sign-job fix (run 39763815465): smoke-natives-jars.sh line 84, `path: unbound variable`. Commit 68ab75d (F269) replaced the line that assembles the consumer's classpath -- the classes jar, every natives jar and the runtime dependencies -- with the Main-Class check and dropped it; bash -n cannot see an unbound variable, so no check caught it. Proven with a stub java on PATH that records its arguments: the committed script fails with the same message before any java call, the restored one runs both the classpath and the module-path round and hands java the classes jar, all natives jars and the dependencies. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- .github/smoke-natives-jars.sh | 4 ++++ CHANGELOG.md | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/smoke-natives-jars.sh b/.github/smoke-natives-jars.sh index 4b4c4d7e2..c35acaeec 100755 --- a/.github/smoke-natives-jars.sh +++ b/.github/smoke-natives-jars.sh @@ -62,6 +62,10 @@ main_class="$(unzip -p "$classes" META-INF/MANIFEST.MF | tr -d '\r' | sed -n 's/ [ "$main_class" = "net.ladenthin.llama.server.ServerLauncher" ] \ || { echo "::error::the classes jar $classes names '$main_class' as Main-Class, expected net.ladenthin.llama.server.ServerLauncher" >&2; exit 1; } echo "classes jar: $classes (Main-Class $main_class); ${#natives[@]} natives jars" +# The consumer's classpath: the classes jar, every natives jar and the runtime dependencies. This +# line went missing once (F269 replaced it with the Main-Class check above), and bash -n cannot see +# an unbound variable: the first package job to reach this step after that died on `$path` below. +path="$classes:$(IFS=:; echo "${natives[*]}"):$DEPS" run() { local label="$1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8622e2bb3..dea5592c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -532,7 +532,11 @@ the first two items break every existing consumer at upgrade. behind `package` with it (GitHub's dependency-chain rule), which is what the first form did -- the smoke sets, the report and both publish jobs of every run from 2026-10-16 to 2026-10-22 were skipped behind a green `package`, with the run concluding "success". (Azure Trusted Signing, the - first choice, takes no individual in Germany and was dropped before it ever ran.) + first choice, takes no individual in Germany and was dropped before it ever ran.) The first run to + reach `package`'s natives-jar load step after that (39763815465) died in `smoke-natives-jars.sh` on + `path: unbound variable`: the F269 commit had replaced the script's classpath line with the + Main-Class check, and `bash -n` cannot see an unbound variable. The line is back, proven with a + stub `java` that records the classpath it is given. - **A `v*` tag release is built without the shared compiler cache.** `USE_CACHE` is `false` on every tag ref whatever the dispatch input says, so each natives build of a release compiles every object in the job that ships it; snapshots and PR builds keep the sccache/Depot cache. The cache provider From 7de7a846b836004816baf9a9377e8aa207684ea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:19:33 +0000 Subject: [PATCH 589/589] Record the smoke-script repair under F269 in the bug-hunt ledger Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VBFFnrhtvXiUeyxjmUc4R2 --- BUGHUNT-FOUND.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BUGHUNT-FOUND.md b/BUGHUNT-FOUND.md index 1c3b83472..c3f8b88e1 100644 --- a/BUGHUNT-FOUND.md +++ b/BUGHUNT-FOUND.md @@ -2516,6 +2516,7 @@ Repos plus eins. Geprüfte Klassen ohne Fund stehen in [`BUGHUNT-CHECKED.md`](BU - **Rot-Grün-Nachweis:** Manifest vor dem Fix ohne `Main-Class` (gemessen am Jar aus `mvn package`), der neue Smoke-Check liefe damit rot („names '' as Main-Class“); nach dem Fix enthält das Manifest den Eintrag, `bash -n` über das Skript, `check-natives.py` 0 Abweichungen. Kein Unit-Test: ein Manifest-Eintrag ist erst nach `package` beobachtbar, die Prüfung gehört in den Job, der die Jars lädt. - **Status:** gefixt - **Commit:** java-llama.cpp `68ab75d` +- **Nachtrag (2026-10-22, Lauf 39763815465):** Der Smoke-Check selbst hatte mit diesem Commit seine Classpath-Zeile verloren (`path="$classes:…:$DEPS"` wich der `Main-Class`-Prüfung), und `bash -n` sieht eine unbelegte Variable nicht: der erste `package`-Job, der den Schritt seit dem Sign-Job-Fix erreichte, starb mit „line 84: path: unbound variable“. Zeile wiederhergestellt, mit einem Stub-`java` nachgewiesen (committetes Skript rot mit derselben Meldung, repariertes grün, der Stub bekommt Klassen-Jar, alle Natives-Jars und die Abhängigkeiten auf Classpath und Modulpfad). Commit java-llama.cpp `9c835e8`. ### F270 – java-llama.cpp: `LlamaModelTest.testGenerateGrammar` / `testCompleteGrammar` zählen die erzeugten Token über eine Re-Tokenisierung des Textes