Skip to content

feat: Add ServiceLoader-based ModelProvider SPI for pluggable LLM backends#1363

Open
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/model-provider-spi
Open

feat: Add ServiceLoader-based ModelProvider SPI for pluggable LLM backends#1363
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/model-provider-spi

Conversation

@svetanis

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

ADK Java has no convention-based way to resolve a model name string to a third-party backend.
Using any non-Gemini model requires per-application wiring: constructing or injecting model
objects into .model(...) (the contrib/langchain4j and contrib/spring-ai bridges), or
calling LlmRegistry.registerLlm(pattern, factory) manually in every application — the only
native mechanism for string resolution today. Python ADK resolves a model string from config at
runtime via LiteLLM; Java has no equivalent. This PR adds the missing discovery layer for
OpenAI-compatible endpoints; it does not replace the bridges, which remain the path for other
providers.

Additionally, pattern-based registration has a subtle correctness constraint. When an agent is
created with .model("groq/some-model"), that string is not what reaches the provider:
LlmRegistry calls the registered factory with the requested name, and the "model" field in
the outgoing request is whatever name the returned BaseLlm was constructed with
(Basic.java copies it into LlmRequest.model; ChatCompletionsRequest.fromLlmRequest sends
it verbatim). Since providers accept only bare model IDs, a correct factory must build a
distinct instance per requested name and strip the routing prefix before construction —
otherwise the endpoint receives "groq/some-model" and rejects the request.

Solution:

A minimal ServiceLoader-based SPI in com.google.adk.models — 3 classes, no new
dependencies:

File Role
ModelProvider SPI interface: prefix() + createFromBareModelName(name)BaseLlm; a default create(name) strips the routing prefix (groq/<model-id><model-id>)
ModelProviderRegistry registerAll() discovers all implementations via ServiceLoader and registers each with the existing LlmRegistry; a provider that fails to instantiate is logged at WARN and skipped
OpenAiCompatibleLlm Thin BaseLlm over the native ChatCompletionsHttpClient for any POST /v1/chat/completions endpoint; constructed with the bare model ID; the delegate is held as the ChatCompletionsClient interface

Usage — one explicit opt-in line; provider JARs on the classpath self-register:

ModelProviderRegistry.registerAll();

LlmAgent agent =
    LlmAgent.builder()
        .name("assistant")
        .model("groq/some-model")
        .build();

Adding a provider requires no ADK changes: implement ModelProvider (typically a one-liner
returning new OpenAiCompatibleLlm(bareModelName, apiUrl, apiKey)) and declare it in
META-INF/services/com.google.adk.models.ModelProvider.

Deliberately out of scope:
automatic registerAll() inside Runner/AdkWebServer.start() — registration is explicit opt-in;
auto-registration can be discussed as a follow-up if there is interest.

Provider distribution: ADK owns only the interface contract — nothing to publish or
coordinate. A provider can live directly in an application's own source tree (one class + a
META-INF/services file; no published artifact needed), or be shipped independently by
vendors/community under their own Maven coordinates.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.
Tests run: 18, Failures: 0, Errors: 0, Skipped: 0

ModelProviderTest (6 tests):
  [OK] modelPattern_derivedFromPrefix
  [OK] modelPattern_blankPrefix_throws
  [OK] modelPattern_nullPrefix_throws
  [OK] create_stripsProviderPrefix
  [OK] create_keepsNameWithoutPrefix
  [OK] create_blankModelName_throws

ModelProviderRegistryTest (6 tests):
  [OK] registerAll_singleProvider_registersPatternWithLlmRegistry
  [OK] registerAll_multipleProviders_registersAll
  [OK] registerAll_noProviders_returnsEmptyListAndNothingRegistered
  [OK] registerAll_oneProviderFailsToInstantiate_othersStillRegister
  [OK] registerAll_resolvedLlmReceivesBareModelName
  [OK] registerAll_noArg_discoversProviderFromTestClasspathServiceFile

OpenAiCompatibleLlmTest (6 tests):
  [OK] normalizeBaseUrl_stripsChatCompletionsSuffix
  [OK] normalizeBaseUrl_keepsUrlWithoutSuffix
  [OK] generateContent_delegatesToClient
  [OK] model_isTheBareNamePassedAtConstruction
  [OK] constructor_blankModelName_throws
  [OK] connect_isUnsupported

Notes: the registry tests drive the real java.util.ServiceLoader (via an in-memory
META-INF/services resource plus a real test-classpath services file) and verify effects on
the real LlmRegistry — no static mocking. registerAll_resolvedLlmReceivesBareModelName
covers the full resolution chain: LlmRegistry.getLlm("spi-test-alpha/some-model-id") returns
a BaseLlm whose model name is the bare some-model-id.

Manual End-to-End (E2E) Tests:

Verified against this exact branch with external provider JARs and a live endpoint: two
providers (Groq, OpenRouter) on the classpath were discovered via ServiceLoader and
registered by ModelProviderRegistry, an agent was built with
.model("groq/llama-3.3-70b-versatile"), and Groq returned a normal completion — confirming
the full chain (discovery → registration → name-aware factory → prefix strip → native
ChatCompletionsHttpClient round trip):

INFO com.google.adk.models.ModelProviderRegistry -- Registered model provider
  'GroqModelProvider' for pattern 'groq/.*'
INFO com.google.adk.models.ModelProviderRegistry -- Registered model provider
  'OpenRouterModelProvider' for pattern 'openrouter/.*'

User: Reply in one sentence: what is the Java ServiceLoader pattern?
[demo-agent] The Java ServiceLoader pattern is a design pattern that allows for
dynamic discovery and loading of service providers or implementations at runtime, ...

The same design has also been exercised manually against Groq and OpenRouter endpoints, including
streaming and tool calling.

To try it against this branch with no external provider JAR:

// 1. Implement a provider (Groq shown; any /v1/chat/completions endpoint works)
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")));
  }
}
// 2. List it in META-INF/services/com.google.adk.models.ModelProvider
// 3. ModelProviderRegistry.registerAll(); then .model("groq/<model-id>")

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

Supersedes the earlier, much larger #1199, which I am closing in favor of this scoped change.

@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @svetanis, thank you for your contribution and We appreciate you taking the time to submit this pull request. Currently this PR is under review by our team, we will keep you updated if any additional information is required. thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Support third-party model strings like groq/model-id via a ServiceLoader SPI

2 participants