Skip to content
Merged
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
105 changes: 97 additions & 8 deletions core/src/main/java/com/google/adk/models/ApigeeLlm.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
import static com.google.common.base.Strings.isNullOrEmpty;

import com.google.adk.Version;
import com.google.adk.models.chat.ChatCompletionsClient;
import com.google.adk.models.chat.ChatCompletionsHttpClient;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.genai.Client;
Expand All @@ -28,6 +31,9 @@
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A {@link BaseLlm} implementation for calling an Apigee proxy.
Expand All @@ -36,6 +42,7 @@
* allows for specifying the provider (Gemini or Vertex AI), API version, and model ID.
*/
public class ApigeeLlm extends BaseLlm {
private static final Logger logger = LoggerFactory.getLogger(ApigeeLlm.class);
private static final String GOOGLE_GENAI_USE_VERTEXAI_ENV_VARIABLE_NAME =
"GOOGLE_GENAI_USE_VERTEXAI";
private static final String APIGEE_PROXY_URL_ENV_VARIABLE_NAME = "APIGEE_PROXY_URL";
Expand All @@ -51,9 +58,18 @@ public class ApigeeLlm extends BaseLlm {
"user-agent", versionHeaderValue);
}

/** Defines the type of API to be used by the Apigee proxy. */
public enum ApiType {
UNKNOWN,
CHAT_COMPLETIONS,
GENAI
}

private final Gemini geminiDelegate;
private final ChatCompletionsClient chatCompletionsClient;
private final Client apiClient;
private final HttpOptions httpOptions;
private final ApiType apiType;

