diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java index c073acdbc3..baf1714c37 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModel.java @@ -96,6 +96,44 @@ public AnthropicChatModel( AnthropicBaseFormatter formatter, ProxyConfig proxyConfig, String cacheTtl) { + this( + baseUrl, + apiKey, + null, + modelName, + streamEnabled, + defaultOptions, + formatter, + proxyConfig, + cacheTtl); + } + + /** + * Creates an Anthropic chat model with optional bearer token authentication. + * + *

When both {@code apiKey} and {@code authToken} are configured, the SDK sends both + * {@code X-Api-Key} and {@code Authorization} headers. + * + * @param baseUrl the base URL for the Anthropic API (null for default) + * @param apiKey the API key for authentication (null to omit) + * @param authToken the bearer token without the {@code Bearer } prefix (null to omit) + * @param modelName the model name to use + * @param streamEnabled whether streaming should be enabled + * @param defaultOptions default generation options + * @param formatter the message formatter to use (null for the default formatter) + * @param proxyConfig the proxy configuration (null for no proxy) + * @param cacheTtl the TTL for prompt-caching markers (null for default 5m) + */ + public AnthropicChatModel( + String baseUrl, + String apiKey, + String authToken, + String modelName, + boolean streamEnabled, + GenerateOptions defaultOptions, + AnthropicBaseFormatter formatter, + ProxyConfig proxyConfig, + String cacheTtl) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.modelName = modelName; @@ -112,6 +150,10 @@ public AnthropicChatModel( clientBuilder.apiKey(apiKey); } + if (authToken != null) { + clientBuilder.authToken(authToken); + } + if (baseUrl != null) { clientBuilder.baseUrl(baseUrl); } @@ -282,6 +324,7 @@ public static Builder builder() { public static class Builder { private String baseUrl; private String apiKey; + private String authToken; private String modelName = "claude-sonnet-4-5-20250929"; private boolean streamEnabled = true; private GenerateOptions defaultOptions; @@ -312,6 +355,20 @@ public Builder apiKey(String apiKey) { return this; } + /** + * Sets the bearer token for authentication with an Anthropic-compatible gateway. + * + *

The SDK adds the {@code Bearer } prefix to the {@code Authorization} header. If an + * API key is also configured, the SDK sends both authentication headers. + * + * @param authToken the token without the {@code Bearer } prefix (null to omit) + * @return this builder + */ + public Builder authToken(String authToken) { + this.authToken = authToken; + return this; + } + /** * Sets the model name. * @@ -397,6 +454,7 @@ public AnthropicChatModel build() { new AnthropicChatModel( baseUrl, apiKey, + authToken, modelName, streamEnabled, defaultOptions, diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelAuthenticationTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelAuthenticationTest.java new file mode 100644 index 0000000000..4b285f6d62 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelAuthenticationTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.extensions.model.anthropic; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** Verifies authentication headers through the real SDK against a local HTTP server. */ +@Tag("integration") +class AnthropicChatModelAuthenticationTest { + + private static final String MESSAGE_RESPONSE = + """ + { + "id": "msg_gateway", "type": "message", "role": "assistant", + "model": "claude-sonnet-4.5", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", "stop_sequence": null, + "usage": {"input_tokens": 1, "output_tokens": 1} + } + """; + + private static final String STREAM_RESPONSE = + """ + event: message_start + data: {"type":"message_start","message":{"id":"msg_gateway","type":"message","role":"assistant","model":"claude-sonnet-4.5","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + + event: content_block_stop + data: {"type":"content_block_stop","index":0} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}} + + event: message_stop + data: {"type":"message_stop"} + + """; + + private MockWebServer server; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + server.close(); + } + + @ParameterizedTest + @CsvSource({ + "false, , test-gateway-token", + "true, , test-gateway-token", + "false, test-api-key, ", + "true, test-api-key, ", + "false, test-api-key, test-gateway-token", + "true, test-api-key, test-gateway-token" + }) + void shouldSendConfiguredAuthenticationHeaders( + boolean streaming, String apiKey, String authToken) throws Exception { + AnthropicChatModel model = + AnthropicChatModel.builder() + .baseUrl(server.url("/anthropic/").toString()) + .apiKey(apiKey) + .authToken(authToken) + .modelName("claude-sonnet-4.5") + .stream(streaming) + .build(); + + assertExchange(model, streaming, apiKey, authToken); + } + + @Test + void shouldPreserveApiKeyAuthenticationWithExistingConstructor() throws Exception { + AnthropicChatModel model = + new AnthropicChatModel( + server.url("/anthropic/").toString(), + "test-api-key", + "claude-sonnet-4.5", + false, + null, + null, + null, + null); + + assertExchange(model, false, "test-api-key", null); + } + + private void assertExchange( + AnthropicChatModel model, boolean streaming, String apiKey, String authToken) + throws Exception { + server.enqueue( + new MockResponse() + .setHeader( + "Content-Type", + streaming ? "text/event-stream" : "application/json") + .setBody(streaming ? STREAM_RESPONSE : MESSAGE_RESPONSE)); + + List text = + model.stream( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("Hello") + .build()), + null, + null) + .flatMapIterable(ChatResponse::getContent) + .ofType(TextBlock.class) + .map(TextBlock::getText) + .collectList() + .block(Duration.ofSeconds(10)); + + assertEquals(List.of("Hello"), text); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(request); + assertEquals("POST", request.getMethod()); + assertEquals("/anthropic/v1/messages", request.getPath()); + assertEquals( + apiKey == null ? List.of() : List.of(apiKey), + request.getHeaders().values("X-Api-Key")); + assertEquals( + authToken == null ? List.of() : List.of("Bearer " + authToken), + request.getHeaders().values("Authorization")); + assertEquals(1, server.getRequestCount()); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java index d4e5541741..90f81e7af0 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java @@ -56,9 +56,13 @@ public AnthropicChatModel anthropicChatModel( } String apiKey = trimToNull(properties.getApiKey()); + String authToken = trimToNull(properties.getAuthToken()); AnthropicChatModel.Builder builder = - AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream( - properties.isStream()); + AnthropicChatModel.builder() + .apiKey(apiKey) + .authToken(authToken) + .modelName(modelName) + .stream(properties.isStream()); String baseUrl = trimToNull(properties.getBaseUrl()); if (baseUrl != null) { diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java index f6cbfbafbc..b17d017833 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java @@ -47,6 +47,12 @@ public class AnthropicProperties { */ private String apiKey; + /** + * Bearer token for an Anthropic-compatible gateway, without the {@code Bearer } prefix. + * When an API key is also configured, the SDK sends both authentication headers. + */ + private String authToken; + /** * Anthropic API base URL (optional). */ @@ -78,6 +84,24 @@ public void setApiKey(String apiKey) { this.apiKey = apiKey; } + /** + * Returns the bearer token for gateway authentication. + * + * @return the token without the {@code Bearer } prefix, or null if unset + */ + public String getAuthToken() { + return authToken; + } + + /** + * Sets the bearer token for gateway authentication. + * + * @param authToken the token without the {@code Bearer } prefix + */ + public void setAuthToken(String authToken) { + this.authToken = authToken; + } + public String getBaseUrl() { return baseUrl; } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java index 8ff93ace98..ba51e3c94c 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java @@ -19,13 +19,20 @@ import io.agentscope.core.ReActAgent; import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; import io.agentscope.core.model.ToolSchema; import io.agentscope.extensions.model.anthropic.AnthropicChatModel; import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import java.time.Duration; import java.util.List; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -71,6 +78,95 @@ void shouldBindSupportedAnthropicProperties() { }); } + @Test + void shouldAuthenticateGatewayRequestsWithAuthToken() throws Exception { + assertGatewayAuthentication( + contextRunner.withPropertyValues( + "agentscope.anthropic.auth-token=test-gateway-token"), + null, + "Bearer test-gateway-token"); + } + + @Test + void shouldIgnoreBlankAuthTokenAndKeepApiKeyAuthentication() throws Exception { + assertGatewayAuthentication( + contextRunner.withPropertyValues( + "agentscope.anthropic.api-key=test-api-key", + "agentscope.anthropic.auth-token= "), + "test-api-key", + null); + } + + @Test + void shouldApplyAuthTokenCustomizerAfterProperties() throws Exception { + assertGatewayAuthentication( + contextRunner + .withPropertyValues("agentscope.anthropic.auth-token=property-token") + .withBean( + AnthropicChatModelBuilderCustomizer.class, + () -> builder -> builder.authToken("customized-token")), + null, + "Bearer customized-token"); + } + + private void assertGatewayAuthentication( + ApplicationContextRunner runner, String expectedApiKey, String expectedAuthorization) + throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.start(); + server.enqueue( + new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody( + """ + { + "id": "msg_gateway", "type": "message", "role": "assistant", + "model": "claude-sonnet-4.5", + "content": [{"type": "text", "text": "Hello"}], + "stop_reason": "end_turn", "stop_sequence": null, + "usage": {"input_tokens": 1, "output_tokens": 1} + } + """)); + + runner.withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.base-url=" + server.url("/anthropic/"), + "agentscope.anthropic.stream=false") + .run( + context -> { + assertThat(context).hasSingleBean(AnthropicChatModel.class); + AnthropicChatModel model = + context.getBean(AnthropicChatModel.class); + List responses = + model.stream( + List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("Hello") + .build()), + null, + null) + .collectList() + .block(Duration.ofSeconds(10)); + + assertThat(responses).hasSize(1); + assertThat(responses.get(0).getContent()) + .singleElement() + .isInstanceOfSatisfying( + TextBlock.class, + text -> + assertThat(text.getText()) + .isEqualTo("Hello")); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertThat(request).isNotNull(); + assertThat(request.getHeader("Authorization")) + .isEqualTo(expectedAuthorization); + assertThat(request.getHeader("X-Api-Key")) + .isEqualTo(expectedApiKey); + }); + } + } + @Test void shouldNotCreateAnthropicModelWhenProviderIsDifferent() { contextRunner diff --git a/docs/v2/en/integration/model/anthropic.md b/docs/v2/en/integration/model/anthropic.md index 1a008d2bf9..596f5ac58a 100644 --- a/docs/v2/en/integration/model/anthropic.md +++ b/docs/v2/en/integration/model/anthropic.md @@ -37,6 +37,24 @@ AnthropicChatModel model = AnthropicChatModel.builder() .build(); ``` +### Bearer token authentication + +For an Anthropic-compatible gateway that requires `Authorization: Bearer `, set +`authToken` on the model builder: + +```java +AnthropicChatModel model = AnthropicChatModel.builder() + .baseUrl("https://gateway.example.com/anthropic") + .authToken(System.getenv("ANTHROPIC_AUTH_TOKEN")) + .modelName("claude-sonnet-4.5") + .build(); +``` + +Pass the token without the `Bearer ` prefix; the SDK adds it. `apiKey` sets `X-Api-Key`, +while `authToken` sets `Authorization`. If both are configured, both headers are sent. +Configure authentication through the builder rather than `GenerateOptions.additionalHeaders`, +because the SDK owns these authentication headers. + ## Spring Boot Spring Boot applications can use the Anthropic starter: @@ -49,4 +67,19 @@ Spring Boot applications can use the Anthropic starter: ``` +To use a gateway with bearer token authentication: + +```yaml +agentscope: + model: + provider: anthropic + anthropic: + base-url: https://gateway.example.com/anthropic + auth-token: ${ANTHROPIC_AUTH_TOKEN} + model-name: claude-sonnet-4.5 +``` + +`agentscope.anthropic.auth-token` is optional. An unset or blank value leaves bearer +authentication disabled. Existing `agentscope.anthropic.api-key` configuration remains supported. + Full builder options, formatters, credentials, and registry context details are covered in [Model](../../docs/building-blocks/model.md). diff --git a/docs/v2/zh/integration/model/anthropic.md b/docs/v2/zh/integration/model/anthropic.md index 5c880e1d71..394dbd86bc 100644 --- a/docs/v2/zh/integration/model/anthropic.md +++ b/docs/v2/zh/integration/model/anthropic.md @@ -37,6 +37,23 @@ AnthropicChatModel model = AnthropicChatModel.builder() .build(); ``` +### Bearer Token 鉴权 + +对于需要 `Authorization: Bearer ` 的 Anthropic 兼容网关,通过模型 builder 设置 +`authToken`: + +```java +AnthropicChatModel model = AnthropicChatModel.builder() + .baseUrl("https://gateway.example.com/anthropic") + .authToken(System.getenv("ANTHROPIC_AUTH_TOKEN")) + .modelName("claude-sonnet-4.5") + .build(); +``` + +传入的 Token 不需要包含 `Bearer ` 前缀,SDK 会自动添加。`apiKey` 设置 `X-Api-Key`, +`authToken` 设置 `Authorization`;同时配置时,两种请求头都会发送。 +这些鉴权请求头由 SDK 管理,请通过 builder 配置,不要通过 `GenerateOptions.additionalHeaders` 添加。 + ## Spring Boot Spring Boot 应用可以使用 Anthropic starter: @@ -49,4 +66,19 @@ Spring Boot 应用可以使用 Anthropic starter: ``` +通过 Bearer Token 接入网关的配置示例: + +```yaml +agentscope: + model: + provider: anthropic + anthropic: + base-url: https://gateway.example.com/anthropic + auth-token: ${ANTHROPIC_AUTH_TOKEN} + model-name: claude-sonnet-4.5 +``` + +`agentscope.anthropic.auth-token` 为可选配置,未设置或为空白时不启用 Bearer 鉴权。 +原有的 `agentscope.anthropic.api-key` 配置仍然可用。 + 完整 builder 选项、formatter、credential 和 registry context 细节见 [模型](../../docs/building-blocks/model.md)。