Skip to content

Commit b3ca86e

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add ApigeeLlm as a model that let's ADK Agent developers to connect with an Apigee proxy
PiperOrigin-RevId: 834843353
1 parent c2c4e46 commit b3ca86e

File tree

3 files changed

+529
-0
lines changed

3 files changed

+529
-0
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/*
2+
* Copyright 2025 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+
package com.google.adk.models;
17+
18+
import static com.google.common.base.StandardSystemProperty.JAVA_VERSION;
19+
import static com.google.common.base.Strings.isNullOrEmpty;
20+
21+
import com.google.adk.Version;
22+
import com.google.common.annotations.VisibleForTesting;
23+
import com.google.common.collect.ImmutableMap;
24+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
25+
import com.google.genai.Client;
26+
import com.google.genai.types.HttpOptions;
27+
import io.reactivex.rxjava3.core.Flowable;
28+
import java.util.HashMap;
29+
import java.util.Map;
30+
import java.util.Objects;
31+
32+
/**
33+
* A {@link BaseLlm} implementation for calling an Apigee proxy.
34+
*
35+
* <p>This class allows requests to be routed through an Apigee proxy. The model string format
36+
* allows for specifying the provider (Gemini or Vertex AI), API version, and model ID.
37+
*/
38+
public class ApigeeLlm extends BaseLlm {
39+
private static final String GOOGLE_GENAI_USE_VERTEXAI_ENV_VARIABLE_NAME =
40+
"GOOGLE_GENAI_USE_VERTEXAI";
41+
private static final String APIGEE_PROXY_URL_ENV_VARIABLE_NAME = "APIGEE_PROXY_URL";
42+
private static final ImmutableMap<String, String> TRACKING_HEADERS;
43+
44+
static {
45+
final String frameworkLabel = "google-adk/" + Version.JAVA_ADK_VERSION;
46+
final String languageLabel = "gl-java/" + JAVA_VERSION.value();
47+
final String versionHeaderValue = String.format("%s %s", frameworkLabel, languageLabel);
48+
TRACKING_HEADERS =
49+
ImmutableMap.of(
50+
"x-goog-api-client", versionHeaderValue,
51+
"user-agent", versionHeaderValue);
52+
}
53+
54+
private final Gemini geminiDelegate;
55+
private final Client apiClient;
56+
private final HttpOptions httpOptions;
57+
58+
/**
59+
* Constructs a new ApigeeLlm instance.
60+
*
61+
* @param modelName The name of the Apigee model to use.
62+
* @param proxyUrl The URL of the Apigee proxy.
63+
* @param customHeaders A map of custom headers to be sent with the request.
64+
*/
65+
private ApigeeLlm(String modelName, String proxyUrl, Map<String, String> customHeaders) {
66+
super(modelName);
67+
68+
if (!validateModelString(modelName)) {
69+
throw new IllegalArgumentException(
70+
"Invalid model string, expected apigee/[<provider>/][<version>/]<model_id>: "
71+
+ modelName);
72+
}
73+
74+
String effectiveProxyUrl = proxyUrl;
75+
if (isNullOrEmpty(effectiveProxyUrl)) {
76+
effectiveProxyUrl = System.getenv(APIGEE_PROXY_URL_ENV_VARIABLE_NAME);
77+
}
78+
if (isNullOrEmpty(effectiveProxyUrl)) {
79+
throw new IllegalArgumentException(
80+
"Apigee proxy URL is not set and not found in the environment variable"
81+
+ " APIGEE_PROXY_URL.");
82+
}
83+
84+
// Build the Client
85+
HttpOptions.Builder httpOptionsBuilder =
86+
HttpOptions.builder().baseUrl(effectiveProxyUrl).headers(TRACKING_HEADERS);
87+
String apiVersion = identifyApiVersion(modelName);
88+
if (!apiVersion.isEmpty()) {
89+
httpOptionsBuilder.apiVersion(apiVersion);
90+
}
91+
if (customHeaders != null) {
92+
httpOptionsBuilder.headers(
93+
ImmutableMap.<String, String>builder()
94+
.putAll(TRACKING_HEADERS)
95+
.putAll(customHeaders)
96+
.buildOrThrow());
97+
}
98+
this.httpOptions = httpOptionsBuilder.build();
99+
Client.Builder apiClientBuilder = Client.builder().httpOptions(this.httpOptions);
100+
if (isVertexAiModel(modelName)) {
101+
apiClientBuilder.vertexAI(true);
102+
}
103+
104+
this.apiClient = apiClientBuilder.build();
105+
this.geminiDelegate = new Gemini(modelName, apiClient);
106+
}
107+
108+
/**
109+
* Constructs a new ApigeeLlm instance for testing purposes.
110+
*
111+
* @param modelName The name of the Apigee model to use.
112+
* @param geminiDelegate The Gemini delegate to use for making API calls.
113+
*/
114+
@VisibleForTesting
115+
ApigeeLlm(String modelName, Gemini geminiDelegate) {
116+
super(modelName);
117+
this.apiClient = null;
118+
this.httpOptions = null;
119+
this.geminiDelegate = geminiDelegate;
120+
}
121+
122+
/**
123+
* Returns the genai {@link com.google.genai.Client} instance for making API calls for testing
124+
* purposes.
125+
*
126+
* @return the genai {@link com.google.genai.Client} instance.
127+
*/
128+
Client getApiClient() {
129+
return this.apiClient;
130+
}
131+
132+
/**
133+
* Returns the {@link HttpOptions} instance for making API calls for testing purposes.
134+
*
135+
* @return the {@link HttpOptions} instance.
136+
*/
137+
@VisibleForTesting
138+
HttpOptions getHttpOptions() {
139+
return this.httpOptions;
140+
}
141+
142+
private static boolean isVertexAiModel(String model) {
143+
// If the model starts with "apigee/gemini/", it is not Vertex AI.
144+
// Otherwise, it is Vertex AI if either the user has explicitly set the model string to be
145+
// "apigee/vertex_ai/" or the GOOGLE_GENAI_USE_VERTEXAI environment variable is set.
146+
return !model.startsWith("apigee/gemini/")
147+
&& (model.startsWith("apigee/vertex_ai/")
148+
|| isEnvEnabled(GOOGLE_GENAI_USE_VERTEXAI_ENV_VARIABLE_NAME));
149+
}
150+
151+
private static String identifyApiVersion(String model) {
152+
String modelPart = model.substring("apigee/".length());
153+
String[] components = modelPart.split("/", -1);
154+
if (components.length == 3) {
155+
return components[1];
156+
}
157+
if (components.length == 2) {
158+
if (!components[0].equals("vertex_ai")
159+
&& !components[0].equals("gemini")
160+
&& components[0].startsWith("v")) {
161+
return components[0];
162+
}
163+
}
164+
return "";
165+
}
166+
167+
/**
168+
* Returns a new Builder for constructing {@link ApigeeLlm} instances.
169+
*
170+
* @return a new {@link Builder}
171+
*/
172+
public static Builder builder() {
173+
return new Builder();
174+
}
175+
176+
/** Builder for {@link ApigeeLlm}. */
177+
public static class Builder {
178+
private String modelName;
179+
private String proxyUrl;
180+
private Map<String, String> customHeaders = new HashMap<>();
181+
182+
protected Builder() {}
183+
184+
/**
185+
* Sets the model string. The model string specifies the LLM provider (e.g., Vertex AI, Gemini),
186+
* API version, and the model ID.
187+
*
188+
* <p><b>Format:</b> {@code apigee/[<provider>/][<version>/]<model_id>}
189+
*
190+
* <p><b>Components:</b>
191+
*
192+
* <ul>
193+
* <li><b>{@code provider}</b> (optional): {@code vertex_ai} or {@code gemini}. If omitted,
194+
* behavior depends on the {@code GOOGLE_GENAI_USE_VERTEXAI} environment variable. If that
195+
* is not set to {@code TRUE} or {@code 1}, it defaults to {@code gemini}.
196+
* <li><b>{@code version}</b> (optional): The API version (e.g., {@code v1}, {@code v1beta}).
197+
* If omitted, the default version for the provider is used.
198+
* <li><b>{@code model_id}</b> (required): The model identifier (e.g., {@code
199+
* gemini-2.5-flash}).
200+
* </ul>
201+
*
202+
* <p><b>Examples:</b>
203+
*
204+
* <ul>
205+
* <li>{@code apigee/gemini-2.5-flash}
206+
* <li>{@code apigee/v1/gemini-2.5-flash}
207+
* <li>{@code apigee/vertex_ai/gemini-2.5-flash}
208+
* <li>{@code apigee/gemini/v1/gemini-2.5-flash}
209+
* <li>{@code apigee/vertex_ai/v1beta/gemini-2.5-flash}
210+
* </ul>
211+
*
212+
* @param modelName the model string.
213+
* @return this builder.
214+
*/
215+
@CanIgnoreReturnValue
216+
public Builder modelName(String modelName) {
217+
this.modelName = modelName;
218+
return this;
219+
}
220+
221+
/**
222+
* Sets the URL of the Apigee proxy. If not set, it will be read from the {@code
223+
* APIGEE_PROXY_URL} environment variable.
224+
*
225+
* @param proxyUrl the Apigee proxy URL.
226+
* @return this builder.
227+
*/
228+
@CanIgnoreReturnValue
229+
public Builder proxyUrl(String proxyUrl) {
230+
this.proxyUrl = proxyUrl;
231+
return this;
232+
}
233+
234+
/**
235+
* Sets a dictionary of headers to be sent with the request.
236+
*
237+
* @param customHeaders the custom headers.
238+
* @return this builder.
239+
*/
240+
@CanIgnoreReturnValue
241+
public Builder customHeaders(Map<String, String> customHeaders) {
242+
this.customHeaders = customHeaders;
243+
return this;
244+
}
245+
246+
/**
247+
* Builds the {@link ApigeeLlm} instance.
248+
*
249+
* @return a new {@link ApigeeLlm} instance.
250+
* @throws NullPointerException if modelName is null.
251+
* @throws IllegalArgumentException if the model string is invalid.
252+
*/
253+
public ApigeeLlm build() {
254+
if (!validateModelString(modelName)) {
255+
throw new IllegalArgumentException("Invalid model string: " + modelName);
256+
}
257+
258+
return new ApigeeLlm(modelName, proxyUrl, customHeaders);
259+
}
260+
}
261+
262+
@Override
263+
public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stream) {
264+
String modelToUse = llmRequest.model().orElse(model());
265+
String modelId = getModelId(modelToUse);
266+
LlmRequest newLlmRequest = llmRequest.toBuilder().model(modelId).build();
267+
return geminiDelegate.generateContent(newLlmRequest, stream);
268+
}
269+
270+
@Override
271+
public BaseLlmConnection connect(LlmRequest llmRequest) {
272+
String modelToUse = llmRequest.model().orElse(model());
273+
String modelId = getModelId(modelToUse);
274+
LlmRequest newLlmRequest = llmRequest.toBuilder().model(modelId).build();
275+
return geminiDelegate.connect(newLlmRequest);
276+
}
277+
278+
private static boolean validateModelString(String model) {
279+
if (!model.startsWith("apigee/")) {
280+
return false;
281+
}
282+
String modelPart = model.substring("apigee/".length());
283+
if (modelPart.isEmpty()) {
284+
return false;
285+
}
286+
String[] components = modelPart.split("/", -1);
287+
if (components[components.length - 1].isEmpty()) {
288+
return false;
289+
}
290+
if (components.length == 1) {
291+
return true;
292+
}
293+
if (components.length == 3) {
294+
if (!components[0].equals("vertex_ai") && !components[0].equals("gemini")) {
295+
return false;
296+
}
297+
return components[1].startsWith("v");
298+
}
299+
if (components.length == 2) {
300+
if (components[0].equals("vertex_ai") || components[0].equals("gemini")) {
301+
return true;
302+
}
303+
return components[0].startsWith("v");
304+
}
305+
return false;
306+
}
307+
308+
private static boolean isEnvEnabled(String envVarName) {
309+
String value = System.getenv(envVarName);
310+
return Boolean.parseBoolean(value) || Objects.equals(value, "1");
311+
}
312+
313+
private static String getModelId(String model) {
314+
if (!validateModelString(model)) {
315+
throw new IllegalArgumentException(
316+
"Invalid model string, expected apigee/[<provider>/][<version>/]<model_id>: " + model);
317+
}
318+
String modelPart = model.substring("apigee/".length());
319+
String[] components = modelPart.split("/", -1);
320+
return components[components.length - 1];
321+
}
322+
}

core/src/main/java/com/google/adk/models/LlmRegistry.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public interface LlmFactory {
3737
/** Registers default LLM factories, e.g. for Gemini models. */
3838
static {
3939
registerLlm("gemini-.*", modelName -> Gemini.builder().modelName(modelName).build());
40+
registerLlm("apigee/.*", modelName -> ApigeeLlm.builder().modelName(modelName).build());
4041
}
4142

4243
/**

0 commit comments

Comments
 (0)