Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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;
Expand All @@ -112,6 +150,10 @@ public AnthropicChatModel(
clientBuilder.apiKey(apiKey);
}

if (authToken != null) {
clientBuilder.authToken(authToken);
}

if (baseUrl != null) {
clientBuilder.baseUrl(baseUrl);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -312,6 +355,20 @@ public Builder apiKey(String apiKey) {
return this;
}

/**
* Sets the bearer token for authentication with an Anthropic-compatible gateway.
*
* <p>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.
*
Expand Down Expand Up @@ -397,6 +454,7 @@ public AnthropicChatModel build() {
new AnthropicChatModel(
baseUrl,
apiKey,
authToken,
modelName,
streamEnabled,
defaultOptions,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading