diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java index 84510ed2d0..37984b744f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/accumulator/ThinkingAccumulator.java @@ -17,6 +17,8 @@ import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.ThinkingBlock; +import java.util.HashMap; +import java.util.Map; /** * Thinking content accumulator for accumulating streaming thinking chunks. @@ -28,6 +30,7 @@ public class ThinkingAccumulator implements ContentAccumulator { private final StringBuilder accumulated = new StringBuilder(); + private final Map metadata = new HashMap<>(); /** * @hidden @@ -37,6 +40,9 @@ public void add(ThinkingBlock block) { if (block != null && block.getThinking() != null) { accumulated.append(block.getThinking()); } + if (block != null && block.getMetadata() != null && !block.getMetadata().isEmpty()) { + metadata.putAll(block.getMetadata()); + } } /** @@ -44,7 +50,7 @@ public void add(ThinkingBlock block) { */ @Override public boolean hasContent() { - return accumulated.length() > 0; + return accumulated.length() > 0 || !metadata.isEmpty(); } /** @@ -55,7 +61,11 @@ public ContentBlock buildAggregated() { if (!hasContent()) { return null; } - return ThinkingBlock.builder().thinking(accumulated.toString()).build(); + ThinkingBlock.Builder builder = ThinkingBlock.builder().thinking(accumulated.toString()); + if (!metadata.isEmpty()) { + builder.metadata(metadata); + } + return builder.build(); } /** @@ -64,6 +74,7 @@ public ContentBlock buildAggregated() { @Override public void reset() { accumulated.setLength(0); + metadata.clear(); } /** 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..25c8703a01 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 @@ -72,30 +72,57 @@ public class AnthropicChatModel extends ChatModelBase { private final GenerateOptions defaultOptions; private final AnthropicBaseFormatter formatter; + public AnthropicChatModel( + String baseUrl, + String apiKey, + String modelName, + boolean streamEnabled, + GenerateOptions defaultOptions, + AnthropicBaseFormatter formatter, + ProxyConfig proxyConfig, + String cacheTtl) { + this( + baseUrl, + apiKey, + null, + modelName, + streamEnabled, + defaultOptions, + formatter, + proxyConfig, + cacheTtl); + } + /** - * Creates a new Anthropic chat model instance. + * Creates an Anthropic chat model with optional bearer token authentication. * - * @param baseUrl the base URL for Anthropic API (null for default) - * @param apiKey the API key for authentication (null to load from - * ANTHROPIC_API_KEY env var) - * @param modelName the model name to use (e.g., - * "claude-sonnet-4-5-20250929") - * @param streamEnabled whether streaming should be enabled + *

{@code apiKey} and {@code authToken} are mutually exclusive. + * + * @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 default - * Anthropic formatter) - * @param proxyConfig the proxy configuration (null for no proxy) - * @param cacheTtl the TTL for prompt-caching markers (null for default 5m) + * @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) + * @throws IllegalArgumentException if both API key and bearer token are configured */ public AnthropicChatModel( String baseUrl, String apiKey, + String authToken, String modelName, boolean streamEnabled, GenerateOptions defaultOptions, AnthropicBaseFormatter formatter, ProxyConfig proxyConfig, String cacheTtl) { + if (apiKey != null && authToken != null) { + throw new IllegalArgumentException( + "apiKey and authToken are mutually exclusive; configure only one credential"); + } this.baseUrl = baseUrl; this.apiKey = apiKey; this.modelName = modelName; @@ -112,6 +139,10 @@ public AnthropicChatModel( clientBuilder.apiKey(apiKey); } + if (authToken != null) { + clientBuilder.authToken(authToken); + } + if (baseUrl != null) { clientBuilder.baseUrl(baseUrl); } @@ -282,6 +313,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 +344,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. + * Configuring both an API key and a bearer token causes model construction to fail. + * + * @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 +443,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/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java index 6fa06a9530..ae7ca64400 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java @@ -15,8 +15,8 @@ */ package io.agentscope.extensions.model.anthropic; -import static io.agentscope.core.model.ModelProviderSupport.firstNonBlank; import static io.agentscope.core.model.ModelProviderSupport.intOption; +import static io.agentscope.core.model.ModelProviderSupport.stringOption; import static io.agentscope.core.model.ModelProviderSupport.trimToNull; import io.agentscope.core.model.GenerateOptions; @@ -27,12 +27,20 @@ import io.agentscope.extensions.model.anthropic.formatter.AnthropicBaseFormatter; import java.util.regex.Pattern; -/** Anthropic provider registered through {@link java.util.ServiceLoader}. */ +/** + * Anthropic provider registered through {@link java.util.ServiceLoader}. + * + *

Credentials come from the context's standard {@code apiKey} field or the {@code + * "authToken"} context option (a bearer token for Anthropic-compatible gateways; the two are + * mutually exclusive). When neither is set, the {@code ANTHROPIC_API_KEY} environment variable + * is used, then {@code ANTHROPIC_AUTH_TOKEN}. + */ public final class AnthropicModelProvider implements ModelProvider { private static final String PREFIX = "anthropic:"; private static final Pattern MODEL_ID = Pattern.compile("anthropic:.+"); private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; + private static final String OPTION_AUTH_TOKEN = "authToken"; @Override public String providerId() { @@ -55,10 +63,22 @@ public Model create(String modelId, ModelCreationContext context) { throw new IllegalArgumentException("Unsupported Anthropic model id: " + modelId); } String modelName = modelId.substring(PREFIX.length()); - String apiKey = firstNonBlank(context.getApiKey(), System.getenv("ANTHROPIC_API_KEY")); + String apiKey = trimToNull(context.getApiKey()); + String authToken = stringOption(context, OPTION_AUTH_TOKEN); + if (apiKey == null && authToken == null) { + // No explicit credential: fall back to the environment, keeping the historical + // precedence of ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN. + apiKey = trimToNull(System.getenv("ANTHROPIC_API_KEY")); + if (apiKey == null) { + authToken = trimToNull(System.getenv("ANTHROPIC_AUTH_TOKEN")); + } + } AnthropicChatModel.Builder builder = - AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream( - context.getStream() != null ? context.getStream() : true); + AnthropicChatModel.builder() + .apiKey(apiKey) + .authToken(authToken) + .modelName(modelName) + .stream(context.getStream() != null ? context.getStream() : true); String baseUrl = trimToNull(context.getBaseUrl()); if (baseUrl != null) { builder.baseUrl(baseUrl); 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..9cdd747a39 --- /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,209 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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, " + }) + 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); + } + + @Test + void shouldRejectBothCredentialsWithoutExposingTheirValues() { + IllegalArgumentException builderError = + assertThrows( + IllegalArgumentException.class, + () -> + AnthropicChatModel.builder() + .apiKey("secret-api-key") + .authToken("secret-bearer-token") + .build()); + IllegalArgumentException constructorError = + assertThrows( + IllegalArgumentException.class, + () -> + new AnthropicChatModel( + null, + "secret-api-key", + "secret-bearer-token", + "claude-sonnet-4.5", + false, + null, + null, + null, + null)); + + for (IllegalArgumentException error : List.of(builderError, constructorError)) { + assertEquals( + "apiKey and authToken are mutually exclusive; configure only one credential", + error.getMessage()); + assertFalse(error.toString().contains("secret-api-key")); + assertFalse(error.toString().contains("secret-bearer-token")); + } + assertEquals(0, server.getRequestCount()); + } + + @Test + void shouldNotExposeBearerTokenInModelOrBuilderToString() { + AnthropicChatModel.Builder builder = + AnthropicChatModel.builder().authToken("secret-bearer-token"); + assertFalse(builder.toString().contains("secret-bearer-token")); + assertFalse(builder.build().toString().contains("secret-bearer-token")); + } + + 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-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java index 16546d32bb..f53ce917cb 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java @@ -17,14 +17,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.model.ChatResponse; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.ModelRegistry; import io.agentscope.core.model.transport.ProxyConfig; +import java.time.Duration; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -72,6 +81,87 @@ void createUsesModelCreationContext() { assertEquals(200000, model.getContextWindowSize()); } + @Test + void createWithAuthTokenOptionSendsBearerHeader() 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} + } + """)); + + AnthropicModelProvider provider = new AnthropicModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .baseUrl(server.url("/anthropic/").toString()) + .stream(false) + .option("authToken", "gateway-token") + .build(); + + Model model = provider.create("anthropic:claude-sonnet-4.5", context); + java.util.List responses = + model.stream( + java.util.List.of( + Msg.builder() + .role(MsgRole.USER) + .textContent("Hello") + .build()), + null, + null) + .collectList() + .block(Duration.ofSeconds(10)); + + assertEquals(1, responses.size()); + RecordedRequest request = server.takeRequest(1, java.util.concurrent.TimeUnit.SECONDS); + assertNotNull(request); + assertEquals("Bearer gateway-token", request.getHeader("Authorization")); + assertNull(request.getHeader("X-Api-Key")); + } + } + + @Test + void createRejectsConflictingExplicitCredentials() { + AnthropicModelProvider provider = new AnthropicModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-anthropic-key") + .option("authToken", "gateway-token") + .build(); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> provider.create("anthropic:claude-sonnet-4.5", context)); + assertEquals( + "apiKey and authToken are mutually exclusive; configure only one credential", + error.getMessage()); + } + + @Test + void createRejectsNonStringAuthTokenOption() { + AnthropicModelProvider provider = new AnthropicModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .baseUrl("https://anthropic.example.com") + .option("authToken", 123) + .build(); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> provider.create("anthropic:claude-sonnet-4.5", context)); + assertTrue(error.getMessage().contains("authToken")); + } + @Test void modelRegistryFindsAnthropicProviderFromServiceLoader() { assertTrue(ModelRegistry.canResolve("anthropic:claude-sonnet-4.5")); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIMultiAgentFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIMultiAgentFormatter.java index b5a5222e88..a05630f10d 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIMultiAgentFormatter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIMultiAgentFormatter.java @@ -17,6 +17,7 @@ import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.ThinkingBlock; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.extensions.model.openai.dto.OpenAIMessage; import java.util.ArrayList; @@ -145,11 +146,26 @@ private MessageGroupType determineGroupType(Msg msg) { if (msg.hasContentBlocks(ToolUseBlock.class)) { yield MessageGroupType.TOOL_SEQUENCE; } + if (msg.getRole() == MsgRole.ASSISTANT && hasReasoningDetails(msg)) { + yield MessageGroupType.TOOL_SEQUENCE; + } yield MessageGroupType.AGENT_CONVERSATION; } }; } + /** + * Check whether a message carries encrypted reasoning details that must be preserved + * on an individual assistant message (cannot be merged into a user history message). + */ + private boolean hasReasoningDetails(Msg msg) { + ThinkingBlock tb = msg.getFirstContentBlock(ThinkingBlock.class); + if (tb == null || tb.getMetadata() == null) { + return false; + } + return tb.getMetadata().containsKey(ThinkingBlock.METADATA_REASONING_DETAILS); + } + /** * Format tool sequence messages. */ 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..597c4a1c06 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. + * Mutually exclusive with the API key; configuring both causes model construction to fail. + */ + 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..8856bd1336 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,138 @@ 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"); + } + + @Test + void shouldRejectBothCredentialsWithoutExposingTheirValues() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.api-key=secret-api-key", + "agentscope.anthropic.auth-token=secret-bearer-token") + .run( + context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasStackTraceContaining( + "apiKey and authToken are mutually exclusive"); + assertThat(context.getStartupFailure()) + .hasStackTraceContaining("configure only one credential"); + assertThat(context.getStartupFailure().toString()) + .doesNotContain("secret-api-key", "secret-bearer-token"); + }); + } + + @Test + void shouldAllowCustomizerToResolveConflictingCredentials() throws Exception { + assertGatewayAuthentication( + contextRunner + .withPropertyValues( + "agentscope.anthropic.api-key=test-api-key", + "agentscope.anthropic.auth-token=test-gateway-token") + .withBean( + AnthropicChatModelBuilderCustomizer.class, + () -> builder -> builder.apiKey(null)), + null, + "Bearer test-gateway-token"); + } + + @Test + void shouldNotExposeCredentialsInPropertiesToString() { + AnthropicProperties properties = new AnthropicProperties(); + properties.setApiKey("secret-api-key"); + properties.setAuthToken("secret-bearer-token"); + assertThat(properties.toString()).doesNotContain("secret-api-key", "secret-bearer-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 c4b4d377e9..96b96f692f 100644 --- a/docs/v2/en/integration/model/anthropic.md +++ b/docs/v2/en/integration/model/anthropic.md @@ -39,6 +39,25 @@ 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`. Configure only one: setting both causes model +construction to fail with an `IllegalArgumentException`. +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: @@ -51,4 +70,21 @@ 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, +but configuring both nonblank credentials causes startup to fail. Builder customizers run before +validation and can clear a credential with `apiKey(null)` or `authToken(null)`. + Full builder options, formatters, credentials, and registry context details are covered in [Model](/v2/en/docs/building-blocks/model). diff --git a/docs/v2/zh/integration/model/anthropic.md b/docs/v2/zh/integration/model/anthropic.md index 5c457fd156..7553692e86 100644 --- a/docs/v2/zh/integration/model/anthropic.md +++ b/docs/v2/zh/integration/model/anthropic.md @@ -39,6 +39,24 @@ 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`;两者只能配置一个,同时配置会在创建模型时抛出 +`IllegalArgumentException`。 +这些鉴权请求头由 SDK 管理,请通过 builder 配置,不要通过 `GenerateOptions.additionalHeaders` 添加。 + ## Spring Boot Spring Boot 应用可以使用 Anthropic starter: @@ -51,4 +69,20 @@ 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 customizer 在校验前执行,可以通过 `apiKey(null)` 或 `authToken(null)` 清除其中一种凭据。 + 完整 builder 选项、formatter、credential 和 registry context 细节见 [模型](/v2/zh/docs/building-blocks/model)。