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:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user