Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion langchain4j-easy-rag-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

Expand Down
10 changes: 8 additions & 2 deletions langchain4j-elasticsearch-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
<packaging>jar</packaging>

<properties>
<!-- For tests only -->
<elastic.version>8.19.2</elastic.version>
<!-- You can run the tests using
# Elasticsearch Cloud
Expand All @@ -41,6 +40,13 @@
<artifactId>langchain4j-elasticsearch</artifactId>
</dependency>

<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<!-- Override elasticsearch version from the Spring Boot BOM -->
<version>${elastic.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
Expand Down Expand Up @@ -82,7 +88,7 @@

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>elasticsearch</artifactId>
<artifactId>testcontainers-elasticsearch</artifactId>
<scope>test</scope>
</dependency>

Expand Down
15 changes: 10 additions & 5 deletions langchain4j-http-client-spring-restclient/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>

<!-- test dependencies -->
Expand Down Expand Up @@ -58,17 +58,22 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty</artifactId>
<scope>test</scope>
</dependency>

<!-- Why "wiremock-jetty12" and not "wiremock": https://github.com/wiremock/wiremock/issues/2922 -->
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-jetty12</artifactId>
<version>3.10.0</version>
<groupId>org.wiremock.integrations</groupId>
<artifactId>wiremock-spring-boot</artifactId>
<version>4.1.0</version>
<scope>test</scope>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.langchain4j.http.client.spring.restclient;

import org.springframework.http.client.ClientHttpRequestFactory;

import java.time.Duration;

/**
* Strategy interface for creating a {@link ClientHttpRequestFactory} with the appropriate
* Spring Boot API, depending on the version available on the classpath.
*
* @see SpringBoot4HttpClientSettings
* @see SpringBoot3HttpClientSettings
*/
interface HttpClientSettingsStrategy {

ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dev.langchain4j.http.client.spring.restclient;

import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.http.client.ClientHttpRequestFactory;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.time.Duration;

/**
* The Spring Boot 3.5+ implementation uses reflection because Spring Boot 4 is no longer included in the langchain4J-spring classpath.
Copy link
Collaborator

@ThomasVitale ThomasVitale Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring Boot 3.5 will reach end of life in June and projects are encouraged to upgrade (see Support page). I would suggest considering if it's desireable to maintain 3.5 support here, even if not supported upstream. If it is, I would recommend considering publishing separate modules, as it's quite risky and complex to support both major version in the same artifact.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering that I see a lot of usage of LC4j-SB modules (and given that they support only 3.x), and it is growing, I think it is worth maintaining it untill we see a considerable drop in usage of these modules.

Also, as far as I see, enterprise support of 3.5.x is planned untill 2032.

Copy link
Contributor Author

@arey arey Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ThomasVitale @dliubarskyi
I think I might have found a solution to avoid duplicated all the Maven modules. I've tested it with langchain4j-openai-springboot-starter on https://github.com/spring-petclinic/spring-petclinic-langchain4j, which still uses Spring Boot 3.4.

My idea is to exclude all the Spring artefacts and allow the application client to decide which Spring Boot version to use. To do that, we could use the Maven <scope>provided</scope>
Exemple: pom-langchain4j-open-ai-spring-boot-starter.xml

Maven dependency tree on Langchain4j starter side:
dependency-tree-langchain4j-open-ai-spring-boot-starter.log

Maven dependency tree on the Spring Petclinic side:
dependency-tree-petclinic.log

Spring Petclinic startup log:

2026-03-07T11:04:28.725+01:00 DEBUG 13176 --- [           main] c.s.r.SpringBootHttpClientSettingsHelper : Detected Spring Boot major version 3

See complete logs: petclinic-logs.log

Existing Spring Boot 3.x applications should not be impacted because the ClientHttpRequestFactorySettings class belongs to the main spring-boot-3.x.jar.

Future Spring Boot 4 applications may have to add the spring-boot-http-client dependency (or spring-boot-restclient) in addition to the langchain4j-open-ai-spring-boot-starter dependency.

@dliubarskyi if you want to test, I could a go ahead and prepare a commit with the described solution.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dliubarskyi @ThomasVitale I have another idea for avoiding the duplication of all the Maven modules.
We could use two Maven profiles to build the project: sb3 and sb4.
Each profile contains a property with the Spring Boot version.
By default, we could target Spring Boot 4 (the sb4 profile). However, GitHub Actions could also build the project against the sb3 profile. We will have to run two Maven releases. Once for each profile.

If we take this approach, we must decide about a naming convention on the artefactId or the Maven version (or another proposal).
In the parent pom.xml:

<profile>
    <id>sb3</id>
    <properties>
        <langchain4j-spring-prefix>sb3</langchain4j-spring-prefix>
        <spring.boot.version>3.5.9</spring.boot.version>
    </properties>
</profile>

At the top of the Open AI Spring boot starter :

<artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
<name>LangChain4j Spring Boot starter for OpenAI</name>
<version>1.12.0-beta20-${langchain4j-spring-prefix}-SNAPSHOT</version>

or

<artifactId>langchain4j-open-ai-spring-boot-starter-${langchain4j-spring-prefix}</artifactId>
<name>LangChain4j Spring Boot starter for OpenAI</name>
<version>1.12.0-beta20-SNAPSHOT</version>

To avoid git tag conflict during the maven release, the first option is maybe the better.

With this second solution, I suppose we could have only 2 duplicated modules for the langchain4j-http-client-spring-restclient artefact, each for one major version of Spring Boot. No more introspection code.

*/
class SpringBoot3HttpClientSettings implements HttpClientSettingsStrategy {

private static final Class<?> DEPRECATED_SETTINGS_CLASS;
private static final Class<?> NEW_SETTINGS_CLASS;
private static final Field DEFAULTS_FIELD;
private static final Method WITH_CONNECT_TIMEOUT_METHOD;
private static final Method WITH_READ_TIMEOUT_METHOD;
private static final Method ADAPT_METHOD;
private static final Method BUILD_METHOD;

static {
try {
DEPRECATED_SETTINGS_CLASS = Class.forName("org.springframework.boot.web.client.ClientHttpRequestFactorySettings");
NEW_SETTINGS_CLASS = Class.forName("org.springframework.boot.http.client.ClientHttpRequestFactorySettings");

DEFAULTS_FIELD = DEPRECATED_SETTINGS_CLASS.getField("DEFAULTS");
WITH_CONNECT_TIMEOUT_METHOD = DEPRECATED_SETTINGS_CLASS.getMethod("withConnectTimeout", Duration.class);
WITH_READ_TIMEOUT_METHOD = DEPRECATED_SETTINGS_CLASS.getMethod("withReadTimeout", Duration.class);
ADAPT_METHOD = DEPRECATED_SETTINGS_CLASS.getDeclaredMethod("adapt");
ADAPT_METHOD.setAccessible(true);

ClientHttpRequestFactoryBuilder<?> builder = ClientHttpRequestFactoryBuilder.detect();
BUILD_METHOD = findBuildMethod(builder.getClass(), NEW_SETTINGS_CLASS);
BUILD_METHOD.setAccessible(true);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Failed to initialize SpringBoot3HttpClientSettings", e);
}
}

@Override
public ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout) {
try {
Object deprecatedSettings = DEFAULTS_FIELD.get(null);

if (connectTimeout != null) {
deprecatedSettings = WITH_CONNECT_TIMEOUT_METHOD.invoke(deprecatedSettings, connectTimeout);
}
if (readTimeout != null) {
deprecatedSettings = WITH_READ_TIMEOUT_METHOD.invoke(deprecatedSettings, readTimeout);
}

Object newSettings = ADAPT_METHOD.invoke(deprecatedSettings);
return (ClientHttpRequestFactory) BUILD_METHOD.invoke(ClientHttpRequestFactoryBuilder.detect(), newSettings);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Failed to create ClientHttpRequestFactory for Spring Boot 3.x", e);
}
}

private static Method findBuildMethod(Class<?> clazz, Class<?> newSettingsClass) throws NoSuchMethodException {
for (Method method : clazz.getMethods()) {
if ("build".equals(method.getName())
&& method.getParameterCount() == 1
&& method.getParameterTypes()[0].isAssignableFrom(newSettingsClass)) {
return method;
}
}
if (clazz.getSuperclass() != null) {
return findBuildMethod(clazz.getSuperclass(), newSettingsClass);
}
throw new NoSuchMethodException("No build method accepting " + newSettingsClass.getName());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dev.langchain4j.http.client.spring.restclient;

import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.http.client.ClientHttpRequestFactory;

import java.time.Duration;

class SpringBoot4HttpClientSettings implements HttpClientSettingsStrategy {

@Override
public ClientHttpRequestFactory createRequestFactory(Duration connectTimeout, Duration readTimeout) {
HttpClientSettings settings = HttpClientSettings.defaults();
if (connectTimeout != null) {
settings = settings.withConnectTimeout(connectTimeout);
}
if (readTimeout != null) {
settings = settings.withReadTimeout(readTimeout);
}
return ClientHttpRequestFactoryBuilder.detect()
.build(settings);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package dev.langchain4j.http.client.spring.restclient;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringBootVersion;
import org.springframework.http.client.ClientHttpRequestFactory;

import java.time.Duration;

/**
* Factory that detects the Spring Boot version on the classpath and delegates
* {@link ClientHttpRequestFactory} creation to the appropriate strategy.
*/
public final class SpringBootHttpClientSettingsHelper {

private static final Logger log = LoggerFactory.getLogger(SpringBootHttpClientSettingsHelper.class);

private static final HttpClientSettingsStrategy STRATEGY;

static {
String version = SpringBootVersion.getVersion();
int majorVersion = Integer.parseInt(version.split("\\.")[0]);
log.debug("Detected Spring Boot major version {}", majorVersion);

HttpClientSettingsStrategy detected;
if (majorVersion >= 4) {
detected = new SpringBoot4HttpClientSettings();
} else {
detected = new SpringBoot3HttpClientSettings();
}
STRATEGY = detected;
}

private SpringBootHttpClientSettingsHelper() {
}

public static ClientHttpRequestFactory createClientHttpRequestFactory(Duration connectTimeout, Duration readTimeout) {
return STRATEGY.createRequestFactory(connectTimeout, readTimeout);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
import dev.langchain4j.http.client.SuccessfulHttpResponse;
import dev.langchain4j.http.client.sse.ServerSentEventListener;
import dev.langchain4j.http.client.sse.ServerSentEventParser;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
Expand All @@ -22,8 +21,11 @@

import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static dev.langchain4j.http.client.spring.restclient.SpringBootHttpClientSettingsHelper.createClientHttpRequestFactory;
import static dev.langchain4j.http.client.sse.ServerSentEventListenerUtils.ignoringExceptions;
import static dev.langchain4j.internal.Utils.getOrDefault;

Expand All @@ -36,21 +38,17 @@ public SpringRestClient(SpringRestClientBuilder builder) {

RestClient.Builder restClientBuilder = getOrDefault(builder.restClientBuilder(), RestClient::builder);

ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults();
if (builder.connectTimeout() != null) {
settings = settings.withConnectTimeout(builder.connectTimeout());
}
if (builder.readTimeout() != null) {
settings = settings.withReadTimeout(builder.readTimeout());
}
ClientHttpRequestFactory clientHttpRequestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings);
ClientHttpRequestFactory clientHttpRequestFactory = createClientHttpRequestFactory(
builder.connectTimeout(),
builder.readTimeout()
);

this.delegate = restClientBuilder
.requestFactory(clientHttpRequestFactory)
.build();

this.streamingRequestExecutor = getOrDefault(builder.streamingRequestExecutor(), () -> {
if (builder.createDefaultStreamingRequestExecutor()) {
if (Boolean.TRUE.equals(builder.createDefaultStreamingRequestExecutor())) {
return createDefaultStreamingRequestExecutor();
} else {
return null;
Expand Down Expand Up @@ -78,7 +76,7 @@ public SuccessfulHttpResponse execute(HttpRequest request) throws HttpException

return SuccessfulHttpResponse.builder()
.statusCode(responseEntity.getStatusCode().value())
.headers(responseEntity.getHeaders())
.headers(toHeadersMap(responseEntity.getHeaders()))
.body(responseEntity.getBody())
.build();
} catch (RestClientResponseException e) {
Expand Down Expand Up @@ -111,7 +109,7 @@ public void execute(HttpRequest request, ServerSentEventParser parser, ServerSen

SuccessfulHttpResponse response = SuccessfulHttpResponse.builder()
.statusCode(statusCode)
.headers(springResponse.getHeaders())
.headers(toHeadersMap(springResponse.getHeaders()))
.build();
ignoringExceptions(() -> listener.onOpen(response));

Expand Down Expand Up @@ -161,4 +159,12 @@ private static MultiValueMap<String, Object> toMultiValueMap(Map<String, String>
}
return multipart;
}

private static Map<String, List<String>> toHeadersMap(HttpHeaders httpHeaders) {
return httpHeaders.headerSet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
));
}
}
2 changes: 1 addition & 1 deletion langchain4j-milvus-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>milvus</artifactId>
<artifactId>testcontainers-milvus</artifactId>
<scope>test</scope>
</dependency>

Expand Down
2 changes: 1 addition & 1 deletion langchain4j-ollama-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.AsyncTaskExecutor;
Expand All @@ -21,7 +20,11 @@

import static dev.langchain4j.ollama.spring.Properties.PREFIX;

@AutoConfiguration(after = RestClientAutoConfiguration.class)
@AutoConfiguration(afterName = {
"org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration", // Spring Boot 4 support
"org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration" // Spring Boot 3 support
}
)
@EnableConfigurationProperties(Properties.class)
public class AutoConfig {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.AsyncTaskExecutor;
Expand All @@ -21,7 +20,11 @@

import static dev.langchain4j.openai.spring.Properties.PREFIX;

@AutoConfiguration(after = RestClientAutoConfiguration.class)
@AutoConfiguration(afterName = {
"org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration", // Spring Boot 4 support
"org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration" // Spring Boot 3 support
}
)
@EnableConfigurationProperties(Properties.class)
public class AutoConfig {

Expand Down
2 changes: 1 addition & 1 deletion langchain4j-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<artifactId>spring-boot-starter-aspectj</artifactId>
<scope>test</scope>
</dependency>

Expand Down
2 changes: 1 addition & 1 deletion langchain4j-voyage-ai-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

Expand Down
Loading
Loading