Skip to content

Commit 1fa6abf

Browse files
damianmomotgooglecopybara-github
authored andcommitted
chore: tag Gemini requests sent through a caller-supplied genai Client
PiperOrigin-RevId: 1002229130
1 parent dc56f11 commit 1fa6abf

6 files changed

Lines changed: 392 additions & 45 deletions

File tree

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

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@
1515
*/
1616
package com.google.adk.models;
1717

18-
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
1918
import static com.google.common.base.Strings.isNullOrEmpty;
2019

21-
import com.google.adk.Version;
2220
import com.google.adk.models.chat.ChatCompletionsClient;
2321
import com.google.adk.models.chat.ChatCompletionsHttpClient;
2422
import com.google.common.annotations.VisibleForTesting;
@@ -46,17 +44,6 @@ public class ApigeeLlm extends BaseLlm {
4644
private static final String GOOGLE_GENAI_USE_VERTEXAI_ENV_VARIABLE_NAME =
4745
"GOOGLE_GENAI_USE_VERTEXAI";
4846
private static final String APIGEE_PROXY_URL_ENV_VARIABLE_NAME = "APIGEE_PROXY_URL";
49-
private static final ImmutableMap<String, String> TRACKING_HEADERS;
50-
51-
static {
52-
final String frameworkLabel = "google-adk/" + Version.JAVA_ADK_VERSION;
53-
final String languageLabel = "gl-java/" + JAVA_VERSION.value();
54-
final String versionHeaderValue = String.format("%s %s", frameworkLabel, languageLabel);
55-
TRACKING_HEADERS =
56-
ImmutableMap.of(
57-
"x-goog-api-client", versionHeaderValue,
58-
"user-agent", versionHeaderValue);
59-
}
6047

6148
/** Defines the type of API to be used by the Apigee proxy. */
6249
public enum ApiType {
@@ -110,15 +97,15 @@ private ApigeeLlm(
11097

11198
// Build the Client
11299
HttpOptions.Builder httpOptionsBuilder =
113-
HttpOptions.builder().baseUrl(effectiveProxyUrl).headers(TRACKING_HEADERS);
100+
HttpOptions.builder().baseUrl(effectiveProxyUrl).headers(TrackingHeaders.HEADERS);
114101
String apiVersion = identifyApiVersion(modelName);
115102
if (!apiVersion.isEmpty()) {
116103
httpOptionsBuilder.apiVersion(apiVersion);
117104
}
118105
if (customHeaders != null) {
119106
httpOptionsBuilder.headers(
120107
ImmutableMap.<String, String>builder()
121-
.putAll(TRACKING_HEADERS)
108+
.putAll(TrackingHeaders.HEADERS)
122109
.putAll(customHeaders)
123110
.buildOrThrow());
124111
}
@@ -134,7 +121,8 @@ private ApigeeLlm(
134121
apiClientBuilder.vertexAI(true);
135122
}
136123
this.apiClient = apiClientBuilder.build();
137-
this.geminiDelegate = new Gemini(modelName, apiClient);
124+
this.geminiDelegate =
125+
new Gemini(modelName, apiClient, /* clientSendsTrackingHeaders= */ true);
138126
this.chatCompletionsClient = null;
139127
}
140128

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

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@
1616

1717
package com.google.adk.models;
1818

19-
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
20-
21-
import com.google.adk.Version;
2219
import com.google.adk.internal.http.HttpClientFactory;
2320
import com.google.common.annotations.VisibleForTesting;
2421
import com.google.common.collect.ImmutableList;
@@ -60,7 +57,6 @@
6057
public class Gemini extends BaseLlm {
6158

6259
private static final Logger logger = LoggerFactory.getLogger(Gemini.class);
63-
private static final ImmutableMap<String, String> TRACKING_HEADERS;
6460

6561
private static OkHttpClient prepareHttpClient(@Nullable ExecutorService executorService) {
6662
OkHttpClient client =
@@ -79,7 +75,7 @@ private static Client buildApiKeyClient(
7975
String apiKey, @Nullable ExecutorService executorService) {
8076
return Client.builder()
8177
.apiKey(apiKey)
82-
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
78+
.httpOptions(HttpOptions.builder().headers(TrackingHeaders.HEADERS).build())
8379
.clientOptions(
8480
ClientOptions.builder().customHttpClient(prepareHttpClient(executorService)).build())
8581
.build();
@@ -89,7 +85,7 @@ private static Client buildVertexClient(
8985
VertexCredentials vertexCredentials, @Nullable ExecutorService executorService) {
9086
Client.Builder apiClientBuilder =
9187
Client.builder()
92-
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
88+
.httpOptions(HttpOptions.builder().headers(TrackingHeaders.HEADERS).build())
9389
.clientOptions(
9490
ClientOptions.builder()
9591
.customHttpClient(prepareHttpClient(executorService))
@@ -102,34 +98,31 @@ private static Client buildVertexClient(
10298

10399
private static Client buildDefaultClient(@Nullable ExecutorService executorService) {
104100
return Client.builder()
105-
.httpOptions(HttpOptions.builder().headers(TRACKING_HEADERS).build())
101+
.httpOptions(HttpOptions.builder().headers(TrackingHeaders.HEADERS).build())
106102
.clientOptions(
107103
ClientOptions.builder().customHttpClient(prepareHttpClient(executorService)).build())
108104
.build();
109105
}
110106

111-
static {
112-
String frameworkLabel = "google-adk/" + Version.JAVA_ADK_VERSION;
113-
String languageLabel = "gl-java/" + JAVA_VERSION.value();
114-
String versionHeaderValue = String.format("%s %s", frameworkLabel, languageLabel);
115-
116-
TRACKING_HEADERS =
117-
ImmutableMap.of(
118-
"x-goog-api-client", versionHeaderValue,
119-
"user-agent", versionHeaderValue);
120-
}
121-
122107
private final Client apiClient;
108+
// True when apiClient already sends TrackingHeaders.HEADERS.
109+
private final boolean clientSendsTrackingHeaders;
123110

124111
/**
125-
* Constructs a new Gemini instance.
112+
* Constructs a new Gemini instance. Each generate request sent through {@code apiClient} carries
113+
* ADK's tracking headers.
126114
*
127115
* @param modelName The name of the Gemini model to use (e.g., "gemini-2.0-flash").
128116
* @param apiClient The genai {@link com.google.genai.Client} instance for making API calls.
129117
*/
130118
public Gemini(String modelName, Client apiClient) {
119+
this(modelName, apiClient, /* clientSendsTrackingHeaders= */ false);
120+
}
121+
122+
Gemini(String modelName, Client apiClient, boolean clientSendsTrackingHeaders) {
131123
super(modelName);
132124
this.apiClient = Objects.requireNonNull(apiClient, "apiClient cannot be null");
125+
this.clientSendsTrackingHeaders = clientSendsTrackingHeaders;
133126
}
134127

135128
/**
@@ -139,9 +132,10 @@ public Gemini(String modelName, Client apiClient) {
139132
* @param apiKey The Google Gemini API key.
140133
*/
141134
public Gemini(String modelName, String apiKey) {
142-
super(modelName);
143-
Objects.requireNonNull(apiKey, "apiKey cannot be null");
144-
this.apiClient = buildApiKeyClient(apiKey, null);
135+
this(
136+
modelName,
137+
buildApiKeyClient(Objects.requireNonNull(apiKey, "apiKey cannot be null"), null),
138+
/* clientSendsTrackingHeaders= */ true);
145139
}
146140

147141
/**
@@ -151,9 +145,11 @@ public Gemini(String modelName, String apiKey) {
151145
* @param vertexCredentials The Vertex AI credentials to access the Gemini model.
152146
*/
153147
public Gemini(String modelName, VertexCredentials vertexCredentials) {
154-
super(modelName);
155-
Objects.requireNonNull(vertexCredentials, "vertexCredentials cannot be null");
156-
this.apiClient = buildVertexClient(vertexCredentials, null);
148+
this(
149+
modelName,
150+
buildVertexClient(
151+
Objects.requireNonNull(vertexCredentials, "vertexCredentials cannot be null"), null),
152+
/* clientSendsTrackingHeaders= */ true);
157153
}
158154

159155
/**
@@ -256,13 +252,16 @@ public Gemini build() {
256252

257253
if (apiClient != null) {
258254
return new Gemini(modelName, apiClient);
259-
} else if (apiKey != null) {
260-
return new Gemini(modelName, buildApiKeyClient(apiKey, httpExecutorService));
255+
}
256+
Client client;
257+
if (apiKey != null) {
258+
client = buildApiKeyClient(apiKey, httpExecutorService);
261259
} else if (vertexCredentials != null) {
262-
return new Gemini(modelName, buildVertexClient(vertexCredentials, httpExecutorService));
260+
client = buildVertexClient(vertexCredentials, httpExecutorService);
263261
} else {
264-
return new Gemini(modelName, buildDefaultClient(httpExecutorService));
262+
client = buildDefaultClient(httpExecutorService);
265263
}
264+
return new Gemini(modelName, client, /* clientSendsTrackingHeaders= */ true);
266265
}
267266
}
268267

@@ -292,6 +291,9 @@ private Flowable<LlmResponse> sendRequest(
292291
logger.trace("Request Contents: {}", llmRequest.contents());
293292
logger.trace("Request Config: {}", config);
294293

294+
if (!clientSendsTrackingHeaders) {
295+
config = withTrackingHeaders(config);
296+
}
295297
if (stream) {
296298
logger.debug("Sending streaming generateContent request to model {}", effectiveModelName);
297299
CompletableFuture<ResponseStream<GenerateContentResponse>> streamFuture =
@@ -316,6 +318,21 @@ private Flowable<LlmResponse> sendRequest(
316318
}
317319
}
318320

321+
/**
322+
* Returns {@code config} with ADK's tracking headers merged into its request headers, which the
323+
* genai SDK appends to the client's own.
324+
*/
325+
private static GenerateContentConfig withTrackingHeaders(@Nullable GenerateContentConfig config) {
326+
GenerateContentConfig base = config != null ? config : GenerateContentConfig.builder().build();
327+
HttpOptions httpOptions = base.httpOptions().orElseGet(() -> HttpOptions.builder().build());
328+
return base.toBuilder()
329+
.httpOptions(
330+
httpOptions.toBuilder()
331+
.headers(TrackingHeaders.mergeInto(httpOptions.headers().orElse(ImmutableMap.of())))
332+
.build())
333+
.build();
334+
}
335+
319336
private static LlmResponse withCacheMetadata(
320337
LlmResponse response, @Nullable CacheMetadata cacheMetadata) {
321338
return cacheMetadata == null
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.models;
18+
19+
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
20+
21+
import com.google.adk.Version;
22+
import com.google.common.base.Ascii;
23+
import com.google.common.base.Splitter;
24+
import com.google.common.collect.ImmutableMap;
25+
import java.util.LinkedHashMap;
26+
import java.util.Map;
27+
import org.jspecify.annotations.Nullable;
28+
29+
/** Headers that let Google APIs attribute model calls to ADK, in the format all ADK SDKs use. */
30+
final class TrackingHeaders {
31+
32+
private static final String ADK_TOKEN_PREFIX = "google-adk/";
33+
34+
static final String VALUE =
35+
ADK_TOKEN_PREFIX + Version.JAVA_ADK_VERSION + " gl-java/" + JAVA_VERSION.value();
36+
37+
static final ImmutableMap<String, String> HEADERS =
38+
ImmutableMap.of("x-goog-api-client", VALUE, "user-agent", VALUE);
39+
40+
/**
41+
* Returns {@code headers} with {@link #HEADERS} merged in. Any tracking header already set,
42+
* regardless of letter case, keeps its value and is extended with ADK's value unless it already
43+
* has an ADK token.
44+
*/
45+
static ImmutableMap<String, String> mergeInto(Map<String, String> headers) {
46+
Map<String, String> merged = new LinkedHashMap<>();
47+
Map<String, String> callerValues = new LinkedHashMap<>();
48+
headers.forEach(
49+
(name, value) -> {
50+
String trackingName = Ascii.toLowerCase(name);
51+
if (HEADERS.containsKey(trackingName)) {
52+
callerValues.merge(trackingName, value, (first, second) -> first + " " + second);
53+
} else {
54+
merged.put(name, value);
55+
}
56+
});
57+
HEADERS.forEach((name, value) -> merged.put(name, extend(callerValues.get(name))));
58+
return ImmutableMap.copyOf(merged);
59+
}
60+
61+
private static String extend(@Nullable String callerValue) {
62+
if (callerValue == null || callerValue.isBlank()) {
63+
return VALUE;
64+
}
65+
boolean hasAdkToken =
66+
Splitter.on(' ')
67+
.splitToStream(callerValue)
68+
.anyMatch(token -> token.startsWith(ADK_TOKEN_PREFIX));
69+
return hasAdkToken ? callerValue : callerValue + " " + VALUE;
70+
}
71+
72+
private TrackingHeaders() {}
73+
}

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
*/
1616
package com.google.adk.models;
1717

18+
import static com.google.common.collect.Iterables.getOnlyElement;
1819
import static com.google.common.truth.Truth.assertThat;
20+
import static java.nio.charset.StandardCharsets.UTF_8;
1921
import static org.junit.Assert.assertThrows;
2022
import static org.junit.Assume.assumeNotNull;
2123
import static org.junit.Assume.assumeTrue;
@@ -28,13 +30,21 @@
2830

2931
import com.google.adk.models.ApigeeLlm.ApiType;
3032
import com.google.adk.models.chat.ChatCompletionsClient;
33+
import com.google.common.base.Splitter;
3134
import com.google.common.collect.ImmutableList;
3235
import com.google.common.collect.ImmutableMap;
36+
import com.google.common.net.InetAddresses;
3337
import com.google.genai.types.Content;
3438
import com.google.genai.types.Part;
39+
import com.sun.net.httpserver.Headers;
40+
import com.sun.net.httpserver.HttpServer;
3541
import io.reactivex.rxjava3.core.Flowable;
42+
import java.io.OutputStream;
43+
import java.net.InetAddress;
44+
import java.net.InetSocketAddress;
3645
import java.util.Map;
3746
import java.util.Objects;
47+
import java.util.concurrent.atomic.AtomicReference;
3848
import org.junit.Before;
3949
import org.junit.Rule;
4050
import org.junit.Test;
@@ -393,4 +403,52 @@ public void build_malformedModelsWithTrailingSlashes_throwsException() {
393403
assertThat(e).hasMessageThat().contains("Invalid model string: " + modelName);
394404
}
395405
}
406+
407+
@Test
408+
public void generateContent_genAi_sendsTrackingHeadersOnce() throws Exception {
409+
AtomicReference<Headers> requestHeaders = new AtomicReference<>();
410+
HttpServer server =
411+
HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
412+
server.createContext(
413+
"/",
414+
exchange -> {
415+
requestHeaders.set(exchange.getRequestHeaders());
416+
byte[] body = GENERATE_CONTENT_JSON.getBytes(UTF_8);
417+
exchange.getResponseHeaders().set("Content-Type", "application/json");
418+
exchange.sendResponseHeaders(200, body.length);
419+
try (OutputStream out = exchange.getResponseBody()) {
420+
out.write(body);
421+
}
422+
});
423+
server.start();
424+
try {
425+
ApigeeLlm llm =
426+
ApigeeLlm.builder()
427+
.modelName("apigee/gemini/v1beta/gemini-test")
428+
.proxyUrl(
429+
"http://"
430+
+ InetAddresses.toUriString(server.getAddress().getAddress())
431+
+ ":"
432+
+ server.getAddress().getPort())
433+
.build();
434+
LlmRequest request =
435+
LlmRequest.builder()
436+
.contents(ImmutableList.of(Content.builder().parts(Part.fromText("hello")).build()))
437+
.build();
438+
439+
llm.generateContent(request, /* stream= */ false).blockingSubscribe();
440+
441+
for (String name : TrackingHeaders.HEADERS.keySet()) {
442+
String value = getOnlyElement(requestHeaders.get().get(name));
443+
assertThat(Splitter.on(' ').splitToStream(value).filter(t -> t.startsWith("google-adk/")))
444+
.hasSize(1);
445+
}
446+
} finally {
447+
server.stop(0);
448+
}
449+
}
450+
451+
private static final String GENERATE_CONTENT_JSON =
452+
"{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"hi\"}]},"
453+
+ "\"finishReason\":\"STOP\"}]}";
396454
}

0 commit comments

Comments
 (0)