Skip to content

Commit 041d94f

Browse files
authored
feat(anthropic): add support for custom HTTP headers in Anthropic API requests (#2343)
Add the ability to specify custom HTTP headers for Anthropic API requests through AnthropicChatOptions. This allows users to override or add headers for authentication, tracking, or other API-specific requirements. - Add httpHeaders field to AnthropicChatOptions with appropriate getters/setters - Implement header merging between default and runtime options - Update AnthropicApi to accept additional HTTP headers in API calls - Add integration test demonstrating API key override via custom headers - Update documentation with the new configuration property Resolves #2335 Signed-off-by: Christian Tzolov <[email protected]>
1 parent 6cb15e4 commit 041d94f

File tree

5 files changed

+157
-4
lines changed

5 files changed

+157
-4
lines changed

Diff for: models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java

+26-4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.util.ArrayList;
2020
import java.util.Base64;
21+
import java.util.HashMap;
2122
import java.util.List;
2223
import java.util.Map;
2324
import java.util.Set;
@@ -78,6 +79,7 @@
7879
import org.springframework.retry.support.RetryTemplate;
7980
import org.springframework.util.Assert;
8081
import org.springframework.util.CollectionUtils;
82+
import org.springframework.util.MultiValueMap;
8183
import org.springframework.util.StringUtils;
8284

8385
/**
@@ -273,8 +275,8 @@ public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespons
273275
this.observationRegistry)
274276
.observe(() -> {
275277

276-
ResponseEntity<ChatCompletionResponse> completionEntity = this.retryTemplate
277-
.execute(ctx -> this.anthropicApi.chatCompletionEntity(request));
278+
ResponseEntity<ChatCompletionResponse> completionEntity = this.retryTemplate.execute(
279+
ctx -> this.anthropicApi.chatCompletionEntity(request, this.getAdditionalHttpHeaders(prompt)));
278280

279281
AnthropicApi.ChatCompletionResponse completionResponse = completionEntity.getBody();
280282
AnthropicApi.Usage usage = completionResponse.usage();
@@ -338,7 +340,8 @@ public Flux<ChatResponse> internalStream(Prompt prompt, ChatResponse previousCha
338340

339341
observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start();
340342

341-
Flux<ChatCompletionResponse> response = this.anthropicApi.chatCompletionStream(request);
343+
Flux<ChatCompletionResponse> response = this.anthropicApi.chatCompletionStream(request,
344+
this.getAdditionalHttpHeaders(prompt));
342345

343346
// @formatter:off
344347
Flux<ChatResponse> chatResponseFlux = response.switchMap(chatCompletionResponse -> {
@@ -462,6 +465,16 @@ else if (mimeType.contains("pdf")) {
462465
+ ". Supported types are: images (image/*) and PDF documents (application/pdf)");
463466
}
464467

468+
private MultiValueMap<String, String> getAdditionalHttpHeaders(Prompt prompt) {
469+
470+
Map<String, String> headers = new HashMap<>(this.defaultOptions.getHttpHeaders());
471+
if (prompt.getOptions() != null && prompt.getOptions() instanceof AnthropicChatOptions chatOptions) {
472+
headers.putAll(chatOptions.getHttpHeaders());
473+
}
474+
return CollectionUtils.toMultiValueMap(
475+
headers.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> List.of(e.getValue()))));
476+
}
477+
465478
Prompt buildRequestPrompt(Prompt prompt) {
466479
// Process runtime options
467480
AnthropicChatOptions runtimeOptions = null;
@@ -487,6 +500,8 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp
487500
// Merge @JsonIgnore-annotated options explicitly since they are ignored by
488501
// Jackson, used by ModelOptionsUtils.
489502
if (runtimeOptions != null) {
503+
requestOptions.setHttpHeaders(
504+
mergeHttpHeaders(runtimeOptions.getHttpHeaders(), this.defaultOptions.getHttpHeaders()));
490505
requestOptions.setInternalToolExecutionEnabled(
491506
ModelOptionsUtils.mergeOption(runtimeOptions.isInternalToolExecutionEnabled(),
492507
this.defaultOptions.isInternalToolExecutionEnabled()));
@@ -498,8 +513,8 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp
498513
this.defaultOptions.getToolContext()));
499514
}
500515
else {
516+
requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders());
501517
requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.isInternalToolExecutionEnabled());
502-
503518
requestOptions.setToolNames(this.defaultOptions.getToolNames());
504519
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
505520
requestOptions.setToolContext(this.defaultOptions.getToolContext());
@@ -510,6 +525,13 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp
510525
return new Prompt(prompt.getInstructions(), requestOptions);
511526
}
512527

528+
private Map<String, String> mergeHttpHeaders(Map<String, String> runtimeHttpHeaders,
529+
Map<String, String> defaultHttpHeaders) {
530+
var mergedHttpHeaders = new HashMap<>(defaultHttpHeaders);
531+
mergedHttpHeaders.putAll(runtimeHttpHeaders);
532+
return mergedHttpHeaders;
533+
}
534+
513535
ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
514536

515537
List<AnthropicMessage> userMessages = prompt.getInstructions()

Diff for: models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java

+22
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ public class AnthropicChatOptions implements ToolCallingChatOptions {
8080
@JsonIgnore
8181
private Map<String, Object> toolContext = new HashMap<>();
8282

83+
84+
/**
85+
* Optional HTTP headers to be added to the chat completion request.
86+
*/
87+
@JsonIgnore
88+
private Map<String, String> httpHeaders = new HashMap<>();
89+
8390
// @formatter:on
8491

8592
public static Builder builder() {
@@ -98,6 +105,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
98105
.toolNames(fromOptions.getToolNames())
99106
.internalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled())
100107
.toolContext(fromOptions.getToolContext())
108+
.httpHeaders(fromOptions.getHttpHeaders())
101109
.build();
102110
}
103111