/**
* Constructs a new ApigeeLlm instance.
Expand All @@ -62,7 +78,8 @@ public class ApigeeLlm extends BaseLlm {
* @param proxyUrl The URL of the Apigee proxy.
* @param customHeaders A map of custom headers to be sent with the request.
*/
private ApigeeLlm(String modelName, String proxyUrl, Map<String, String> customHeaders) {
private ApigeeLlm(
String modelName, String proxyUrl, Map<String, String> customHeaders, ApiType apiType) {
super(modelName);

if (!validateModelString(modelName)) {
Expand All @@ -71,6 +88,16 @@ private ApigeeLlm(String modelName, String proxyUrl, Map<String, String> customH
+ modelName);
}

if (apiType == ApiType.UNKNOWN) {
if (modelName.startsWith("apigee/openai/")) {
this.apiType = ApiType.CHAT_COMPLETIONS;
} else {
this.apiType = ApiType.GENAI;
}
} else {
this.apiType = apiType;
}

String effectiveProxyUrl = proxyUrl;
if (isNullOrEmpty(effectiveProxyUrl)) {
effectiveProxyUrl = System.getenv(APIGEE_PROXY_URL_ENV_VARIABLE_NAME);
Expand All @@ -96,13 +123,26 @@ private ApigeeLlm(String modelName, String proxyUrl, Map<String, String> customH
.buildOrThrow());
}
this.httpOptions = httpOptionsBuilder.build();
Client.Builder apiClientBuilder = Client.builder().httpOptions(this.httpOptions);
if (isVertexAiModel(modelName)) {
apiClientBuilder.vertexAI(true);

if (this.apiType == ApiType.CHAT_COMPLETIONS) {
this.apiClient = null;
this.geminiDelegate = null;
this.chatCompletionsClient = new ChatCompletionsHttpClient(this.httpOptions);
} else {
Client.Builder apiClientBuilder = Client.builder().httpOptions(this.httpOptions);
if (isVertexAiModel(modelName)) {
apiClientBuilder.vertexAI(true);
}
this.apiClient = apiClientBuilder.build();
this.geminiDelegate = new Gemini(modelName, apiClient);
this.chatCompletionsClient = null;
}

this.apiClient = apiClientBuilder.build();
this.geminiDelegate = new Gemini(modelName, apiClient);
logger.trace(
"ApigeeLlm constructed: modelName={} apiType={} effectiveProxyUrl={}",
modelName,
this.apiType,
effectiveProxyUrl);
}

/**
Expand All @@ -113,10 +153,31 @@ private ApigeeLlm(String modelName, String proxyUrl, Map<String, String> customH
*/
@VisibleForTesting
ApigeeLlm(String modelName, Gemini geminiDelegate) {
this(modelName, geminiDelegate, null);
}

/**
* Constructs a new ApigeeLlm instance for testing purposes.
*
* @param modelName The name of the Apigee model to use.
* @param geminiDelegate The Gemini delegate to use for making API calls.
* @param chatCompletionsClient The ChatCompletionsClient to use for making API calls.
*/
@VisibleForTesting
ApigeeLlm(
String modelName,
Gemini geminiDelegate,
@Nullable ChatCompletionsClient chatCompletionsClient) {
super(modelName);
this.apiClient = null;
this.httpOptions = null;
this.geminiDelegate = geminiDelegate;
this.chatCompletionsClient = chatCompletionsClient;
if (chatCompletionsClient != null) {
this.apiType = ApiType.CHAT_COMPLETIONS;
} else {
this.apiType = ApiType.GENAI;
}
}

/**
Expand Down Expand Up @@ -178,6 +239,7 @@ public static class Builder {
private String modelName;
private String proxyUrl;
private Map<String, String> customHeaders = new HashMap<>();
private ApiType apiType = ApiType.UNKNOWN;

protected Builder() {}

Expand Down Expand Up @@ -243,6 +305,19 @@ public Builder customHeaders(Map<String, String> customHeaders) {
return this;
}

/**
* Sets the explicit {@link ApiType} to use (e.g., CHAT_COMPLETIONS or GENAI).
*
* @param apiType the type of API.
* @return this builder.
* @throws NullPointerException if {@code apiType} is null.
*/
@CanIgnoreReturnValue
public Builder apiType(ApiType apiType) {
this.apiType = Preconditions.checkNotNull(apiType);
return this;
}

/**
* Builds the {@link ApigeeLlm} instance.
*
Expand All @@ -255,7 +330,7 @@ public ApigeeLlm build() {
throw new IllegalArgumentException("Invalid model string: " + modelName);
}

return new ApigeeLlm(modelName, proxyUrl, customHeaders);
return new ApigeeLlm(modelName, proxyUrl, customHeaders, apiType);
}
}

Expand All @@ -264,11 +339,23 @@ public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stre
String modelToUse = llmRequest.model().orElse(model());
String modelId = getModelId(modelToUse);
LlmRequest newLlmRequest = llmRequest.toBuilder().model(modelId).build();

logger.debug("ApigeeLlm.generateContent routing through {} for model {}", apiType, modelId);

if (apiType == ApiType.CHAT_COMPLETIONS) {
return chatCompletionsClient.complete(newLlmRequest, stream);
}

return geminiDelegate.generateContent(newLlmRequest, stream);
}

@Override
public BaseLlmConnection connect(LlmRequest llmRequest) {
if (apiType == ApiType.CHAT_COMPLETIONS) {
throw new UnsupportedOperationException(
"Streaming connections are not supported for chat completions.");
}

String modelToUse = llmRequest.model().orElse(model());
String modelId = getModelId(modelToUse);
LlmRequest newLlmRequest = llmRequest.toBuilder().model(modelId).build();
Expand Down Expand Up @@ -297,7 +384,9 @@ private static boolean validateModelString(String model) {
return components[1].startsWith("v");
}
if (components.length == 2) {
if (components[0].equals("vertex_ai") || components[0].equals("gemini")) {
if (components[0].equals("vertex_ai")
|| components[0].equals("gemini")
|| components[0].equals("openai")) {
return true;
}
return components[0].startsWith("v");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2026 Google LLC
*
* 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
*
* https://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 com.google.adk.models.chat;

import com.google.adk.models.LlmRequest;
import com.google.adk.models.LlmResponse;
import io.reactivex.rxjava3.core.Flowable;

/**
* A client for interacting with OpenAI-compatible chat completions endpoints.
*
* <p>Supports both non-streaming responses (single {@link LlmResponse} emission) and streaming
* Server-Sent Events (SSE) responses (multiple incremental {@link LlmResponse} emissions). See the
* <a href="https://developers.openai.com/api/reference/resources/chat">OpenAI Chat Completions API
* reference</a> for the wire protocol.
*/
public interface ChatCompletionsClient {

/**
* Generates a conversational response from the chat completions endpoint based on the provided
* messages. This encapsulates building the payload, sending the request to the completions
* endpoint, and initiating the handling of complete calls.
*
* @param llmRequest The request containing the model, configuration, and sequence of messages.
* @param stream Whether to request a streaming response.
* @return A {@link Flowable} emitting the discrete (or combined) {@link LlmResponse} objects.
*/
Flowable<LlmResponse> complete(LlmRequest llmRequest, boolean stream);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,12 @@
import org.slf4j.LoggerFactory;

/**
* An HTTP client for interacting with OpenAI-compatible chat completions endpoints.
*
* <p>Supports both non-streaming responses (single {@link LlmResponse} emission) and streaming
* Server-Sent Events (SSE) responses (multiple incremental {@link LlmResponse} emissions). See the
* <a href="https://developers.openai.com/api/reference/resources/chat">OpenAI Chat Completions API
* reference</a> for the wire protocol.
* An OkHttp-based implementation of {@link ChatCompletionsClient} that targets OpenAI-compatible
* chat completions endpoints. Both non-streaming responses (single {@link LlmResponse} emission)
* and streaming Server-Sent Events (SSE) responses (multiple incremental {@link LlmResponse}
* emissions) are supported.
*/
public final class ChatCompletionsHttpClient {
public final class ChatCompletionsHttpClient implements ChatCompletionsClient {
private static final Logger logger = LoggerFactory.getLogger(ChatCompletionsHttpClient.class);
private static final ObjectMapper objectMapper = JsonBaseModel.getMapper();

Expand Down Expand Up @@ -178,15 +176,7 @@ private static Duration resolveCallTimeout(HttpOptions httpOptions) {
return timeoutMs == 0L ? Duration.ZERO : Duration.ofMillis(timeoutMs);
}

/**
* Generates a conversational response from the chat completions endpoint based on the provided
* messages. This encapsulates building the HTTP payload, sending the request to the completions
* endpoint, and initiating the handling of complete calls.
*
* @param llmRequest The request containing the model, configuration, and sequence of messages.
* @param stream Whether to request a streaming response.
* @return A {@link Flowable} emitting the discrete (or combined) {@link LlmResponse} objects.
*/
@Override
public Flowable<LlmResponse> complete(LlmRequest llmRequest, boolean stream) {
return Flowable.defer(
() -> {
Expand Down
Loading
Loading