diff --git a/README.md b/README.md index 025baf0..39e9fb3 100644 --- a/README.md +++ b/README.md @@ -23,5 +23,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur | [`providers/`](providers) | One ticket-summarising app on the real `OpenAiChatModel`, `AnthropicChatModel` and `GoogleGenAiChatModel` against a local server in all three wire formats: where each puts the system prompt and what it sends by default, portable versus provider-specific options (and the per-call `ChatOptions` that crashes two providers and silently resets the third), prompt caching, a cost table from the vendors' price sheets, and failover with the retry multiplication measured. No vendor API called; token counts and cache hits are simulated from documented rules; latency not measured. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Anthropic Claude vs OpenAI vs Gemini in Spring AI 2.0](https://ankurm.com/spring-ai-claude-vs-openai-vs-gemini-switch-providers-compare-cost/) | | [`llm-gateway/`](llm-gateway) | A gateway service on Spring AI 2.0: model-hint routing, failover under the tool-calling advisor (a provider failing mid tool loop does not re-run the tool), one circuit breaker per provider, dollar caps that reserve before the call, a token-per-hour limiter, and a tenant-keyed semantic cache. Real OpenAI and Anthropic models against a local fake; no vendor API called. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [The LLM Gateway Pattern for Java Microservices](https://ankurm.com/llm-gateway-pattern-java-microservices/) | | [`semantic-cache/`](semantic-cache) | A `CallAdvisor` that serves a stored answer when a new question embeds close to an old one, over a Redis Stack vector index and a real in-process embedding model (all-MiniLM-L6-v2). A labelled set of 30 intents x 4 phrasings plus 20 off-topic questions swept across thresholds (hit rate against wrong answers), 600 requests replayed with an illustrative cost model, and the traps reproduced: Spring AI's score is `(1 + cosine) / 2`, reset-password and reset-2FA are close enough to answer for each other, a tenant filter, expiry, and an index built for another embedding model. The chat model is a script that counts calls; one test uses a real local model. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Semantic Caching for LLM Calls in Spring Boot with Redis Vector Search](https://ankurm.com/semantic-caching-for-llm-calls-in-spring-boot-with-redis-vector-search/) | +| [`langchain4j-interop/`](langchain4j-interop) | The same order assistant in Spring AI 2.0.1 and LangChain4j 1.22.0 in one Spring Boot 4.1.1 app, against a local fake OpenAI server: the bean collision when both OpenAI starters are present, the two requests compared on the wire, a throwing tool and a looping model (what each framework bounds), the meters each records, one MCP tool server used from both, and which lines of the 2025 version of the article still compile. Java 25. | [Spring AI vs LangChain4j in 2026: Is the Pairing Still Worth It?](https://ankurm.com/spring-ai-langchain4j-production-ai-microservices-java/) | Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/). diff --git a/langchain4j-interop/.gitignore b/langchain4j-interop/.gitignore new file mode 100644 index 0000000..b1c13f4 --- /dev/null +++ b/langchain4j-interop/.gitignore @@ -0,0 +1,2 @@ +target/ +*.log diff --git a/langchain4j-interop/README.md b/langchain4j-interop/README.md new file mode 100644 index 0000000..7630d6d --- /dev/null +++ b/langchain4j-interop/README.md @@ -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. diff --git a/langchain4j-interop/output/01-both-starters.txt b/langchain4j-interop/output/01-both-starters.txt new file mode 100644 index 0000000..3b47b4d --- /dev/null +++ b/langchain4j-interop/output/01-both-starters.txt @@ -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 diff --git a/langchain4j-interop/output/02-same-assistant.txt b/langchain4j-interop/output/02-same-assistant.txt new file mode 100644 index 0000000..404cef9 --- /dev/null +++ b/langchain4j-interop/output/02-same-assistant.txt @@ -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"]} diff --git a/langchain4j-interop/output/03-tool-throws.txt b/langchain4j-interop/output/03-tool-throws.txt new file mode 100644 index 0000000..cfe62f5 --- /dev/null +++ b/langchain4j-interop/output/03-tool-throws.txt @@ -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) diff --git a/langchain4j-interop/output/04-observability.txt b/langchain4j-interop/output/04-observability.txt new file mode 100644 index 0000000..d185fd6 --- /dev/null +++ b/langchain4j-interop/output/04-observability.txt @@ -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 diff --git a/langchain4j-interop/output/05-mcp-first.txt b/langchain4j-interop/output/05-mcp-first.txt new file mode 100644 index 0000000..f8fbc57 --- /dev/null +++ b/langchain4j-interop/output/05-mcp-first.txt @@ -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. diff --git a/langchain4j-interop/output/06-old-code-vs-current.txt b/langchain4j-interop/output/06-old-code-vs-current.txt new file mode 100644 index 0000000..2066cc6 --- /dev/null +++ b/langchain4j-interop/output/06-old-code-vs-current.txt @@ -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 +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 diff --git a/langchain4j-interop/pom.xml b/langchain4j-interop/pom.xml new file mode 100644 index 0000000..5ff855a --- /dev/null +++ b/langchain4j-interop/pom.xml @@ -0,0 +1,124 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + langchain4j-interop + 1.0.0 + langchain4j-interop + 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. + + + 25 + 2.0.1 + 1.22.0 + 1.22.0-beta32 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.ai + spring-ai-starter-model-openai + + + org.springframework.ai + spring-ai-starter-mcp-client + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + + dev.langchain4j + langchain4j-open-ai-spring-boot4-starter + ${langchain4j.beta.version} + + + dev.langchain4j + langchain4j-spring-boot4-starter + ${langchain4j.beta.version} + + + dev.langchain4j + langchain4j-http-client-jdk + + + dev.langchain4j + langchain4j-mcp + ${langchain4j.beta.version} + + + dev.langchain4j + langchain4j-micrometer-metrics + ${langchain4j.beta.version} + + + + org.springframework.ai + spring-ai-vector-store + + + + org.springframework.ai + spring-ai-pdf-document-reader + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/langchain4j-interop/scripts/compile-old-snippets.sh b/langchain4j-interop/scripts/compile-old-snippets.sh new file mode 100755 index 0000000..2e1b032 --- /dev/null +++ b/langchain4j-interop/scripts/compile-old-snippets.sh @@ -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 '[^<]*' | 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" diff --git a/langchain4j-interop/scripts/run-all.sh b/langchain4j-interop/scripts/run-all.sh new file mode 100755 index 0000000..e0185c3 --- /dev/null +++ b/langchain4j-interop/scripts/run-all.sh @@ -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 diff --git a/langchain4j-interop/src/broken/OldCallAroundAdvisor.java b/langchain4j-interop/src/broken/OldCallAroundAdvisor.java new file mode 100644 index 0000000..1373a08 --- /dev/null +++ b/langchain4j-interop/src/broken/OldCallAroundAdvisor.java @@ -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; } +} diff --git a/langchain4j-interop/src/broken/OldChatService.java b/langchain4j-interop/src/broken/OldChatService.java new file mode 100644 index 0000000..dcb9202 --- /dev/null +++ b/langchain4j-interop/src/broken/OldChatService.java @@ -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 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 issues, List suggestions, int score) { } +} diff --git a/langchain4j-interop/src/broken/OldDocumentGetContent.java b/langchain4j-interop/src/broken/OldDocumentGetContent.java new file mode 100644 index 0000000..42d3c13 --- /dev/null +++ b/langchain4j-interop/src/broken/OldDocumentGetContent.java @@ -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(); + } +} diff --git a/langchain4j-interop/src/broken/OldLangChain4jBuilder.java b/langchain4j-interop/src/broken/OldLangChain4jBuilder.java new file mode 100644 index 0000000..b712077 --- /dev/null +++ b/langchain4j-interop/src/broken/OldLangChain4jBuilder.java @@ -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(); + } +} diff --git a/langchain4j-interop/src/broken/OldMessageGetContent.java b/langchain4j-interop/src/broken/OldMessageGetContent.java new file mode 100644 index 0000000..fad4ac6 --- /dev/null +++ b/langchain4j-interop/src/broken/OldMessageGetContent.java @@ -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(); + } +} diff --git a/langchain4j-interop/src/broken/OldMeterRegistryCustomizer.java b/langchain4j-interop/src/broken/OldMeterRegistryCustomizer.java new file mode 100644 index 0000000..f41f173 --- /dev/null +++ b/langchain4j-interop/src/broken/OldMeterRegistryCustomizer.java @@ -0,0 +1,6 @@ +import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; + +// From the original ObservabilityConfig. +class OldMeterRegistryCustomizer { + MeterRegistryCustomizer unused; +} diff --git a/langchain4j-interop/src/broken/OldPdfConfig.java b/langchain4j-interop/src/broken/OldPdfConfig.java new file mode 100644 index 0000000..e8f883f --- /dev/null +++ b/langchain4j-interop/src/broken/OldPdfConfig.java @@ -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(); + } +} diff --git a/langchain4j-interop/src/broken/OldSearchRequest.java b/langchain4j-interop/src/broken/OldSearchRequest.java new file mode 100644 index 0000000..bb80dea --- /dev/null +++ b/langchain4j-interop/src/broken/OldSearchRequest.java @@ -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); + } +} diff --git a/langchain4j-interop/src/broken/OldTokenTextSplitter.java b/langchain4j-interop/src/broken/OldTokenTextSplitter.java new file mode 100644 index 0000000..b92d605 --- /dev/null +++ b/langchain4j-interop/src/broken/OldTokenTextSplitter.java @@ -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); +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/Lc4jTools.java b/langchain4j-interop/src/main/java/com/ankurm/interop/Lc4jTools.java new file mode 100644 index 0000000..7521d6e --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/Lc4jTools.java @@ -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); + } +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/MaxToolRounds.java b/langchain4j-interop/src/main/java/com/ankurm/interop/MaxToolRounds.java new file mode 100644 index 0000000..03e6d59 --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/MaxToolRounds.java @@ -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; + } +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/OrderService.java b/langchain4j-interop/src/main/java/com/ankurm/interop/OrderService.java new file mode 100644 index 0000000..351e8ee --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/OrderService.java @@ -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 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 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); + } +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/SpringAiTools.java b/langchain4j-interop/src/main/java/com/ankurm/interop/SpringAiTools.java new file mode 100644 index 0000000..fb6e058 --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/SpringAiTools.java @@ -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); + } +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/Support.java b/langchain4j-interop/src/main/java/com/ankurm/interop/Support.java new file mode 100644 index 0000000..e474d6c --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/Support.java @@ -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); +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/TracingChatInterceptor.java b/langchain4j-interop/src/main/java/com/ankurm/interop/TracingChatInterceptor.java new file mode 100644 index 0000000..5276ab6 --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/TracingChatInterceptor.java @@ -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(); + } +} diff --git a/langchain4j-interop/src/main/java/com/ankurm/interop/Wiring.java b/langchain4j-interop/src/main/java/com/ankurm/interop/Wiring.java new file mode 100644 index 0000000..32288c6 --- /dev/null +++ b/langchain4j-interop/src/main/java/com/ankurm/interop/Wiring.java @@ -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(); + } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/BothStartersTest.java b/langchain4j-interop/src/test/java/com/ankurm/interop/BothStartersTest.java new file mode 100644 index 0000000..91a9198 --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/BothStartersTest.java @@ -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 beans) { } + + static Outcome start(FakeOpenAi fake, String... extra) { + List 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 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 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"); + } + } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/FakeOpenAi.java b/langchain4j-interop/src/test/java/com/ankurm/interop/FakeOpenAi.java new file mode 100644 index 0000000..45f3bec --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/FakeOpenAi.java @@ -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 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); } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/McpFirstTest.java b/langchain4j-interop/src/test/java/com/ankurm/interop/McpFirstTest.java new file mode 100644 index 0000000..8ae938b --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/McpFirstTest.java @@ -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(); + } + } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/ObservabilityTest.java b/langchain4j-interop/src/test/java/com/ankurm/interop/ObservabilityTest.java new file mode 100644 index 0000000..45984dc --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/ObservabilityTest.java @@ -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 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 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(); + } + } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/SameAssistantTest.java b/langchain4j-interop/src/test/java/com/ankurm/interop/SameAssistantTest.java new file mode 100644 index 0000000..c95fd5b --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/SameAssistantTest.java @@ -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 summarise(String body) { + JsonNode req = JSON.readTree(body); + List out = new ArrayList<>(); + TreeSet 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 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 springBodies = new ArrayList<>(fake.bodies); + fake.reset(); + String lcAnswer = Wiring.lc4jAssistant(fake.url(), lcOrders).chat("Where is ORD-1001?"); + List 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); + } + } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/ToolFailureTest.java b/langchain4j-interop/src/test/java/com/ankurm/interop/ToolFailureTest.java new file mode 100644 index 0000000..c95530e --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/ToolFailureTest.java @@ -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 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); + } + } +} diff --git a/langchain4j-interop/src/test/java/com/ankurm/interop/Transcript.java b/langchain4j-interop/src/test/java/com/ankurm/interop/Transcript.java new file mode 100644 index 0000000..7321a4c --- /dev/null +++ b/langchain4j-interop/src/test/java/com/ankurm/interop/Transcript.java @@ -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); + } +}