Skip to content

Commit fe94f6a

Browse files
committed
feat: Add ServiceLoader-based ModelProvider SPI for pluggable LLM backends
1 parent 1929be7 commit fe94f6a

7 files changed

Lines changed: 786 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2026 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+
17+
package com.google.adk.models;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static com.google.common.base.Preconditions.checkState;
21+
22+
import com.google.common.base.Strings;
23+
24+
/**
25+
* Service Provider Interface (SPI) for pluggable LLM backends.
26+
*
27+
* <p>Implementations let third-party model providers (Groq, Ollama, OpenRouter, etc.) register
28+
* themselves with {@link LlmRegistry} automatically, so agents can reference models by prefixed
29+
* name strings such as {@code "groq/<model-id>"} without per-application registration code — adding
30+
* the provider dependency and its configuration (e.g. an API-key environment variable) is all an
31+
* application needs.
32+
*
33+
* <h2>How it works</h2>
34+
*
35+
* <ol>
36+
* <li>A provider library implements this interface.
37+
* <li>It declares the implementation class in {@code
38+
* META-INF/services/com.google.adk.models.ModelProvider}.
39+
* <li>The application calls {@link ModelProviderRegistry#registerAll()} once at startup, which
40+
* uses {@link java.util.ServiceLoader} to discover and register every provider on the
41+
* classpath.
42+
* </ol>
43+
*
44+
* <h2>Why the provider receives a bare model name</h2>
45+
*
46+
* <p>{@link LlmRegistry} resolves a model string by invoking the registered factory with the
47+
* requested name, and the resolved {@link BaseLlm}'s own model name is what is ultimately sent to
48+
* the backend as the wire-format model identifier. The {@code "groq/"} prefix is purely a routing
49+
* namespace, so the default {@link #create(String)} strips it before delegating to {@link
50+
* #createFromBareModelName(String)} — ensuring the backend receives a bare model identifier it
51+
* actually recognizes.
52+
*/
53+
public interface ModelProvider {
54+
55+
/**
56+
* Returns the provider prefix without the trailing slash, e.g. {@code "groq"}.
57+
*
58+
* <p>The prefix is used to derive the {@link #modelPattern()} and is stripped from the model name
59+
* before delegating to {@link #createFromBareModelName(String)}.
60+
*
61+
* @return the provider prefix, must not be blank
62+
*/
63+
String prefix();
64+
65+
/**
66+
* Returns the regex pattern of model names this provider handles.
67+
*
68+
* <p>The default implementation derives the pattern from {@link #prefix()}, e.g. {@code
69+
* "groq/.*"}.
70+
*/
71+
default String modelPattern() {
72+
String prefix = prefix();
73+
checkState(isNotNullOrBlank(prefix), "Provider prefix cannot be blank");
74+
return prefix + "/.*";
75+
}
76+
77+
/**
78+
* Creates a {@link BaseLlm} instance for the given model name.
79+
*
80+
* <p>The default implementation strips the {@link #prefix()} and separator slash from the model
81+
* name and delegates to {@link #createFromBareModelName(String)}.
82+
*
83+
* @param modelName the full model name, e.g. {@code "groq/some-model"}
84+
*/
85+
default BaseLlm create(String modelName) {
86+
checkArgument(isNotNullOrBlank(modelName), "modelName cannot be blank");
87+
String prefixWithSlash = prefix() + "/";
88+
String bareModelName =
89+
modelName.startsWith(prefixWithSlash)
90+
? modelName.substring(prefixWithSlash.length())
91+
: modelName;
92+
return createFromBareModelName(bareModelName);
93+
}
94+
95+
/**
96+
* Creates a {@link BaseLlm} for the given bare model identifier.
97+
*
98+
* <p>The {@code bareModelName} has already had the provider prefix removed and can be passed
99+
* directly to the underlying API.
100+
*
101+
* @param bareModelName the model name without prefix, e.g. {@code "some-model"}
102+
*/
103+
BaseLlm createFromBareModelName(String bareModelName);
104+
105+
private static boolean isNotNullOrBlank(String s) {
106+
return !Strings.nullToEmpty(s).isBlank();
107+
}
108+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright 2026 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+
17+
package com.google.adk.models;
18+
19+
import static com.google.common.collect.ImmutableList.toImmutableList;
20+
21+
import com.google.common.collect.ImmutableList;
22+
import java.util.Optional;
23+
import java.util.ServiceConfigurationError;
24+
import java.util.ServiceLoader;
25+
import org.slf4j.Logger;
26+
import org.slf4j.LoggerFactory;
27+
28+
/**
29+
* Discovers and registers all {@link ModelProvider} implementations on the classpath using Java's
30+
* {@link ServiceLoader} mechanism.
31+
*
32+
* <p>Without this class, model name strings such as {@code "groq/<model-id>"} cannot be passed to
33+
* {@code LlmAgent.builder().model(String)} unless application code has first called {@link
34+
* LlmRegistry#registerLlm} for the matching pattern. Calling {@link #registerAll()} once at startup
35+
* replaces all such manual registration: any provider JAR on the classpath that declares an
36+
* implementation in {@code META-INF/services/com.google.adk.models.ModelProvider} is picked up
37+
* automatically.
38+
*
39+
* <pre>{@code
40+
* ModelProviderRegistry.registerAll();
41+
*
42+
* LlmAgent agent =
43+
* LlmAgent.builder()
44+
* .name("assistant")
45+
* .model("groq/some-model")
46+
* .build();
47+
* }</pre>
48+
*/
49+
public final class ModelProviderRegistry {
50+
51+
private static final Logger logger = LoggerFactory.getLogger(ModelProviderRegistry.class);
52+
53+
private ModelProviderRegistry() {}
54+
55+
/**
56+
* Loads all {@link ModelProvider} implementations via {@link ServiceLoader} and registers each
57+
* one with {@link LlmRegistry}.
58+
*
59+
* @return an immutable list of registered providers (useful for logging and diagnostics)
60+
*/
61+
public static ImmutableList<ModelProvider> registerAll() {
62+
return registerAll(ModelProviderRegistry.class.getClassLoader());
63+
}
64+
65+
/**
66+
* Same as {@link #registerAll()} but discovers providers through the given {@link ClassLoader}.
67+
*
68+
* <p>Each provider is discovered and instantiated in isolation: a provider that fails to
69+
* instantiate (e.g. a throwing constructor) is logged at {@code WARN} and skipped without
70+
* preventing the remaining providers on the classpath from registering.
71+
*/
72+
public static ImmutableList<ModelProvider> registerAll(ClassLoader classLoader) {
73+
ImmutableList<ModelProvider> providers =
74+
ServiceLoader.load(ModelProvider.class, classLoader).stream()
75+
.flatMap(descriptor -> tryInstantiate(descriptor).stream())
76+
.collect(toImmutableList());
77+
providers.forEach(ModelProviderRegistry::registerProvider);
78+
return providers;
79+
}
80+
81+
private static void registerProvider(ModelProvider provider) {
82+
String pattern = provider.modelPattern();
83+
String className = provider.getClass().getName();
84+
LlmRegistry.registerLlm(pattern, provider::create);
85+
logger.info("Registered model provider '{}' for pattern '{}'", className, pattern);
86+
}
87+
88+
/**
89+
* Attempts to instantiate a {@link ModelProvider} from its {@link ServiceLoader.Provider}
90+
* descriptor.
91+
*
92+
* @return the provider instance, or empty if instantiation failed
93+
*/
94+
private static Optional<ModelProvider> tryInstantiate(
95+
ServiceLoader.Provider<ModelProvider> descriptor) {
96+
try {
97+
return Optional.of(descriptor.get());
98+
} catch (ServiceConfigurationError e) {
99+
String type = descriptor.type().getName();
100+
logger.warn("Skipping ModelProvider {} - failed to instantiate: {}", type, e.getMessage(), e);
101+
return Optional.empty();
102+
}
103+
}
104+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2026 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+
17+
package com.google.adk.models;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static com.google.common.base.Preconditions.checkNotNull;
21+
22+
import com.google.adk.models.chat.ChatCompletionsClient;
23+
import com.google.adk.models.chat.ChatCompletionsHttpClient;
24+
import com.google.common.annotations.VisibleForTesting;
25+
import com.google.common.base.Strings;
26+
import com.google.genai.types.HttpOptions;
27+
import io.reactivex.rxjava3.core.Flowable;
28+
import java.util.Map;
29+
import java.util.Optional;
30+
31+
/**
32+
* A {@link BaseLlm} for any endpoint implementing the OpenAI Chat Completions API format (Groq,
33+
* Ollama, OpenRouter, Azure OpenAI, vLLM, and others).
34+
*
35+
* <p>HTTP transport and JSON mapping are delegated entirely to ADK's native {@link
36+
* ChatCompletionsHttpClient}. Instances are immutable: the underlying client (including its {@code
37+
* Authorization} header) is built once at construction.
38+
*
39+
* <p>Instances are constructed with the <em>bare</em> model identifier, which is what the backend
40+
* receives as the wire-format {@code "model"} field — any routing prefix such as {@code "groq/"}
41+
* must be stripped by the caller before construction (see {@link ModelProvider#create(String)}).
42+
*
43+
* <p>Example, together with the {@link ModelProvider} SPI:
44+
*
45+
* <pre>{@code
46+
* public final class GroqModelProvider implements ModelProvider {
47+
* @Override
48+
* public String prefix() {
49+
* return "groq";
50+
* }
51+
*
52+
* @Override
53+
* public BaseLlm createFromBareModelName(String bareModelName) {
54+
* return new OpenAiCompatibleLlm(
55+
* bareModelName,
56+
* "https://api.groq.com/openai/v1",
57+
* Optional.ofNullable(System.getenv("GROQ_API_KEY")));
58+
* }
59+
* }
60+
* }</pre>
61+
*
62+
* <p>Live bidirectional connections are not supported; the Chat Completions API does not provide
63+
* this capability.
64+
*/
65+
public class OpenAiCompatibleLlm extends BaseLlm {
66+
67+
private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";
68+
69+
private final ChatCompletionsClient client;
70+
71+
/**
72+
* Creates a new OpenAI-compatible LLM.
73+
*
74+
* @param modelName the bare model name, sent to the backend as the {@code "model"} field
75+
* @param apiUrl the URL of the chat-completions endpoint; a trailing {@code /chat/completions}
76+
* segment is accepted and stripped, since the client appends it internally
77+
* @param apiKey optional API key; if empty, no {@code Authorization} header is sent
78+
*/
79+
public OpenAiCompatibleLlm(String modelName, String apiUrl, Optional<String> apiKey) {
80+
this(modelName, createClient(apiUrl, apiKey));
81+
}
82+
83+
@VisibleForTesting
84+
OpenAiCompatibleLlm(String modelName, ChatCompletionsClient client) {
85+
super(requireNotBlank(modelName, "modelName cannot be blank"));
86+
this.client = checkNotNull(client, "client");
87+
}
88+
89+
private static ChatCompletionsHttpClient createClient(String apiUrl, Optional<String> apiKey) {
90+
requireNotBlank(apiUrl, "apiUrl cannot be blank");
91+
HttpOptions.Builder optionsBuilder = HttpOptions.builder().baseUrl(normalizeBaseUrl(apiUrl));
92+
apiKey.ifPresent(key -> optionsBuilder.headers(Map.of("Authorization", "Bearer " + key)));
93+
return new ChatCompletionsHttpClient(optionsBuilder.build());
94+
}
95+
96+
/**
97+
* Strips a trailing {@code /chat/completions} segment, which {@link ChatCompletionsHttpClient}
98+
* appends internally.
99+
*/
100+
@VisibleForTesting
101+
static String normalizeBaseUrl(String apiUrl) {
102+
if (apiUrl.endsWith(CHAT_COMPLETIONS_PATH)) {
103+
return apiUrl.substring(0, apiUrl.length() - CHAT_COMPLETIONS_PATH.length());
104+
}
105+
return apiUrl;
106+
}
107+
108+
@Override
109+
public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stream) {
110+
return client.complete(llmRequest, stream);
111+
}
112+
113+
@Override
114+
public BaseLlmConnection connect(LlmRequest llmRequest) {
115+
throw new UnsupportedOperationException(
116+
"OpenAiCompatibleLlm does not support live bidirectional connections.");
117+
}
118+
119+
private static String requireNotBlank(String s, String errorMessage) {
120+
checkArgument(!Strings.nullToEmpty(s).isBlank(), errorMessage);
121+
return s;
122+
}
123+
}

0 commit comments

Comments
 (0)