Skip to content

Commit 13b10bb

Browse files
damianmomotgooglecopybara-github
authored andcommitted
chore: send ADK tracking headers from Claude and from Apigee custom headers
PiperOrigin-RevId: 1003140617
1 parent 48e9ae7 commit 13b10bb

4 files changed

Lines changed: 174 additions & 25 deletions

File tree

‎core/src/main/java/com/google/adk/models/ApigeeLlm.java‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,27 +95,21 @@ private ApigeeLlm(
9595
+ " APIGEE_PROXY_URL.");
9696
}
9797

98-
// Chat completions clients send no language tag, so they get both tags; the genai SDK adds its
99-
// own gl-java tag.
100-
ImmutableMap<String, String> trackingHeaders =
101-
this.apiType == ApiType.CHAT_COMPLETIONS
102-
? TrackingHeaders.HEADERS
103-
: TrackingHeaders.GENAI_SDK_HEADERS;
104-
10598
// Build the Client
99+
Map<String, String> callerHeaders = customHeaders != null ? customHeaders : ImmutableMap.of();
106100
HttpOptions.Builder httpOptionsBuilder =
107-
HttpOptions.builder().baseUrl(effectiveProxyUrl).headers(trackingHeaders);
101+
HttpOptions.builder()
102+
.baseUrl(effectiveProxyUrl)
103+
// Chat completions clients send no language tag, so they get both tags; the genai SDK
104+
// adds its own gl-java tag.
105+
.headers(
106+
this.apiType == ApiType.CHAT_COMPLETIONS
107+
? TrackingHeaders.mergeInto(callerHeaders)
108+
: TrackingHeaders.mergeIntoGenAiSdkHeaders(callerHeaders));
108109
String apiVersion = identifyApiVersion(modelName);
109110
if (!apiVersion.isEmpty()) {
110111
httpOptionsBuilder.apiVersion(apiVersion);
111112
}
112-
if (customHeaders != null) {
113-
httpOptionsBuilder.headers(
114-
ImmutableMap.<String, String>builder()
115-
.putAll(trackingHeaders)
116-
.putAll(customHeaders)
117-
.buildOrThrow());
118-
}
119113
this.httpOptions = httpOptionsBuilder.build();
120114

