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 <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov
2025-02-28 15:57:10 +01:00
committed by GitHub
parent 6cb15e4727
commit 041d94f0c9
5 changed files with 157 additions and 4 deletions

View File

@@ -18,6 +18,7 @@ package org.springframework.ai.anthropic;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -78,6 +79,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
/**
@@ -273,8 +275,8 @@ public class AnthropicChatModel extends AbstractToolCallSupport implements ChatM
this.observationRegistry)
.observe(() -> {
ResponseEntity<ChatCompletionResponse> completionEntity = this.retryTemplate
.execute(ctx -> this.anthropicApi.chatCompletionEntity(request));
ResponseEntity<ChatCompletionResponse> completionEntity = this.retryTemplate.execute(
ctx -> this.anthropicApi.chatCompletionEntity(request, this.getAdditionalHttpHeaders(prompt)));
AnthropicApi.ChatCompletionResponse completionResponse = completionEntity.getBody();
AnthropicApi.Usage usage = completionResponse.usage();
@@ -338,7 +340,8 @@ public class AnthropicChatModel extends AbstractToolCallSupport implements ChatM
observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start();
Flux<ChatCompletionResponse> response = this.anthropicApi.chatCompletionStream(request);
Flux<ChatCompletionResponse> response = this.anthropicApi.chatCompletionStream(request,
this.getAdditionalHttpHeaders(prompt));
// @formatter:off
Flux<ChatResponse> chatResponseFlux = response.switchMap(chatCompletionResponse -> {
@@ -462,6 +465,16 @@ public class AnthropicChatModel extends AbstractToolCallSupport implements ChatM
+ ". Supported types are: images (image/*) and PDF documents (application/pdf)");
}
private MultiValueMap<String, String> getAdditionalHttpHeaders(Prompt prompt) {
Map<String, String> headers = new HashMap<>(this.defaultOptions.getHttpHeaders());
if (prompt.getOptions() != null && prompt.getOptions() instanceof AnthropicChatOptions chatOptions) {
headers.putAll(chatOptions.getHttpHeaders());
}
return CollectionUtils.toMultiValueMap(
headers.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> List.of(e.getValue()))));
}
Prompt buildRequestPrompt(Prompt prompt) {
// Process runtime options
AnthropicChatOptions runtimeOptions = null;
@@ -487,6 +500,8 @@ public class AnthropicChatModel extends AbstractToolCallSupport implements ChatM
// Merge @JsonIgnore-annotated options explicitly since they are ignored by
// Jackson, used by ModelOptionsUtils.
if (runtimeOptions != null) {
requestOptions.setHttpHeaders(
mergeHttpHeaders(runtimeOptions.getHttpHeaders(), this.defaultOptions.getHttpHeaders()));
requestOptions.setInternalToolExecutionEnabled(
ModelOptionsUtils.mergeOption(runtimeOptions.isInternalToolExecutionEnabled(),
this.defaultOptions.isInternalToolExecutionEnabled()));
@@ -498,8 +513,8 @@ public class AnthropicChatModel extends AbstractToolCallSupport implements ChatM
this.defaultOptions.getToolContext()));
}
else {
requestOptions.setHttpHeaders(this.defaultOptions.getHttpHeaders());
requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.isInternalToolExecutionEnabled());
requestOptions.setToolNames(this.defaultOptions.getToolNames());
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
requestOptions.setToolContext(this.defaultOptions.getToolContext());
@@ -510,6 +525,13 @@ public class AnthropicChatModel extends AbstractToolCallSupport implements ChatM
return new Prompt(prompt.getInstructions(), requestOptions);
}
private Map<String, String> mergeHttpHeaders(Map<String, String> runtimeHttpHeaders,
Map<String, String> defaultHttpHeaders) {
var mergedHttpHeaders = new HashMap<>(defaultHttpHeaders);
mergedHttpHeaders.putAll(runtimeHttpHeaders);
return mergedHttpHeaders;
}
ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
List<AnthropicMessage> userMessages = prompt.getInstructions()

View File

@@ -80,6 +80,13 @@ public class AnthropicChatOptions implements ToolCallingChatOptions {
@JsonIgnore
private Map<String, Object> toolContext = new HashMap<>();
/**
* Optional HTTP headers to be added to the chat completion request.
*/
@JsonIgnore
private Map<String, String> httpHeaders = new HashMap<>();
// @formatter:on
public static Builder builder() {
@@ -98,6 +105,7 @@ public class AnthropicChatOptions implements ToolCallingChatOptions {
.toolNames(fromOptions.getToolNames())
.internalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled())
.toolContext(fromOptions.getToolContext())
.httpHeaders(fromOptions.getHttpHeaders())
.build();
}
@@ -270,6 +278,15 @@ public class AnthropicChatOptions implements ToolCallingChatOptions {
this.toolContext = toolContext;
}
@JsonIgnore
public Map<String, String> getHttpHeaders() {
return httpHeaders;
}
public void setHttpHeaders(Map<String, String> httpHeaders) {
this.httpHeaders = httpHeaders;
}
@Override
public AnthropicChatOptions copy() {
return fromOptions(this);
@@ -380,6 +397,11 @@ public class AnthropicChatOptions implements ToolCallingChatOptions {
return this;
}
public Builder httpHeaders(Map<String, String> httpHeaders) {
this.options.setHttpHeaders(httpHeaders);
return this;
}
public AnthropicChatOptions build() {
return this.options;
}

View File

@@ -32,6 +32,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionResponse;
import org.springframework.ai.anthropic.api.StreamHelper.ChatCompletionResponseBuilder;
import org.springframework.ai.model.ChatModelDescription;
import org.springframework.ai.model.ModelOptionsUtils;
@@ -42,6 +43,8 @@ import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
@@ -157,12 +160,26 @@ public class AnthropicApi {
* status code and headers.
*/
public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletionRequest chatRequest) {
return chatCompletionEntity(chatRequest, new LinkedMultiValueMap<>());
}
/**
* Creates a model response for the given chat conversation.
* @param chatRequest The chat completion request.
* @param additionalHttpHeader Additional HTTP headers.
* @return Entity response with {@link ChatCompletionResponse} as a body and HTTP
* status code and headers.
*/
public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
Assert.notNull(chatRequest, "The request body can not be null.");
Assert.isTrue(!chatRequest.stream(), "Request must set the stream property to false.");
Assert.notNull(additionalHttpHeader, "The additional HTTP headers can not be null.");
return this.restClient.post()
.uri("/v1/messages")
.headers(headers -> headers.addAll(additionalHttpHeader))
.body(chatRequest)
.retrieve()
.toEntity(ChatCompletionResponse.class);
@@ -175,9 +192,22 @@ public class AnthropicApi {
* @return Returns a {@link Flux} stream from chat completion chunks.
*/
public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest chatRequest) {
return chatCompletionStream(chatRequest, new LinkedMultiValueMap<>());
}
/**
* Creates a streaming chat response for the given chat conversation.
* @param chatRequest The chat completion request. Must have the stream property set
* to true.
* @param additionalHttpHeader Additional HTTP headers.
* @return Returns a {@link Flux} stream from chat completion chunks.
*/
public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
Assert.notNull(chatRequest, "The request body can not be null.");
Assert.isTrue(chatRequest.stream(), "Request must set the stream property to true.");
Assert.notNull(additionalHttpHeader, "The additional HTTP headers can not be null.");
AtomicBoolean isInsideTool = new AtomicBoolean(false);
@@ -185,6 +215,7 @@ public class AnthropicApi {
return this.webClient.post()
.uri("/v1/messages")
.headers(headers -> headers.addAll(additionalHttpHeader))
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
.retrieve()
.bodyToFlux(String.class)

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.anthropic;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.retry.NonTransientAiException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
/**
* @author Christian Tzolov
*/
@SpringBootTest(classes = AnthropicChatModelAdditionalHttpHeadersIT.Config.class)
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+")
public class AnthropicChatModelAdditionalHttpHeadersIT {
@Autowired
private AnthropicChatModel chatModel;
@Test
void additionalApiKeyHeader() {
assertThatThrownBy(() -> this.chatModel.call("Tell me a joke")).isInstanceOf(NonTransientAiException.class);
// Use the additional headers to override the Api Key.
// Mind that you have to prefix the Api Key with the "Bearer " prefix.
AnthropicChatOptions options = AnthropicChatOptions.builder()
.httpHeaders(Map.of("x-api-key", System.getenv("ANTHROPIC_API_KEY")))
.build();
ChatResponse response = this.chatModel.call(new Prompt("Tell me a joke", options));
assertThat(response).isNotNull();
}
@SpringBootConfiguration
static class Config {
@Bean
public AnthropicApi anthropicApi() {
return new AnthropicApi("Invalid API Key");
}
@Bean
public AnthropicChatModel anthropicChatModel(AnthropicApi api) {
return AnthropicChatModel.builder().anthropicApi(api).build();
}
}
}

View File

@@ -114,6 +114,7 @@ The prefix `spring.ai.anthropic.chat` is the property prefix that lets you confi
| (**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. | -
| (**deprecated** - replaced by `toolCallbacks`) spring.ai.anthropic.chat.options.functionCallbacks | Tool Function Callbacks to register with the ChatModel. | -
| (**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
| spring.ai.anthropic.chat.options.http-headers | Optional HTTP headers to be added to the chat completion request. | -
|====
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.