diff --git a/core/deployment/pom.xml b/core/deployment/pom.xml
index ab47f8411..4333b498d 100644
--- a/core/deployment/pom.xml
+++ b/core/deployment/pom.xml
@@ -46,6 +46,11 @@
true
+
+ org.eclipse.microprofile.rest.client
+ microprofile-rest-client-api
+
+
io.quarkus
quarkus-vertx-http-dev-ui-tests
diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/AiServicesProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/AiServicesProcessor.java
index b8fed660a..f595405a8 100644
--- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/AiServicesProcessor.java
+++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/AiServicesProcessor.java
@@ -43,8 +43,10 @@
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.spi.DeploymentException;
+import jakarta.enterprise.util.AnnotationLiteral;
import jakarta.inject.Inject;
+import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationTarget;
import org.jboss.jandex.AnnotationValue;
@@ -77,6 +79,7 @@
import io.quarkiverse.langchain4j.deployment.items.MethodParameterIgnoredAnnotationsBuildItem;
import io.quarkiverse.langchain4j.deployment.items.SelectedChatModelProviderBuildItem;
import io.quarkiverse.langchain4j.deployment.items.ToolMethodBuildItem;
+import io.quarkiverse.langchain4j.deployment.items.ToolQualifierProvider;
import io.quarkiverse.langchain4j.guardrails.OutputGuardrail;
import io.quarkiverse.langchain4j.guardrails.OutputGuardrailAccumulator;
import io.quarkiverse.langchain4j.runtime.AiServicesRecorder;
@@ -262,11 +265,18 @@ public void findDeclarativeServices(CombinedIndexBuildItem indexBuildItem,
chatModelNames.add(chatModelName);
}
- List toolDotNames = Collections.emptyList();
+ List toolClassInfos = Collections.emptyList();
AnnotationValue toolsInstance = instance.value("tools");
if (toolsInstance != null) {
- toolDotNames = Arrays.stream(toolsInstance.asClassArray()).map(Type::name)
- .collect(Collectors.toList());
+ toolClassInfos = Arrays.stream(toolsInstance.asClassArray()).map(t -> {
+ var ci = index.getClassByName(t.name());
+ if (ci == null) {
+ throw new IllegalArgumentException("Cannot find class " + t.name()
+ + " in index. Please make sure it's a valid CDI bean known to Quarkus");
+ }
+ return ci;
+ })
+ .toList();
}
// the default value depends on whether tools exists or not - if they do, then we require a ChatMemoryProvider bean
@@ -397,7 +407,7 @@ public void findDeclarativeServices(CombinedIndexBuildItem indexBuildItem,
declarativeAiServiceClassInfo,
chatLanguageModelSupplierClassDotName,
streamingChatLanguageModelSupplierClassDotName,
- toolDotNames,
+ toolClassInfos,
chatMemoryProviderSupplierClassDotName,
retrieverClassDotName,
retrievalAugmentorSupplierClassName,
@@ -476,11 +486,27 @@ private boolean isImageOrImageResultResult(Type returnType) {
return false;
}
+ @BuildStep
+ public void toolQualifiers(BuildProducer producer) {
+ producer.produce(new ToolQualifierProvider.BuildItem(new ToolQualifierProvider() {
+ @Override
+ public boolean supports(ClassInfo classInfo) {
+ return classInfo.hasAnnotation(DotNames.REGISTER_REST_CLIENT);
+ }
+
+ @Override
+ public AnnotationLiteral> qualifier(ClassInfo classInfo) {
+ return new RestClient.RestClientLiteral();
+ }
+ }));
+ }
+
@BuildStep
@Record(ExecutionTime.STATIC_INIT)
public void handleDeclarativeServices(AiServicesRecorder recorder,
List declarativeAiServiceItems,
List selectedChatModelProvider,
+ List toolQualifierProviderItems,
BuildProducer syntheticBeanProducer,
BuildProducer unremovableProducer) {
@@ -507,7 +533,19 @@ public void handleDeclarativeServices(AiServicesRecorder recorder,
? bi.getStreamingChatLanguageModelSupplierClassDotName().toString()
: null);
- List toolClassNames = bi.getToolDotNames().stream().map(DotName::toString).collect(Collectors.toList());
+ List toolQualifierProviders = toolQualifierProviderItems.stream().map(
+ ToolQualifierProvider.BuildItem::getProvider).toList();
+ Map> toolToQualifierMap = new HashMap<>();
+ for (ClassInfo ci : bi.getToolClassInfos()) {
+ AnnotationLiteral> qualifier = null;
+ for (ToolQualifierProvider provider : toolQualifierProviders) {
+ if (provider.supports(ci)) {
+ qualifier = provider.qualifier(ci);
+ break;
+ }
+ }
+ toolToQualifierMap.put(ci.name().toString(), qualifier);
+ }
String toolProviderSupplierClassName = (bi.getToolProviderClassDotName() != null
? bi.getToolProviderClassDotName().toString()
@@ -597,7 +635,7 @@ public void handleDeclarativeServices(AiServicesRecorder recorder,
serviceClassName,
chatLanguageModelSupplierClassName,
streamingChatLanguageModelSupplierClassName,
- toolClassNames,
+ toolToQualifierMap,
toolProviderSupplierClassName,
chatMemoryProviderSupplierClassName, retrieverClassName,
retrievalAugmentorSupplierClassName,
@@ -639,12 +677,16 @@ public void handleDeclarativeServices(AiServicesRecorder recorder,
needsChatModelBean = true;
}
- if (!toolClassNames.isEmpty()) {
- for (String toolClassName : toolClassNames) {
- DotName dotName = DotName.createSimple(toolClassName);
+ for (var entry : toolToQualifierMap.entrySet()) {
+ DotName dotName = DotName.createSimple(entry.getKey());
+ AnnotationLiteral> qualifier = entry.getValue();
+ if (qualifier == null) {
configurator.addInjectionPoint(ClassType.create(dotName));
- allToolNames.add(dotName);
+ } else {
+ configurator.addInjectionPoint(ClassType.create(dotName),
+ AnnotationInstance.builder(qualifier.annotationType()).build());
}
+ allToolNames.add(dotName);
}
if (LangChain4jDotNames.BEAN_CHAT_MEMORY_PROVIDER_SUPPLIER.toString().equals(chatMemoryProviderSupplierClassName)) {
diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DeclarativeAiServiceBuildItem.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DeclarativeAiServiceBuildItem.java
index 6fd1fc997..03449e2f4 100644
--- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DeclarativeAiServiceBuildItem.java
+++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DeclarativeAiServiceBuildItem.java
@@ -16,7 +16,7 @@ public final class DeclarativeAiServiceBuildItem extends MultiBuildItem {
private final ClassInfo serviceClassInfo;
private final DotName chatLanguageModelSupplierClassDotName;
private final DotName streamingChatLanguageModelSupplierClassDotName;
- private final List toolDotNames;
+ private final List toolClassInfos;
private final DotName toolProviderClassDotName;
private final DotName chatMemoryProviderSupplierClassDotName;
@@ -37,7 +37,7 @@ public DeclarativeAiServiceBuildItem(
ClassInfo serviceClassInfo,
DotName chatLanguageModelSupplierClassDotName,
DotName streamingChatLanguageModelSupplierClassDotName,
- List toolDotNames,
+ List toolClassInfos,
DotName chatMemoryProviderSupplierClassDotName,
DotName retrieverClassDotName,
DotName retrievalAugmentorSupplierClassDotName,
@@ -55,7 +55,7 @@ public DeclarativeAiServiceBuildItem(
this.serviceClassInfo = serviceClassInfo;
this.chatLanguageModelSupplierClassDotName = chatLanguageModelSupplierClassDotName;
this.streamingChatLanguageModelSupplierClassDotName = streamingChatLanguageModelSupplierClassDotName;
- this.toolDotNames = toolDotNames;
+ this.toolClassInfos = toolClassInfos;
this.chatMemoryProviderSupplierClassDotName = chatMemoryProviderSupplierClassDotName;
this.retrieverClassDotName = retrieverClassDotName;
this.retrievalAugmentorSupplierClassDotName = retrievalAugmentorSupplierClassDotName;
@@ -84,8 +84,8 @@ public DotName getStreamingChatLanguageModelSupplierClassDotName() {
return streamingChatLanguageModelSupplierClassDotName;
}
- public List getToolDotNames() {
- return toolDotNames;
+ public List getToolClassInfos() {
+ return toolClassInfos;
}
public DotName getChatMemoryProviderSupplierClassDotName() {
diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DotNames.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DotNames.java
index 49b09380d..af4591915 100644
--- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DotNames.java
+++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/DotNames.java
@@ -8,6 +8,7 @@
import jakarta.enterprise.inject.Instance;
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import org.jboss.jandex.DotName;
import dev.langchain4j.agent.tool.Tool;
@@ -62,6 +63,8 @@ public class DotNames {
public static final DotName MODEL_AUTH_PROVIDER = DotName.createSimple(ModelAuthProvider.class);
public static final DotName TOOL = DotName.createSimple(Tool.class);
+ public static final DotName REGISTER_REST_CLIENT = DotName.createSimple(RegisterRestClient.class);
+
public static final DotName OUTPUT_GUARDRAIL_ACCUMULATOR = DotName.createSimple(OutputGuardrailAccumulator.class);
/**
diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java
index dcc9217d2..7964fbfa5 100644
--- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java
+++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/ToolProcessor.java
@@ -92,6 +92,7 @@ public class ToolProcessor {
private static final MethodDescriptor HASHMAP_CTOR = MethodDescriptor.ofConstructor(HashMap.class);
public static final MethodDescriptor MAP_PUT = MethodDescriptor.ofMethod(Map.class, "put", Object.class, Object.class,
Object.class);
+ private static final ResultHandle[] EMPTY_RESULT_HANDLE_ARRAY = new ResultHandle[0];
private static final Logger log = Logger.getLogger(ToolProcessor.class);
@@ -136,7 +137,19 @@ public void handleTools(
MethodInfo methodInfo = instance.target().asMethod();
ClassInfo classInfo = methodInfo.declaringClass();
- if (classInfo.isInterface() || Modifier.isAbstract(classInfo.flags())) {
+ boolean causeValidationError = false;
+ if (classInfo.isInterface()) {
+
+ if (classInfo.hasAnnotation(LangChain4jDotNames.REGISTER_AI_SERVICES) || classInfo.hasAnnotation(
+ DotNames.REGISTER_REST_CLIENT)) {
+ // we allow tools on method of these interfaces because we know they will be beans
+ } else {
+ causeValidationError = true;
+ }
+ } else if (Modifier.isAbstract(classInfo.flags())) {
+ causeValidationError = true;
+ }
+ if (causeValidationError) {
validation.produce(
new ValidationPhaseBuildItem.ValidationErrorBuildItem(new IllegalStateException(
"@Tool is only supported on non-abstract classes, all other usages are ignored. Offending method is '"
@@ -409,16 +422,21 @@ private static String generateInvoker(MethodInfo methodInfo, ClassOutput classOu
MethodDescriptor.ofMethod(implClassName, "invoke", Object.class, Object.class, Object[].class));
ResultHandle result;
+ ResultHandle[] targetMethodHandles = EMPTY_RESULT_HANDLE_ARRAY;
if (methodInfo.parametersCount() > 0) {
List argumentHandles = new ArrayList<>(methodInfo.parametersCount());
for (int i = 0; i < methodInfo.parametersCount(); i++) {
argumentHandles.add(invokeMc.readArrayValue(invokeMc.getMethodParam(1), i));
}
- ResultHandle[] targetMethodHandles = argumentHandles.toArray(new ResultHandle[0]);
- result = invokeMc.invokeVirtualMethod(MethodDescriptor.of(methodInfo), invokeMc.getMethodParam(0),
+ targetMethodHandles = argumentHandles.toArray(EMPTY_RESULT_HANDLE_ARRAY);
+ }
+
+ if (methodInfo.declaringClass().isInterface()) {
+ result = invokeMc.invokeInterfaceMethod(MethodDescriptor.of(methodInfo), invokeMc.getMethodParam(0),
targetMethodHandles);
} else {
- result = invokeMc.invokeVirtualMethod(MethodDescriptor.of(methodInfo), invokeMc.getMethodParam(0));
+ result = invokeMc.invokeVirtualMethod(MethodDescriptor.of(methodInfo), invokeMc.getMethodParam(0),
+ targetMethodHandles);
}
boolean toolReturnsVoid = methodInfo.returnType().kind() == Type.Kind.VOID;
diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java
index 5898fe684..dc130ceaf 100644
--- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java
+++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java
@@ -78,7 +78,7 @@ private void addEmbeddingStorePage(CardPageBuildItem card) {
private void addAiServicesPage(CardPageBuildItem card, List aiServices) {
List infos = new ArrayList<>();
for (DeclarativeAiServiceBuildItem aiService : aiServices) {
- List tools = aiService.getToolDotNames().stream().map(dotName -> dotName.toString()).toList();
+ List tools = aiService.getToolClassInfos().stream().map(ci -> ci.name().toString()).toList();
infos.add(new AiServiceInfo(aiService.getServiceClassInfo().name().toString(), tools));
}
diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/items/ToolQualifierProvider.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/items/ToolQualifierProvider.java
new file mode 100644
index 000000000..0f200322b
--- /dev/null
+++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/items/ToolQualifierProvider.java
@@ -0,0 +1,30 @@
+package io.quarkiverse.langchain4j.deployment.items;
+
+import jakarta.enterprise.util.AnnotationLiteral;
+
+import org.jboss.jandex.ClassInfo;
+
+import io.quarkus.builder.item.MultiBuildItem;
+
+/**
+ * Used to determine if a class containing a tool should be used along with a CDI qualifier
+ */
+public interface ToolQualifierProvider {
+
+ boolean supports(ClassInfo classInfo);
+
+ AnnotationLiteral> qualifier(ClassInfo classInfo);
+
+ final class BuildItem extends MultiBuildItem {
+
+ private final ToolQualifierProvider provider;
+
+ public BuildItem(ToolQualifierProvider provider) {
+ this.provider = provider;
+ }
+
+ public ToolQualifierProvider getProvider() {
+ return provider;
+ }
+ }
+}
diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/AiServicesRecorder.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/AiServicesRecorder.java
index b99ca38e3..5307640be 100644
--- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/AiServicesRecorder.java
+++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/AiServicesRecorder.java
@@ -11,6 +11,7 @@
import java.util.function.Supplier;
import jakarta.enterprise.inject.Instance;
+import jakarta.enterprise.util.AnnotationLiteral;
import jakarta.enterprise.util.TypeLiteral;
import dev.langchain4j.data.segment.TextSegment;
@@ -148,12 +149,21 @@ public T apply(SyntheticCreationalContext creationalContext) {
}
}
- List toolsClasses = info.toolsClassNames();
+ Map> toolsClasses = info.toolsClassInfo();
if ((toolsClasses != null) && !toolsClasses.isEmpty()) {
List
+
+ io.quarkus
+ quarkus-rest
+ test
+
io.smallrye.certs
smallrye-certificate-generator-junit5
diff --git a/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/RestClientToolTest.java b/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/RestClientToolTest.java
new file mode 100644
index 000000000..311a4b9c2
--- /dev/null
+++ b/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/RestClientToolTest.java
@@ -0,0 +1,219 @@
+package org.acme.examples.aiservices;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.enterprise.context.control.ActivateRequestContext;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.client.ClientRequestContext;
+import jakarta.ws.rs.client.ClientResponseContext;
+import jakarta.ws.rs.client.ClientResponseFilter;
+import jakarta.ws.rs.ext.Provider;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.spec.JavaArchive;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.stubbing.Scenario;
+
+import dev.langchain4j.agent.tool.Tool;
+import io.quarkiverse.langchain4j.RegisterAiService;
+import io.quarkiverse.langchain4j.openai.testing.internal.OpenAiBaseTest;
+import io.quarkiverse.langchain4j.testing.internal.WiremockAware;
+import io.quarkus.test.QuarkusUnitTest;
+
+public class RestClientToolTest extends OpenAiBaseTest {
+
+ @RegisterExtension
+ static final QuarkusUnitTest unitTest = new QuarkusUnitTest()
+ .setArchiveProducer(
+ () -> ShrinkWrap.create(JavaArchive.class))
+ .overrideRuntimeConfigKey("quarkus.langchain4j.openai.api-key", "whatever")
+ .overrideRuntimeConfigKey("quarkus.langchain4j.openai.base-url",
+ WiremockAware.wiremockUrlForConfig("/v1"))
+ .overrideConfigKey("quarkus.rest-client.rest-calculator.url", "http://localhost:${quarkus.http.test-port:8081}");
+
+ private static final String scenario = "tools";
+ private static final String secondState = "second";
+
+ @BeforeEach
+ void setUp() {
+ wiremock().resetMappings();
+ wiremock().resetRequests();
+ }
+
+ @Inject
+ Bot bot;
+
+ @Test
+ @ActivateRequestContext
+ void should_execute_tool_then_answer() throws IOException {
+ var firstResponse = """
+ {
+ "id": "chatcmpl-8D88Dag1gAKnOPP9Ed4bos7vSpaNz",
+ "object": "chat.completion",
+ "created": 1698140213,
+ "model": "gpt-3.5-turbo-0613",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": null,
+ "function_call": {
+ "name": "squareRoot",
+ "arguments": "{\\n \\"number\\": 485906798473894056\\n}"
+ }
+ },
+ "finish_reason": "function_call"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 65,
+ "completion_tokens": 20,
+ "total_tokens": 85
+ }
+ }
+ """;
+
+ var secondResponse = """
+ {
+ "id": "chatcmpl-8D88FIAUWSpwLaShFr0w8G1SWuVdl",
+ "object": "chat.completion",
+ "created": 1698140215,
+ "model": "gpt-3.5-turbo-0613",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "The square root of 485,906,798,473,894,056 in scientific notation is approximately 6.97070153193991E8."
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 102,
+ "completion_tokens": 33,
+ "total_tokens": 135
+ }
+ }
+ """;
+
+ wiremock().register(
+ post(urlEqualTo("/v1/chat/completions"))
+ .withHeader("Authorization", equalTo("Bearer whatever"))
+ .inScenario(scenario)
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(aResponse()
+ .withHeader("Content-Type", "application/json")
+ .withBody(firstResponse)));
+ wiremock().register(
+ post(urlEqualTo("/v1/chat/completions"))
+ .withHeader("Authorization", equalTo("Bearer whatever"))
+ .inScenario(scenario)
+ .whenScenarioStateIs(secondState)
+ .willReturn(aResponse()
+ .withHeader("Content-Type", "application/json")
+ .withBody(secondResponse)));
+
+ wiremock().setSingleScenarioState(scenario, Scenario.STARTED);
+
+ String userMessage = "What is the square root of 485906798473894056 in scientific notation?";
+
+ String answer = bot.chat(userMessage);
+
+ assertThat(answer).isEqualTo(
+ "The square root of 485,906,798,473,894,056 in scientific notation is approximately 6.97070153193991E8.");
+
+ assertThat(wiremock().getServeEvents()).hasSize(2);
+
+ Map firstApiRequest = getRequestAsMap(getRequestBody(wiremock().getServeEvents().get(1)));
+ assertSingleRequestMessage(firstApiRequest,
+ "What is the square root of 485906798473894056 in scientific notation?");
+ assertSingleFunction(firstApiRequest, "squareRoot");
+ Map secondApiRequest = getRequestAsMap(getRequestBody(wiremock().getServeEvents().get(0)));
+ assertMultipleRequestMessage(secondApiRequest,
+ List.of(
+ new MessageContent("user",
+ "What is the square root of 485906798473894056 in scientific notation?"),
+ new MessageContent("assistant", null),
+ new MessageContent("function", "6.97070153193991E8")));
+ }
+
+ @RegisterAiService(tools = RestCalculator.class)
+ interface Bot {
+
+ String chat(String message);
+ }
+
+ @Singleton
+ public static class CalculatorAfter implements Runnable {
+
+ private final Integer wiremockPort;
+
+ public CalculatorAfter(@ConfigProperty(name = "quarkus.wiremock.devservices.port") Integer wiremockPort) {
+ this.wiremockPort = wiremockPort;
+ }
+
+ @Override
+ public void run() {
+ WireMock wireMock = new WireMock(wiremockPort);
+ wireMock.setSingleScenarioState(scenario, secondState);
+ }
+ }
+
+ @Path("calculator")
+ @RegisterRestClient(configKey = "rest-calculator")
+ @RegisterProvider(RestCalculator.ResponseFilter.class)
+ interface RestCalculator {
+
+ @POST
+ @Tool("calculates the square root of the provided number")
+ @Consumes("text/plain")
+ @Produces("text/plain")
+ double squareRoot(double number);
+
+ @Provider
+ class ResponseFilter implements ClientResponseFilter {
+
+ @Inject
+ CalculatorAfter after;
+
+ @Override
+ public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) {
+ after.run();
+ }
+ }
+ }
+
+ @Path("calculator")
+ public static class CalculatorResource {
+
+ @POST
+ @Consumes("text/plain")
+ @Produces("text/plain")
+ public double squareRoot(double number) {
+ return Math.sqrt(number);
+ }
+ }
+
+}
diff --git a/samples/pom.xml b/samples/pom.xml
index c2acc9c41..b04dd2cc7 100644
--- a/samples/pom.xml
+++ b/samples/pom.xml
@@ -22,6 +22,7 @@
secure-poem-multiple-models
secure-sql-chatbot
sql-chatbot
+ weather-agent
diff --git a/samples/weather-agent/README.md b/samples/weather-agent/README.md
new file mode 100644
index 000000000..407545337
--- /dev/null
+++ b/samples/weather-agent/README.md
@@ -0,0 +1,63 @@
+# Chatbot example
+
+This example demonstrates how to create an AI agent using Quarkus LangChain4j.
+
+## Running the example
+
+A prerequisite to running this example is to provide your OpenAI API key.
+
+```
+export QUARKUS_LANGCHAIN4J_OPENAI_API_KEY=
+```
+
+Then, simply run the project in Dev mode:
+
+```
+mvn quarkus:dev
+```
+
+## Using the example
+
+Execute:
+
+```
+curl http://localhost:8080/weather?city=Athens
+```
+
+and you should get a response a like so:
+
+```
+The weather in Athens today is mostly cloudy, with a maximum temperature of 15.6°C and a minimum of 7.4°C. There is no expected precipitation and wind speeds can reach up to 8.1 km/h
+```
+
+## Using other model providers
+
+### Compatible OpenAI serving infrastructure
+
+Add `quarkus.langchain4j.openai.base-url=http://yourerver` to `application.properties`.
+
+In this case, `quarkus.langchain4j.openai.api-key` is generally not needed.
+
+### Ollama
+
+
+Replace:
+
+```xml
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-openai
+ ${quarkus-langchain4j.version}
+
+```
+
+with
+
+```xml
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-ollama
+ ${quarkus-langchain4j.version}
+
+```
+
diff --git a/samples/weather-agent/pom.xml b/samples/weather-agent/pom.xml
new file mode 100644
index 000000000..4ebd9b3a6
--- /dev/null
+++ b/samples/weather-agent/pom.xml
@@ -0,0 +1,135 @@
+
+
+ 4.0.0
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-sample-weather-agent
+ Quarkus LangChain4j - Sample - Weather Agent
+ 1.0-SNAPSHOT
+
+
+ 3.13.0
+ true
+ 17
+ UTF-8
+ UTF-8
+ quarkus-bom
+ io.quarkus
+ 3.15.1
+ true
+ 3.2.5
+ 999-SNAPSHOT
+
+
+
+
+
+ ${quarkus.platform.group-id}
+ ${quarkus.platform.artifact-id}
+ ${quarkus.platform.version}
+ pom
+ import
+
+
+
+
+
+
+ io.quarkus
+ quarkus-rest-jackson
+
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-openai
+ ${quarkus-langchain4j.version}
+
+
+ io.quarkus
+ quarkus-cache
+
+
+
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-openai-deployment
+ ${quarkus-langchain4j.version}
+ test
+ pom
+
+
+ *
+ *
+
+
+
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+ ${quarkus.platform.version}
+
+
+
+ build
+
+
+
+
+
+ maven-compiler-plugin
+ ${compiler-plugin.version}
+
+
+ maven-surefire-plugin
+ 3.5.1
+
+
+ org.jboss.logmanager.LogManager
+ ${maven.home}
+
+
+
+
+
+
+
+
+ native
+
+
+ native
+
+
+
+
+
+ maven-failsafe-plugin
+ 3.5.1
+
+
+
+ integration-test
+ verify
+
+
+
+ ${project.build.directory}/${project.build.finalName}-runner
+ org.jboss.logmanager.LogManager
+ ${maven.home}
+
+
+
+
+
+
+
+
+ native
+
+
+
+
+
+
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/CityExtractorAgent.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/CityExtractorAgent.java
new file mode 100644
index 000000000..ee911a066
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/CityExtractorAgent.java
@@ -0,0 +1,21 @@
+package io.quarkiverse.langchain4j.weather.agent;
+
+import dev.langchain4j.agent.tool.Tool;
+import dev.langchain4j.service.UserMessage;
+import io.quarkiverse.langchain4j.RegisterAiService;
+import jakarta.enterprise.context.ApplicationScoped;
+
+@ApplicationScoped
+@RegisterAiService(chatMemoryProviderSupplier = RegisterAiService.NoChatMemoryProviderSupplier.class)
+public interface CityExtractorAgent {
+
+ @UserMessage("""
+ You are given one question and you have to extract city name from it
+ Only reply the city name if it exists or reply 'unknown_city' if there is no city name in question
+
+ Here is the question: {question}
+ """)
+ @Tool("Extracts the city from a question")
+ String extractCity(String question);
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/WeatherForecastAgent.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/WeatherForecastAgent.java
new file mode 100644
index 000000000..e050e9372
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/WeatherForecastAgent.java
@@ -0,0 +1,24 @@
+package io.quarkiverse.langchain4j.weather.agent;
+
+import dev.langchain4j.service.SystemMessage;
+import io.quarkiverse.langchain4j.RegisterAiService;
+import io.quarkiverse.langchain4j.weather.agent.geo.GeoCodingService;
+import io.quarkiverse.langchain4j.weather.agent.weather.WeatherForecastService;
+
+@RegisterAiService(tools = { CityExtractorAgent.class, WeatherForecastService.class, GeoCodingService.class})
+public interface WeatherForecastAgent {
+
+ @SystemMessage("""
+ You are a meteorologist, and you need to answer questions asked by the user about weather using at most 3 lines.
+
+ The weather information is a JSON object and has the following fields:
+
+ maxTemperature is the maximum temperature of the day in Celsius degrees
+ minTemperature is the minimum temperature of the day in Celsius degrees
+ precipitation is the amount of water in mm
+ windSpeed is the speed of wind in kilometers per hour
+ weather is the overall weather.
+ """)
+ String chat(String query);
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/WeatherResource.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/WeatherResource.java
new file mode 100644
index 000000000..95ece59af
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/WeatherResource.java
@@ -0,0 +1,27 @@
+package io.quarkiverse.langchain4j.weather.agent;
+
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import org.jboss.resteasy.reactive.RestQuery;
+
+
+@Path("/weather")
+public class WeatherResource {
+
+ private final WeatherForecastAgent agent;
+
+ public WeatherResource(WeatherForecastAgent agent) {
+ this.agent = agent;
+ }
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getWeather(@RestQuery @DefaultValue("Manilla") String city) {
+ return agent.chat(String.format("What is the weather in %s ?", city));
+ }
+
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoCodingService.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoCodingService.java
new file mode 100644
index 000000000..6e23ce893
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoCodingService.java
@@ -0,0 +1,22 @@
+package io.quarkiverse.langchain4j.weather.agent.geo;
+
+import dev.langchain4j.agent.tool.Tool;
+import io.quarkus.cache.CacheResult;
+import io.quarkus.rest.client.reactive.ClientQueryParam;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+import org.jboss.resteasy.reactive.RestQuery;
+
+@RegisterRestClient(configKey = "geocoding")
+@Path("/v1")
+public interface GeoCodingService {
+
+ @GET
+ @Path("/search")
+ @CacheResult(cacheName = "geo-results")
+ @ClientQueryParam(name = "count", value = "1")
+ @Tool("Finds the latitude and longitude of a given city")
+ GeoResults search(@RestQuery String name);
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoResult.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoResult.java
new file mode 100644
index 000000000..404eb810f
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoResult.java
@@ -0,0 +1,4 @@
+package io.quarkiverse.langchain4j.weather.agent.geo;
+
+public record GeoResult(double latitude, double longitude) {
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoResults.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoResults.java
new file mode 100644
index 000000000..03322a5c6
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/geo/GeoResults.java
@@ -0,0 +1,11 @@
+package io.quarkiverse.langchain4j.weather.agent.geo;
+
+import java.util.List;
+
+public record GeoResults(List results) {
+
+ public GeoResult getFirst() {
+ return results.get(0);
+ }
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/Daily.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/Daily.java
new file mode 100644
index 000000000..cf16c72e3
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/Daily.java
@@ -0,0 +1,29 @@
+package io.quarkiverse.langchain4j.weather.agent.weather;
+
+import java.util.Arrays;
+
+public record Daily(double[] temperature_2m_max,
+ double[] temperature_2m_min,
+ double[] precipitation_sum,
+ double[] wind_speed_10m_max,
+ int[] weather_code) {
+
+ public DailyWeatherData getFirstDay() {
+ return new DailyWeatherData(temperature_2m_max[0],
+ temperature_2m_min[0],
+ precipitation_sum[0],
+ wind_speed_10m_max[0],
+ weather_code[0]);
+ }
+
+ @Override
+ public String toString() {
+ return "Daily{" + "temperature_2m_max=" + Arrays.toString(temperature_2m_max)
+ + ", temperature_2m_min=" + Arrays.toString(temperature_2m_min)
+ + ", precipitation_sum=" + Arrays.toString(precipitation_sum)
+ + ", wind_speed_10m_max=" + Arrays.toString(wind_speed_10m_max)
+ + ", weather_code=" + Arrays.toString(weather_code)
+ + '}';
+ }
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/DailyUnits.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/DailyUnits.java
new file mode 100644
index 000000000..f6d9799f0
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/DailyUnits.java
@@ -0,0 +1,7 @@
+package io.quarkiverse.langchain4j.weather.agent.weather;
+
+public record DailyUnits(String time,
+ String temperature_2m_max,
+ String precipitation_sum,
+ String wind_speed_10m_max) {
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/DailyWeatherData.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/DailyWeatherData.java
new file mode 100644
index 000000000..25f46a4e6
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/DailyWeatherData.java
@@ -0,0 +1,23 @@
+package io.quarkiverse.langchain4j.weather.agent.weather;
+
+import io.vertx.core.json.JsonObject;
+
+public record DailyWeatherData(double temperature_2m_max,
+ double temperature_2m_min,
+ double precipitation_sum,
+ double wind_speed_10m_max,
+ int weather_code) {
+
+
+ public JsonObject toJson() {
+ JsonObject json = new JsonObject();
+ json.put("maxTemperature", temperature_2m_max());
+ json.put("minTemperature", temperature_2m_min());
+ json.put("precipitation", precipitation_sum());
+ json.put("windSpeed", wind_speed_10m_max());
+ json.put("weather", WmoCode.translate(weather_code()));
+
+ return json;
+ }
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WeatherForecast.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WeatherForecast.java
new file mode 100644
index 000000000..fbae14887
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WeatherForecast.java
@@ -0,0 +1,6 @@
+package io.quarkiverse.langchain4j.weather.agent.weather;
+
+public record WeatherForecast(DailyUnits daily_units, Daily daily) {
+
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WeatherForecastService.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WeatherForecastService.java
new file mode 100644
index 000000000..4827f18b0
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WeatherForecastService.java
@@ -0,0 +1,27 @@
+package io.quarkiverse.langchain4j.weather.agent.weather;
+
+import dev.langchain4j.agent.tool.Tool;
+import io.quarkus.rest.client.reactive.ClientQueryParam;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+import org.jboss.resteasy.reactive.RestQuery;
+
+@RegisterRestClient(configKey = "openmeteo")
+@Path("/v1")
+public interface WeatherForecastService {
+
+ @GET
+ @Path("/forecast")
+ @Tool("Forecasts the weather for the given latitude and longitude")
+ @ClientQueryParam(name = "forecast_days", value = "1")
+ @ClientQueryParam(name = "daily", value = {
+ "temperature_2m_max",
+ "temperature_2m_min",
+ "precipitation_sum",
+ "wind_speed_10m_max",
+ "weather_code"
+ })
+ WeatherForecast forecast(@RestQuery double latitude, @RestQuery double longitude);
+
+}
diff --git a/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WmoCode.java b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WmoCode.java
new file mode 100644
index 000000000..195b0bcb8
--- /dev/null
+++ b/samples/weather-agent/src/main/java/io/quarkiverse/langchain4j/weather/agent/weather/WmoCode.java
@@ -0,0 +1,31 @@
+package io.quarkiverse.langchain4j.weather.agent.weather;
+
+import java.util.Arrays;
+
+public enum WmoCode {
+
+ CLEAR_SKY(0), MAINLY_CLEAR(1), PARTLY_CLOUDY(2), OVERCAST(3),
+ FOG(45), DEPOSITING_RIME_FOG(46), DRIZZLE_LIGHT(51), DRIZZLE_MEDIUM(53),
+ DRIZZLE_DENSE(55), FREEZING_DRIZZLE_LIGHT(56), FREEZING_DRIZZLE_DENSE(57),
+ RAIN_SLIGHT(61), RAIN_MODERATE(63), RAIN_HEAVY(65), FREEZING_RAIN_LIGHT(66), FREEZING_RAIN_HEAVY(67),
+ SNOW_FALL_SLIGHT(71), SNOW_FALL_MODERATE(73), SNOW_FALL_HEAVY(75), SNOW_GRAINS(77),
+ RAIN_SHOWERS_SLIGHT(80), RAIN_SHOWERS_MODERATE(81), RAIN_SHOWERS_VIOLENT(82),
+ SNOW_SHOWERS_SLIGHT(85), SNOW_SHOWERS_HEAVY(86), THUNDERSTORM(95),
+ THUNDERSTORM_SLIGHT_HAIL(96), THUNDERSTORM_HEAVY_HAIL(99);
+
+ final int code;
+
+ WmoCode(int code) {
+ this.code = code;
+ }
+
+ public static WmoCode translate(int code) {
+ WmoCode[] values = WmoCode.values();
+
+ return Arrays.stream(values)
+ .filter(wmoCode -> code == wmoCode.code)
+ .findFirst()
+ .orElse(null);
+ }
+
+}
diff --git a/samples/weather-agent/src/main/resources/application.properties b/samples/weather-agent/src/main/resources/application.properties
new file mode 100644
index 000000000..d25dc6921
--- /dev/null
+++ b/samples/weather-agent/src/main/resources/application.properties
@@ -0,0 +1,5 @@
+quarkus.langchain4j.log-requests=true
+quarkus.langchain4j.log-responses=true
+
+quarkus.rest-client.geocoding.url=https://geocoding-api.open-meteo.com
+quarkus.rest-client.openmeteo.url=https://api.open-meteo.com