From 3168cce583285f8b23c40a05a8bcfda379aef355 Mon Sep 17 00:00:00 2001 From: Jan Martiska Date: Fri, 22 Nov 2024 10:06:14 +0100 Subject: [PATCH] Upgrade to LangChain4j 0.36.2 --- .../deployment/BeansProcessor.java | 7 +++++ .../langchain4j/QuarkusJsonCodecFactory.java | 19 ++++++++++++ .../aiservice/AiServiceMethodCreateInfo.java | 4 +-- ...rkusAiServiceStreamingResponseHandler.java | 11 +++++++ .../QuarkusAiServiceTokenStream.java | 15 ++++++++++ .../ToolSpecificationObjectSubstitution.java | 29 ++++++++++++++----- .../ROOT/pages/includes/attributes.adoc | 2 +- .../AnthropicChatLanguageModelSmokeTest.java | 1 + ...icStreamingChatLanguageModelSmokeTest.java | 1 + .../langchain4j/jlama/JlamaModel.java | 14 +++++++-- .../mistralai/MistralAiRestApi.java | 6 ++++ .../mistralai/QuarkusMistralAiClient.java | 8 +++++ .../langchain4j/ollama/MessageMapper.java | 19 +++++++++++- .../quarkiverse/langchain4j/ollama/Tool.java | 1 + .../watsonx/bean/TextChatMessage.java | 7 +++-- pom.xml | 4 +-- 16 files changed, 129 insertions(+), 19 deletions(-) diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/BeansProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/BeansProcessor.java index ed8171cd3..1dcd08508 100644 --- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/BeansProcessor.java +++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/BeansProcessor.java @@ -56,6 +56,7 @@ import io.quarkus.deployment.builditem.FeatureBuildItem; import io.quarkus.deployment.builditem.IndexDependencyBuildItem; import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; import io.quarkus.deployment.logging.LogCleanupFilterBuildItem; import io.quarkus.runtime.configuration.ConfigurationException; @@ -608,4 +609,10 @@ void logCleanupFilters(BuildProducer logCleanupFilter logCleanupFilters .produce(new LogCleanupFilterBuildItem("ai.djl.huggingface.tokenizers.jni.LibUtils", Level.INFO, "Extracting")); } + + @BuildStep + public void nativeSupport(BuildProducer producer) { + // RetryUtils initializes a java.lang.Random instance + producer.produce(new RuntimeInitializedClassBuildItem("dev.langchain4j.internal.RetryUtils")); + } } diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java index 00f3f80f7..61801a727 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.lang.reflect.Type; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,6 +16,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.json.JsonReadFeature; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.PropertyNamingStrategies; @@ -60,6 +62,23 @@ public T fromJson(String json, Class type) { } } + @Override + public T fromJson(String json, Type type) { + JavaType javaType = ObjectMapperHolder.MAPPER.constructType(type); + try { + String sanitizedJson = sanitize(json, javaType.getRawClass()); + return ObjectMapperHolder.MAPPER.readValue(sanitizedJson, javaType); + } catch (JsonProcessingException e) { + if ((e instanceof JsonParseException) && (javaType.isEnumType())) { + // this is the case where LangChain4j simply passes the string value of the enum to Json.fromJson() + // and Jackson does not handle it + Class enumClass = javaType.getRawClass().asSubclass(Enum.class); + return (T) Enum.valueOf(enumClass, json); + } + throw new UncheckedIOException(e); + } + } + private String sanitize(String original, Class type) { if (String.class.equals(type)) { return original; diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/AiServiceMethodCreateInfo.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/AiServiceMethodCreateInfo.java index d7ccb5c42..68b3a513a 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/AiServiceMethodCreateInfo.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/AiServiceMethodCreateInfo.java @@ -1,7 +1,5 @@ package io.quarkiverse.langchain4j.runtime.aiservice; -import static org.apache.commons.lang3.StringUtils.EMPTY; - import java.lang.reflect.Type; import java.util.List; import java.util.Map; @@ -202,7 +200,7 @@ public String getUserMessageTemplate() { Optional userMessageTemplateOpt = this.getUserMessageInfo().template() .flatMap(AiServiceMethodCreateInfo.TemplateInfo::text); - return userMessageTemplateOpt.orElse(EMPTY); + return userMessageTemplateOpt.orElse(""); } public boolean isSwitchToWorkerThread() { diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java index d1205d7e0..fc520aa77 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceStreamingResponseHandler.java @@ -20,6 +20,7 @@ import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; import dev.langchain4j.service.AiServiceContext; +import dev.langchain4j.service.tool.ToolExecution; import dev.langchain4j.service.tool.ToolExecutor; import io.smallrye.mutiny.infrastructure.Infrastructure; import io.vertx.core.Context; @@ -38,6 +39,7 @@ public class QuarkusAiServiceStreamingResponseHandler implements StreamingRespon private final Consumer tokenHandler; private final Consumer> completionHandler; + private final Consumer toolExecuteHandler; private final Consumer errorHandler; private final List temporaryMemory; @@ -51,6 +53,7 @@ public class QuarkusAiServiceStreamingResponseHandler implements StreamingRespon QuarkusAiServiceStreamingResponseHandler(AiServiceContext context, Object memoryId, Consumer tokenHandler, + Consumer toolExecuteHandler, Consumer> completionHandler, Consumer errorHandler, List temporaryMemory, @@ -62,6 +65,7 @@ public class QuarkusAiServiceStreamingResponseHandler implements StreamingRespon this.tokenHandler = ensureNotNull(tokenHandler, "tokenHandler"); this.completionHandler = completionHandler; + this.toolExecuteHandler = toolExecuteHandler; this.errorHandler = errorHandler; this.temporaryMemory = new ArrayList<>(temporaryMemory); @@ -116,6 +120,12 @@ public void run() { ToolExecutionResultMessage toolExecutionResultMessage = ToolExecutionResultMessage.from( toolExecutionRequest, toolExecutionResult); + ToolExecution toolExecution = ToolExecution.builder() + .request(toolExecutionRequest).result(toolExecutionResult) + .build(); + if (toolExecuteHandler != null) { + toolExecuteHandler.accept(toolExecution); + } QuarkusAiServiceStreamingResponseHandler.this.addToMemory(toolExecutionResultMessage); } @@ -126,6 +136,7 @@ public void run() { context, memoryId, tokenHandler, + toolExecuteHandler, completionHandler, errorHandler, temporaryMemory, diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java index 1b0f4edb4..fa8939938 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/aiservice/QuarkusAiServiceTokenStream.java @@ -20,6 +20,7 @@ import dev.langchain4j.rag.content.Content; import dev.langchain4j.service.AiServiceContext; import dev.langchain4j.service.TokenStream; +import dev.langchain4j.service.tool.ToolExecution; import dev.langchain4j.service.tool.ToolExecutor; import io.vertx.core.Context; @@ -44,12 +45,14 @@ public class QuarkusAiServiceTokenStream implements TokenStream { private Consumer> contentsHandler; private Consumer errorHandler; private Consumer> completionHandler; + private Consumer toolExecuteHandler; private int onNextInvoked; private int onCompleteInvoked; private int onRetrievedInvoked; private int onErrorInvoked; private int ignoreErrorsInvoked; + private int toolExecuteInvoked; public QuarkusAiServiceTokenStream(List messages, List toolSpecifications, @@ -82,6 +85,13 @@ public TokenStream onRetrieved(Consumer> contentsHandler) { return this; } + @Override + public TokenStream onToolExecuted(Consumer toolExecuteHandler) { + this.toolExecuteHandler = toolExecuteHandler; + this.toolExecuteInvoked++; + return this; + } + @Override public TokenStream onComplete(Consumer> completionHandler) { this.completionHandler = completionHandler; @@ -110,6 +120,7 @@ public void start() { context, memoryId, tokenHandler, + toolExecuteHandler, completionHandler, errorHandler, initTemporaryMemory(context, messages), @@ -150,6 +161,10 @@ private void validateConfiguration() { throw new IllegalConfigurationException("onRetrieved must be invoked at most 1 time"); } + if (toolExecuteInvoked > 1) { + throw new IllegalConfigurationException("onToolExecuted must be invoked at most 1 time"); + } + if (onErrorInvoked + ignoreErrorsInvoked != 1) { throw new IllegalConfigurationException("One of onError or ignoreErrors must be invoked exactly 1 time"); } diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/tool/ToolSpecificationObjectSubstitution.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/tool/ToolSpecificationObjectSubstitution.java index f79fb5310..2700e127e 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/tool/ToolSpecificationObjectSubstitution.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/tool/ToolSpecificationObjectSubstitution.java @@ -2,6 +2,7 @@ import dev.langchain4j.agent.tool.ToolParameters; import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; import io.quarkus.runtime.ObjectSubstitution; import io.quarkus.runtime.annotations.RecordableConstructor; @@ -10,26 +11,36 @@ public class ToolSpecificationObjectSubstitution @Override public Serialized serialize(ToolSpecification obj) { - return new Serialized(obj.name(), obj.description(), obj.parameters()); + return new Serialized(obj.name(), obj.description(), obj.toolParameters(), obj.parameters()); } @Override public ToolSpecification deserialize(Serialized obj) { - return ToolSpecification.builder() + ToolSpecification.Builder builder = ToolSpecification.builder() .name(obj.name) - .description(obj.description) - .parameters(obj.parameters).build(); + .description(obj.description); + if (obj.toolParameters != null) { + builder.parameters(obj.toolParameters); + } + if (obj.parameters != null) { + builder.parameters(obj.parameters); + } + return builder.build(); } public static class Serialized { private final String name; private final String description; - private final ToolParameters parameters; + private final ToolParameters toolParameters; + private final JsonObjectSchema parameters; @RecordableConstructor - public Serialized(String name, String description, ToolParameters parameters) { + public Serialized(String name, String description, + ToolParameters toolParameters, + JsonObjectSchema parameters) { this.name = name; this.description = description; + this.toolParameters = toolParameters; this.parameters = parameters; } @@ -41,7 +52,11 @@ public String getDescription() { return description; } - public ToolParameters getParameters() { + public ToolParameters getToolParameters() { + return toolParameters; + } + + public JsonObjectSchema getParameters() { return parameters; } diff --git a/docs/modules/ROOT/pages/includes/attributes.adoc b/docs/modules/ROOT/pages/includes/attributes.adoc index 36d9c4c5b..427a65be9 100644 --- a/docs/modules/ROOT/pages/includes/attributes.adoc +++ b/docs/modules/ROOT/pages/includes/attributes.adoc @@ -1,3 +1,3 @@ :project-version: 0.21.0 -:langchain4j-version: 0.35.0 +:langchain4j-version: 0.36.2 :examples-dir: ./../examples/ \ No newline at end of file diff --git a/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicChatLanguageModelSmokeTest.java b/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicChatLanguageModelSmokeTest.java index c5ba41d21..54031c879 100644 --- a/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicChatLanguageModelSmokeTest.java +++ b/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicChatLanguageModelSmokeTest.java @@ -92,6 +92,7 @@ void blocking() { "text" : "Hello, how are you today?" } ] } ], + "system" : [ ], "max_tokens" : 1024, "stream" : false, "top_k" : 40 diff --git a/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicStreamingChatLanguageModelSmokeTest.java b/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicStreamingChatLanguageModelSmokeTest.java index 4859517fb..ceb4ae02a 100644 --- a/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicStreamingChatLanguageModelSmokeTest.java +++ b/model-providers/anthropic/deployment/src/test/java/io/quarkiverse/langchain4j/anthropic/deployment/AnthropicStreamingChatLanguageModelSmokeTest.java @@ -237,6 +237,7 @@ public void onComplete(Response response) { "text" : "Hello, how are you today?" } ] } ], + "system" : [ ], "max_tokens" : 1024, "stream" : true, "top_k" : 40 diff --git a/model-providers/jlama/runtime/src/main/java/io/quarkiverse/langchain4j/jlama/JlamaModel.java b/model-providers/jlama/runtime/src/main/java/io/quarkiverse/langchain4j/jlama/JlamaModel.java index 4365ac266..e2a71b74d 100644 --- a/model-providers/jlama/runtime/src/main/java/io/quarkiverse/langchain4j/jlama/JlamaModel.java +++ b/model-providers/jlama/runtime/src/main/java/io/quarkiverse/langchain4j/jlama/JlamaModel.java @@ -14,6 +14,8 @@ import com.github.tjake.jlama.safetensors.prompt.Tool; import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.request.json.JsonSchemaElementHelper; import dev.langchain4j.model.output.FinishReason; /** @@ -125,8 +127,16 @@ static Tool toTool(ToolSpecification toolSpecification) { .name(toolSpecification.name()) .description(toolSpecification.description()); - for (Map.Entry> p : toolSpecification.parameters().properties().entrySet()) { - builder.addParameter(p.getKey(), p.getValue(), toolSpecification.parameters().required().contains(p.getKey())); + if (toolSpecification.toolParameters() != null) { + for (Map.Entry> p : toolSpecification.toolParameters().properties().entrySet()) { + builder.addParameter(p.getKey(), p.getValue(), + toolSpecification.toolParameters().required().contains(p.getKey())); + } + } else if (toolSpecification.parameters() != null) { + for (Map.Entry p : toolSpecification.parameters().properties().entrySet()) { + builder.addParameter(p.getKey(), JsonSchemaElementHelper.toMap(p.getValue()), + toolSpecification.parameters().required().contains(p.getKey())); + } } return Tool.from(builder.build()); diff --git a/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/MistralAiRestApi.java b/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/MistralAiRestApi.java index 9c46a1269..93307ecda 100644 --- a/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/MistralAiRestApi.java +++ b/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/MistralAiRestApi.java @@ -39,6 +39,8 @@ import dev.langchain4j.model.mistralai.internal.api.MistralAiEmbeddingRequest; import dev.langchain4j.model.mistralai.internal.api.MistralAiEmbeddingResponse; import dev.langchain4j.model.mistralai.internal.api.MistralAiModelResponse; +import dev.langchain4j.model.mistralai.internal.api.MistralAiModerationRequest; +import dev.langchain4j.model.mistralai.internal.api.MistralAiModerationResponse; import io.quarkiverse.langchain4j.QuarkusJsonCodecFactory; import io.quarkus.rest.client.reactive.NotBody; import io.smallrye.mutiny.Multi; @@ -82,6 +84,10 @@ Multi streamingChatCompletion(MistralAiChatComp @GET MistralAiModelResponse models(@NotBody String token); + @Path("moderations") + @POST + MistralAiModerationResponse moderation(MistralAiModerationRequest mistralAiModerationRequest, @NotBody String token); + /** * The point of this is to properly set the {@code stream} value of the request * so users don't have to remember to set it manually diff --git a/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/QuarkusMistralAiClient.java b/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/QuarkusMistralAiClient.java index 8194cc255..c90e684b8 100644 --- a/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/QuarkusMistralAiClient.java +++ b/model-providers/mistral/runtime/src/main/java/io/quarkiverse/langchain4j/mistralai/QuarkusMistralAiClient.java @@ -25,6 +25,8 @@ import dev.langchain4j.model.mistralai.internal.api.MistralAiEmbeddingRequest; import dev.langchain4j.model.mistralai.internal.api.MistralAiEmbeddingResponse; import dev.langchain4j.model.mistralai.internal.api.MistralAiModelResponse; +import dev.langchain4j.model.mistralai.internal.api.MistralAiModerationRequest; +import dev.langchain4j.model.mistralai.internal.api.MistralAiModerationResponse; import dev.langchain4j.model.mistralai.internal.api.MistralAiUsage; import dev.langchain4j.model.mistralai.internal.client.MistralAiClient; import dev.langchain4j.model.mistralai.internal.client.MistralAiClientBuilderFactory; @@ -116,6 +118,12 @@ public MistralAiEmbeddingResponse embedding(MistralAiEmbeddingRequest request) { return restApi.embedding(request, apiKey); } + // TODO: we don't provide support for MistralAiModerationModel yet + @Override + public MistralAiModerationResponse moderation(MistralAiModerationRequest mistralAiModerationRequest) { + return restApi.moderation(mistralAiModerationRequest, apiKey); + } + @Override public MistralAiModelResponse listModels() { return restApi.models(apiKey); diff --git a/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/MessageMapper.java b/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/MessageMapper.java index 9ae53b77a..aea73737c 100644 --- a/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/MessageMapper.java +++ b/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/MessageMapper.java @@ -27,6 +27,8 @@ import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElementHelper; import io.quarkiverse.langchain4j.QuarkusJsonCodecFactory; // TODO: this could use a lot of refactoring @@ -140,8 +142,14 @@ static List toTools(Collection toolSpecifications) { } private static Tool toTool(ToolSpecification toolSpecification) { + Tool.Function.Parameters functionParameters; + if (toolSpecification.toolParameters() != null) { + functionParameters = toFunctionParameters(toolSpecification.toolParameters()); + } else { + functionParameters = toFunctionParameters(toolSpecification.parameters()); + } return new Tool(Tool.Type.FUNCTION, new Tool.Function(toolSpecification.name(), toolSpecification.description(), - toFunctionParameters(toolSpecification.parameters()))); + functionParameters)); } private static Tool.Function.Parameters toFunctionParameters(ToolParameters toolParameters) { @@ -150,4 +158,13 @@ private static Tool.Function.Parameters toFunctionParameters(ToolParameters tool } return Tool.Function.Parameters.objectType(toolParameters.properties(), toolParameters.required()); } + + private static Tool.Function.Parameters toFunctionParameters(JsonObjectSchema parameters) { + if (parameters == null) { + return Tool.Function.Parameters.empty(); + } + return Tool.Function.Parameters.objectType(JsonSchemaElementHelper.toMap(parameters.properties()), + parameters.required()); + } + } diff --git a/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/Tool.java b/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/Tool.java index 08b67ba70..d5fd44834 100644 --- a/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/Tool.java +++ b/model-providers/ollama/runtime/src/main/java/io/quarkiverse/langchain4j/ollama/Tool.java @@ -35,6 +35,7 @@ public static Parameters objectType(Map> properties, public static Parameters empty() { return new Parameters(OBJECT_TYPE, Collections.emptyMap(), Collections.emptyList()); } + } } } diff --git a/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/bean/TextChatMessage.java b/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/bean/TextChatMessage.java index 6c5301e43..55e365f1f 100644 --- a/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/bean/TextChatMessage.java +++ b/model-providers/watsonx/runtime/src/main/java/io/quarkiverse/langchain4j/watsonx/bean/TextChatMessage.java @@ -223,10 +223,11 @@ public record TextChatParameterFunction(String name, String description, Map 3.15.2 - 0.35.0 - 0.35.0 + 0.36.2 + 0.36.2 0.2.0 2.0.4 3.26.3