121115
if (this.apiType == ApiType.CHAT_COMPLETIONS) {
@@ -289,7 +283,9 @@ public Builder proxyUrl(String proxyUrl) {
289283
}
290284

291285
/**
292-
* Sets a dictionary of headers to be sent with the request.
286+
* Sets a dictionary of headers to be sent with the request. A {@code user-agent} or {@code
287+
* x-goog-api-client} value, matched case-insensitively, is kept and extended with the ADK
288+
* tracking tags it lacks.
293289
*
294290
* @param customHeaders the custom headers.
295291
* @return this builder.

‎core/src/main/java/com/google/adk/models/Claude.java‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import static java.nio.charset.StandardCharsets.UTF_8;
2020

2121
import com.anthropic.client.AnthropicClient;
22+
import com.anthropic.core.Properties;
23+
import com.anthropic.core.http.Headers;
2224
import com.anthropic.models.messages.ContentBlock;
2325
import com.anthropic.models.messages.ContentBlockParam;
2426
import com.anthropic.models.messages.Message;
@@ -89,22 +91,52 @@ public class Claude extends BaseLlm {
8991
private final AnthropicClient anthropicClient;
9092

9193
/**
92-
* Constructs a new Claude instance.
94+
* Constructs a new Claude instance. Requests sent through {@code anthropicClient} carry ADK's
95+
* usage-tracking headers, added to the ones the client already sends.
9396
*
9497
* @param modelName The name of the Claude model to use (e.g., "claude-3-opus-20240229").
9598
* @param anthropicClient The Anthropic API client instance.
9699
*/
97100
public Claude(String modelName, AnthropicClient anthropicClient) {
98101
super(modelName);
99-
this.anthropicClient = anthropicClient;
102+
this.anthropicClient = withTrackingHeaders(anthropicClient);
100103
}
101104

102105
public Claude(String modelName, AnthropicClient anthropicClient, int maxTokens) {
103106
super(modelName);
104-
this.anthropicClient = anthropicClient;
107+
this.anthropicClient = withTrackingHeaders(anthropicClient);
105108
this.maxTokens = maxTokens;
106109
}
107110

111+
/**
112+
* Returns a copy of {@code client} that shares its connections and sends ADK's tracking headers,
113+
* extending any the client already sets. A null client, or a test double that returns no copy, is
114+
* returned unchanged.
115+
*/
116+
private static @Nullable AnthropicClient withTrackingHeaders(@Nullable AnthropicClient client) {
117+
if (client == null) {
118+
return null;
119+
}
120+
AnthropicClient tracked =
121+
client.withOptions(
122+
options -> {
123+
Headers configured = options.build().headers();
124+
Map<String, String> current = new HashMap<>();
125+
// Start from the SDK's default User-Agent, which it adds only when none is set.
126+
current.put(
127+
"user-agent",
128+
client.getClass().getSimpleName() + "/Java " + Properties.getPackageVersion());
129+
for (String name : TrackingHeaders.HEADERS.keySet()) {
130+
List<String> values = configured.values(name);
131+
if (!values.isEmpty()) {
132+
current.put(name, String.join(" ", values));
133+
}
134+
}
135+
TrackingHeaders.mergeInto(current).forEach(options::replaceHeaders);
136+
});
137+
return tracked != null ? tracked : client;
138+
}
139+
108140
@Override
109141
public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stream) {
110142
// TODO: Switch to streaming API.

‎core/src/test/java/com/google/adk/models/ApigeeLlmTest.java‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,15 +331,23 @@ public void validateModelString_rejectsOpenAiWithVersion() {
331331
}
332332

333333
@Test
334-
public void build_withCustomHeadersOverlappingTrackingHeaders_throwsException() {
335-
ImmutableMap<String, String> overlappingHeaders = ImmutableMap.of("user-agent", "custom-agent");
336-
ApigeeLlm.Builder builder =
334+
public void build_chatCompletions_addsTheTrackingTagsCustomHeadersLack() {
335+
ImmutableMap<String, String> overlappingHeaders =
336+
ImmutableMap.of(
337+
"User-Agent", "custom-agent", "x-goog-api-client", "lib/1 google-adk/0.0.1");
338+
ApigeeLlm llm =
337339
ApigeeLlm.builder()
338340
.modelName("apigee/openai/gpt-4o")
339341
.proxyUrl(PROXY_URL)
340-
.customHeaders(overlappingHeaders);
341-
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build);
342-
assertThat(e).hasMessageThat().contains("Multiple entries with same key: user-agent=");
342+
.customHeaders(overlappingHeaders)
343+
.build();
344+
345+
assertThat(llm.getHttpOptions().headers().get())
346+
.containsExactly(
347+
"user-agent",
348+
"custom-agent " + TrackingHeaders.VALUE,
349+
"x-goog-api-client",
350+
"lib/1 google-adk/0.0.1 gl-java/" + JAVA_VERSION.value());
343351
}
344352

345353
@Test
@@ -428,6 +436,31 @@ public void generateContent_chatCompletions_sendsTrackingHeaders() throws Except
428436
}
429437
}
430438

