Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(anthropic): add support for custom HTTP headers in Anthropic API #2343

Merged
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -78,6 +79,7 @@
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;

/**
Expand Down Expand Up @@ -273,8 +275,8 @@ public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespons
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();
Expand Down Expand Up @@ -338,7 +340,8 @@ public Flux<ChatResponse> internalStream(Prompt prompt, ChatResponse previousCha

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 -> {
Expand Down Expand Up @@ -462,6 +465,16 @@ else if (mimeType.contains("pdf")) {
+ ". 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;
Expand All @@ -487,6 +500,8 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp
// 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()));
Expand All @@ -498,8 +513,8 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp
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());
Expand All @@ -510,6 +525,13 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -98,6 +105,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
.toolNames(fromOptions.getToolNames())
.internalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled())
.toolContext(fromOptions.getToolContext())
.httpHeaders(fromOptions.getHttpHeaders())
.build();
}

Expand Down Expand Up @@ -270,6 +278,15 @@ public void setToolContext(Map<String, Object> toolContext) {
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);
Expand Down Expand Up @@ -380,6 +397,11 @@ public Builder toolContext(Map<String, Object> toolContext) {
return this;
}

public Builder httpHeaders(Map<String, String> httpHeaders) {
this.options.setHttpHeaders(httpHeaders);
return this;
}

public AnthropicChatOptions build() {
return this.options;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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;
Expand All @@ -42,6 +43,8 @@
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;
Expand Down Expand Up @@ -157,12 +160,26 @@ public AnthropicApi(String baseUrl, String anthropicApiKey, String anthropicVers
* 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);
Expand All @@ -175,16 +192,30 @@ public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletio
* @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);

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

return this.webClient.post()
.uri("/v1/messages")
.headers(headers -> headers.addAll(additionalHttpHeader))
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
.retrieve()
.bodyToFlux(String.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down