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
108 changes: 108 additions & 0 deletions core/src/main/java/com/google/adk/models/ModelProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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
*
* 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 com.google.adk.models;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;

import com.google.common.base.Strings;

/**
* Service Provider Interface (SPI) for pluggable LLM backends.
*
* <p>Implementations let third-party model providers (Groq, Ollama, OpenRouter, etc.) register
* themselves with {@link LlmRegistry} automatically, so agents can reference models by prefixed
* name strings such as {@code "groq/<model-id>"} without per-application registration code — adding
* the provider dependency and its configuration (e.g. an API-key environment variable) is all an
* application needs.
*
* <h2>How it works</h2>
*
* <ol>
* <li>A provider library implements this interface.
* <li>It declares the implementation class in {@code
* META-INF/services/com.google.adk.models.ModelProvider}.
* <li>The application calls {@link ModelProviderRegistry#registerAll()} once at startup, which
* uses {@link java.util.ServiceLoader} to discover and register every provider on the
* classpath.
* </ol>
*
* <h2>Why the provider receives a bare model name</h2>
*
* <p>{@link LlmRegistry} resolves a model string by invoking the registered factory with the
* requested name, and the resolved {@link BaseLlm}'s own model name is what is ultimately sent to
* the backend as the wire-format model identifier. The {@code "groq/"} prefix is purely a routing
* namespace, so the default {@link #create(String)} strips it before delegating to {@link
* #createFromBareModelName(String)} — ensuring the backend receives a bare model identifier it
* actually recognizes.
*/
public interface ModelProvider {

/**
* Returns the provider prefix without the trailing slash, e.g. {@code "groq"}.
*
* <p>The prefix is used to derive the {@link #modelPattern()} and is stripped from the model name
* before delegating to {@link #createFromBareModelName(String)}.
*
* @return the provider prefix, must not be blank
*/
String prefix();

/**
* Returns the regex pattern of model names this provider handles.
*
* <p>The default implementation derives the pattern from {@link #prefix()}, e.g. {@code
* "groq/.*"}.
*/
default String modelPattern() {
String prefix = prefix();
checkState(isNotNullOrBlank(prefix), "Provider prefix cannot be blank");
return prefix + "/.*";
}

/**
* Creates a {@link BaseLlm} instance for the given model name.
*
* <p>The default implementation strips the {@link #prefix()} and separator slash from the model
* name and delegates to {@link #createFromBareModelName(String)}.
*
* @param modelName the full model name, e.g. {@code "groq/some-model"}
*/
default BaseLlm create(String modelName) {
checkArgument(isNotNullOrBlank(modelName), "modelName cannot be blank");
String prefixWithSlash = prefix() + "/";
String bareModelName =
modelName.startsWith(prefixWithSlash)
? modelName.substring(prefixWithSlash.length())
: modelName;
return createFromBareModelName(bareModelName);
}

/**
* Creates a {@link BaseLlm} for the given bare model identifier.
*
* <p>The {@code bareModelName} has already had the provider prefix removed and can be passed
* directly to the underlying API.
*
* @param bareModelName the model name without prefix, e.g. {@code "some-model"}
*/
BaseLlm createFromBareModelName(String bareModelName);

private static boolean isNotNullOrBlank(String s) {
return !Strings.nullToEmpty(s).isBlank();
}
}
104 changes: 104 additions & 0 deletions core/src/main/java/com/google/adk/models/ModelProviderRegistry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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
*
* 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 com.google.adk.models;

