"} without per-application registration code — adding
+ * the provider dependency and its configuration (e.g. an API-key environment variable) is all an
+ * application needs.
+ *
+ * How it works
+ *
+ *
+ * - A provider library implements this interface.
+ *
- It declares the implementation class in {@code
+ * META-INF/services/com.google.adk.models.ModelProvider}.
+ *
- The application calls {@link ModelProviderRegistry#registerAll()} once at startup, which
+ * uses {@link java.util.ServiceLoader} to discover and register every provider on the
+ * classpath.
+ *
+ *
+ * Why the provider receives a bare model name
+ *
+ * {@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"}.
+ *
+ *
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.
+ *
+ *
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.
+ *
+ *
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.
+ *
+ *
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();
+ }
+}
diff --git a/core/src/main/java/com/google/adk/models/ModelProviderRegistry.java b/core/src/main/java/com/google/adk/models/ModelProviderRegistry.java
new file mode 100644
index 000000000..b78d567a1
--- /dev/null
+++ b/core/src/main/java/com/google/adk/models/ModelProviderRegistry.java
@@ -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.
+ *
+ *
Without this class, model name strings such as {@code "groq/"} 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.
+ *
+ * {@code
+ * ModelProviderRegistry.registerAll();
+ *
+ * LlmAgent agent =
+ * LlmAgent.builder()
+ * .name("assistant")
+ * .model("groq/some-model")
+ * .build();
+ * }
+ */
+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 registerAll() {
+ return registerAll(ModelProviderRegistry.class.getClassLoader());
+ }
+
+ /**
+ * Same as {@link #registerAll()} but discovers providers through the given {@link ClassLoader}.
+ *
+ * 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 registerAll(ClassLoader classLoader) {
+ ImmutableList 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 tryInstantiate(
+ ServiceLoader.Provider 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();
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java b/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java
new file mode 100644
index 000000000..d0ed5e0f2
--- /dev/null
+++ b/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java
@@ -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).
+ *
+ * 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.
+ *
+ *
Instances are constructed with the bare 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)}).
+ *
+ *
Example, together with the {@link ModelProvider} SPI:
+ *
+ *
{@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")));
+ * }
+ * }
+ * }
+ *
+ * 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 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 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 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;
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/ModelProviderRegistryTest.java b/core/src/test/java/com/google/adk/models/ModelProviderRegistryTest.java
new file mode 100644
index 000000000..4ec502a73
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/ModelProviderRegistryTest.java
@@ -0,0 +1,277 @@
+/*
+ * 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.truth.Truth.assertThat;
+
+import io.reactivex.rxjava3.core.Flowable;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link ModelProviderRegistry}.
+ *
+ * Rather than fighting ServiceLoader's class-name-based instantiation with mocks (which use
+ * synthetic class names), these tests use simple real implementations of {@link ModelProvider},
+ * served to {@link java.util.ServiceLoader} through an in-memory {@code META-INF/services}
+ * resource, and verify the effect on the real {@link LlmRegistry}.
+ */
+@RunWith(JUnit4.class)
+public final class ModelProviderRegistryTest {
+
+ // -----------------------------------------------------------------------
+ // Lightweight real providers for ClassLoader-based ServiceLoader testing
+ // -----------------------------------------------------------------------
+
+ /** A minimal {@link BaseLlm} carrying only a model name. */
+ static final class FakeLlm extends BaseLlm {
+ FakeLlm(String modelName) {
+ super(modelName);
+ }
+
+ @Override
+ public Flowable generateContent(LlmRequest llmRequest, boolean stream) {
+ return Flowable.empty();
+ }
+
+ @Override
+ public BaseLlmConnection connect(LlmRequest llmRequest) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ /** A real (non-mock) provider used to drive the ServiceLoader in tests. */
+ public static final class AlphaTestProvider implements ModelProvider {
+ @Override
+ public String prefix() {
+ return "spi-test-alpha";
+ }
+
+ @Override
+ public BaseLlm createFromBareModelName(String bareModelName) {
+ return new FakeLlm(bareModelName);
+ }
+ }
+
+ /** A second real provider. */
+ public static final class BetaTestProvider implements ModelProvider {
+ @Override
+ public String prefix() {
+ return "spi-test-beta";
+ }
+
+ @Override
+ public BaseLlm createFromBareModelName(String bareModelName) {
+ return new FakeLlm(bareModelName);
+ }
+ }
+
+ /**
+ * A provider whose no-arg constructor throws, simulating a broken provider on the classpath.
+ * {@link java.util.ServiceLoader} wraps the constructor failure in a {@link
+ * java.util.ServiceConfigurationError} when the provider is instantiated.
+ */
+ public static final class FailingTestProvider implements ModelProvider {
+ public FailingTestProvider() {
+ throw new IllegalStateException("boom - simulated broken provider constructor");
+ }
+
+ @Override
+ public String prefix() {
+ return "spi-test-failing";
+ }
+
+ @Override
+ public BaseLlm createFromBareModelName(String bareModelName) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ /**
+ * Provider registered for the no-arg {@link ModelProviderRegistry#registerAll()} test through the
+ * real {@code META-INF/services} file in {@code src/test/resources}.
+ */
+ public static final class ServiceFileTestProvider implements ModelProvider {
+ @Override
+ public String prefix() {
+ return "spi-test-servicefile";
+ }
+
+ @Override
+ public BaseLlm createFromBareModelName(String bareModelName) {
+ return new FakeLlm(bareModelName);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // registerAll(ClassLoader) tests
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void registerAll_singleProvider_registersPatternWithLlmRegistry() {
+ ClassLoader cl =
+ InMemoryServiceClassLoader.of(ModelProvider.class, List.of(AlphaTestProvider.class));
+
+ List registered = ModelProviderRegistry.registerAll(cl);
+
+ assertThat(registered).hasSize(1);
+ assertThat(registered.get(0).modelPattern()).isEqualTo("spi-test-alpha/.*");
+ assertThat(LlmRegistry.matchesAnyPattern("spi-test-alpha/some-model")).isTrue();
+ }
+
+ @Test
+ public void registerAll_multipleProviders_registersAll() {
+ ClassLoader cl =
+ InMemoryServiceClassLoader.of(
+ ModelProvider.class, List.of(AlphaTestProvider.class, BetaTestProvider.class));
+
+ List registered = ModelProviderRegistry.registerAll(cl);
+
+ assertThat(registered.stream().map(ModelProvider::modelPattern).toList())
+ .containsExactly("spi-test-alpha/.*", "spi-test-beta/.*");
+ assertThat(LlmRegistry.matchesAnyPattern("spi-test-beta/some-model")).isTrue();
+ }
+
+ @Test
+ public void registerAll_noProviders_returnsEmptyListAndNothingRegistered() {
+ ClassLoader cl = InMemoryServiceClassLoader.of(ModelProvider.class, List.of());
+
+ List registered = ModelProviderRegistry.registerAll(cl);
+
+ assertThat(registered).isEmpty();
+ assertThat(LlmRegistry.matchesAnyPattern("spi-test-unregistered/some-model")).isFalse();
+ }
+
+ @Test
+ public void registerAll_oneProviderFailsToInstantiate_othersStillRegister() {
+ ClassLoader cl =
+ InMemoryServiceClassLoader.of(
+ ModelProvider.class,
+ List.of(AlphaTestProvider.class, FailingTestProvider.class, BetaTestProvider.class));
+
+ List registered = ModelProviderRegistry.registerAll(cl);
+
+ // The broken provider is skipped; the two healthy providers still register.
+ assertThat(registered.stream().map(ModelProvider::modelPattern).toList())
+ .containsExactly("spi-test-alpha/.*", "spi-test-beta/.*");
+ assertThat(LlmRegistry.matchesAnyPattern("spi-test-failing/some-model")).isFalse();
+ }
+
+ @Test
+ public void registerAll_resolvedLlmReceivesBareModelName() {
+ ClassLoader cl =
+ InMemoryServiceClassLoader.of(ModelProvider.class, List.of(AlphaTestProvider.class));
+ ModelProviderRegistry.registerAll(cl);
+
+ BaseLlm llm = LlmRegistry.getLlm("spi-test-alpha/some-model-id");
+
+ // The routing prefix is stripped: the wire-format model name is the bare identifier.
+ assertThat(llm).isInstanceOf(FakeLlm.class);
+ assertThat(llm.model()).isEqualTo("some-model-id");
+ }
+
+ @Test
+ public void registerAll_noArg_discoversProviderFromTestClasspathServiceFile() {
+ List registered = ModelProviderRegistry.registerAll();
+
+ assertThat(registered.stream().anyMatch(p -> p instanceof ServiceFileTestProvider)).isTrue();
+ assertThat(LlmRegistry.matchesAnyPattern("spi-test-servicefile/some-model")).isTrue();
+ }
+
+ // -----------------------------------------------------------------------
+ // Helper: ClassLoader backed by an in-memory META-INF/services file
+ // -----------------------------------------------------------------------
+
+ /**
+ * A {@link ClassLoader} that serves a synthetic {@code META-INF/services/} resource listing the
+ * given implementation classes, enabling {@link java.util.ServiceLoader} to discover them without
+ * any real JAR on the classpath.
+ */
+ static final class InMemoryServiceClassLoader extends ClassLoader {
+
+ private final String resourcePath;
+ private final URL serviceFileUrl;
+
+ // The URL(..., URLStreamHandler) constructor is deprecated (not for removal) as of Java 20,
+ // but its replacement URL.of() does not exist in Java 17, this project's language level.
+ @SuppressWarnings("deprecation")
+ static InMemoryServiceClassLoader of(
+ Class> serviceInterface, List> implementations) {
+ StringBuilder sb = new StringBuilder();
+ for (Class> impl : implementations) {
+ sb.append(impl.getName()).append("\n");
+ }
+
+ byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8);
+
+ try {
+ URL url = new URL("mem", null, 0, "/", new InMemoryStreamHandler(bytes));
+ return new InMemoryServiceClassLoader(
+ "META-INF/services/" + serviceInterface.getName(), url);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to create in-memory URL", e);
+ }
+ }
+
+ private InMemoryServiceClassLoader(String resourcePath, URL serviceFileUrl) {
+ super(InMemoryServiceClassLoader.class.getClassLoader());
+ this.resourcePath = resourcePath;
+ this.serviceFileUrl = serviceFileUrl;
+ }
+
+ @Override
+ public Enumeration getResources(String name) throws IOException {
+ if (resourcePath.equals(name)) {
+ return Collections.enumeration(List.of(serviceFileUrl));
+ }
+ return super.getResources(name);
+ }
+
+ private static final class InMemoryStreamHandler extends URLStreamHandler {
+ private final byte[] content;
+
+ InMemoryStreamHandler(byte[] content) {
+ this.content = content;
+ }
+
+ @Override
+ protected URLConnection openConnection(URL u) {
+ return new URLConnection(u) {
+ @Override
+ public void connect() {}
+
+ @Override
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(content);
+ }
+ };
+ }
+ }
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/ModelProviderTest.java b/core/src/test/java/com/google/adk/models/ModelProviderTest.java
new file mode 100644
index 000000000..1da5c26d6
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/ModelProviderTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for the {@link ModelProvider} default methods. */
+@RunWith(JUnit4.class)
+public final class ModelProviderTest {
+
+ /** Provider capturing the bare model name it receives. */
+ private static final class CapturingProvider implements ModelProvider {
+ private final String prefix;
+ private final AtomicReference seenBareModelName = new AtomicReference<>();
+
+ CapturingProvider(String prefix) {
+ this.prefix = prefix;
+ }
+
+ @Override
+ public String prefix() {
+ return prefix;
+ }
+
+ @Override
+ public BaseLlm createFromBareModelName(String bareModelName) {
+ seenBareModelName.set(bareModelName);
+ return null;
+ }
+ }
+
+ @Test
+ public void modelPattern_derivedFromPrefix() {
+ assertThat(new CapturingProvider("myprovider").modelPattern()).isEqualTo("myprovider/.*");
+ }
+
+ @Test
+ public void modelPattern_blankPrefix_throws() {
+ assertThrows(IllegalStateException.class, () -> new CapturingProvider(" ").modelPattern());
+ }
+
+ @Test
+ public void modelPattern_nullPrefix_throws() {
+ assertThrows(IllegalStateException.class, () -> new CapturingProvider(null).modelPattern());
+ }
+
+ @Test
+ public void create_stripsProviderPrefix() {
+ CapturingProvider provider = new CapturingProvider("myprovider");
+
+ provider.create("myprovider/some-model");
+
+ assertThat(provider.seenBareModelName.get()).isEqualTo("some-model");
+ }
+
+ @Test
+ public void create_keepsNameWithoutPrefix() {
+ CapturingProvider provider = new CapturingProvider("myprovider");
+
+ provider.create("some-model");
+
+ assertThat(provider.seenBareModelName.get()).isEqualTo("some-model");
+ }
+
+ @Test
+ public void create_blankModelName_throws() {
+ assertThrows(
+ IllegalArgumentException.class, () -> new CapturingProvider("myprovider").create(" "));
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java
new file mode 100644
index 000000000..a6cc1b842
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.adk.models.chat.ChatCompletionsClient;
+import io.reactivex.rxjava3.core.Flowable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link OpenAiCompatibleLlm}. */
+@RunWith(JUnit4.class)
+public final class OpenAiCompatibleLlmTest {
+
+ @Test
+ public void normalizeBaseUrl_stripsChatCompletionsSuffix() {
+ assertThat(OpenAiCompatibleLlm.normalizeBaseUrl("http://host/v1/chat/completions"))
+ .isEqualTo("http://host/v1");
+ }
+
+ @Test
+ public void normalizeBaseUrl_keepsUrlWithoutSuffix() {
+ assertThat(OpenAiCompatibleLlm.normalizeBaseUrl("http://host/v1")).isEqualTo("http://host/v1");
+ }
+
+ @Test
+ public void generateContent_delegatesToClient() {
+ ChatCompletionsClient client = mock(ChatCompletionsClient.class);
+ when(client.complete(any(), anyBoolean())).thenReturn(Flowable.empty());
+ OpenAiCompatibleLlm llm = new OpenAiCompatibleLlm("some-model", client);
+ LlmRequest request = LlmRequest.builder().build();
+
+ llm.generateContent(request, true).test().assertComplete();
+
+ verify(client).complete(request, true);
+ }
+
+ @Test
+ public void model_isTheBareNamePassedAtConstruction() {
+ OpenAiCompatibleLlm llm =
+ new OpenAiCompatibleLlm("some-model", mock(ChatCompletionsClient.class));
+
+ assertThat(llm.model()).isEqualTo("some-model");
+ }
+
+ @Test
+ public void constructor_blankModelName_throws() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new OpenAiCompatibleLlm(" ", mock(ChatCompletionsClient.class)));
+ }
+
+ @Test
+ public void connect_isUnsupported() {
+ OpenAiCompatibleLlm llm =
+ new OpenAiCompatibleLlm("some-model", mock(ChatCompletionsClient.class));
+
+ assertThrows(
+ UnsupportedOperationException.class, () -> llm.connect(LlmRequest.builder().build()));
+ }
+}
diff --git a/core/src/test/resources/META-INF/services/com.google.adk.models.ModelProvider b/core/src/test/resources/META-INF/services/com.google.adk.models.ModelProvider
new file mode 100644
index 000000000..fd1404cc5
--- /dev/null
+++ b/core/src/test/resources/META-INF/services/com.google.adk.models.ModelProvider
@@ -0,0 +1 @@
+com.google.adk.models.ModelProviderRegistryTest$ServiceFileTestProvider