From f05c37640ba6fabc8685a27cc11bfcd9c2a5c3d1 Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Thu, 29 May 2025 01:18:25 +0200 Subject: [PATCH 1/3] Add support for ApiKey for Anthropic and use it dynamically for every request Signed-off-by: Filip Hrisafov --- .../ai/anthropic/api/AnthropicApi.java | 27 +- .../api/AnthropicApiBuilderTests.java | 235 ++++++++++++++++++ 2 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java 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 adcb897ad89..15d0b900a21 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 @@ -34,8 +34,10 @@ import reactor.core.publisher.Mono; import org.springframework.ai.anthropic.api.StreamHelper.ChatCompletionResponseBuilder; +import org.springframework.ai.model.ApiKey; import org.springframework.ai.model.ChatModelDescription; import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.SimpleApiKey; import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.ai.retry.RetryUtils; import org.springframework.http.HttpHeaders; @@ -107,12 +109,11 @@ public static Builder builder() { * @param responseErrorHandler Response error handler. * @param anthropicBetaFeatures Anthropic beta features. */ - private AnthropicApi(String baseUrl, String completionsPath, String anthropicApiKey, String anthropicVersion, + private AnthropicApi(String baseUrl, String completionsPath, ApiKey anthropicApiKey, String anthropicVersion, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler, String anthropicBetaFeatures) { Consumer jsonContentHeaders = headers -> { - headers.add(HEADER_X_API_KEY, anthropicApiKey); headers.add(HEADER_ANTHROPIC_VERSION, anthropicVersion); headers.add(HEADER_ANTHROPIC_BETA, anthropicBetaFeatures); headers.setContentType(MediaType.APPLICATION_JSON); @@ -124,6 +125,12 @@ private AnthropicApi(String baseUrl, String completionsPath, String anthropicApi .baseUrl(baseUrl) .defaultHeaders(jsonContentHeaders) .defaultStatusHandler(responseErrorHandler) + .defaultRequest(requestHeadersSpec -> { + String value = anthropicApiKey.getValue(); + if (StringUtils.hasText(value)) { + requestHeadersSpec.header(HEADER_X_API_KEY, value); + } + }) .build(); this.webClient = webClientBuilder.clone() @@ -133,6 +140,12 @@ private AnthropicApi(String baseUrl, String completionsPath, String anthropicApi resp -> resp.bodyToMono(String.class) .flatMap(it -> Mono.error(new RuntimeException( "Response exception, Status: [" + resp.statusCode() + "], Body:[" + it + "]")))) + .defaultRequest(requestHeadersSpec -> { + String value = anthropicApiKey.getValue(); + if (StringUtils.hasText(value)) { + requestHeadersSpec.header(HEADER_X_API_KEY, value); + } + }) .build(); } @@ -1339,7 +1352,7 @@ public static class Builder { private String completionsPath = DEFAULT_MESSAGE_COMPLETIONS_PATH; - private String apiKey; + private ApiKey apiKey; private String anthropicVersion = DEFAULT_ANTHROPIC_VERSION; @@ -1363,12 +1376,18 @@ public Builder completionsPath(String completionsPath) { return this; } - public Builder apiKey(String apiKey) { + public Builder apiKey(ApiKey apiKey) { Assert.notNull(apiKey, "apiKey cannot be null"); this.apiKey = apiKey; return this; } + public Builder apiKey(String simpleApiKey) { + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); + this.apiKey = new SimpleApiKey(simpleApiKey); + return this; + } + public Builder anthropicVersion(String anthropicVersion) { Assert.notNull(anthropicVersion, "anthropicVersion cannot be null"); this.anthropicVersion = anthropicVersion; diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java new file mode 100644 index 00000000000..0736c922f93 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java @@ -0,0 +1,235 @@ +/* + * Copyright 2023-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.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Queue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.SimpleApiKey; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +public class AnthropicApiBuilderTests { + + private static final ApiKey TEST_API_KEY = new SimpleApiKey("test-api-key"); + + private static final String TEST_BASE_URL = "https://test.anthropic.com"; + + private static final String TEST_COMPLETIONS_PATH = "/test/completions"; + + @Test + void testMinimalBuilder() { + AnthropicApi api = AnthropicApi.builder().apiKey(TEST_API_KEY).build(); + + assertThat(api).isNotNull(); + } + + @Test + void testFullBuilder() { + RestClient.Builder restClientBuilder = RestClient.builder(); + WebClient.Builder webClientBuilder = WebClient.builder(); + ResponseErrorHandler errorHandler = mock(ResponseErrorHandler.class); + + AnthropicApi api = AnthropicApi.builder() + .apiKey(TEST_API_KEY) + .baseUrl(TEST_BASE_URL) + .completionsPath(TEST_COMPLETIONS_PATH) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) + .responseErrorHandler(errorHandler) + .build(); + + assertThat(api).isNotNull(); + } + + @Test + void testMissingApiKey() { + assertThatThrownBy(() -> AnthropicApi.builder().build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("apiKey must be set"); + } + + @Test + void testInvalidBaseUrl() { + assertThatThrownBy(() -> AnthropicApi.builder().baseUrl("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("baseUrl cannot be null or empty"); + + assertThatThrownBy(() -> AnthropicApi.builder().baseUrl(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("baseUrl cannot be null or empty"); + } + + @Test + void testInvalidCompletionsPath() { + assertThatThrownBy(() -> AnthropicApi.builder().completionsPath("").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("completionsPath cannot be null or empty"); + + assertThatThrownBy(() -> AnthropicApi.builder().completionsPath(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("completionsPath cannot be null or empty"); + } + + @Test + void testInvalidRestClientBuilder() { + assertThatThrownBy(() -> AnthropicApi.builder().restClientBuilder(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("restClientBuilder cannot be null"); + } + + @Test + void testInvalidWebClientBuilder() { + assertThatThrownBy(() -> AnthropicApi.builder().webClientBuilder(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("webClientBuilder cannot be null"); + } + + @Test + void testInvalidResponseErrorHandler() { + assertThatThrownBy(() -> AnthropicApi.builder().responseErrorHandler(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("responseErrorHandler cannot be null"); + } + + @Nested + class MockRequests { + + MockWebServer mockWebServer; + + @BeforeEach + void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + } + + @AfterEach + void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @Test + void dynamicApiKeyRestClient() throws InterruptedException { + Queue apiKeys = new LinkedList<>(List.of(new SimpleApiKey("key1"), new SimpleApiKey("key2"))); + AnthropicApi api = AnthropicApi.builder() + .apiKey(() -> Objects.requireNonNull(apiKeys.poll()).getValue()) + .baseUrl(mockWebServer.url("/").toString()) + .build(); + + MockResponse mockResponse = new MockResponse().setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(""" + { + "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-opus-3-latest", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1 + } + } + """); + mockWebServer.enqueue(mockResponse); + mockWebServer.enqueue(mockResponse); + + AnthropicApi.AnthropicMessage chatCompletionMessage = new AnthropicApi.AnthropicMessage( + List.of(new AnthropicApi.ContentBlock("Hello world")), AnthropicApi.Role.USER); + AnthropicApi.ChatCompletionRequest request = AnthropicApi.ChatCompletionRequest.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_OPUS) + .temperature(0.8) + .messages(List.of(chatCompletionMessage)) + .build(); + ResponseEntity response = api.chatCompletionEntity(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key1"); + + response = api.chatCompletionEntity(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key2"); + } + + @Test + void dynamicApiKeyWebClient() throws InterruptedException { + Queue apiKeys = new LinkedList<>(List.of(new SimpleApiKey("key1"), new SimpleApiKey("key2"))); + AnthropicApi api = AnthropicApi.builder() + .apiKey(() -> Objects.requireNonNull(apiKeys.poll()).getValue()) + .baseUrl(mockWebServer.url("/").toString()) + .build(); + + MockResponse mockResponse = new MockResponse().setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .setBody( + """ + {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-4-20250514", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} + """); + mockWebServer.enqueue(mockResponse); + mockWebServer.enqueue(mockResponse); + + AnthropicApi.AnthropicMessage chatCompletionMessage = new AnthropicApi.AnthropicMessage( + List.of(new AnthropicApi.ContentBlock("Hello world")), AnthropicApi.Role.USER); + AnthropicApi.ChatCompletionRequest request = AnthropicApi.ChatCompletionRequest.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_OPUS) + .temperature(0.8) + .messages(List.of(chatCompletionMessage)) + .stream(true) + .build(); + List response = api.chatCompletionStream(request) + .collectList() + .block(); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key1"); + + response = api.chatCompletionStream(request).collectList().block(); + + recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key2"); + } + + } + +} From f0858ad32b176f604faf8714685b2f047dbe797d Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Sat, 31 May 2025 13:43:11 +0200 Subject: [PATCH 2/3] Set ApiKey as late as possible Signed-off-by: Filip Hrisafov --- .../ai/anthropic/api/AnthropicApi.java | 35 +++-- .../api/AnthropicApiBuilderTests.java | 120 +++++++++++++++++- 2 files changed, 137 insertions(+), 18 deletions(-) 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 15d0b900a21..03ff67cc8df 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 @@ -98,6 +98,8 @@ public static Builder builder() { private final WebClient webClient; + private final ApiKey apiKey; + /** * Create a new client api. * @param baseUrl api base URL. @@ -120,17 +122,12 @@ private AnthropicApi(String baseUrl, String completionsPath, ApiKey anthropicApi }; this.completionsPath = completionsPath; + this.apiKey = anthropicApiKey; this.restClient = restClientBuilder.clone() .baseUrl(baseUrl) .defaultHeaders(jsonContentHeaders) .defaultStatusHandler(responseErrorHandler) - .defaultRequest(requestHeadersSpec -> { - String value = anthropicApiKey.getValue(); - if (StringUtils.hasText(value)) { - requestHeadersSpec.header(HEADER_X_API_KEY, value); - } - }) .build(); this.webClient = webClientBuilder.clone() @@ -140,12 +137,6 @@ private AnthropicApi(String baseUrl, String completionsPath, ApiKey anthropicApi resp -> resp.bodyToMono(String.class) .flatMap(it -> Mono.error(new RuntimeException( "Response exception, Status: [" + resp.statusCode() + "], Body:[" + it + "]")))) - .defaultRequest(requestHeadersSpec -> { - String value = anthropicApiKey.getValue(); - if (StringUtils.hasText(value)) { - requestHeadersSpec.header(HEADER_X_API_KEY, value); - } - }) .build(); } @@ -175,7 +166,15 @@ public ResponseEntity chatCompletionEntity(ChatCompletio return this.restClient.post() .uri(this.completionsPath) - .headers(headers -> headers.addAll(additionalHttpHeader)) + .headers(headers -> { + headers.addAll(additionalHttpHeader); + if (!headers.containsKey(HEADER_X_API_KEY)) { + String apiKeyValue = this.apiKey.getValue(); + if (StringUtils.hasText(apiKeyValue)) { + headers.add(HEADER_X_API_KEY, apiKeyValue); + } + } + }) .body(chatRequest) .retrieve() .toEntity(ChatCompletionResponse.class); @@ -211,7 +210,15 @@ public Flux chatCompletionStream(ChatCompletionRequest c return this.webClient.post() .uri(this.completionsPath) - .headers(headers -> headers.addAll(additionalHttpHeader)) + .headers(headers -> { + headers.addAll(additionalHttpHeader); + if (!headers.containsKey(HEADER_X_API_KEY)) { + String apiKeyValue = this.apiKey.getValue(); + if (StringUtils.hasText(apiKeyValue)) { + headers.add(HEADER_X_API_KEY, apiKeyValue); + } + } + }) .body(Mono.just(chatRequest), ChatCompletionRequest.class) .retrieve() .bodyToFlux(String.class) diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java index 0736c922f93..0f6ba2478bb 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/api/AnthropicApiBuilderTests.java @@ -36,6 +36,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; @@ -43,6 +45,7 @@ import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import org.opentest4j.AssertionFailedError; public class AnthropicApiBuilderTests { @@ -191,6 +194,50 @@ void dynamicApiKeyRestClient() throws InterruptedException { assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key2"); } + @Test + void dynamicApiKeyRestClientWithAdditionalApiKeyHeader() throws InterruptedException { + AnthropicApi api = AnthropicApi.builder() + .apiKey(() -> { + throw new AssertionFailedError("Should not be called, API key is provided in headers"); + }) + .baseUrl(mockWebServer.url("/").toString()) + .build(); + + MockResponse mockResponse = new MockResponse().setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(""" + { + "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-opus-3-latest", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1 + } + } + """); + mockWebServer.enqueue(mockResponse); + + AnthropicApi.AnthropicMessage chatCompletionMessage = new AnthropicApi.AnthropicMessage( + List.of(new AnthropicApi.ContentBlock("Hello world")), AnthropicApi.Role.USER); + AnthropicApi.ChatCompletionRequest request = AnthropicApi.ChatCompletionRequest.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_OPUS) + .temperature(0.8) + .messages(List.of(chatCompletionMessage)) + .build(); + MultiValueMap additionalHeaders = new LinkedMultiValueMap<>(); + additionalHeaders.add("x-api-key", "additional-key"); + ResponseEntity response = api.chatCompletionEntity(request, additionalHeaders); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("additional-key"); + } + @Test void dynamicApiKeyWebClient() throws InterruptedException { Queue apiKeys = new LinkedList<>(List.of(new SimpleApiKey("key1"), new SimpleApiKey("key2"))); @@ -203,8 +250,23 @@ void dynamicApiKeyWebClient() throws InterruptedException { .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) .setBody( """ - {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-4-20250514", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}} - """); + { + "type": "message_start", + "message": { + "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-opus-4-20250514", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1 + } + } + } + """.replace("\n", "")); mockWebServer.enqueue(mockResponse); mockWebServer.enqueue(mockResponse); @@ -216,20 +278,70 @@ void dynamicApiKeyWebClient() throws InterruptedException { .messages(List.of(chatCompletionMessage)) .stream(true) .build(); - List response = api.chatCompletionStream(request) + api.chatCompletionStream(request) .collectList() .block(); RecordedRequest recordedRequest = mockWebServer.takeRequest(); assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key1"); - response = api.chatCompletionStream(request).collectList().block(); + api.chatCompletionStream(request).collectList().block(); recordedRequest = mockWebServer.takeRequest(); assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key2"); } + @Test + void dynamicApiKeyWebClientWithAdditionalApiKey() throws InterruptedException { + Queue apiKeys = new LinkedList<>(List.of(new SimpleApiKey("key1"), new SimpleApiKey("key2"))); + AnthropicApi api = AnthropicApi.builder() + .apiKey(() -> Objects.requireNonNull(apiKeys.poll()).getValue()) + .baseUrl(mockWebServer.url("/").toString()) + .build(); + + MockResponse mockResponse = new MockResponse().setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .setBody( + """ + { + "type": "message_start", + "message": { + "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-opus-4-20250514", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1 + } + } + } + """.replace("\n", "")); + mockWebServer.enqueue(mockResponse); + + AnthropicApi.AnthropicMessage chatCompletionMessage = new AnthropicApi.AnthropicMessage( + List.of(new AnthropicApi.ContentBlock("Hello world")), AnthropicApi.Role.USER); + AnthropicApi.ChatCompletionRequest request = AnthropicApi.ChatCompletionRequest.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_OPUS) + .temperature(0.8) + .messages(List.of(chatCompletionMessage)) + .stream(true) + .build(); + MultiValueMap additionalHeaders = new LinkedMultiValueMap<>(); + additionalHeaders.add("x-api-key", "additional-key"); + + api.chatCompletionStream(request, additionalHeaders) + .collectList() + .block(); + RecordedRequest recordedRequest = mockWebServer.takeRequest(); + assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); + assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("additional-key"); + } + } } From 9a8799b281f4aed935d1f00d12e300376259f98a Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Sat, 31 May 2025 16:15:37 +0200 Subject: [PATCH 3/3] Fix formatting Signed-off-by: Filip Hrisafov --- .../ai/anthropic/api/AnthropicApi.java | 28 ++++----- .../api/AnthropicApiBuilderTests.java | 58 ++++++++----------- 2 files changed, 40 insertions(+), 46 deletions(-) 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 03ff67cc8df..f2db871a300 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 @@ -164,20 +164,17 @@ public ResponseEntity chatCompletionEntity(ChatCompletio Assert.isTrue(!chatRequest.stream(), "Request must set the stream property to false."); Assert.notNull(additionalHttpHeader, "The additional HTTP headers can not be null."); + // @formatter:off return this.restClient.post() .uri(this.completionsPath) .headers(headers -> { headers.addAll(additionalHttpHeader); - if (!headers.containsKey(HEADER_X_API_KEY)) { - String apiKeyValue = this.apiKey.getValue(); - if (StringUtils.hasText(apiKeyValue)) { - headers.add(HEADER_X_API_KEY, apiKeyValue); - } - } + addDefaultHeadersIfMissing(headers); }) .body(chatRequest) .retrieve() .toEntity(ChatCompletionResponse.class); + // @formatter:on } /** @@ -208,17 +205,13 @@ public Flux chatCompletionStream(ChatCompletionRequest c AtomicReference chatCompletionReference = new AtomicReference<>(); + // @formatter:off return this.webClient.post() .uri(this.completionsPath) .headers(headers -> { headers.addAll(additionalHttpHeader); - if (!headers.containsKey(HEADER_X_API_KEY)) { - String apiKeyValue = this.apiKey.getValue(); - if (StringUtils.hasText(apiKeyValue)) { - headers.add(HEADER_X_API_KEY, apiKeyValue); - } - } - }) + addDefaultHeadersIfMissing(headers); + }) // @formatter:off .body(Mono.just(chatRequest), ChatCompletionRequest.class) .retrieve() .bodyToFlux(String.class) @@ -252,6 +245,15 @@ public Flux chatCompletionStream(ChatCompletionRequest c .filter(chatCompletionResponse -> chatCompletionResponse.type() != null); } + private void addDefaultHeadersIfMissing(HttpHeaders headers) { + if (!headers.containsKey(HEADER_X_API_KEY)) { + String apiKeyValue = this.apiKey.getValue(); + if (StringUtils.hasText(apiKeyValue)) { + headers.add(HEADER_X_API_KEY, apiKeyValue); + } + } + } + /** * Check the Models * overview and { - throw new AssertionFailedError("Should not be called, API key is provided in headers"); - }) - .baseUrl(mockWebServer.url("/").toString()) - .build(); + AnthropicApi api = AnthropicApi.builder().apiKey(() -> { + throw new AssertionFailedError("Should not be called, API key is provided in headers"); + }).baseUrl(mockWebServer.url("/").toString()).build(); MockResponse mockResponse = new MockResponse().setResponseCode(200) .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) @@ -231,7 +228,8 @@ void dynamicApiKeyRestClientWithAdditionalApiKeyHeader() throws InterruptedExcep .build(); MultiValueMap additionalHeaders = new LinkedMultiValueMap<>(); additionalHeaders.add("x-api-key", "additional-key"); - ResponseEntity response = api.chatCompletionEntity(request, additionalHeaders); + ResponseEntity response = api.chatCompletionEntity(request, + additionalHeaders); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); RecordedRequest recordedRequest = mockWebServer.takeRequest(); assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); @@ -248,8 +246,7 @@ void dynamicApiKeyWebClient() throws InterruptedException { MockResponse mockResponse = new MockResponse().setResponseCode(200) .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) - .setBody( - """ + .setBody(""" { "type": "message_start", "message": { @@ -278,9 +275,7 @@ void dynamicApiKeyWebClient() throws InterruptedException { .messages(List.of(chatCompletionMessage)) .stream(true) .build(); - api.chatCompletionStream(request) - .collectList() - .block(); + api.chatCompletionStream(request).collectList().block(); RecordedRequest recordedRequest = mockWebServer.takeRequest(); assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("key1"); @@ -301,26 +296,25 @@ void dynamicApiKeyWebClientWithAdditionalApiKey() throws InterruptedException { .build(); MockResponse mockResponse = new MockResponse().setResponseCode(200) - .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) - .setBody( - """ - { - "type": "message_start", - "message": { - "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", - "type": "message", - "role": "assistant", - "content": [], - "model": "claude-opus-4-20250514", - "stop_reason": null, - "stop_sequence": null, - "usage": { - "input_tokens": 25, - "output_tokens": 1 - } + .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE) + .setBody(""" + { + "type": "message_start", + "message": { + "id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-opus-4-20250514", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1 } } - """.replace("\n", "")); + } + """.replace("\n", "")); mockWebServer.enqueue(mockResponse); AnthropicApi.AnthropicMessage chatCompletionMessage = new AnthropicApi.AnthropicMessage( @@ -334,9 +328,7 @@ void dynamicApiKeyWebClientWithAdditionalApiKey() throws InterruptedException { MultiValueMap additionalHeaders = new LinkedMultiValueMap<>(); additionalHeaders.add("x-api-key", "additional-key"); - api.chatCompletionStream(request, additionalHeaders) - .collectList() - .block(); + api.chatCompletionStream(request, additionalHeaders).collectList().block(); RecordedRequest recordedRequest = mockWebServer.takeRequest(); assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull(); assertThat(recordedRequest.getHeader("x-api-key")).isEqualTo("additional-key");