diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java index e92cb9dedc..5218829fc1 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java @@ -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; @@ -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; /** @@ -273,8 +275,8 @@ public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespons this.observationRegistry) .observe(() -> { - ResponseEntity completionEntity = this.retryTemplate - .execute(ctx -> this.anthropicApi.chatCompletionEntity(request)); + ResponseEntity 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 Flux internalStream(Prompt prompt, ChatResponse previousCha observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); - Flux response = this.anthropicApi.chatCompletionStream(request); + Flux response = this.anthropicApi.chatCompletionStream(request, + this.getAdditionalHttpHeaders(prompt)); // @formatter:off Flux chatResponseFlux = response.switchMap(chatCompletionResponse -> { @@ -462,6 +465,16 @@ else if (mimeType.contains("pdf")) { + ". Supported types are: images (image/*) and PDF documents (application/pdf)"); } + private MultiValueMap getAdditionalHttpHeaders(Prompt prompt) { + + Map 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 @@ 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())); @@ -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()); @@ -510,6 +525,13 @@ else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOp return new Prompt(prompt.getInstructions(), requestOptions); } + private Map mergeHttpHeaders(Map runtimeHttpHeaders, + Map defaultHttpHeaders) { + var mergedHttpHeaders = new HashMap<>(defaultHttpHeaders); + mergedHttpHeaders.putAll(runtimeHttpHeaders); + return mergedHttpHeaders; + } + ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { List userMessages = prompt.getInstructions() diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java index c1b319a27f..ba66a5b95f 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java @@ -80,6 +80,13 @@ public class AnthropicChatOptions implements ToolCallingChatOptions { @JsonIgnore private Map toolContext = new HashMap<>(); + + /** + * Optional HTTP headers to be added to the chat completion request. + */ + @JsonIgnore + private Map httpHeaders = new HashMap<>(); + // @formatter:on public static Builder builder() { @@ -98,6 +105,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) .toolNames(fromOptions.getToolNames()) .internalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled()) .toolContext(fromOptions.getToolContext()) + .httpHeaders(fromOptions.getHttpHeaders()) .build(); } @@ -270,6 +278,15 @@ public void setToolContext(Map toolContext) { this.toolContext = toolContext; } + @JsonIgnore + public Map getHttpHeaders() { + return httpHeaders; + } + + public void setHttpHeaders(Map httpHeaders) { + this.httpHeaders = httpHeaders; + } + @Override public AnthropicChatOptions copy() { return fromOptions(this); @@ -380,6 +397,11 @@ public Builder toolContext(Map toolContext) { return this; } + public Builder httpHeaders(Map httpHeaders) { + this.options.setHttpHeaders(httpHeaders); + return this; + } + public AnthropicChatOptions build() { return this.options; } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index 891a8b7723..ab95f3c8cb 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -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; @@ -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; @@ -157,12 +160,26 @@ public AnthropicApi(String baseUrl, String anthropicApiKey, String anthropicVers * status code and headers. */ public ResponseEntity 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 chatCompletionEntity(ChatCompletionRequest chatRequest, + MultiValueMap 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 ResponseEntity chatCompletionEntity(ChatCompletio * @return Returns a {@link Flux} stream from chat completion chunks. */ public Flux 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 chatCompletionStream(ChatCompletionRequest chatRequest, + MultiValueMap 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 Flux chatCompletionStream(ChatCompletionRequest c return this.webClient.post() .uri("/v1/messages") + .headers(headers -> headers.addAll(additionalHttpHeader)) .body(Mono.just(chatRequest), ChatCompletionRequest.class) .retrieve() .bodyToFlux(String.class) diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelAdditionalHttpHeadersIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelAdditionalHttpHeadersIT.java new file mode 100644 index 0000000000..79742e733a --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelAdditionalHttpHeadersIT.java @@ -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(); + } + + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc index b7f6b702a7..884e07a695 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc @@ -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 <> to the `Prompt` call.