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:
Claude
2026-10-11 10:09:45 +00:00
parent d51ef56468
commit e79b75a105
35 changed files with 1330 additions and 0 deletions
@@ -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);
}
}