Skip to content

Commit 6457787

Browse files
committed
GH-3379: Spring Framework 7.x compatibility
- Use only Spring Framework APIs available both in 6.x and 7.x branches - Add constructors without logic to ``*Api` classes in `models` modules to simplify extensibility; effective final fields are marked as final - Kotlin 2.x support; use kotlin compiler version 2.2.21 - Update MCP SDK to 0.15.0 - Update MCP Annotations to 0.6.0 Signed-off-by: Dmitry Bedrin <[email protected]>
1 parent 3cf3db1 commit 6457787

File tree

16 files changed

+177
-43
lines changed

16 files changed

+177
-43
lines changed

auto-configurations/common/spring-ai-autoconfigure-retry/src/main/java/org/springframework/ai/retry/autoconfigure/SpringAiRetryAutoConfiguration.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.ai.retry.autoconfigure;
1818

1919
import java.io.IOException;
20+
import java.net.URI;
2021
import java.nio.charset.StandardCharsets;
2122

2223
import org.slf4j.Logger;
@@ -30,6 +31,7 @@
3031
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
3132
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3233
import org.springframework.context.annotation.Bean;
34+
import org.springframework.http.HttpMethod;
3335
import org.springframework.http.client.ClientHttpResponse;
3436
import org.springframework.lang.NonNull;
3537
import org.springframework.retry.RetryCallback;
@@ -87,6 +89,12 @@ public boolean hasError(@NonNull ClientHttpResponse response) throws IOException
8789
}
8890

8991
@Override
92+
public void handleError(@NonNull URI url, @NonNull HttpMethod method, @NonNull ClientHttpResponse response)
93+
throws IOException {
94+
handleError(response);
95+
}
96+
97+
@SuppressWarnings("removal")
9098
public void handleError(@NonNull ClientHttpResponse response) throws IOException {
9199
if (!response.getStatusCode().isError()) {
92100
return;

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,20 @@ private AnthropicApi(String baseUrl, String completionsPath, ApiKey anthropicApi
148148
.build();
149149
}
150150

151+
/**
152+
* Create a new client api.
153+
* @param completionsPath path to append to the base URL.
154+
* @param restClient RestClient instance.
155+
* @param webClient WebClient instance.
156+
* @param apiKey Anthropic api Key.
157+
*/
158+
public AnthropicApi(String completionsPath, RestClient restClient, WebClient webClient, ApiKey apiKey) {
159+
this.completionsPath = completionsPath;
160+
this.restClient = restClient;
161+
this.webClient = webClient;
162+
this.apiKey = apiKey;
163+
}
164+
151165
/**
152166
* Creates a model response for the given chat conversation.
153167
* @param chatRequest The chat completion request.
@@ -176,7 +190,7 @@ public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletio
176190
return this.restClient.post()
177191
.uri(this.completionsPath)
178192
.headers(headers -> {
179-
headers.addAll(additionalHttpHeader);
193+
headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader));
180194
addDefaultHeadersIfMissing(headers);
181195
})
182196
.body(chatRequest)
@@ -217,7 +231,7 @@ public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest c
217231
return this.webClient.post()
218232
.uri(this.completionsPath)
219233
.headers(headers -> {
220-
headers.addAll(additionalHttpHeader);
234+
headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader));
221235
addDefaultHeadersIfMissing(headers);
222236
}) // @formatter:off
223237
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
@@ -256,7 +270,7 @@ public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest c
256270
}
257271

258272
private void addDefaultHeadersIfMissing(HttpHeaders headers) {
259-
if (!headers.containsKey(HEADER_X_API_KEY)) {
273+
if (null == headers.getFirst(HEADER_X_API_KEY)) {
260274
String apiKeyValue = this.apiKey.getValue();
261275
if (StringUtils.hasText(apiKeyValue)) {
262276
headers.add(HEADER_X_API_KEY, apiKeyValue);

models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/api/DeepSeekApi.java

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public class DeepSeekApi {
6767

6868
private final WebClient webClient;
6969

70-
private DeepSeekStreamFunctionCallingHelper chunkMerger = new DeepSeekStreamFunctionCallingHelper();
70+
private final DeepSeekStreamFunctionCallingHelper chunkMerger = new DeepSeekStreamFunctionCallingHelper();
7171

7272
/**
7373
* Create a new chat completion api.
@@ -90,21 +90,39 @@ public DeepSeekApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String>
9090

9191
this.completionsPath = completionsPath;
9292
this.betaPrefixPath = betaPrefixPath;
93-
// @formatter:off
93+
9494
Consumer<HttpHeaders> finalHeaders = h -> {
9595
h.setBearerAuth(apiKey.getValue());
9696
h.setContentType(MediaType.APPLICATION_JSON);
97-
h.addAll(headers);
97+
h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
9898
};
9999
this.restClient = restClientBuilder.baseUrl(baseUrl)
100100
.defaultHeaders(finalHeaders)
101101
.defaultStatusHandler(responseErrorHandler)
102102
.build();
103103

104-
this.webClient = webClientBuilder
105-
.baseUrl(baseUrl)
106-
.defaultHeaders(finalHeaders)
107-
.build(); // @formatter:on
104+
this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(finalHeaders).build();
105+
106+
}
107+
108+
/**
109+
* Create a new chat completion api.
110+
* @param completionsPath the path to the chat completions endpoint.
111+
* @param betaPrefixPath the prefix path to the beta feature endpoint.
112+
* @param restClient RestClient instance.
113+
* @param webClient WebClient instance.
114+
*/
115+
public DeepSeekApi(String completionsPath, String betaPrefixPath, RestClient restClient, WebClient webClient) {
116+
117+
Assert.hasText(completionsPath, "Completions Path must not be null");
118+
Assert.hasText(betaPrefixPath, "Beta feature path must not be null");
119+
Assert.notNull(restClient, "RestClient must not be null");
120+
Assert.notNull(webClient, "WebClient must not be null");
121+
122+
this.completionsPath = completionsPath;
123+
this.betaPrefixPath = betaPrefixPath;
124+
this.restClient = restClient;
125+
this.webClient = webClient;
108126
}
109127

110128
/**
@@ -153,7 +171,7 @@ public Flux<ChatCompletionChunk> chatCompletionStream(ChatCompletionRequest chat
153171

154172
return this.webClient.post()
155173
.uri(this.getEndpoint(chatRequest))
156-
.headers(headers -> headers.addAll(additionalHttpHeader))
174+
.headers(headers -> headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader)))
157175
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
158176
.retrieve()
159177
.bodyToFlux(String.class)

models/spring-ai-elevenlabs/src/main/java/org/springframework/ai/elevenlabs/api/ElevenLabsApi.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ private ElevenLabsApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
7070
if (!(apiKey instanceof NoopApiKey)) {
7171
h.set("xi-api-key", apiKey.getValue());
7272
}
73-
h.addAll(headers);
73+
h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
7474
h.setContentType(MediaType.APPLICATION_JSON);
7575
};
7676

@@ -82,6 +82,16 @@ private ElevenLabsApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
8282
this.webClient = webClientBuilder.baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build();
8383
}
8484

85+
/**
86+
* Create a new ElevenLabs API client.
87+
* @param restClient Spring RestClient instance.
88+
* @param webClient Spring WebClient instance.
89+
*/
90+
public ElevenLabsApi(RestClient restClient, WebClient webClient) {
91+
this.restClient = restClient;
92+
this.webClient = webClient;
93+
}
94+
8595
public static Builder builder() {
8696
return new Builder();
8797
}

models/spring-ai-elevenlabs/src/main/java/org/springframework/ai/elevenlabs/api/ElevenLabsVoicesApi.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public ElevenLabsVoicesApi(String baseUrl, ApiKey apiKey, MultiValueMap<String,
6262
if (!(apiKey instanceof NoopApiKey)) {
6363
h.set("xi-api-key", apiKey.getValue());
6464
}
65-
h.addAll(headers);
65+
h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
6666
h.setContentType(MediaType.APPLICATION_JSON);
6767
};
6868

@@ -73,6 +73,14 @@ public ElevenLabsVoicesApi(String baseUrl, ApiKey apiKey, MultiValueMap<String,
7373

7474
}
7575

76+
/**
77+
* Create a new ElevenLabs Voices API client.
78+
* @param restClient Spring RestClient instance.
79+
*/
80+
public ElevenLabsVoicesApi(RestClient restClient) {
81+
this.restClient = restClient;
82+
}
83+
7684
public static Builder builder() {
7785
return new Builder();
7886
}

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public static Builder builder() {
114114

115115
private final WebClient webClient;
116116

117-
private OpenAiStreamFunctionCallingHelper chunkMerger = new OpenAiStreamFunctionCallingHelper();
117+
private final OpenAiStreamFunctionCallingHelper chunkMerger = new OpenAiStreamFunctionCallingHelper();
118118

119119
/**
120120
* Create a new chat completion api.
@@ -145,7 +145,7 @@ public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> he
145145
Consumer<HttpHeaders> finalHeaders = h -> {
146146
h.setContentType(MediaType.APPLICATION_JSON);
147147
h.set(HTTP_USER_AGENT_HEADER, SPRING_AI_USER_AGENT);
148-
h.addAll(headers);
148+
h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
149149
};
150150
this.restClient = restClientBuilder.clone()
151151
.baseUrl(baseUrl)
@@ -159,6 +159,30 @@ public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> he
159159
.build(); // @formatter:on
160160
}
161161

162+
/**
163+
* Create a new chat completion api.
164+
* @param baseUrl api base URL.
165+
* @param apiKey OpenAI apiKey.
166+
* @param headers the http headers to use.
167+
* @param completionsPath the path to the chat completions endpoint.
168+
* @param embeddingsPath the path to the embeddings endpoint.
169+
* @param restClient RestClient instance.
170+
* @param webClient WebClient instance.
171+
* @param responseErrorHandler Response error handler.
172+
*/
173+
public OpenAiApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> headers, String completionsPath,
174+
String embeddingsPath, ResponseErrorHandler responseErrorHandler, RestClient restClient,
175+
WebClient webClient) {
176+
this.baseUrl = baseUrl;
177+
this.apiKey = apiKey;
178+
this.headers = headers;
179+
this.completionsPath = completionsPath;
180+
this.embeddingsPath = embeddingsPath;
181+
this.responseErrorHandler = responseErrorHandler;
182+
this.restClient = restClient;
183+
this.webClient = webClient;
184+
}
185+
162186
/**
163187
* Returns a string containing all text values from the given media content list. Only
164188
* elements of type "text" are processed and concatenated in order.
@@ -204,7 +228,7 @@ public ResponseEntity<ChatCompletion> chatCompletionEntity(ChatCompletionRequest
204228
return this.restClient.post()
205229
.uri(this.completionsPath)
206230
.headers(headers -> {
207-
headers.addAll(additionalHttpHeader);
231+
headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader));
208232
addDefaultHeadersIfMissing(headers);
209233
})
210234
.body(chatRequest)
@@ -243,7 +267,7 @@ public Flux<ChatCompletionChunk> chatCompletionStream(ChatCompletionRequest chat
243267
return this.webClient.post()
244268
.uri(this.completionsPath)
245269
.headers(headers -> {
246-
headers.addAll(additionalHttpHeader);
270+
headers.addAll(HttpHeaders.readOnlyHttpHeaders(additionalHttpHeader));
247271
addDefaultHeadersIfMissing(headers);
248272
}) // @formatter:on
249273
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
@@ -328,7 +352,7 @@ public <T> ResponseEntity<EmbeddingList<Embedding>> embeddings(EmbeddingRequest<
328352
}
329353

330354
private void addDefaultHeadersIfMissing(HttpHeaders headers) {
331-
if (!headers.containsKey(HttpHeaders.AUTHORIZATION) && !(this.apiKey instanceof NoopApiKey)) {
355+
if (null == headers.getFirst(HttpHeaders.AUTHORIZATION) && !(this.apiKey instanceof NoopApiKey)) {
332356
headers.setBearerAuth(this.apiKey.getValue());
333357
}
334358
}

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiAudioApi.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public OpenAiAudioApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
7373
RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder,
7474
ResponseErrorHandler responseErrorHandler) {
7575

76-
Consumer<HttpHeaders> authHeaders = h -> h.addAll(headers);
76+
Consumer<HttpHeaders> authHeaders = h -> h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
7777

7878
// @formatter:off
7979
this.restClient = restClientBuilder.clone()
@@ -98,6 +98,16 @@ public OpenAiAudioApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
9898
.build(); // @formatter:on
9999
}
100100

101+
/**
102+
* Create a new audio api.
103+
* @param restClient RestClient instance.
104+
* @param webClient WebClient instance.
105+
*/
106+
public OpenAiAudioApi(RestClient restClient, WebClient webClient) {
107+
this.restClient = restClient;
108+
this.webClient = webClient;
109+
}
110+
101111
public static Builder builder() {
102112
return new Builder();
103113
}

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiFileApi.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public class OpenAiFileApi {
5151

5252
public OpenAiFileApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> headers,
5353
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
54-
Consumer<HttpHeaders> authHeaders = h -> h.addAll(headers);
54+
Consumer<HttpHeaders> authHeaders = h -> h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
5555

5656
this.restClient = restClientBuilder.clone()
5757
.baseUrl(baseUrl)
@@ -65,6 +65,10 @@ public OpenAiFileApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String
6565
.build();
6666
}
6767

68+
public OpenAiFileApi(RestClient restClient) {
69+
this.restClient = restClient;
70+
}
71+
6872
public static Builder builder() {
6973
return new Builder();
7074
}

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiImageApi.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public OpenAiImageApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
6868
.baseUrl(baseUrl)
6969
.defaultHeaders(h -> {
7070
h.setContentType(MediaType.APPLICATION_JSON);
71-
h.addAll(headers);
71+
h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
7272
})
7373
.defaultStatusHandler(responseErrorHandler)
7474
.defaultRequest(requestHeadersSpec -> {
@@ -82,6 +82,16 @@ public OpenAiImageApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, Strin
8282
this.imagesPath = imagesPath;
8383
}
8484

85+
/**
86+
* Create a new OpenAI Image API with the provided rest client.
87+
* @param restClient the rest client instance to use.
88+
* @param imagesPath the images path to use.
89+
*/
90+
public OpenAiImageApi(RestClient restClient, String imagesPath) {
91+
this.restClient = restClient;
92+
this.imagesPath = imagesPath;
93+
}
94+
8595
public ResponseEntity<OpenAiImageResponse> createImage(OpenAiImageRequest openAiImageRequest) {
8696
Assert.notNull(openAiImageRequest, "Image request cannot be null.");
8797
Assert.hasLength(openAiImageRequest.prompt(), "Prompt cannot be empty.");

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiModerationApi.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
2020
import com.fasterxml.jackson.annotation.JsonInclude;
2121
import com.fasterxml.jackson.annotation.JsonProperty;
22-
import com.fasterxml.jackson.databind.DeserializationFeature;
23-
import com.fasterxml.jackson.databind.ObjectMapper;
2422

2523
import org.springframework.ai.model.ApiKey;
2624
import org.springframework.ai.model.NoopApiKey;
@@ -49,12 +47,8 @@ public class OpenAiModerationApi {
4947

5048
public static final String DEFAULT_MODERATION_MODEL = "omni-moderation-latest";
5149

52-
private static final String DEFAULT_BASE_URL = "https://api.openai.com";
53-
5450
private final RestClient restClient;
5551

56-
private final ObjectMapper objectMapper;
57-
5852
/**
5953
* Create a new OpenAI Moderation API with the provided base URL.
6054
* @param baseUrl the base URL for the OpenAI API.
@@ -64,14 +58,12 @@ public class OpenAiModerationApi {
6458
public OpenAiModerationApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> headers,
6559
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
6660

67-
this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
68-
6961
// @formatter:off
7062
this.restClient = restClientBuilder.clone()
7163
.baseUrl(baseUrl)
7264
.defaultHeaders(h -> {
7365
h.setContentType(MediaType.APPLICATION_JSON);
74-
h.addAll(headers);
66+
h.addAll(HttpHeaders.readOnlyHttpHeaders(headers));
7567
})
7668
.defaultStatusHandler(responseErrorHandler)
7769
.defaultRequest(requestHeadersSpec -> {
@@ -82,6 +74,14 @@ public OpenAiModerationApi(String baseUrl, ApiKey apiKey, MultiValueMap<String,
8274
.build(); // @formatter:on
8375
}
8476

77+
/**
78+
* Create a new OpenAI Moderation API with the provided rest client.
79+
* @param restClient the rest client instance to use.
80+
*/
81+
public OpenAiModerationApi(RestClient restClient) {
82+
this.restClient = restClient;
83+
}
84+
8585
public ResponseEntity<OpenAiModerationResponse> createModeration(OpenAiModerationRequest openAiModerationRequest) {
8686
Assert.notNull(openAiModerationRequest, "Moderation request cannot be null.");
8787
Assert.hasLength(openAiModerationRequest.prompt(), "Prompt cannot be empty.");

0 commit comments

Comments
 (0)