diff --git a/core/src/main/java/com/google/adk/models/ModelProvider.java b/core/src/main/java/com/google/adk/models/ModelProvider.java new file mode 100644 index 000000000..d5fa569da --- /dev/null +++ b/core/src/main/java/com/google/adk/models/ModelProvider.java @@ -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. + * + *

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/"} 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

+ * + *
    + *
  1. A provider library implements this interface. + *
  2. It declares the implementation class in {@code + * META-INF/services/com.google.adk.models.ModelProvider}. + *
  3. 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