@@ -270,6 +278,15 @@ public void setToolContext(Map<String, Object> toolContext) {
270278
this.toolContext = toolContext;
271279
}
272280

281+
@JsonIgnore
282+
public Map<String, String> getHttpHeaders() {
283+
return httpHeaders;
284+
}
285+
286+
public void setHttpHeaders(Map<String, String> httpHeaders) {
287+
this.httpHeaders = httpHeaders;
288+
}
289+
273290
@Override
274291
public AnthropicChatOptions copy() {
275292
return fromOptions(this);
@@ -380,6 +397,11 @@ public Builder toolContext(Map<String, Object> toolContext) {
380397
return this;
381398
}
382399

400+
public Builder httpHeaders(Map<String, String> httpHeaders) {
401+
this.options.setHttpHeaders(httpHeaders);
402+
return this;
403+
}
404+
383405
public AnthropicChatOptions build() {
384406
return this.options;
385407
}

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

+31
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import reactor.core.publisher.Flux;
3333
import reactor.core.publisher.Mono;
3434

35+
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse;
3536
import org.springframework.ai.anthropic.api.StreamHelper.ChatCompletionResponseBuilder;
3637
import org.springframework.ai.model.ChatModelDescription;
3738
import org.springframework.ai.model.ModelOptionsUtils;
@@ -42,6 +43,8 @@
4243
import org.springframework.http.MediaType;
4344
import org.springframework.http.ResponseEntity;
4445
import org.springframework.util.Assert;
46+
import org.springframework.util.LinkedMultiValueMap;
47+
import org.springframework.util.MultiValueMap;
4548
import org.springframework.util.StringUtils;
4649
import org.springframework.web.client.ResponseErrorHandler;
4750
import org.springframework.web.client.RestClient;
@@ -157,12 +160,26 @@ public AnthropicApi(String baseUrl, String anthropicApiKey, String anthropicVers
157160
* status code and headers.
158161
*/
159162
public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletionRequest chatRequest) {
163+
return chatCompletionEntity(chatRequest, new LinkedMultiValueMap<>());
164+
}
165+
166+
/**
167+
* Creates a model response for the given chat conversation.
168+
* @param chatRequest The chat completion request.
169+
* @param additionalHttpHeader Additional HTTP headers.
170+
* @return Entity response with {@link ChatCompletionResponse} as a body and HTTP
171+
* status code and headers.
172+
*/
173+
public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletionRequest chatRequest,
174+
MultiValueMap<String, String> additionalHttpHeader) {
160175

161176
Assert.notNull(chatRequest, "The request body can not be null.");
162177
Assert.isTrue(!chatRequest.stream(), "Request must set the stream property to false.");
178+
Assert.notNull(additionalHttpHeader, "The additional HTTP headers can not be null.");
163179

164180
return this.restClient.post()
165181
.uri("/v1/messages")
182+
.headers(headers -> headers.addAll(additionalHttpHeader))
166183
.body(chatRequest)
167184
.retrieve()
168185
.toEntity(ChatCompletionResponse.class);
@@ -175,16 +192,30 @@ public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletio
175192
* @return Returns a {@link Flux} stream from chat completion chunks.
176193
*/
177194
public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest chatRequest) {
195+
return chatCompletionStream(chatRequest, new LinkedMultiValueMap<>());
196+
}
197+
198+
/**
199+
* Creates a streaming chat response for the given chat conversation.
200+
* @param chatRequest The chat completion request. Must have the stream property set
201+
* to true.
202+
* @param additionalHttpHeader Additional HTTP headers.
203+
* @return Returns a {@link Flux} stream from chat completion chunks.
204+
*/
205+
public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest chatRequest,
206+
MultiValueMap<String, String> additionalHttpHeader) {
178207

179208
Assert.notNull(chatRequest, "The request body can not be null.");
180209
Assert.isTrue(chatRequest.stream(), "Request must set the stream property to true.");
210+
Assert.notNull(additionalHttpHeader, "The additional HTTP headers can not be null.");
181211

182212
AtomicBoolean isInsideTool = new AtomicBoolean(false);
183213

184214
AtomicReference<ChatCompletionResponseBuilder> chatCompletionReference = new AtomicReference<>();
185215

186216
return this.webClient.post()
187217
.uri("/v1/messages")
218+
.headers(headers -> headers.addAll(additionalHttpHeader))
188219
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
189220
.retrieve()
190221
.bodyToFlux(String.class)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.anthropic;
18+
19+
import java.util.Map;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
23+
24+
import org.springframework.ai.anthropic.api.AnthropicApi;
25+
import org.springframework.ai.chat.model.ChatResponse;
26+
import org.springframework.ai.chat.prompt.Prompt;
27+
import org.springframework.ai.retry.NonTransientAiException;
28+
import org.springframework.beans.factory.annotation.Autowired;
29+
import org.springframework.boot.SpringBootConfiguration;
30+
import org.springframework.boot.test.context.SpringBootTest;
31+
import org.springframework.context.annotation.Bean;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
35+
36+
/**
37+
* @author Christian Tzolov
38+
*/
39+
@SpringBootTest(classes = AnthropicChatModelAdditionalHttpHeadersIT.Config.class)
40+
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+")
41+
public class AnthropicChatModelAdditionalHttpHeadersIT {
42+
43+
@Autowired
44+
private AnthropicChatModel chatModel;
45+
46+
@Test
47+
void additionalApiKeyHeader() {
48+
49+
assertThatThrownBy(() -> this.chatModel.call("Tell me a joke")).isInstanceOf(NonTransientAiException.class);
50+
51+
// Use the additional headers to override the Api Key.
52+
// Mind that you have to prefix the Api Key with the "Bearer " prefix.
53+
AnthropicChatOptions options = AnthropicChatOptions.builder()
54+
.httpHeaders(Map.of("x-api-key", System.getenv("ANTHROPIC_API_KEY")))
55+
.build();
56+
57+
ChatResponse response = this.chatModel.call(new Prompt("Tell me a joke", options));
58+
59+
assertThat(response).isNotNull();
60+
}
61+
62+
@SpringBootConfiguration
63+
static class Config {
64+
65+
@Bean
66+
public AnthropicApi anthropicApi() {
67+
return new AnthropicApi("Invalid API Key");
68+
}
69+
70+
@Bean
71+
public AnthropicChatModel anthropicChatModel(AnthropicApi api) {
72+
return AnthropicChatModel.builder().anthropicApi(api).build();
73+
}
74+
75+
}
76+
77+
}

Diff for: spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc

+1
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ The prefix `spring.ai.anthropic.chat` is the property prefix that lets you confi
114114
| (**deprecated** - replaced by `toolNames`) spring.ai.anthropic.chat.options.functions | List of functions, identified by their names, to enable for function calling in a single prompt requests. Functions with those names must exist in the functionCallbacks registry. | -
115115
| (**deprecated** - replaced by `toolCallbacks`) spring.ai.anthropic.chat.options.functionCallbacks | Tool Function Callbacks to register with the ChatModel. | -
116116
| (**deprecated** - replaced by a negated `internal-tool-execution-enabled`) spring.ai.anthropic.chat.options.proxy-tool-calls | If true, the Spring AI will not handle the function calls internally, but will proxy them to the client. Then is the client's responsibility to handle the function calls, dispatch them to the appropriate function, and return the results. If false (the default), the Spring AI will handle the function calls internally. Applicable only for chat models with function calling support | false
117+
| spring.ai.anthropic.chat.options.http-headers | Optional HTTP headers to be added to the chat completion request. | -
117118
|====
118119

119120
TIP: All properties prefixed with `spring.ai.anthropic.chat.options` can be overridden at runtime by adding a request specific <<chat-options>> to the `Prompt` call.

0 commit comments

Comments
 (0)