439+
@Test
440+
public void generateContent_genAi_extendsACustomUserAgentWithTheAdkTag() throws Exception {
441+
Headers headers =
442+
headersSentToProxy(
443+
"apigee/gemini/v1beta/gemini-test",
444+
ImmutableMap.of("User-Agent", "custom-agent"),
445+
GENERATE_CONTENT_JSON);
446+
447+
assertThat(getOnlyElement(headers.get("user-agent")))
448+
.endsWith(" custom-agent " + TrackingHeaders.ADK_TAG);
449+
assertGenAiTrackingHeaders(headers);
450+
}
451+
452+
@Test
453+
public void generateContent_chatCompletions_extendsACustomUserAgent() throws Exception {
454+
Headers headers =
455+
headersSentToProxy(
456+
"apigee/openai/gpt-4o",
457+
ImmutableMap.of("User-Agent", "custom-agent"),
458+
CHAT_COMPLETION_JSON);
459+
460+
assertThat(headers.get("user-agent")).containsExactly("custom-agent " + TrackingHeaders.VALUE);
461+
assertThat(headers.get("x-goog-api-client")).containsExactly(TrackingHeaders.VALUE);
462+
}
463+
431464
/**
432465
* Sends one request through an {@link ApigeeLlm} for {@code modelName} to a local proxy that
433466
* answers with {@code responseJson}, and returns the request headers the proxy received.

‎core/src/test/java/com/google/adk/models/ClaudeTest.java‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.adk.models;
1818

19+
import static com.google.common.collect.Iterables.getOnlyElement;
1920
import static com.google.common.truth.Truth.assertThat;
2021
import static java.nio.charset.StandardCharsets.UTF_8;
2122
import static org.junit.Assert.assertEquals;
@@ -26,7 +27,9 @@
2627
import static org.mockito.Mockito.verify;
2728
import static org.mockito.Mockito.when;
2829

30+
import com.anthropic.backends.Backend;
2931
import com.anthropic.client.AnthropicClient;
32+
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
3033
import com.anthropic.core.JsonValue;
3134
import com.anthropic.models.messages.ContentBlock;
3235
import com.anthropic.models.messages.ContentBlockParam;
@@ -46,17 +49,24 @@
4649
import com.fasterxml.jackson.core.type.TypeReference;
4750
import com.google.common.collect.ImmutableList;
4851
import com.google.common.collect.ImmutableMap;
52+
import com.google.common.net.InetAddresses;
4953
import com.google.genai.types.Content;
5054
import com.google.genai.types.FunctionCall;
5155
import com.google.genai.types.FunctionDeclaration;
5256
import com.google.genai.types.FunctionResponse;
5357
import com.google.genai.types.Part;
5458
import com.google.genai.types.Schema;
59+
import com.sun.net.httpserver.Headers;
60+
import com.sun.net.httpserver.HttpServer;
61+
import java.io.OutputStream;
5562
import java.lang.reflect.Method;
63+
import java.net.InetAddress;
64+
import java.net.InetSocketAddress;
5665
import java.util.Collections;
5766
import java.util.List;
5867
import java.util.Map;
5968
import java.util.Optional;
69+
import java.util.concurrent.atomic.AtomicReference;
6070
import org.junit.Before;
6171
import org.junit.Test;
6272
import org.junit.runner.RunWith;
@@ -704,4 +714,82 @@ public void generateContent_userTurnOfOnlyEmptyText_isSentAsPlaceholder() {
704714
assertThat(blocks).hasSize(1);
705715
assertThat(blocks.get(0).asText().text()).isEqualTo(".");
706716
}
717+
718+
@Test
719+
public void generateContent_extendsTheSdkUserAgentWithAdkTrackingHeaders() throws Exception {
720+
Headers headers = headersSentThrough(AnthropicOkHttpClient.builder());
721+
722+
String userAgent = getOnlyElement(headers.get("user-agent"));
723+
assertThat(userAgent).startsWith("AnthropicClientImpl/Java ");
724+
assertThat(userAgent).endsWith(" " + TrackingHeaders.VALUE);
725+
assertThat(headers.get("x-goog-api-client")).containsExactly(TrackingHeaders.VALUE);
726+
}
727+
728+
@Test
729+
public void generateContent_extendsTheCallerUserAgentWithAdkTrackingHeaders() throws Exception {
730+
Headers headers =
731+
headersSentThrough(AnthropicOkHttpClient.builder().putHeader("User-Agent", "my-app/1.0"));
732+
733+
assertThat(headers.get("user-agent")).containsExactly("my-app/1.0 " + TrackingHeaders.VALUE);
734+
assertThat(headers.get("x-goog-api-client")).containsExactly(TrackingHeaders.VALUE);
735+
}
736+
737+
@Test
738+
public void constructor_nullClient_doesNotThrow() {
739+
assertThat(new Claude("claude-test", (AnthropicClient) null).model()).isEqualTo("claude-test");
740+
}
741+
742+
private static final String MESSAGE_JSON =
743+
"{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-test\","
744+
+ "\"content\":[{\"type\":\"text\",\"text\":\"pong\"}],\"stop_reason\":\"end_turn\","
745+
+ "\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}";
746+
747+
/**
748+
* Sends one request through Claude using a client built from {@code clientBuilder} for a local
749+
* server, and returns the headers the server received.
750+
*/
751+
private static Headers headersSentThrough(AnthropicOkHttpClient.Builder clientBuilder)
752+
throws Exception {
753+
AtomicReference<Headers> requestHeaders = new AtomicReference<>();
754+
HttpServer server =
755+
HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
756+
server.createContext(
757+
"/",
758+
exchange -> {
759+
requestHeaders.set(exchange.getRequestHeaders());
760+
byte[] body = MESSAGE_JSON.getBytes(UTF_8);
761+
exchange.getResponseHeaders().set("Content-Type", "application/json");
762+
exchange.sendResponseHeaders(200, body.length);
763+
try (OutputStream out = exchange.getResponseBody()) {
764+
out.write(body);
765+
}
766+
});
767+
server.start();
768+
try {
769+
String baseUrl =
770+
"http://"
771+
+ InetAddresses.toUriString(server.getAddress().getAddress())
772+
+ ":"
773+
+ server.getAddress().getPort();
774+
Backend backend =
775+
new Backend() {
776+
@Override
777+
public String baseUrl() {
778+
return baseUrl;
779+
}
780+
781+
@Override
782+
public void close() {}
783+
};
784+
Claude model = new Claude("claude-test", clientBuilder.backend(backend).build());
785+
786+
LlmResponse response =
787+
model.generateContent(request(userText("Ping")), /* stream= */ false).blockingFirst();
788+
789+
assertThat(response.content().get().parts().get().get(0).text()).hasValue("pong");
790+
return requestHeaders.get();
791+
} finally {
792+
server.stop(0);
793+
}
794+
}
707795
}

0 commit comments

Comments
 (0)