Add langchain4j-interop: Spring AI 2.0 and LangChain4j 1.22 side by side
Same order assistant in both frameworks against a local fake OpenAI server: starter bean collision, wire differences, tool failure and loop bounds, observability meters, MCP-first tools, and a compile check of the 2025 article's snippets and Maven coordinates. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
*.log
|
||||
@@ -0,0 +1,26 @@
|
||||
# langchain4j-interop
|
||||
|
||||
Companion code for [Spring AI vs LangChain4j in 2026: Is the Pairing Still Worth It?](https://ankurm.com/spring-ai-langchain4j-production-ai-microservices-java/), part of the [Spring AI series](../README.md) on ankurm.com.
|
||||
|
||||
One order-support assistant built twice, once with Spring AI 2.0.1 and once with LangChain4j 1.22.0, in one Spring Boot 4.1.1 project, against a local fake OpenAI server (no vendor API is called). The tests answer five questions and `scripts/compile-old-snippets.sh` answers a sixth. Every figure in the article is quoted from a file in [`output/`](output).
|
||||
|
||||
| File | Question |
|
||||
|---|---|
|
||||
| `01-both-starters.txt` | What happens when both OpenAI starters are on the classpath? |
|
||||
| `02-same-assistant.txt` | Same assistant, same question: what differs on the wire? |
|
||||
| `03-tool-throws.txt` | A tool that throws, and a model that will not stop calling it: what bounds the loop? |
|
||||
| `04-observability.txt` | Which meters does each framework record? |
|
||||
| `05-mcp-first.txt` | One MCP tool server consumed from both frameworks |
|
||||
| `06-old-code-vs-current.txt` | Which lines from the original (2025) article still compile, and do its Maven coordinates still exist? |
|
||||
|
||||
## Versions
|
||||
|
||||
Spring Boot 4.1.1, Spring AI 2.0.1, LangChain4j 1.22.0 (`langchain4j-open-ai-spring-boot4-starter` and `langchain4j-spring-boot4-starter` are 1.22.0-beta32, beta only), Java 25.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
scripts/run-all.sh # tests -> output/01..05, then the old-snippet check -> output/06
|
||||
```
|
||||
|
||||
No services are needed. Script 06 looks up Maven Central (it retries on rate limiting); `src/broken/` holds the old snippets, one per file, which are compiled outside the main build on purpose.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Both starters in one Spring Boot 4.1.1 application
|
||||
|
||||
Spring AI: spring-ai-starter-model-openai 2.0.1. LangChain4j: langchain4j-open-ai-spring-boot4-starter 1.22.0-beta32.
|
||||
|
||||
1. Both starters, nothing else:
|
||||
FAILED: BeanDefinitionOverrideException: bean 'openAiChatModel' is defined twice, by org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration and by dev.langchain4j.openai.spring.OpenAiAutoConfiguration
|
||||
|
||||
2. spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration:
|
||||
context started
|
||||
bean chatClientBuilder -> org.springframework.ai.chat.client.ChatClient$Builder
|
||||
bean openAiChatModel -> org.springframework.ai.openai.OpenAiChatModel
|
||||
beans of type dev.langchain4j.model.chat.ChatModel: 0
|
||||
beans of type org.springframework.ai.chat.model.ChatModel: 1
|
||||
|
||||
3. spring.main.allow-bean-definition-overriding=true:
|
||||
context started
|
||||
bean openAiChatModel -> org.springframework.ai.openai.OpenAiChatModel
|
||||
bean chatClientBuilder -> org.springframework.ai.chat.client.ChatClient$Builder
|
||||
beans of type dev.langchain4j.model.chat.ChatModel: 0
|
||||
beans of type org.springframework.ai.chat.model.ChatModel: 1
|
||||
@@ -0,0 +1,41 @@
|
||||
# The same order assistant in Spring AI and in LangChain4j, one question: "Where is ORD-1001?"
|
||||
|
||||
Spring AI : ChatClient.builder(model).defaultSystem(..).defaultTools(new SpringAiTools(orders))
|
||||
LangChain4j: AiServices.builder(Support.class).chatModel(model).tools(new Lc4jTools(orders))
|
||||
|
||||
Spring AI answer : Done: "Order ORD-1001: Status=SHIPPED, ETA=2026-10-14"
|
||||
LangChain4j answer : Done: Order ORD-1001: Status=SHIPPED, ETA=2026-10-14
|
||||
HTTP requests sent : Spring AI 2, LangChain4j 2. Tool body executed: Spring AI 1, LangChain4j 1.
|
||||
|
||||
What differs on the wire (computed from the four bodies below):
|
||||
top-level fields : Spring AI [messages, model, tools], LangChain4j [messages, model, stream, tools]
|
||||
tool schema : Spring AI has $schema=true additionalProperties=true; LangChain4j has $schema=false additionalProperties=false
|
||||
tool result message: Spring AI sent "\"Order ORD-1001: Status=SHIPPED, ETA=2026-10-14\"", LangChain4j sent "Order ORD-1001: Status=SHIPPED, ETA=2026-10-14"
|
||||
|
||||
--- Spring AI, request 1
|
||||
fields: [messages, model, tools]
|
||||
system: "You are a customer support agent. Use the tools for order questions. Be concise."
|
||||
user: "Where is ORD-1001?"
|
||||
tool getOrderStatus - Get current order status by order ID params {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"],"additionalProperties":false}
|
||||
|
||||
--- Spring AI, request 2
|
||||
fields: [messages, model, tools]
|
||||
system: "You are a customer support agent. Use the tools for order questions. Be concise."
|
||||
user: "Where is ORD-1001?"
|
||||
assistant: tool_call getOrderStatus {"orderId":"ORD-1001"}
|
||||
tool: ""Order ORD-1001: Status=SHIPPED, ETA=2026-10-14""
|
||||
tool getOrderStatus - Get current order status by order ID params {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"],"additionalProperties":false}
|
||||
|
||||
--- LangChain4j, request 1
|
||||
fields: [messages, model, stream, tools]
|
||||
system: "You are a customer support agent. Use the tools for order questions. Be concise."
|
||||
user: "Where is ORD-1001?"
|
||||
tool getOrderStatus - Get current order status by order ID params {"type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"]}
|
||||
|
||||
--- LangChain4j, request 2
|
||||
fields: [messages, model, stream, tools]
|
||||
system: "You are a customer support agent. Use the tools for order questions. Be concise."
|
||||
user: "Where is ORD-1001?"
|
||||
assistant: tool_call getOrderStatus {"orderId":"ORD-1001"}
|
||||
tool: "Order ORD-1001: Status=SHIPPED, ETA=2026-10-14"
|
||||
tool getOrderStatus - Get current order status by order ID params {"type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"]}
|
||||
@@ -0,0 +1,19 @@
|
||||
# A tool that throws (ORD-500 raises IllegalStateException: order database unavailable)
|
||||
|
||||
Case 1: a well-behaved model. It asks for the tool once, then answers from whatever the tool message says.
|
||||
Spring AI : 2 requests, tool body ran 1 time(s). Tool message the model received: "order database unavailable"
|
||||
answer: Done: order database unavailable
|
||||
LangChain4j : 2 requests, tool body ran 1 time(s). Tool message the model received: "order database unavailable"
|
||||
answer: Done: order database unavailable
|
||||
|
||||
Case 2: an insistent model. It asks for the tool again after every result. The server stops it with an HTTP 500 after 30 requests.
|
||||
Spring AI : 31 requests, tool body ran 30 time(s)
|
||||
exception InternalServerException: 500: cutoff reached
|
||||
LangChain4j : 31 requests, tool body ran 30 time(s)
|
||||
exception InternalServerException (root: HttpException): {"error":{"message":"cutoff reached"}}
|
||||
|
||||
Case 3: the same insistent model, each framework asked to stop after 5 tool rounds.
|
||||
Spring AI (ToolCallingAdvisor + our MaxToolRounds(5) checker): 6 requests, tool body ran 5 time(s)
|
||||
answer:
|
||||
LangChain4j (maxSequentialToolsInvocations(5)) : 6 requests, tool body ran 5 time(s)
|
||||
exception RuntimeException: Something is wrong, exceeded 5 tool calling round trips (maxToolCallingRoundTrips)
|
||||
@@ -0,0 +1,27 @@
|
||||
# What each framework records for one question that uses one tool (2 model requests)
|
||||
|
||||
Spring AI 2.0.1: model and client given an ObservationRegistry plus DefaultMeterObservationHandler and ChatModelMeterObservationHandler (the two Spring Boot registers for you).
|
||||
gen_ai.client.operation [TIMER] error=none, gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai
|
||||
gen_ai.client.operation.active [LONG_TASK_TIMER] gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=none, gen_ai.system=openai
|
||||
gen_ai.client.token.usage [COUNTER] gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai, gen_ai.token.type=input
|
||||
gen_ai.client.token.usage [COUNTER] gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai, gen_ai.token.type=output
|
||||
gen_ai.client.token.usage [COUNTER] gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai, gen_ai.token.type=total
|
||||
okhttp.requests [TIMER] error=none, outcome=SUCCESS
|
||||
okhttp.requests.active [LONG_TASK_TIMER] outcome=UNKNOWN
|
||||
spring.ai.advisor [TIMER] error=none, gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=advisor
|
||||
spring.ai.advisor.active [LONG_TASK_TIMER] gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=advisor
|
||||
spring.ai.chat.client [TIMER] error=none, gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=chat_client
|
||||
spring.ai.chat.client.active [LONG_TASK_TIMER] gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=chat_client
|
||||
spring.ai.tool [TIMER] error=none, gen_ai.operation.name=execute_tool, gen_ai.system=spring_ai, spring.ai.kind=tool_call
|
||||
spring.ai.tool.active [LONG_TASK_TIMER] gen_ai.operation.name=execute_tool, gen_ai.system=spring_ai, spring.ai.kind=tool_call
|
||||
|
||||
LangChain4j 1.22.0, no listener registered: 0 meters.
|
||||
|
||||
LangChain4j 1.22.0 with TracingChatInterceptor, the listener from the original article (it still compiles and runs):
|
||||
llm.request.latency [TIMER] model=gpt-6.1-sol
|
||||
llm.tokens.input [COUNTER] model=gpt-6.1-sol
|
||||
llm.tokens.output [COUNTER] model=gpt-6.1-sol
|
||||
|
||||
LangChain4j 1.22.0 with the shipped MicrometerMetricsChatModelListener (langchain4j-micrometer-metrics 1.22.0-beta32):
|
||||
gen_ai.client.token.usage [DISTRIBUTION_SUMMARY] gen_ai.operation.name=chat, gen_ai.provider.name=openai, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.token.type=input
|
||||
gen_ai.client.token.usage [DISTRIBUTION_SUMMARY] gen_ai.operation.name=chat, gen_ai.provider.name=openai, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.token.type=output
|
||||
@@ -0,0 +1,12 @@
|
||||
# The order tool served once over MCP (Streamable HTTP), used from Spring AI and from LangChain4j
|
||||
|
||||
MCP server: Spring AI 2.0.1 spring-ai-starter-mcp-server-webmvc, one @McpTool method, on port 33237.
|
||||
Spring AI client : McpClient.sync(HttpClientStreamableHttpTransport) -> SyncMcpToolCallbackProvider -> ChatClient.defaultToolCallbacks
|
||||
LangChain4j client: DefaultMcpClient + StreamableHttpMcpTransport -> McpToolProvider -> AiServices.toolProvider
|
||||
|
||||
Spring AI answer : Done: [{"text":"Order ORD-1002: Status=PROCESSING, ETA=2026-10-18"}] (2 requests to the model)
|
||||
tool offered to the model: tool getOrderStatus - Get current order status by order ID params {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"]}
|
||||
LangChain4j answer : Done: Order ORD-1002: Status=PROCESSING, ETA=2026-10-18 (2 requests to the model)
|
||||
tool offered to the model: tool getOrderStatus - Get current order status by order ID params {"type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"]}
|
||||
|
||||
Times the one tool method on the server ran: 1 for Spring AI, then 1 more for LangChain4j.
|
||||
@@ -0,0 +1,56 @@
|
||||
# The original article's code, compiled against Spring AI 2.0.1 and LangChain4j 1.22.0 (one file per API line)
|
||||
|
||||
OldCallAroundAdvisor: DOES NOT COMPILE
|
||||
OldCallAroundAdvisor.java:1: error: cannot find symbol
|
||||
symbol: class AdvisedRequest
|
||||
location: package org.springframework.ai.chat.client.advisor.api
|
||||
OldCallAroundAdvisor.java:2: error: cannot find symbol
|
||||
symbol: class AdvisedResponse
|
||||
location: package org.springframework.ai.chat.client.advisor.api
|
||||
OldChatService: compiles
|
||||
OldDocumentGetContent: DOES NOT COMPILE
|
||||
OldDocumentGetContent.java:6: error: cannot find symbol
|
||||
symbol: method getContent()
|
||||
location: variable d of type Document
|
||||
OldLangChain4jBuilder: DOES NOT COMPILE
|
||||
OldLangChain4jBuilder.java:9: error: cannot find symbol
|
||||
symbol: method chatLanguageModel(OpenAiChatModel)
|
||||
location: class AiServices<Agent>
|
||||
OldMessageGetContent: DOES NOT COMPILE
|
||||
OldMessageGetContent.java:6: error: cannot find symbol
|
||||
symbol: method getContent()
|
||||
location: variable m of type Message
|
||||
OldMeterRegistryCustomizer: DOES NOT COMPILE
|
||||
OldMeterRegistryCustomizer.java:1: error: package org.springframework.boot.actuate.autoconfigure.metrics does not exist
|
||||
OldMeterRegistryCustomizer.java:5: error: cannot find symbol
|
||||
symbol: class MeterRegistryCustomizer
|
||||
location: class OldMeterRegistryCustomizer
|
||||
OldPdfConfig: compiles
|
||||
OldSearchRequest: DOES NOT COMPILE
|
||||
OldSearchRequest.java:6: error: cannot find symbol
|
||||
symbol: method query(String)
|
||||
location: class SearchRequest
|
||||
OldTokenTextSplitter: DOES NOT COMPILE
|
||||
OldTokenTextSplitter.java:5: error: no suitable constructor found for TokenTextSplitter(int,int,int,int,boolean)
|
||||
|
||||
# The original article's Maven coordinates, looked up on Maven Central
|
||||
|
||||
org/springframework/ai/spring-ai-openai-spring-boot-starter: latest release none, latest of any kind 1.0.0-M6
|
||||
org/springframework/ai/spring-ai-pgvector-store-spring-boot-starter: latest release none, latest of any kind 1.0.0-M6
|
||||
org/springframework/ai/spring-ai-starter-model-openai: latest release 2.0.1, latest of any kind 2.1.0-M1
|
||||
org/springframework/ai/spring-ai-starter-vector-store-pgvector: latest release 2.0.1, latest of any kind 2.1.0-M1
|
||||
dev/langchain4j/langchain4j-spring-boot-starter: latest release 0.36.2, latest of any kind 1.22.0-beta32
|
||||
dev/langchain4j/langchain4j-open-ai: latest release 1.22.0, latest of any kind 1.22.0
|
||||
dev/langchain4j/langchain4j-spring-boot4-starter: latest release none, latest of any kind 1.22.0-beta32
|
||||
|
||||
# Class names the original article used, searched in the jars this build resolves (mvn dependency:build-classpath)
|
||||
|
||||
TokenBudgetAdvisor: not found in any jar
|
||||
RequestResponseAdvisor: not found in any jar
|
||||
CallAroundAdvisor: not found in any jar
|
||||
CallAdvisor: found
|
||||
ToolCallingAdvisor: found
|
||||
AdvisedRequest: not found in any jar
|
||||
ChatClientRequest: found
|
||||
EmbeddingClient: not found in any jar
|
||||
EmbeddingModel: found
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>langchain4j-interop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>langchain4j-interop</name>
|
||||
<description>Spring AI 2.0 and LangChain4j in one Spring Boot 4 application: the same order-assistant in both, what each sends, how each handles a failing tool, observability, and tools served once over MCP.</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<spring-ai.version>2.0.1</spring-ai.version>
|
||||
<langchain4j.version>1.22.0</langchain4j.version>
|
||||
<langchain4j.beta.version>1.22.0-beta32</langchain4j.beta.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-bom</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI: the OpenAI model and its auto-configuration -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- LangChain4j: the Spring Boot 4 starter family (there is a separate -spring-boot4- artifact set) -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-open-ai-spring-boot4-starter</artifactId>
|
||||
<version>${langchain4j.beta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-spring-boot4-starter</artifactId>
|
||||
<version>${langchain4j.beta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-http-client-jdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-mcp</artifactId>
|
||||
<version>${langchain4j.beta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-micrometer-metrics</artifactId>
|
||||
<version>${langchain4j.beta.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-vector-store</artifactId>
|
||||
</dependency>
|
||||
<!-- Only so scripts/compile-old-snippets.sh has the PDF reader on its classpath. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-pdf-document-reader</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles each API line of the original (2025-era) article, one file each, against today's Spring AI 2.0.1 and
|
||||
# LangChain4j 1.22.0 jars, and records what the compiler says. Also looks up the original Maven coordinates.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=output/06-old-code-vs-current.txt
|
||||
mvn -q -B dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test >/dev/null 2>&1
|
||||
CP="$(cat target/cp.txt)"
|
||||
mkdir -p target/old
|
||||
{
|
||||
echo "# The original article's code, compiled against Spring AI 2.0.1 and LangChain4j 1.22.0 (one file per API line)"
|
||||
echo
|
||||
for f in src/broken/Old*.java; do
|
||||
n=$(basename "$f" .java)
|
||||
rm -rf target/old/$n; mkdir -p target/old/$n
|
||||
if err=$(javac -proc:none --release 25 -cp "$CP" -d target/old/$n "$f" 2>&1 | grep -v '^Picked up' | grep -v '^Note:'); then :; fi
|
||||
if [ -z "$err" ]; then
|
||||
echo "$n: compiles"
|
||||
else
|
||||
echo "$n: DOES NOT COMPILE"
|
||||
echo "$err" | grep -E "error:|symbol:|location:" | head -6 | sed 's#^src/broken/##; s/^/ /'
|
||||
fi
|
||||
done
|
||||
echo
|
||||
echo "# The original article's Maven coordinates, looked up on Maven Central"
|
||||
echo
|
||||
meta() { # groupPath artifact
|
||||
for try in 1 2 3 4 5 6; do
|
||||
code=$(curl -s -o target/meta.xml -w '%{http_code}' "https://repo1.maven.org/maven2/$1/$2/maven-metadata.xml" || echo 000)
|
||||
case "$code" in 200|404) break;; esac
|
||||
sleep $((try * 8))
|
||||
done
|
||||
if [ "$code" = "404" ]; then echo "$1/$2: no such artifact"; return; fi
|
||||
if [ "$code" != "200" ]; then echo "$1/$2: lookup failed (HTTP $code)"; return; fi
|
||||
all=$(tr '\n' ' ' < target/meta.xml | grep -o '<version>[^<]*</version>' | sed 's/<[^>]*>//g')
|
||||
ga=$(echo "$all" | grep -v -E 'M[0-9]|RC|beta|alpha|SNAPSHOT' | tail -1)
|
||||
last=$(echo "$all" | tail -1)
|
||||
echo "$1/$2: latest release ${ga:-none}, latest of any kind $last"
|
||||
}
|
||||
meta org/springframework/ai spring-ai-openai-spring-boot-starter
|
||||
meta org/springframework/ai spring-ai-pgvector-store-spring-boot-starter
|
||||
meta org/springframework/ai spring-ai-starter-model-openai
|
||||
meta org/springframework/ai spring-ai-starter-vector-store-pgvector
|
||||
meta dev/langchain4j langchain4j-spring-boot-starter
|
||||
meta dev/langchain4j langchain4j-open-ai
|
||||
meta dev/langchain4j langchain4j-spring-boot4-starter
|
||||
|
||||
echo
|
||||
echo "# Class names the original article used, searched in the jars this build resolves (mvn dependency:build-classpath)"
|
||||
echo
|
||||
mvn -q -B dependency:build-classpath -Dmdep.outputFile=target/cp.txt >/dev/null 2>&1
|
||||
for name in TokenBudgetAdvisor RequestResponseAdvisor CallAroundAdvisor CallAdvisor ToolCallingAdvisor AdvisedRequest ChatClientRequest EmbeddingClient EmbeddingModel; do
|
||||
hits=0
|
||||
for j in $(tr ':' ' ' < target/cp.txt); do
|
||||
listing=$(unzip -l "$j" 2>/dev/null || true)
|
||||
if grep -q "/$name\.class" <<< "$listing"; then hits=$((hits+1)); fi
|
||||
done
|
||||
if [ "$hits" -gt 0 ]; then echo "$name: found"; else echo "$name: not found in any jar"; fi
|
||||
done
|
||||
} > "$OUT"
|
||||
cat "$OUT"
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/ (01-05 from the tests, 06 from the old-snippet script).
|
||||
# No services needed: the model is a local fake OpenAI server inside the tests.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -q -B test 2>&1 | grep -E "Tests run:|BUILD|FAIL|ERROR" || true
|
||||
bash scripts/compile-old-snippets.sh
|
||||
ls output
|
||||
@@ -0,0 +1,18 @@
|
||||
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
|
||||
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
|
||||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain;
|
||||
|
||||
// From the original TokenBudgetAdvisor.
|
||||
class OldCallAroundAdvisor implements CallAroundAdvisor {
|
||||
@Override
|
||||
public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) {
|
||||
return chain.nextAroundCall(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() { return "x"; }
|
||||
|
||||
@Override
|
||||
public int getOrder() { return 0; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
// The original ChatService, unchanged: this one is expected to still compile.
|
||||
class OldChatService {
|
||||
private final ChatClient chatClient;
|
||||
|
||||
OldChatService(ChatClient.Builder builder) {
|
||||
this.chatClient = builder
|
||||
.defaultSystem("You are a senior Java architect. Be precise and concise.")
|
||||
.build();
|
||||
}
|
||||
|
||||
public String ask(String userMessage) {
|
||||
return chatClient.prompt().user(userMessage).call().content();
|
||||
}
|
||||
|
||||
public Flux<String> stream(String userMessage) {
|
||||
return chatClient.prompt().user(userMessage).stream().content();
|
||||
}
|
||||
|
||||
public ArchitectureReview reviewCode(String codeSnippet) {
|
||||
return chatClient.prompt()
|
||||
.user(u -> u.text("Review this Java code for production readiness: {code}").param("code", codeSnippet))
|
||||
.call()
|
||||
.entity(ArchitectureReview.class);
|
||||
}
|
||||
|
||||
public record ArchitectureReview(String verdict, List<String> issues, List<String> suggestions, int score) { }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.document.Document;
|
||||
|
||||
// From the original RAGService: Document::getContent
|
||||
class OldDocumentGetContent {
|
||||
String text(Document d) {
|
||||
return d.getContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
|
||||
// From the original AgentConfig.
|
||||
class OldLangChain4jBuilder {
|
||||
interface Agent { String chat(String m); }
|
||||
|
||||
Agent agent(OpenAiChatModel model) {
|
||||
return AiServices.builder(Agent.class).chatLanguageModel(model).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
|
||||
// From the original TokenBudgetAdvisor.estimateTokens: m.getContent()
|
||||
class OldMessageGetContent {
|
||||
int length(Message m) {
|
||||
return m.getContent().length();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
|
||||
|
||||
// From the original ObservabilityConfig.
|
||||
class OldMeterRegistryCustomizer {
|
||||
MeterRegistryCustomizer<?> unused;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
|
||||
|
||||
// From the original DocumentIngestionService.
|
||||
class OldPdfConfig {
|
||||
Object config() {
|
||||
return PdfDocumentReaderConfig.builder().withPageTopMargin(0).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
|
||||
// From the original RAGService and SemanticCacheService.
|
||||
class OldSearchRequest {
|
||||
Object build(String question, int topK) {
|
||||
return SearchRequest.query(question).withTopK(topK).withSimilarityThreshold(0.75);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
|
||||
|
||||
// From the original DocumentIngestionService: this one is expected to still compile.
|
||||
class OldTokenTextSplitter {
|
||||
TokenTextSplitter splitter = new TokenTextSplitter(512, 50, 5, 10000, true);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import dev.langchain4j.agent.tool.P;
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
|
||||
/** The same tool as LangChain4j sees it: same body, different annotations. */
|
||||
public class Lc4jTools {
|
||||
|
||||
private final OrderService orders;
|
||||
|
||||
public Lc4jTools(OrderService orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@Tool("Get current order status by order ID")
|
||||
public String getOrderStatus(@P("The unique identifier of the order") String orderId) {
|
||||
return orders.describe(orderId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.model.tool.ToolExecutionEligibilityChecker;
|
||||
|
||||
/**
|
||||
* A round limit for Spring AI's tool loop. The advisor has no "max iterations" setting; what it has is this hook,
|
||||
* asked once per model reply whether the tool calls in it should be executed. One instance counts one call,
|
||||
* so build one per request.
|
||||
*/
|
||||
public final class MaxToolRounds implements ToolExecutionEligibilityChecker {
|
||||
|
||||
private final int limit;
|
||||
private final AtomicInteger rounds = new AtomicInteger();
|
||||
|
||||
public MaxToolRounds(int limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean apply(ChatResponse response) {
|
||||
return response != null && response.hasToolCalls() && rounds.incrementAndGet() <= limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** The service both frameworks' tools call. Plain Java: it knows nothing about either framework. */
|
||||
public class OrderService {
|
||||
|
||||
public record Order(String id, String status, String eta) { }
|
||||
|
||||
private final Map<String, Order> orders = Map.of(
|
||||
"ORD-1001", new Order("ORD-1001", "SHIPPED", "2026-10-14"),
|
||||
"ORD-1002", new Order("ORD-1002", "PROCESSING", "2026-10-18"));
|
||||
|
||||
public final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
public Optional<Order> find(String id) {
|
||||
calls.incrementAndGet();
|
||||
if (id.equals("ORD-500")) {
|
||||
throw new IllegalStateException("order database unavailable");
|
||||
}
|
||||
return Optional.ofNullable(orders.get(id));
|
||||
}
|
||||
|
||||
public String describe(String id) {
|
||||
return find(id).map(o -> "Order %s: Status=%s, ETA=%s".formatted(o.id(), o.status(), o.eta()))
|
||||
.orElse("Order not found: " + id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
|
||||
/** The order tool as Spring AI sees it. */
|
||||
public class SpringAiTools {
|
||||
|
||||
private final OrderService orders;
|
||||
|
||||
public SpringAiTools(OrderService orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@Tool(description = "Get current order status by order ID")
|
||||
public String getOrderStatus(@ToolParam(description = "The unique identifier of the order") String orderId) {
|
||||
return orders.describe(orderId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import dev.langchain4j.service.SystemMessage;
|
||||
|
||||
/** The LangChain4j side of the assistant: an interface whose implementation LangChain4j generates. */
|
||||
public interface Support {
|
||||
|
||||
@SystemMessage("You are a customer support agent. Use the tools for order questions. Be concise.")
|
||||
String chat(String userMessage);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import dev.langchain4j.model.chat.listener.ChatModelErrorContext;
|
||||
import dev.langchain4j.model.chat.listener.ChatModelListener;
|
||||
import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
|
||||
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
|
||||
import dev.langchain4j.model.output.TokenUsage;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
/**
|
||||
* The listener from the original version of this article, with Lombok and the Spring annotations removed.
|
||||
* It is the same logic: a timer for latency, counters for input and output tokens, a counter for errors.
|
||||
*/
|
||||
public class TracingChatInterceptor implements ChatModelListener {
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
|
||||
private static final String START_TIME_KEY = "start_time";
|
||||
|
||||
public TracingChatInterceptor(MeterRegistry meterRegistry) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequest(ChatModelRequestContext ctx) {
|
||||
ctx.attributes().put(START_TIME_KEY, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(ChatModelResponseContext ctx) {
|
||||
Object startTimeObj = ctx.attributes().get(START_TIME_KEY);
|
||||
if (!(startTimeObj instanceof Long startTime)) return;
|
||||
|
||||
long latencyMs = System.currentTimeMillis() - startTime;
|
||||
TokenUsage usage = ctx.chatResponse().metadata().tokenUsage();
|
||||
String modelName = ctx.chatRequest().parameters().modelName();
|
||||
|
||||
meterRegistry.timer("llm.request.latency", "model", modelName).record(latencyMs, TimeUnit.MILLISECONDS);
|
||||
if (usage != null) {
|
||||
meterRegistry.counter("llm.tokens.input", "model", modelName).increment(usage.inputTokenCount());
|
||||
meterRegistry.counter("llm.tokens.output", "model", modelName).increment(usage.outputTokenCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ChatModelErrorContext ctx) {
|
||||
meterRegistry.counter("llm.request.errors", "model", ctx.chatRequest().parameters().modelName()).increment();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
|
||||
import dev.langchain4j.service.AiServices;
|
||||
|
||||
/** The two frameworks pointed at the same server, each wired by hand so nothing is auto-configured. */
|
||||
public final class Wiring {
|
||||
|
||||
public static final String SYSTEM = "You are a customer support agent. Use the tools for order questions. Be concise.";
|
||||
|
||||
private Wiring() { }
|
||||
|
||||
public static OpenAiChatModel springModel(String baseUrl) {
|
||||
return OpenAiChatModel.builder().options(OpenAiChatOptions.builder()
|
||||
.baseUrl(baseUrl + "/v1").apiKey("k").model("gpt-6.1-sol").maxRetries(0).build()).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* With both langchain4j-http-client-jdk and the Spring starter's RestClient adapter on the classpath, building a
|
||||
* model by hand without naming an HTTP client throws; the explicit builder below avoids it.
|
||||
*/
|
||||
public static dev.langchain4j.model.openai.OpenAiChatModel lc4jModel(String baseUrl) {
|
||||
return dev.langchain4j.model.openai.OpenAiChatModel.builder()
|
||||
.httpClientBuilder(dev.langchain4j.http.client.jdk.JdkHttpClient.builder())
|
||||
.baseUrl(baseUrl + "/v1").apiKey("k").modelName("gpt-6.1-sol").maxRetries(0).build();
|
||||
}
|
||||
|
||||
public static ChatClient springAssistant(String baseUrl, OrderService orders) {
|
||||
return ChatClient.builder(springModel(baseUrl)).defaultSystem(SYSTEM).defaultTools(new SpringAiTools(orders)).build();
|
||||
}
|
||||
|
||||
public static Support lc4jAssistant(String baseUrl, OrderService orders) {
|
||||
return AiServices.builder(Support.class).chatModel(lc4jModel(baseUrl)).tools(new Lc4jTools(orders)).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
class BothStartersTest {
|
||||
|
||||
@Configuration
|
||||
@EnableAutoConfiguration
|
||||
static class BothApp { }
|
||||
|
||||
record Outcome(String result, List<String> beans) { }
|
||||
|
||||
static Outcome start(FakeOpenAi fake, String... extra) {
|
||||
List<String> props = new ArrayList<>(List.of(
|
||||
"spring.ai.openai.base-url=" + fake.url(), "spring.ai.openai.api-key=k",
|
||||
"spring.ai.openai.chat.options.model=gpt-6.1-sol",
|
||||
"spring.ai.mcp.client.enabled=false", "spring.ai.mcp.server.enabled=false",
|
||||
"langchain4j.open-ai.chat-model.base-url=" + fake.url() + "/v1",
|
||||
"langchain4j.open-ai.chat-model.api-key=k",
|
||||
"langchain4j.open-ai.chat-model.model-name=gpt-6.1-sol"));
|
||||
props.addAll(List.of(extra));
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(BothApp.class)
|
||||
.web(WebApplicationType.NONE).properties(props.toArray(String[]::new)).run()) {
|
||||
List<String> beans = new ArrayList<>();
|
||||
for (String n : ctx.getBeanDefinitionNames()) {
|
||||
Class<?> c = null;
|
||||
try {
|
||||
c = ctx.getType(n);
|
||||
} catch (Exception ignored) {
|
||||
// not resolvable without creating it
|
||||
}
|
||||
if (c != null && (c.getName().endsWith("OpenAiChatModel") || c.getName().endsWith("ChatClient$Builder")))
|
||||
beans.add(n + " -> " + c.getName());
|
||||
}
|
||||
beans.add("(count) beans of type dev.langchain4j.model.chat.ChatModel: " + ctx.getBeanNamesForType(dev.langchain4j.model.chat.ChatModel.class).length);
|
||||
beans.add("(count) beans of type org.springframework.ai.chat.model.ChatModel: " + ctx.getBeanNamesForType(org.springframework.ai.chat.model.ChatModel.class).length);
|
||||
return new Outcome("context started", beans);
|
||||
} catch (Exception e) {
|
||||
Throwable root = e;
|
||||
while (root.getCause() != null) root = root.getCause();
|
||||
String msg = root.getMessage();
|
||||
if (root.getClass().getSimpleName().equals("BeanDefinitionOverrideException")) {
|
||||
Matcher m = Pattern.compile("factoryBeanName=([\\w.$]+)").matcher(msg);
|
||||
List<String> owners = new ArrayList<>();
|
||||
while (m.find()) owners.add(m.group(1));
|
||||
msg = "bean 'openAiChatModel' is defined twice, by " + String.join(" and by ", owners);
|
||||
}
|
||||
return new Outcome("FAILED: " + root.getClass().getSimpleName() + ": " + msg, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothStartersInOneApplication() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
Outcome both = start(fake);
|
||||
Outcome excluded = start(fake, "spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration");
|
||||
Outcome overriding = start(fake, "spring.main.allow-bean-definition-overriding=true");
|
||||
try (var t = new Transcript("01-both-starters.txt", "Both starters in one Spring Boot 4.1.1 application")) {
|
||||
t.line("Spring AI: spring-ai-starter-model-openai 2.0.1. LangChain4j: langchain4j-open-ai-spring-boot4-starter 1.22.0-beta32.");
|
||||
t.blank();
|
||||
t.line("1. Both starters, nothing else:");
|
||||
t.line(" %s", both.result());
|
||||
t.blank();
|
||||
t.line("2. spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration:");
|
||||
t.line(" %s", excluded.result());
|
||||
excluded.beans().forEach(b -> t.line(" %s", b.startsWith("(count)") ? b.substring(8) : "bean " + b));
|
||||
t.blank();
|
||||
t.line("3. spring.main.allow-bean-definition-overriding=true:");
|
||||
t.line(" %s", overriding.result());
|
||||
overriding.beans().forEach(b -> t.line(" %s", b.startsWith("(count)") ? b.substring(8) : "bean " + b));
|
||||
}
|
||||
assertThat(both.result()).startsWith("FAILED: BeanDefinitionOverrideException");
|
||||
assertThat(excluded.result()).isEqualTo("context started");
|
||||
assertThat(overriding.result()).isEqualTo("context started");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* A local server speaking the OpenAI chat-completions wire format, so the REAL Spring AI and LangChain4j OpenAI
|
||||
* models talk to it. It is a rule, not a language model: if the last message is a tool result it answers with
|
||||
* that result; if the user mentions an order id and a tool is on offer it asks for the tool; otherwise it
|
||||
* greets. Every request body is recorded.
|
||||
*/
|
||||
public final class FakeOpenAi implements AutoCloseable {
|
||||
|
||||
public enum Mode {
|
||||
/** Ask for the tool once, then answer from the tool result. */
|
||||
NORMAL,
|
||||
/** Ask for the tool again after every tool result, like a model that never gives up. */
|
||||
INSISTENT
|
||||
}
|
||||
|
||||
private static final JsonMapper JSON = JsonMapper.builder().build();
|
||||
private static final Pattern ORDER = Pattern.compile("ORD-\\d+");
|
||||
|
||||
private final HttpServer server;
|
||||
public final List<String> bodies = new CopyOnWriteArrayList<>();
|
||||
public volatile Mode mode = Mode.NORMAL;
|
||||
/** After this many requests the server answers 500, so a loop that never ends still ends in a test. */
|
||||
public volatile int cutoff = Integer.MAX_VALUE;
|
||||
|
||||
public FakeOpenAi() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/v1/chat/completions", this::serve);
|
||||
server.start();
|
||||
}
|
||||
|
||||
public String url() { return "http://127.0.0.1:" + server.getAddress().getPort(); }
|
||||
|
||||
public void reset() {
|
||||
bodies.clear();
|
||||
mode = Mode.NORMAL;
|
||||
cutoff = Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
private void serve(HttpExchange ex) throws IOException {
|
||||
String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
bodies.add(body);
|
||||
String reply;
|
||||
try {
|
||||
reply = bodies.size() > cutoff ? "{\"error\":{\"message\":\"cutoff reached\"}}" : decide(JSON.readTree(body));
|
||||
} catch (Exception e) {
|
||||
reply = "{\"error\":{\"message\":\"fake could not read the request: " + e.getMessage().replace("\"", "'") + "\"}}";
|
||||
}
|
||||
byte[] out = reply.getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().add("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(reply.startsWith("{\"error") ? 500 : 200, out.length);
|
||||
try (OutputStream os = ex.getResponseBody()) {
|
||||
os.write(out);
|
||||
}
|
||||
}
|
||||
|
||||
private String decide(JsonNode req) {
|
||||
JsonNode messages = req.get("messages");
|
||||
JsonNode last = messages.get(messages.size() - 1);
|
||||
boolean toolsOffered = req.has("tools") && req.get("tools").size() > 0;
|
||||
String lastRole = last.get("role").asText();
|
||||
int promptChars = req.toString().length();
|
||||
|
||||
if (lastRole.equals("tool")) {
|
||||
if (mode == Mode.INSISTENT && toolsOffered) {
|
||||
return toolCall(lastUserText(messages), promptChars);
|
||||
}
|
||||
return text("Done: " + last.get("content").asText(), promptChars);
|
||||
}
|
||||
String user = lastUserText(messages);
|
||||
Matcher m = ORDER.matcher(user);
|
||||
if (toolsOffered && m.find()) {
|
||||
return toolCall(user, promptChars);
|
||||
}
|
||||
return text("Hello. I can look up orders.", promptChars);
|
||||
}
|
||||
|
||||
private static String lastUserText(JsonNode messages) {
|
||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||
JsonNode m = messages.get(i);
|
||||
if (m.get("role").asText().equals("user")) {
|
||||
JsonNode c = m.get("content");
|
||||
return c.isString() ? c.asString() : c.toString();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String toolCall(String user, int promptChars) {
|
||||
Matcher m = ORDER.matcher(user);
|
||||
String id = m.find() ? m.group() : "ORD-0";
|
||||
return "{\"id\":\"chatcmpl-t\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-6.1-sol\","
|
||||
+ "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_1\","
|
||||
+ "\"type\":\"function\",\"function\":{\"name\":\"getOrderStatus\",\"arguments\":\"{\\\"orderId\\\":\\\"" + id + "\\\"}\"}}]},"
|
||||
+ "\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":" + promptChars / 4 + ",\"completion_tokens\":12,\"total_tokens\":"
|
||||
+ (promptChars / 4 + 12) + "}}";
|
||||
}
|
||||
|
||||
private static String text(String text, int promptChars) {
|
||||
return "{\"id\":\"chatcmpl-x\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-6.1-sol\","
|
||||
+ "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + JSON.writeValueAsString(text)
|
||||
+ "},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":" + promptChars / 4 + ",\"completion_tokens\":"
|
||||
+ Math.max(1, text.length() / 4) + ",\"total_tokens\":" + (promptChars / 4 + Math.max(1, text.length() / 4)) + "}}";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() { server.stop(0); }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
|
||||
import org.springframework.ai.mcp.annotation.McpTool;
|
||||
import org.springframework.ai.mcp.annotation.McpToolParam;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import dev.langchain4j.mcp.McpToolProvider;
|
||||
import dev.langchain4j.mcp.client.DefaultMcpClient;
|
||||
import dev.langchain4j.mcp.client.transport.http.StreamableHttpMcpTransport;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
|
||||
/** The order tool written once, behind an MCP server, and used from both frameworks as a client. */
|
||||
class McpFirstTest {
|
||||
|
||||
/** Nothing here imports either framework's tool annotation: only MCP's. */
|
||||
public static class McpOrderTools {
|
||||
|
||||
private final OrderService orders;
|
||||
|
||||
public McpOrderTools(OrderService orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@McpTool(name = "getOrderStatus", description = "Get current order status by order ID")
|
||||
public String getOrderStatus(@McpToolParam(description = "The unique identifier of the order", required = true) String orderId) {
|
||||
return orders.describe(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableAutoConfiguration
|
||||
static class ServerApp {
|
||||
|
||||
static final OrderService ORDERS = new OrderService();
|
||||
|
||||
@Bean
|
||||
McpOrderTools mcpOrderTools() {
|
||||
return new McpOrderTools(ORDERS);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theToolBehindMcpServesBothFrameworks() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi();
|
||||
ConfigurableApplicationContext server = new SpringApplicationBuilder(ServerApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "spring.ai.openai.api-key=unused",
|
||||
"spring.ai.mcp.client.enabled=false",
|
||||
"spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration",
|
||||
"spring.ai.mcp.server.name=orders", "spring.ai.mcp.server.version=1.0.0",
|
||||
"spring.ai.mcp.server.protocol=streamable")
|
||||
.run()) {
|
||||
int port = server.getEnvironment().getProperty("local.server.port", Integer.class);
|
||||
String base = "http://127.0.0.1:" + port;
|
||||
|
||||
// Spring AI as an MCP client: the tools arrive as ToolCallbacks.
|
||||
var sdkClient = McpClient.sync(HttpClientStreamableHttpTransport.builder(base).endpoint("/mcp").build())
|
||||
.requestTimeout(Duration.ofSeconds(10)).build();
|
||||
sdkClient.initialize();
|
||||
var springProvider = SyncMcpToolCallbackProvider.builder().mcpClients(sdkClient).build();
|
||||
ChatClient spring = ChatClient.builder(Wiring.springModel(fake.url())).defaultSystem(Wiring.SYSTEM)
|
||||
.defaultToolCallbacks(springProvider).build();
|
||||
String springAnswer = spring.prompt().user("Where is ORD-1002?").call().content();
|
||||
int springRequests = fake.bodies.size();
|
||||
String springTool = SameAssistantTest.summarise(fake.bodies.get(0)).stream().filter(l -> l.contains("tool getOrderStatus")).findFirst().orElse("(no tool offered)");
|
||||
int callsAfterSpring = ServerApp.ORDERS.calls.get();
|
||||
fake.reset();
|
||||
|
||||
// LangChain4j as an MCP client.
|
||||
var lcMcp = DefaultMcpClient.builder().key("orders")
|
||||
.transport(StreamableHttpMcpTransport.builder().url(base + "/mcp").build()).build();
|
||||
Support lc = AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url()))
|
||||
.toolProvider(McpToolProvider.builder().mcpClients(List.of(lcMcp)).build()).build();
|
||||
String lcAnswer = lc.chat("Where is ORD-1002?");
|
||||
int lcRequests = fake.bodies.size();
|
||||
String lcTool = SameAssistantTest.summarise(fake.bodies.get(0)).stream().filter(l -> l.contains("tool getOrderStatus")).findFirst().orElse("(no tool offered)");
|
||||
int callsAfterBoth = ServerApp.ORDERS.calls.get();
|
||||
|
||||
try (var t = new Transcript("05-mcp-first.txt", "The order tool served once over MCP (Streamable HTTP), used from Spring AI and from LangChain4j")) {
|
||||
t.line("MCP server: Spring AI 2.0.1 spring-ai-starter-mcp-server-webmvc, one @McpTool method, on port %d.", port);
|
||||
t.line("Spring AI client : McpClient.sync(HttpClientStreamableHttpTransport) -> SyncMcpToolCallbackProvider -> ChatClient.defaultToolCallbacks");
|
||||
t.line("LangChain4j client: DefaultMcpClient + StreamableHttpMcpTransport -> McpToolProvider -> AiServices.toolProvider");
|
||||
t.blank();
|
||||
t.line("Spring AI answer : %s (%d requests to the model)", springAnswer, springRequests);
|
||||
t.line(" tool offered to the model: %s", springTool);
|
||||
t.line("LangChain4j answer : %s (%d requests to the model)", lcAnswer, lcRequests);
|
||||
t.line(" tool offered to the model: %s", lcTool);
|
||||
t.blank();
|
||||
t.line("Times the one tool method on the server ran: %d for Spring AI, then %d more for LangChain4j.", callsAfterSpring, callsAfterBoth - callsAfterSpring);
|
||||
}
|
||||
sdkClient.close();
|
||||
lcMcp.close();
|
||||
assertThat(springAnswer).contains("PROCESSING").isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
|
||||
import dev.langchain4j.micrometer.metrics.listeners.MicrometerMetricsChatModelListener;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler;
|
||||
|
||||
class ObservabilityTest {
|
||||
|
||||
static List<String> describe(MeterRegistry r) {
|
||||
return r.getMeters().stream().map(m -> {
|
||||
Meter.Id id = m.getId();
|
||||
String tags = id.getTags().stream().filter(t -> List.of("gen_ai.token.type", "gen_ai.request.model", "gen_ai.response.model",
|
||||
"gen_ai.operation.name", "gen_ai.provider.name", "spring.ai.kind", "model", "gen_ai.system", "tool.name", "error", "outcome")
|
||||
.contains(t.getKey())).map(t -> t.getKey() + "=" + t.getValue()).sorted().collect(Collectors.joining(", "));
|
||||
return id.getName() + " [" + id.getType() + "]" + (tags.isEmpty() ? "" : " " + tags);
|
||||
}).distinct().sorted().toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatEachFrameworkRecordsWithoutBeingAsked() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
// Spring AI: a model and a client that share an ObservationRegistry; a handler turns observations into meters.
|
||||
SimpleMeterRegistry springMeters = new SimpleMeterRegistry();
|
||||
ObservationRegistry observations = ObservationRegistry.create();
|
||||
observations.observationConfig().observationHandler(new DefaultMeterObservationHandler(springMeters));
|
||||
// the handler Spring Boot registers for you; it turns the model's token usage into a meter
|
||||
observations.observationConfig().observationHandler(new org.springframework.ai.chat.observation.ChatModelMeterObservationHandler(springMeters));
|
||||
OrderService sOrders = new OrderService();
|
||||
OpenAiChatModel model = OpenAiChatModel.builder().observationRegistry(observations).options(OpenAiChatOptions.builder()
|
||||
.baseUrl(fake.url() + "/v1").apiKey("k").model("gpt-6.1-sol").maxRetries(0).build()).build();
|
||||
ChatClient spring = ChatClient.builder(model, observations, null, null).defaultSystem(Wiring.SYSTEM)
|
||||
.defaultTools(new SpringAiTools(sOrders)).build();
|
||||
spring.prompt().user("Where is ORD-1001?").call().content();
|
||||
|
||||
// LangChain4j: nothing is recorded unless a listener is registered on the model.
|
||||
SimpleMeterRegistry bare = new SimpleMeterRegistry();
|
||||
AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url())).tools(new Lc4jTools(new OrderService())).build()
|
||||
.chat("Where is ORD-1001?");
|
||||
List<String> lcNone = describe(bare);
|
||||
|
||||
SimpleMeterRegistry lcOwn = new SimpleMeterRegistry(), lcShipped = new SimpleMeterRegistry();
|
||||
var withOwn = dev.langchain4j.model.openai.OpenAiChatModel.builder()
|
||||
.httpClientBuilder(dev.langchain4j.http.client.jdk.JdkHttpClient.builder())
|
||||
.baseUrl(fake.url() + "/v1").apiKey("k").modelName("gpt-6.1-sol")
|
||||
.listeners(List.of(new TracingChatInterceptor(lcOwn))).build();
|
||||
AiServices.builder(Support.class).chatModel(withOwn).tools(new Lc4jTools(new OrderService())).build().chat("Where is ORD-1001?");
|
||||
var withShipped = dev.langchain4j.model.openai.OpenAiChatModel.builder()
|
||||
.httpClientBuilder(dev.langchain4j.http.client.jdk.JdkHttpClient.builder())
|
||||
.baseUrl(fake.url() + "/v1").apiKey("k").modelName("gpt-6.1-sol")
|
||||
.listeners(List.of(new MicrometerMetricsChatModelListener(lcShipped))).build();
|
||||
AiServices.builder(Support.class).chatModel(withShipped).tools(new Lc4jTools(new OrderService())).build().chat("Where is ORD-1001?");
|
||||
|
||||
try (var t = new Transcript("04-observability.txt", "What each framework records for one question that uses one tool (2 model requests)")) {
|
||||
t.line("Spring AI 2.0.1: model and client given an ObservationRegistry plus DefaultMeterObservationHandler and ChatModelMeterObservationHandler (the two Spring Boot registers for you).");
|
||||
describe(springMeters).forEach(l -> t.line(" %s", l));
|
||||
t.blank();
|
||||
t.line("LangChain4j 1.22.0, no listener registered: %d meters.", lcNone.size());
|
||||
t.blank();
|
||||
t.line("LangChain4j 1.22.0 with TracingChatInterceptor, the listener from the original article (it still compiles and runs):");
|
||||
describe(lcOwn).forEach(l -> t.line(" %s", l));
|
||||
t.blank();
|
||||
t.line("LangChain4j 1.22.0 with the shipped MicrometerMetricsChatModelListener (langchain4j-micrometer-metrics 1.22.0-beta32):");
|
||||
describe(lcShipped).forEach(l -> t.line(" %s", l));
|
||||
}
|
||||
assertThat(springMeters.getMeters()).isNotEmpty();
|
||||
assertThat(lcNone).isEmpty();
|
||||
assertThat(lcOwn.getMeters()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class SameAssistantTest {
|
||||
|
||||
static final JsonMapper JSON = JsonMapper.builder().build();
|
||||
|
||||
/** One request body reduced to what a reader compares: top-level fields, messages and tools. */
|
||||
static List<String> summarise(String body) {
|
||||
JsonNode req = JSON.readTree(body);
|
||||
List<String> out = new ArrayList<>();
|
||||
TreeSet<String> keys = new TreeSet<>();
|
||||
req.propertyNames().forEach(keys::add);
|
||||
out.add("fields: " + keys);
|
||||
for (JsonNode m : req.get("messages")) {
|
||||
String role = m.get("role").asText();
|
||||
String detail;
|
||||
if (m.has("tool_calls") && m.get("tool_calls").size() > 0) {
|
||||
JsonNode f = m.get("tool_calls").get(0).get("function");
|
||||
detail = "tool_call " + f.get("name").asText() + " " + f.get("arguments").asText();
|
||||
} else {
|
||||
JsonNode c = m.get("content");
|
||||
detail = c == null || c.isNull() ? "" : c.isString() ? "\"" + c.asString() + "\"" : c.toString();
|
||||
}
|
||||
out.add(" " + role + ": " + detail);
|
||||
}
|
||||
if (req.has("tools")) {
|
||||
for (JsonNode tool : req.get("tools")) {
|
||||
JsonNode f = tool.get("function");
|
||||
out.add(" tool " + f.get("name").asText() + " - " + f.path("description").asText("(no description)") + " params " + f.path("parameters"));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String names(JsonNode n) {
|
||||
TreeSet<String> k = new TreeSet<>();
|
||||
n.propertyNames().forEach(k::add);
|
||||
return k.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameOrderAssistantInBothFrameworks() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
OrderService springOrders = new OrderService(), lcOrders = new OrderService();
|
||||
String springAnswer = Wiring.springAssistant(fake.url(), springOrders).prompt().user("Where is ORD-1001?").call().content();
|
||||
List<String> springBodies = new ArrayList<>(fake.bodies);
|
||||
fake.reset();
|
||||
String lcAnswer = Wiring.lc4jAssistant(fake.url(), lcOrders).chat("Where is ORD-1001?");
|
||||
List<String> lcBodies = new ArrayList<>(fake.bodies);
|
||||
|
||||
try (var t = new Transcript("02-same-assistant.txt", "The same order assistant in Spring AI and in LangChain4j, one question: \"Where is ORD-1001?\"")) {
|
||||
t.line("Spring AI : ChatClient.builder(model).defaultSystem(..).defaultTools(new SpringAiTools(orders))");
|
||||
t.line("LangChain4j: AiServices.builder(Support.class).chatModel(model).tools(new Lc4jTools(orders))");
|
||||
t.blank();
|
||||
t.line("Spring AI answer : %s", springAnswer);
|
||||
t.line("LangChain4j answer : %s", lcAnswer);
|
||||
t.line("HTTP requests sent : Spring AI %d, LangChain4j %d. Tool body executed: Spring AI %d, LangChain4j %d.",
|
||||
springBodies.size(), lcBodies.size(), springOrders.calls.get(), lcOrders.calls.get());
|
||||
t.blank();
|
||||
t.line("What differs on the wire (computed from the four bodies below):");
|
||||
JsonNode s1 = JSON.readTree(springBodies.get(0)), l1 = JSON.readTree(lcBodies.get(0));
|
||||
JsonNode s2 = JSON.readTree(springBodies.get(1)), l2 = JSON.readTree(lcBodies.get(1));
|
||||
t.line(" top-level fields : Spring AI %s, LangChain4j %s", names(s1), names(l1));
|
||||
t.line(" tool schema : Spring AI has $schema=%s additionalProperties=%s; LangChain4j has $schema=%s additionalProperties=%s",
|
||||
s1.at("/tools/0/function/parameters").has("$schema"), s1.at("/tools/0/function/parameters").has("additionalProperties"),
|
||||
l1.at("/tools/0/function/parameters").has("$schema"), l1.at("/tools/0/function/parameters").has("additionalProperties"));
|
||||
t.line(" tool result message: Spring AI sent %s, LangChain4j sent %s", s2.at("/messages/3/content"), l2.at("/messages/3/content"));
|
||||
for (int i = 0; i < springBodies.size(); i++) {
|
||||
t.blank();
|
||||
t.line("--- Spring AI, request %d", i + 1);
|
||||
summarise(springBodies.get(i)).forEach(l -> t.line("%s", l));
|
||||
}
|
||||
for (int i = 0; i < lcBodies.size(); i++) {
|
||||
t.blank();
|
||||
t.line("--- LangChain4j, request %d", i + 1);
|
||||
summarise(lcBodies.get(i)).forEach(l -> t.line("%s", l));
|
||||
}
|
||||
}
|
||||
assertThat(springAnswer.replace("\"", "")).isEqualTo(lcAnswer);
|
||||
assertThat(springAnswer).isNotEqualTo(lcAnswer);
|
||||
assertThat(springBodies).hasSize(2);
|
||||
assertThat(lcBodies).hasSize(2);
|
||||
assertThat(springOrders.calls.get()).isEqualTo(1);
|
||||
assertThat(lcOrders.calls.get()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class ToolFailureTest {
|
||||
|
||||
static final JsonMapper JSON = JsonMapper.builder().build();
|
||||
|
||||
record Run(String result, int requests, int toolBodies, String toolMessage) { }
|
||||
|
||||
static Run run(FakeOpenAi fake, OrderService orders, Supplier<String> call) {
|
||||
fake.bodies.clear();
|
||||
orders.calls.set(0);
|
||||
String result;
|
||||
try {
|
||||
result = "answer: " + call.get();
|
||||
} catch (Exception e) {
|
||||
Throwable root = e;
|
||||
while (root.getCause() != null) root = root.getCause();
|
||||
result = "exception " + e.getClass().getSimpleName() + (root != e ? " (root: " + root.getClass().getSimpleName() + ")" : "")
|
||||
+ ": " + String.valueOf(root.getMessage()).lines().findFirst().orElse("");
|
||||
}
|
||||
String toolMsg = "(none sent)";
|
||||
for (String b : fake.bodies) {
|
||||
JsonNode msgs = JSON.readTree(b).get("messages");
|
||||
for (JsonNode m : msgs) if (m.get("role").asText().equals("tool")) toolMsg = m.get("content").toString();
|
||||
}
|
||||
return new Run(result, fake.bodies.size(), orders.calls.get(), toolMsg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatEachFrameworkDoesWhenAToolThrows() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
OrderService sOrders = new OrderService(), lOrders = new OrderService();
|
||||
var spring = Wiring.springAssistant(fake.url(), sOrders);
|
||||
var lc = Wiring.lc4jAssistant(fake.url(), lOrders);
|
||||
|
||||
Run s1 = run(fake, sOrders, () -> spring.prompt().user("Where is ORD-500?").call().content());
|
||||
Run l1 = run(fake, lOrders, () -> lc.chat("Where is ORD-500?"));
|
||||
|
||||
// A model that keeps asking for the tool after every result, stopped by the server after 30 requests.
|
||||
fake.mode = FakeOpenAi.Mode.INSISTENT;
|
||||
fake.cutoff = 30;
|
||||
Run s2 = run(fake, sOrders, () -> spring.prompt().user("Where is ORD-500?").call().content());
|
||||
Run l2 = run(fake, lOrders, () -> lc.chat("Where is ORD-500?"));
|
||||
// The same insistent model, now with each framework's own way of capping the rounds at 5.
|
||||
fake.cutoff = 30;
|
||||
var cappedSpring = org.springframework.ai.chat.client.ChatClient.builder(Wiring.springModel(fake.url()))
|
||||
.defaultSystem(Wiring.SYSTEM).defaultTools(new SpringAiTools(sOrders))
|
||||
.defaultAdvisors(org.springframework.ai.chat.client.advisor.ToolCallingAdvisor.builder()
|
||||
.toolExecutionEligibilityChecker(new MaxToolRounds(5)).build()).build();
|
||||
var cappedLc = dev.langchain4j.service.AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url()))
|
||||
.tools(new Lc4jTools(lOrders)).maxSequentialToolsInvocations(5).build();
|
||||
Run s3 = run(fake, sOrders, () -> String.valueOf(cappedSpring.prompt().user("Where is ORD-500?").call().content()));
|
||||
Run l3 = run(fake, lOrders, () -> cappedLc.chat("Where is ORD-500?"));
|
||||
fake.reset();
|
||||
|
||||
try (var t = new Transcript("03-tool-throws.txt", "A tool that throws (ORD-500 raises IllegalStateException: order database unavailable)")) {
|
||||
t.line("Case 1: a well-behaved model. It asks for the tool once, then answers from whatever the tool message says.");
|
||||
t.line(" Spring AI : %d requests, tool body ran %d time(s). Tool message the model received: %s", s1.requests(), s1.toolBodies(), s1.toolMessage());
|
||||
t.line(" %s", s1.result());
|
||||
t.line(" LangChain4j : %d requests, tool body ran %d time(s). Tool message the model received: %s", l1.requests(), l1.toolBodies(), l1.toolMessage());
|
||||
t.line(" %s", l1.result());
|
||||
t.blank();
|
||||
t.line("Case 2: an insistent model. It asks for the tool again after every result. The server stops it with an HTTP 500 after 30 requests.");
|
||||
t.line(" Spring AI : %d requests, tool body ran %d time(s)", s2.requests(), s2.toolBodies());
|
||||
t.line(" %s", s2.result());
|
||||
t.line(" LangChain4j : %d requests, tool body ran %d time(s)", l2.requests(), l2.toolBodies());
|
||||
t.line(" %s", l2.result());
|
||||
t.blank();
|
||||
t.line("Case 3: the same insistent model, each framework asked to stop after 5 tool rounds.");
|
||||
t.line(" Spring AI (ToolCallingAdvisor + our MaxToolRounds(5) checker): %d requests, tool body ran %d time(s)", s3.requests(), s3.toolBodies());
|
||||
t.line(" %s", s3.result());
|
||||
t.line(" LangChain4j (maxSequentialToolsInvocations(5)) : %d requests, tool body ran %d time(s)", l3.requests(), l3.toolBodies());
|
||||
t.line(" %s", l3.result());
|
||||
}
|
||||
assertThat(s1.requests()).isGreaterThan(0);
|
||||
assertThat(l1.requests()).isGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Writes a numbered transcript under {@code output/} and echoes it. Every console block in the article is one of these files. */
|
||||
final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user