import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.common.collect.ImmutableList;
import java.util.Optional;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Discovers and registers all {@link ModelProvider} implementations on the classpath using Java's
* {@link ServiceLoader} mechanism.
*
* <p>Without this class, model name strings such as {@code "groq/<model-id>"} cannot be passed to
* {@code LlmAgent.builder().model(String)} unless application code has first called {@link
* LlmRegistry#registerLlm} for the matching pattern. Calling {@link #registerAll()} once at startup
* replaces all such manual registration: any provider JAR on the classpath that declares an
* implementation in {@code META-INF/services/com.google.adk.models.ModelProvider} is picked up
* automatically.
*
* <pre>{@code
* ModelProviderRegistry.registerAll();
*
* LlmAgent agent =
* LlmAgent.builder()
* .name("assistant")
* .model("groq/some-model")
* .build();
* }</pre>
*/
public final class ModelProviderRegistry {

private static final Logger logger = LoggerFactory.getLogger(ModelProviderRegistry.class);

private ModelProviderRegistry() {}

/**
* Loads all {@link ModelProvider} implementations via {@link ServiceLoader} and registers each
* one with {@link LlmRegistry}.
*
* @return an immutable list of registered providers (useful for logging and diagnostics)
*/
public static ImmutableList<ModelProvider> registerAll() {
return registerAll(ModelProviderRegistry.class.getClassLoader());
}

/**
* Same as {@link #registerAll()} but discovers providers through the given {@link ClassLoader}.
*
* <p>Each provider is discovered and instantiated in isolation: a provider that fails to
* instantiate (e.g. a throwing constructor) is logged at {@code WARN} and skipped without
* preventing the remaining providers on the classpath from registering.
*/
public static ImmutableList<ModelProvider> registerAll(ClassLoader classLoader) {
ImmutableList<ModelProvider> providers =
ServiceLoader.load(ModelProvider.class, classLoader).stream()
.flatMap(descriptor -> tryInstantiate(descriptor).stream())
.collect(toImmutableList());
providers.forEach(ModelProviderRegistry::registerProvider);
return providers;
}

private static void registerProvider(ModelProvider provider) {
String pattern = provider.modelPattern();
String className = provider.getClass().getName();
LlmRegistry.registerLlm(pattern, provider::create);
logger.info("Registered model provider '{}' for pattern '{}'", className, pattern);
}

/**
* Attempts to instantiate a {@link ModelProvider} from its {@link ServiceLoader.Provider}
* descriptor.
*
* @return the provider instance, or empty if instantiation failed
*/
private static Optional<ModelProvider> tryInstantiate(
ServiceLoader.Provider<ModelProvider> descriptor) {
try {
return Optional.of(descriptor.get());
} catch (ServiceConfigurationError e) {
String type = descriptor.type().getName();
logger.warn("Skipping ModelProvider {} - failed to instantiate: {}", type, e.getMessage(), e);
return Optional.empty();
}
}
}
123 changes: 123 additions & 0 deletions core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* 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
*
* 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 com.google.adk.models;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

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.Strings;
import com.google.genai.types.HttpOptions;
import io.reactivex.rxjava3.core.Flowable;
import java.util.Map;
import java.util.Optional;

/**
* A {@link BaseLlm} for any endpoint implementing the OpenAI Chat Completions API format (Groq,
* Ollama, OpenRouter, Azure OpenAI, vLLM, and others).
*
* <p>HTTP transport and JSON mapping are delegated entirely to ADK's native {@link
* ChatCompletionsHttpClient}. Instances are immutable: the underlying client (including its {@code
* Authorization} header) is built once at construction.
*
* <p>Instances are constructed with the <em>bare</em> model identifier, which is what the backend
* receives as the wire-format {@code "model"} field — any routing prefix such as {@code "groq/"}
* must be stripped by the caller before construction (see {@link ModelProvider#create(String)}).
*
* <p>Example, together with the {@link ModelProvider} SPI:
*
* <pre>{@code
* public final class GroqModelProvider implements ModelProvider {
* @Override
* public String prefix() {
* return "groq";
* }
*
* @Override
* public BaseLlm createFromBareModelName(String bareModelName) {
* return new OpenAiCompatibleLlm(
* bareModelName,
* "https://api.groq.com/openai/v1",
* Optional.ofNullable(System.getenv("GROQ_API_KEY")));
* }
* }
* }</pre>
*
* <p>Live bidirectional connections are not supported; the Chat Completions API does not provide
* this capability.
*/
public class OpenAiCompatibleLlm extends BaseLlm {

private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";

private final ChatCompletionsClient client;

/**
* Creates a new OpenAI-compatible LLM.
*
* @param modelName the bare model name, sent to the backend as the {@code "model"} field
* @param apiUrl the URL of the chat-completions endpoint; a trailing {@code /chat/completions}
* segment is accepted and stripped, since the client appends it internally
* @param apiKey optional API key; if empty, no {@code Authorization} header is sent
*/
public OpenAiCompatibleLlm(String modelName, String apiUrl, Optional<String> apiKey) {
this(modelName, createClient(apiUrl, apiKey));
}

@VisibleForTesting
OpenAiCompatibleLlm(String modelName, ChatCompletionsClient client) {
super(requireNotBlank(modelName, "modelName cannot be blank"));
this.client = checkNotNull(client, "client");
}

private static ChatCompletionsHttpClient createClient(String apiUrl, Optional<String> apiKey) {
requireNotBlank(apiUrl, "apiUrl cannot be blank");
HttpOptions.Builder optionsBuilder = HttpOptions.builder().baseUrl(normalizeBaseUrl(apiUrl));
apiKey.ifPresent(key -> optionsBuilder.headers(Map.of("Authorization", "Bearer " + key)));
return new ChatCompletionsHttpClient(optionsBuilder.build());
}

/**
* Strips a trailing {@code /chat/completions} segment, which {@link ChatCompletionsHttpClient}
* appends internally.
*/
@VisibleForTesting
static String normalizeBaseUrl(String apiUrl) {
if (apiUrl.endsWith(CHAT_COMPLETIONS_PATH)) {
return apiUrl.substring(0, apiUrl.length() - CHAT_COMPLETIONS_PATH.length());
}
return apiUrl;
}

@Override
public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stream) {
return client.complete(llmRequest, stream);
}

@Override
public BaseLlmConnection connect(LlmRequest llmRequest) {
throw new UnsupportedOperationException(
"OpenAiCompatibleLlm does not support live bidirectional connections.");
}

private static String requireNotBlank(String s, String errorMessage) {
checkArgument(!Strings.nullToEmpty(s).isBlank(), errorMessage);
return s;
}
}
Loading