diff --git a/contrib/model-prism/.gitignore b/contrib/model-prism/.gitignore new file mode 100644 index 000000000..776d519f4 --- /dev/null +++ b/contrib/model-prism/.gitignore @@ -0,0 +1,29 @@ +# Compiled class file +*.class +*.classpath +*.project +*.settings +*.factorypath +target/ + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* diff --git a/contrib/model-prism/README.md b/contrib/model-prism/README.md new file mode 100644 index 000000000..029d5a4eb --- /dev/null +++ b/contrib/model-prism/README.md @@ -0,0 +1,562 @@ +# adk-model-prism + +A proposal for pluggable LLM backends in [Google ADK Java](https://github.com/google/adk-java) +via the Java **ServiceLoader** SPI pattern. + +--- + +## Scope + +This project supports OpenAI-compatible REST providers for **text-based** LLM interaction. +All covered providers expose the same `POST /v1/chat/completions` HTTP endpoint. + +| Supported | Not supported | +|---|---| +| Text chat (non-streaming) | Audio generation / transcription | +| SSE token streaming | Image generation | +| Function / tool calling (`FunctionTool`) | Video generation | +| Any OpenAI-compatible REST endpoint | Live / bidirectional streaming (WebSocket, gRPC) | + +--- + +## Context: What ADK Already Provides + +Google ADK Java ships with built-in support for **Gemini** models. +For all other providers ADK offers an official option: + +### LangChain4j bridge (`contrib/langchain4j`) + +The `google-adk-langchain4j` contrib module wraps any [LangChain4j `ChatModel`](https://docs.langchain4j.dev/category/language-models) +as an ADK `BaseLlm`. This covers Anthropic, OpenAI, Ollama, Mistral, Cohere, and many others - no HTTP or serialization code to write: + +```java + // Add google-adk-langchain4j + langchain4j-anthropic to pom.xml, then: +AnthropicChatModel claude = AnthropicChatModel.builder() + .apiKey(System.getenv("ANTHROPIC_API_KEY")) + .modelName("claude-sonnet-4-6") + .build(); + +LlmAgent agent = LlmAgent.builder() + .name("my-agent") + .model(LangChain4j.builder().chatModel(claude).modelName("claude-sonnet-4-6").build()) + .build(); +``` +--- + +## The Problem This Project Addresses + +The LangChain4j bridge solves the implementation boilerplate, but every application +that uses a non-Gemini model must still: + +1. **Import** the specific provider library (`langchain4j-anthropic`, `langchain4j-ollama`, ...) +2. **Construct** the provider object explicitly in code +3. **Pass** a `LangChain4j` wrapper object to `.model()` - the model name string + `"groq/llama3-70b-8192"` cannot be used; there is no conversion-based discovery + +Adding or swapping a provider always requires code changes in every application. + +## The Proposed Solution + +Introduce a `ModelProvider` SPI and a `ModelProviderRegistry` that uses `java.util.ServiceLoader` +to discover all providers on the classpath automatically: + +```java +// Proposed approach - zero provider knowledge in App.java +ModelProviderRegistry.registerAll(); +``` + +Drop a provider JAR on the classpath -> it auto-registers and model name strings like +`"groq/llama3-70b-8192"` resolve automatically. Remove the JAR -> it's gone. +No code changes ever. + +### Comparison with the LangChain4j bridge + +| | LangChain4j bridge | This project | +|---|---|---| +| Status | Official ADK contrib | Prototype / proposal | +| Provider coverage | Any LangChain4j provider (Anthropic, OpenAI, Mistral, ...) | OpenAI-compatible REST only (Groq, Ollama, OpenRouter, ...) | +| Registration | Manual - construct model object per agent | Automatic via `ServiceLoader` | +| Model name string in `.model()` | pass object, not string | `"groq/llama3-70b-8192"` | +| App imports provider class | Yes | No | +| Code changes to add a provider | Yes | Add one Maven dependency | + +The two approaches are **complementary**. Use the LangChain4j bridge for providers outside the OpenAI-compatible REST space (Anthropic, Cohere, etc.); +use this proposed solution for the common case where you want zero-boilerplate model name strings and classpath-driven discovery. + + + +--- + +## Project Structure + +| Module | | +|---|---| +| model-prism/ | parent pom | +| model-prism-core/ | ModelProvider SPI + OpenAI base (-> ADK core)| +| model-prism-groq/ | Groq JAR (its own META-INF/services) | +| model-prism-ollama/ | Ollama JAR (its own META-INF/services) | +| model-prism-openrouter/ | OpenRouter JAR (its own META-INF/services)| +| model-prism-demo/ | DemoApp - zero provider wiring code | + + +Each provider is its own Maven artifact. The demo depends only on `adk-model-prism-core` +plus whichever provider JARs are listed as dependencies - no provider class is ever imported +in `DemoApp`. + +| Module | Artifact | README | +|---|---|---| +| `model-prism-core` | `adk-model-prism-core` | [model-prism-core/README.md](model-prism-core/README.md) | +| `model-prism-groq` | `adk-model-prism-groq` | [model-prism-groq/README.md](model-prism-groq/README.md) | +| `model-prism-ollama` | `adk-model-prism-ollama` | [model-prism-ollama/README.md](model-prism-ollama/README.md) | +| `model-prism-openrouter` | `adk-model-prism-openrouter` | [model-prism-openrouter/README.md](model-prism-openrouter/README.md) | +| `model-prism-demo` | `adk-model-prism-demo` | [model-prism-demo/README.md](model-prism-demo/README.md) | + +--- + +## Value Proposition + +ADK's orchestration layer (`LoopAgent`, `ParallelAgent`, `SequentialAgent`, tool calling, session management) +is built on `BaseLlm` and does not care which model is underneath. +By implementing `BaseLlm` correctly, any provider gets the full ADK feature set for free: + +``` +LoopAgent / ParallelAgent / SequentialAgent + | + ADK orchestration (session, memory, tool loop) + | + BaseLlm <- the only contract ADK cares about + | + OpenAiCompatibleLlm <- this project (model-prism-core) + | + Groq / Ollama / OpenRouter / DeepSeek / ... +``` + +--- + +## Built-in Providers + +### Groq - fast hosted interface, free tier + +```yaml +model: groq/llama-3.1-8b-instant +``` +Requires `GROQ_API_KEY`. Sign up: https://console.groq.com +See [model-prism-groq/README.md](model-prism-groq/README.md) for model list. + +### Ollama - local models, no API key, no cost + +```yaml +model: ollama/llama3 +``` +Requires Ollama running locally. Install: https://ollama.com +See [model-prism-ollama/README.md](model-prism-ollama/README.md) for setup. + +### OpenRouter - hundreds of models, many free tiers + +```yaml +model: openrouter/auto +``` +Requires `OPENROUTER_API_KEY`. Sign up: https://openrouter.ai +See [model-prism-openrouter/README.md](model-prism-openrouter/README.md) for model list. + +--- + +## Other Compatible Providers + +Any service that speaks `POST /v1/chat/completions` works as a one-liner subclass of +`OpenAiCompatibleLlm` from `adk-model-prism-core`. + +## Hosted interface services + +| Provider | Base URL | API Key Env Var | +|---|---|---| +| Together AI | `https://api.together.xyz/v1/chat/completions` | `TOGETHER_API_KEY` | +| Fireworks AI | `https://api.fireworks.ai/interface/v1/chat/completions` | `FIREWORKS_API_KEY` | +| Perplexity | `https://api.perplexity.ai/chat/completions` | `PERPLEXITY_API_KEY` | +| Mistral AI | `https://api.mistral.ai/v1/chat/completions` | `MISTRAL_API_KEY` | +| Cerebras | `https://api.cerebras.ai/v1/chat/completions` | `CEREBRAS_API_KEY` | +| DeepSeek | `https://api.deepseek.com/v1/chat/completions` | `DEEPSEEK_API_KEY` | +| xAI (Grok) | `https://api.x.ai/v1/chat/completions` | `XAI_API_KEY` | + +### Local / self-hosted + +| Provider | Default Base Url | API Key | +|---|---|---| +| LM Studio | `http://localhost:1234/v1/chat/completions` | none | +| llama.cpp server | `http://localhost:8080/v1/chat/completions` | none | +| vLLM | `http://localhost:8000/v1/chat/completions` | none | + +### Cloud providers via compatibility layer + +| Provider | Notes | +|---|---| +| Azure OpenAI | Different URL shape, same JSON format | +| AWS Bedrock | Via OpenAI-compact endpoint | +| Vertex AI (Google) | `v1beta1/openai` compatibility endpoint | + +--- + +## How to Add a New Provider + +1. Implement `ModelProvider` (in a new Maven module or your own JAR): + +```java +public class MyProviderModelProvider implements ModelProvider { + + private static final String PREFIX = "myprovider/"; + private static final String API_URL = "https://api.myprovider.com/v1/chat/completions"; + + @Override public String modelPattern() {return "myprovider/.*";} + + @Override public BaseLlm create(String modelName) { + Optional apiKey = Optional.ofNullable(System.getenv("MY_PROVIDER_API_KEY")); + String model = modelName.startsWith(PREFIX) ? modelName.substring(PREFIX.length()) : modelName; + return new OpenAiCompatibleLlm(model, API_URL, apiKey); + } +} +``` + +2. Register it for ServiceLoader: + +``` +src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider +``` +Content: + +``` +com.example.myprovider.MyProviderModelProvider +``` + +3. That's it. Drop the JAR on the classpath - agents can use: `model: myprovider/some-model`. + +--- + +## Tool calling + +ADK owns the multi-turn tool loop: + +``` +User prompt + - ADK sends request + tool declaration to LLM + - LLM responds with a tool_call + - ADK executes the Java method + - ADK sends result back to LLM as a tool response + - LLM produces the final text answer +``` + +`DefaultOpenAiMessageSerializer` handles the wire-format translation at both ends. +The same `FunctionTool` works on Groq, Ollama, OpenRouter, or any other provider. + +**MCP toolsets** (`MCPToolset`) also work with any provider that supports the OpenAI +function-calling format. ADK connects to the MCP server (via stdio or SSE), discovers +its tool schemas, and forwards tool calls exactly as it does for `FunctionTool`. + +> **Note:** Tool calling support varies by model. 70B+ models are generally reliable. +> Smaller local models via Ollama may ignore or misformat tool calls. + +--- + +## Mixing Models in Multi-Agent Systems + +One of the most powerful patterns enabled by this SPI is assigning **different models to +different agents** within the same multi-agent pipeline. Each `LlmAgent` sets its own +`model` field independently - ADK's orchestration layer does not care which LLM is underneath. + +**Practical example: cost-optimised research pipeline** + +```java +// Researcher - needs GoogleSearchTool, must be Gemini +LlmAgent researcher = LlmAgent.builder() + .name("researcher") + .model("gemini-2.5-flash") + .tools(new GoogleSearchTool()) + .build() + +// Writer - heavy reasoning, use a capable hosted model +LlmAgent writer = LlmAgent.builder() + .name("writer") + .model("groq/llama-3.1-8b-instant") // Groq free tier: fast, no cost + .build() + +// Critic - lightweight review pass, smallest model is fine +LlmAgent critic = LlmAgent.builder() + .name("critic") + .model("ollama/llama3") // Ollama: fully local, zero cost + .build() + +SequentialAgent pipeline = SequentialAgent.builder() + .name("pipeline") + .subAgents(List.of(researcher, writer, critic)) + .build() +``` + +**Why this matters:** +- Use **Gemini** only where its built-in tools (`GoogleSearchTool`, code execution) +are actually needed - keeping Gemini API usage minimal +- Route **heavy generation** tasks to fast free-tier hosted models (Groq, OpenRouter) +- Run **lightweight** classification, critique, or formatting steps on local Ollama models +at zero cost +- The result is a more powerful system at significantly lower cost than running every +agent on a premium model +- Individual developers and open-source projects can build capable multi-agent Java +systems using only free-tier (Groq, OpenRouter) and local (Ollama) models - no +enterprise AI subscription required + +> This pattern is demonstrated in `MultiAgentDemoApp` - swap individual agent models +> without changing any orchestration logic. + +--- + +## `GoogleSearchTool` and Non-Google Models +`GoogleSearchTool` is Gemini-specific - it injects a `GoogleSearch` grounding config +into the request and only works with `gemini-2.*` or `gemini-3.*` models. +A non-Google model cannot use it directly. + +### Bridge pattern: `GoogleSearchAgentTool` + +ADK ships `GoogleSearchAgentTool`, a ready-made `AgentTool` that wraps a Gemini sub-agent +as a callable tool. A non-Google orchestrating agent simply invokes it like any other +`FunctionTool` - the routing to Gemini happens transparently inside `AgentTool.runAsync()`. + +```java +ModelProviderRegistry.registerAll(); // requires Groq / Ollama / OpenRouter + +// Inner agent: Gemini 2.5 Flash + native Google Search grounding +GoogleSearchAgentTool searchTool = + GoogleSearchAgentTool.create(LlmRegistry.getLlm("gemini-2.5-flash")); + +// Outer agent: any non-Google model - sees google_search_agent as a normal tool call +LlmAgent analyst = LlmAgent.builder() + .name("research-analyst") + .model("groq/llama-3.1-8b-instant") + .instruction("When you need up-to-date information, call google_search_agent.") + .tool(searchTool) + .build(); +``` + +Data flow: +``` +User prompt + | +Groq agent -> tool_call: google_search_agent({"request": "..."}) + | +AgentTool.runAsync() -> nested InMemoryRunner + | +Gemini 2.5 Flash + GoogleSearchTool (live web grounding) + | +result returned to Groq agent as a tool response + | +Groq synthesises the final answer +``` + +**Prerequisites:** `GROQ_API_KEY` + `GEMINI_API_KEY` or (`GOOGLE_API_KEY`). +See `AgentToolDemoApp` for runnable example. + +### Schema type normalization + +`AgentTool.declaration()` builds its `FunctionDeclaration` using Google genai's +`Schema` type, which uses uppercase enum names (`"OBJECT"`, `"STRING"`). The +OpenAI wire format requires lowercase JSON Schema types (`"object"`, `"string"`). + +Both `OpenAiJsonSerializer` and proposed `AdkChatSerializer` need to apply a +`normalizeSchemaTypes(JsonNode)` helper that recursively lowercases every `"type"` +field before the declaration is sent to the provider. Without this normalization, Groq +(and most other OpenAI-compatible endpoints) return HTTP 400 with an "invalid JSON schema" +error for any agent that carries an `AgentTool`. + +### Alternative: bring your own search API + +If you don't have a Gemini key, any search HTTP API works as a plain `FunctionTool`: + +```java +public Map webSearch(String query) {/*call any search API*/} +FunctionTool.create(this, "webSearch") +``` + +Recommended search APIs for LLM agents: **Tavily** (free tier, designed for agents), +**Brave Search API** (free tier), **Google Custom Search JSON API** (paid). + +--- + +## Demo Classes +| Class | What it shows | Extra prereqs | +|---|---|---| +| `DemoApp` | SPI auto-discovery via `ModelProviderRegistry.registerAll()` - prints registered providers and runs a single turn | - | +| `SessionDemoApp` | Multi-turn conversation memory - same `InMemoryRunner` + sessionId reused across three turns | - | +| `StreamingDemoApp` | SSE token-by-token streaming - partial events printed inline as they arrive | - | +| `ToolsDemoApp` | Three `FunctionTool`s (`getCurrentTime`, `getWeather`, `calculate`) wired to a live agent | - | +| `AgentToolDemoApp` | `GoogleSearchAgentTool`: Groq outer agent delegates live search to a Gemini sub-agent | GEMINI_API_KEY | +| `McpStdioDemoApp` | MCP filesystem tools via `@modelcontextprotocol/server-filesystem` (stdio) | Node.js + npx | +| `StructuredDemoApp` | `outputSchema` - agent extracts typed JSON (`title`, `director`, `year`, `genre`, `summary`) from a free-text movie blurb | - | +| `ParallelAgentDemoApp` | `ParallelAgent` - historian, scientiest, and economist run concurrently on one topic | - | +| `MultiAgentDemoApp` | `SequentialAgent` + `LoopAgent` : researcher -> (writer <-> critic x2); shows `outputKey` + `Instruction.Provider` for inter-agent state passing | GEMINI_API_KEY | +| `CallbacksDemoApp` | `beforeModel`, `afterModel`, `beforeTool`, `afterTool` lifecycle callbacks; includes a guardrail that short-circuits the tool call | - | +| `WebServerDemoApp` | ADK Dev web server (`AdkWebServer`) - chat via browser at `http://localhost:8080` | - | + +--- + +## Prerequisites + +```powershell +# OpenRouter (used by all demos) +$env:OPENROUTER_API_KEY = "your_key_here" + +# Gemini (used in AgentToolDemoApp and MultiAgentDemoApp to utilize GoogleSearchTool) +$env:GEMINI_API_KEY = "your_key_here" + +# Groq (optional - swap model names in the demo classes to use it) +$env:GROQ_API_KEY = "your_key_here" + +# Ollama (optional - no key needed, just have it running) +ollama server +``` + +## Running +All commands run from the **repo root**. On Powershell, quote the `-D` value. + +```powershell +# SPI registration demo (prints registered providers, no interface call) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.DemoApp" + +# SessionDemo - multi-turn memory: three turns share one sessionId +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.SessionDemoApp" + +# Streaming demo - partial SSE tokens printed inline as they arrive +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.StreamingDemoApp" + +# Tool-calling demo - agent calls getCurrentTime, getWeather, and calculate +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.tools.ToolsDemoApp" + +# AgentTool-calling demo - Groq -> GoogleSearchAgentTool -> Gemini (requires GEMINI_API_KEY) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.agenttool.AgentToolDemoApp" + +# MCP demo via npx/filesystem (requires Node.js) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.mcp.McpStdioDemoApp" + +# Structured output demo - typed JSON extracted from a movie blurb +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.structured.StructuredOutputDemoApp" + +# Parallel Agent Demo - historian, scientist, economist run concurrently +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.parallel.ParallelDemoApp" + +# Multi-agent pipeline demo (SequentialAgent: researcher -> (writer <-> critic x2) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.multiagent.MultiAgentDemoApp" + +# Callbacks demo - beforeModel, afterModel, beforeTool, afterTool; guardrail example +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.callbacks.CallbacksDemoApp" + +# ADK Dev Web server - open http://localhost:8080 in the ADK Dev UI +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.WebServerDemoApp" + +# Web server demo on a custom port +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.WebServerDemoApp" "-Dexec.args=--server.port=9090" +``` + +On bash: + +```bash +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.DemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.SessionDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.StreamingDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.tools.ToolsDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.agenttool.AgentToolDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.mcp.McpStdioDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.structured.StructuredOutputDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.parallel.ParallelAgentDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.multiagent.MultiAgentDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.callbacks.CallbacksDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.WebServerDemoApp +``` + +--- + +## How the ServiceLoader Mechanism Works + +``` +META-INF/services/com.google.adk.models.spi.ModelProvider + | (one file per provider JAR, one entry per file) + |-- adk-model-prism-groq.jar -> com.google.adk.models.groq.GroqModelProvider + |-- adk-model-prism-ollama.jar -> com.google.adk.models.ollama.OllamaModelProvider + |-- adk-model-prism-openrouter.jar -> com.google.adk.models.openrouter.OpenRouterModelProvider +``` + +At runtime: + +```java +ServiceLoader loader = ServiceLoader.load(ModelProvider.class); +for(ModelProvider provider : loader) { + LlmRegistry.registerLlm(provider.modelPattern(), provider::create); +} +``` + +Each provider JAR is fully self-contained and independently deployable. + +--- + +## Proposing This Upstream + +Three files from `adk-model-prism-core` would go into `google-adk.jar` +- `ModelProvider.java` - public SPI interface +- `ModelProviderRegistry.java` - ServiceLoader wiring +- `OpenAiCompatibleLlm.java` - reusable base class for Open-AI-format APIs + +First integration point: `Runner` + +```java +// Runner constructor - called by every ADK application +public Runner(BaseAgent agent, String appName, ...){ + ModelProviderRegistry.registerAll(); // <- one line +``` + +By calling `registerAll()` once inside `Runner`, **every ADK application** +gets automatic provider discovery with no code changes, regardless of the deployment pattern. + +Second integration point: `AdkWebServer.start()` in `google-adk-dev` +would call `registerAll()` once, so application code never needs to call it directly. + +```java +ModelProviderRegistry.registerAll(); +``` + +Provider JARs are published independently - no PRs to ADK core needed for new providers. + +--- + +## Optional: `ADKChatSerializer` - Richer responses via ADK 1.1.0 Internals + +The default serializer (`DefaultOpenAiMessageSerializer`) maps the essential fields +of the OpenAI response - text content and tool calls. ADK 1.1.0 ships internal DTOs in +`com.google.adk.models.chat` that already handle a richer mapping. +Implement `AdkChatSerializer` to utilize those DTOs to additionally populate usage metadata, +model version, custom metadata, etc... + +**How to wire it in** + +Pass `AdkChatSerializer` via the injection constructor when creating a provider: + +```java +// In your ModelProvider.create() implementation: +BaseLlm create(String modelName){ + String model = modelName.substring("groq/".length()); + return new OpenAiCompatibleLlm( + model, + new DefaultOpenAiHttpClient("https://api.groq.com/openai/v1/chat/completions"), + System.getenv("GROQ_API_KEY")), + new AdkChatSerializer()); // <- swap in here +} +``` + +Or subclass `OpenAiCompatibleLlm` directly: + +```java +public class GroqLlm extends OpenAiCompatibleLlm { + public GroqLlm(String modelName, String apiKey){ + super(modelName, + new DefaultOpenAiHttpClient("https://api.groq.com/openai/v1/chat/completions", apiKey), + new AdkChatSerializer()); + } +} +``` + + + diff --git a/contrib/model-prism/model-prism-core/README.md b/contrib/model-prism/model-prism-core/README.md new file mode 100644 index 000000000..a9cbba90a --- /dev/null +++ b/contrib/model-prism/model-prism-core/README.md @@ -0,0 +1,126 @@ +# adk-model-prism-core + +The core ModelPrism module for pluggable LLM backends in Google ADK Java via the Java ServiceLoader SPI pattern. +This is the module proposed for contribution to `google-adk.jar` + +--- + +## Contents + +| Class | Role | +|---|---| +| `ModelProvider` | SPI interface every provider implements | +| `ModelProviderRegistry` | ServiceLoader wiring - calls `LlmRegistry.registerLlm()` for each discovered provider | +| `OpenAiCompatibleLlm` | Reusable `BaseLlm` base class for any OpenAI-format REST API | +| `OpenAiHttpClient` | HTTP transport interface | +| `DefaultOpenAiHttpClient` | `java.net.http` implementation of `OpenAiHttpClient` | +| `OpenAiMessageSerializer` | JSON serialization interface | +| `DefaultOpenAiMessageSerializer` | Jackson implementation of `OpenAiMessageSerializer` | + +--- + +## `ModelProvider` - the SPI (Service Provider Interface) + +```java +public interface ModelProvider { + + /** + * Regex matched against the agent's model string, e.g. "groq/.*" */ + String modelPattern(); + + /** Factory - called by ADK when an agent uses a matching model name. */ + BaseLlm create(String modelName); +} +``` + +This is the only interface a provider author needs to implement. +Convention: prefix the pattern with the provider name followed by `/` +(e.g. `"groq/.*"`, `"ollama/.*"`) to avoid collisions. + +--- + +## `ModelProviderRegistry` - ServiceLoader wiring + +```java +// Discovers and registers all providers on the classpath +List registered = ModelProviderRegistry.registerAll(); + +// Overload for custom ClassLoader +List registered = ModelProviderRegistry.registerAll(myClassLoader); +``` + +Iterates every `META-INF/services/com.google.adk.models.spi.ModelProvider` file +found on the classpath and calls `LlmRegistry.registerLlm(pattern, factory)` for each entry. + +**Proposed ADK integration point:** + +First integration point: `Runner` in `google-adk.jar` + +```java +// Runner constructor - called by every ADK application +public Runner(BaseAgent agent, String appName, ...){ + ModelProviderRegistry.registerAll(); // <- one line +``` + +By calling `registerAll()` once inside `Runner`, **every ADK application** +gets automatic provider discovery with no code changes, regardless of the deployment pattern. + +Second integration point: `AdkWebServer.start()` in `google-adk-dev` +would call `registerAll()` once, so application code never needs to call it directly. + +```java +ModelProviderRegistry.registerAll(); +``` + +--- + +## `OpenAiCompatibleLlm` - base class for OpenAI-format APIs + +Groq, Ollama, OpenRouter, Together AI, Fireworks, and many others all expose +the same `POST /v1/chat/completions` REST interface. A new provider +needs only a one-liner subclass: + +```java +public class GroqLlm extends OpenAiCompatibleLlm { + public GroqLlm(String modelName, Optional apiKey) { + super(modelName, "https://api.groq.com/openai/v1/chat/completions", apiKey); + } +} +``` + +Constructor parameters: +- `modelName` - bare model name sent to the API (e.g. `llama-3.1-8b-instant`) +- `apiUrl` - full endpoint URL +- `apiKey` - bearer token, or `Optional.empty()` for keyless providers like Ollama + +A protected injection constructor is available for testing with custom collaborators: + +```java +protected OpenAiCompatibleLlm(String modelName, + OpenAiHttpClient httpClient, + OpenAiMessageSerializer serializer) +``` + +**Supported features:** + +| Feature | Details | +|---|---| +| System instructions | Mapped to `{"role":"system"}` message | +| Conversation history | ADK `"model"` role -> OpenAI `"assistant"` | +| Tool declarations | `FunctionDeclaration` -> OpenAI function schema | +| Tool call responses | `tool_calls` in response -> `Part.fromFunctionCall(...)` | +| Function response history | `Part.functionResponse()` -> `role=tool` messages | +| Non-streaming | `generateContent(request, false)` - single `LlmResponse` | +| SSE token streaming | `generateContent(request, true)` - `Flowable` of partial responses | + +--- + +## Dependency + +```xml + + com.google.adk + adk-model-prism-core + 0.1.0-SNAPSHOT + +``` diff --git a/contrib/model-prism/model-prism-core/pom.xml b/contrib/model-prism/model-prism-core/pom.xml new file mode 100644 index 000000000..f2cedf4a1 --- /dev/null +++ b/contrib/model-prism/model-prism-core/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.3.1-SNAPSHOT + ../../../pom.xml + + + adk-model-prism-core + Agent Development Kit - Model Prism Core + OpenAi Model Providers interface + + + + + com.google.adk + google-adk + ${project.version} + + + + jakarta.inject + jakarta.inject-api + compile + + + + org.apache.commons + commons-lang3 + compile + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + org.assertj + assertj-core + test + + + \ No newline at end of file diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/http/DefaultOpenAiHttpClient.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/http/DefaultOpenAiHttpClient.java new file mode 100644 index 000000000..a31fef020 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/http/DefaultOpenAiHttpClient.java @@ -0,0 +1,80 @@ +package com.google.adk.base.http; + +import static com.google.api.client.util.Preconditions.checkNotNull; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** {@link OpenAiHttpClient} implementation backed by {@code java.net.http.HttpClient}. */ +public class DefaultOpenAiHttpClient implements OpenAiHttpClient { + + public DefaultOpenAiHttpClient(String apiUrl, Optional apiKey) { + this.http = HttpClient.newHttpClient(); + this.apiUrl = checkNotNull(apiUrl, "apiUrl"); + this.apiKey = checkNotNull(apiKey, "apiKey"); + } + + private final HttpClient http; + private final String apiUrl; + private final Optional apiKey; + + @Override + public String get(String url) throws Exception { + HttpRequest request = + HttpRequest.newBuilder() // + .uri(URI.create(url)) // + .header("Accept", "application/json") // + .GET() // + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (!success(response)) { + String msg = "HTTP [%s] -> %s"; + throw new RuntimeException(msg.formatted(response.statusCode(), response.body())); + } + return response.body(); + } + + @Override + public String post(String requestBody) throws Exception { + HttpRequest request = request(requestBody); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (!success(response)) { + String msg = "HTTP [%s] -> %s"; + throw new RuntimeException(msg.formatted(response.statusCode(), response.body())); + } + return response.body(); + } + + @Override + public Stream postStream(String requestBody) throws Exception { + HttpRequest request = request(requestBody); + HttpResponse> response = http.send(request, HttpResponse.BodyHandlers.ofLines()); + if (!success(response)) { + String msg = "HTTP [%s] -> %s"; + String error = response.body().collect(Collectors.joining()); + throw new RuntimeException(msg.formatted(response.statusCode(), error)); + } + return response.body(); + } + + private HttpRequest request(String body) { + HttpRequest.Builder builder = + HttpRequest.newBuilder() // + .uri(URI.create(apiUrl)) // + .header("Content-Type", "application/json") // + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (apiKey.isPresent()) { + builder.header("Authorization", "Bearer " + apiKey.get()); + } + return builder.build(); + } + + private boolean success(HttpResponse response) { + return response.statusCode() >= 200 && response.statusCode() < 300; + } +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/http/OpenAiHttpClient.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/http/OpenAiHttpClient.java new file mode 100644 index 000000000..cf0403744 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/http/OpenAiHttpClient.java @@ -0,0 +1,35 @@ +package com.google.adk.base.http; + +import java.util.stream.Stream; + +/** + * Transport contract for the OpenAI chat-completions wire format. + * + *

Isolates HTTP mechanism from serialization so that either side can be swapped or tested + * independently (e.g. inject a mock in unit tests). + */ +public interface OpenAiHttpClient { + + /** + * Sends a blocking GET to the given URL and returns the full response body. + * + * @throws RuntimeException if the server returns a non-2xx status + */ + String get(String url) throws Exception; + + /** + * Sends a blocking POST with the given JSON body and returns the full response body. + * + * @throws RuntimeException if the server returns a non-2xx status + */ + String post(String requestBody) throws Exception; + + /** + * Sends a POST and returns the SSE response as a lazy line {@link Stream}. + * + *

Callers are responsible for closing the stream (use try-with-resources). + * + * @throws RuntimeException if the server returns a non-2xx status + */ + Stream postStream(String requestBody) throws Exception; +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/DefaultOpenAiMessageSerializer.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/DefaultOpenAiMessageSerializer.java new file mode 100644 index 000000000..f9e367d71 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/DefaultOpenAiMessageSerializer.java @@ -0,0 +1,139 @@ +package com.google.adk.base.serializer; + +import static com.google.adk.base.serializer.SerializerUtils.ARGUMENTS; +import static com.google.adk.base.serializer.SerializerUtils.CHOICES; +import static com.google.adk.base.serializer.SerializerUtils.CONTENT; +import static com.google.adk.base.serializer.SerializerUtils.DELTA; +import static com.google.adk.base.serializer.SerializerUtils.ERROR; +import static com.google.adk.base.serializer.SerializerUtils.FUNCTION; +import static com.google.adk.base.serializer.SerializerUtils.ID; +import static com.google.adk.base.serializer.SerializerUtils.INDEX; +import static com.google.adk.base.serializer.SerializerUtils.MAPPER; +import static com.google.adk.base.serializer.SerializerUtils.MESSAGE; +import static com.google.adk.base.serializer.SerializerUtils.NAME; +import static com.google.adk.base.serializer.SerializerUtils.TOOL_CALLS; +import static com.google.adk.base.serializer.SerializerUtils.content; +import static com.google.adk.base.serializer.SerializerUtils.finalTextResponse; +import static com.google.adk.base.serializer.SerializerUtils.parseJsonArgs; +import static com.google.genai.types.Part.fromFunctionCall; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.genai.types.Part; +import io.reactivex.rxjava3.core.FlowableEmitter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** Jackson-backed {@link OpenAiMessageSerializer} for the OpenAI chat-completions format. */ +public final class DefaultOpenAiMessageSerializer implements OpenAiMessageSerializer { + + private static final String SSE_DATA_PREFIX = "data: "; + private static final String SSE_DONE = "data: [DONE]"; + + @Override + public String serializeRequest(LlmRequest request, String modelName, boolean stream) + throws Exception { + RequestFunction function = new RequestFunction(modelName, stream); + return function.apply(request); + } + + @Override + public LlmResponse deserializeResponse(String responseBody) throws Exception { + return ResponseFunction.INSTANCE.apply(responseBody); + } + + @Override + public void processStreamLines(Stream lines, FlowableEmitter emitter) + throws Exception { + StringBuilder sb = new StringBuilder(); + List nodes = new ArrayList<>(); + Iterator it = lines.iterator(); + while (it.hasNext()) { + String line = it.next(); + if (SSE_DONE.equals(line)) { + break; + } + if (!line.startsWith(SSE_DATA_PREFIX)) { + continue; + } + + String json = line.substring(SSE_DATA_PREFIX.length()).trim(); + if (isBlank(json)) { + continue; + } + + JsonNode chunk; + try { + chunk = MAPPER.readTree(json); + } catch (Exception e) { + continue; + } + + if (chunk.has(ERROR)) { + String msg = chunk.path(ERROR).path(MESSAGE).asText(json); + throw new IllegalArgumentException("Stream error: %s".formatted(msg)); + } + + JsonNode delta = chunk.path(CHOICES).path(0).path(DELTA); + // Text delta + String textDelta = delta.path(CONTENT).asText(null); + if (isNotBlank(textDelta)) { + sb.append(textDelta); + emitter.onNext(partialTextResponse(textDelta)); + } + + // Tool call delta - arguments arrive fragmented across chunks + JsonNode toolCalls = delta.path(TOOL_CALLS); + if (toolCalls.isArray()) { + for (JsonNode tc : toolCalls) { + int index = tc.path(INDEX).asInt(0); + while (nodes.size() <= index) { + nodes.add(MAPPER.createObjectNode()); + } + ObjectNode acc = nodes.get(index); + if (tc.has(ID)) { + acc.put(ID, tc.path(ID).asText("")); + } + + if (tc.path(FUNCTION).has(NAME)) { + acc.put(NAME, tc.path(FUNCTION).path(NAME).asText("")); + } + + if (tc.path(FUNCTION).has(ARGUMENTS)) { + String arg = acc.path(ARGUMENTS).asText(""); + String farg = tc.path(FUNCTION).path(ARGUMENTS).asText(""); + acc.put(ARGUMENTS, arg + farg); + } + } + } + } + emitter.onNext(nodes.isEmpty() ? finalTextResponse(sb.toString()) : toolCallResponse(nodes)); + } + + private LlmResponse toolCallResponse(List nodes) throws Exception { + List parts = new ArrayList<>(); + for (ObjectNode node : nodes) { + Map args = parseJsonArgs(node.path(ARGUMENTS).asText("{}")); + parts.add(fromFunctionCall(node.path(NAME).asText(""), args)); + } + return LlmResponse.builder() // + .content(content(parts)) // + .turnComplete(false) // + .build(); + } + + private LlmResponse partialTextResponse(String token) { + return LlmResponse.builder() // + .content(content(List.of(Part.fromText(token)))) // + .partial(true) // + .turnComplete(false) // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/OpenAiMessageSerializer.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/OpenAiMessageSerializer.java new file mode 100644 index 000000000..d4295a9e5 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/OpenAiMessageSerializer.java @@ -0,0 +1,40 @@ +package com.google.adk.base.serializer; + +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import io.reactivex.rxjava3.core.FlowableEmitter; +import java.util.stream.Stream; + +/** + * Contract for converting between ADK domain objects and the OpenAI chat-completions JSON wire + * format. + * + *

Separating this from HTTP transport means the two can evolve and be tested independently. + */ +public interface OpenAiMessageSerializer { + + /** + * Serializes an {@link LlmRequest} to an OpenAI-format JSON request body + * + * @param request the ADK request + * @param modelName bare model name to embed in the payload + * @param stream whether to request SSE streaming from the API + */ + String serializeRequest(LlmRequest request, String modelName, boolean stream) throws Exception; + + /** + * Deserializes a full (non-streaming) OpenAI chat-completions response body into a single {@link + * LlmResponse} + */ + LlmResponse deserializeResponse(String responseBody) throws Exception; + + /** + * Processes an SSE line stream, emitting partial and final {@link LlmResponse} events onto the + * supplied {@link FlowableEmitter}. + * + *

Does not call {@link FlowableEmitter#onComplete()} - the caller is responsible for + * that so it can manage the stream lifecycle. + */ + void processStreamLines(Stream lines, FlowableEmitter emitter) + throws Exception; +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/RequestFunction.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/RequestFunction.java new file mode 100644 index 000000000..333cf809e --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/RequestFunction.java @@ -0,0 +1,155 @@ +package com.google.adk.base.serializer; + +import static com.google.adk.base.serializer.SerializerUtils.ARGUMENTS; +import static com.google.adk.base.serializer.SerializerUtils.ASSISTANT; +import static com.google.adk.base.serializer.SerializerUtils.CALL_0; +import static com.google.adk.base.serializer.SerializerUtils.CONTENT; +import static com.google.adk.base.serializer.SerializerUtils.DESCRIPTION; +import static com.google.adk.base.serializer.SerializerUtils.FUNCTION; +import static com.google.adk.base.serializer.SerializerUtils.ID; +import static com.google.adk.base.serializer.SerializerUtils.MAPPER; +import static com.google.adk.base.serializer.SerializerUtils.MESSAGES; +import static com.google.adk.base.serializer.SerializerUtils.MODEL; +import static com.google.adk.base.serializer.SerializerUtils.NAME; +import static com.google.adk.base.serializer.SerializerUtils.OBJECT; +import static com.google.adk.base.serializer.SerializerUtils.PARAMETERS; +import static com.google.adk.base.serializer.SerializerUtils.ROLE; +import static com.google.adk.base.serializer.SerializerUtils.STREAM; +import static com.google.adk.base.serializer.SerializerUtils.SYSTEM; +import static com.google.adk.base.serializer.SerializerUtils.TOOL; +import static com.google.adk.base.serializer.SerializerUtils.TOOLS; +import static com.google.adk.base.serializer.SerializerUtils.TOOL_CALLS; +import static com.google.adk.base.serializer.SerializerUtils.TOOL_CALL_ID; +import static com.google.adk.base.serializer.SerializerUtils.TYPE; +import static com.google.adk.base.serializer.SerializerUtils.USER; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.adk.models.LlmRequest; +import com.google.adk.tools.BaseTool; +import com.google.genai.types.Content; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.FunctionDeclaration; +import com.google.genai.types.Part; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public final class RequestFunction implements Function { + + public RequestFunction(String modelName, boolean stream) { + this.stream = stream; + this.modelName = checkNotNull(modelName, "modelName"); + } + + private final boolean stream; + private final String modelName; + + @Override + public String apply(LlmRequest request) { + try { + ObjectNode body = MAPPER.createObjectNode(); + body.put(MODEL, modelName); + body.put(STREAM, stream); + + ArrayNode messages = body.putArray(MESSAGES); + request + .getFirstSystemInstruction() + .ifPresent(inst -> messages.addObject().put(ROLE, SYSTEM).put(CONTENT, inst)); + for (Content content : request.contents()) { + String role = content.role().orElse(USER); + if (MODEL.equals(role)) { + role = ASSISTANT; + } + appendContentParts(messages, role, content); + } + + if (!request.tools().isEmpty()) { + ArrayNode tools = body.putArray(TOOLS); + for (BaseTool tool : request.tools().values()) { + tool.declaration().ifPresent(decl -> appendToolDeclaration(tools, decl)); + } + } + return MAPPER.writeValueAsString(body); + } catch (Exception e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + private void appendContentParts(ArrayNode messages, String role, Content content) + throws Exception { + List parts = content.parts().orElse(List.of()); + List callParts = parts.stream().filter(p -> p.functionCall().isPresent()).toList(); + if (!callParts.isEmpty()) { + appendCallParts(messages, callParts); + return; + } + List respParts = parts.stream().filter(p -> p.functionResponse().isPresent()).toList(); + if (!respParts.isEmpty()) { + appendResponseParts(messages, respParts); + return; + } + String text = content.text(); + if (isNotBlank(text)) { + messages.addObject().put(ROLE, role).put(CONTENT, text); + } + } + + private void appendResponseParts(ArrayNode messages, List responseParts) throws Exception { + for (Part part : responseParts) { + var fr = part.functionResponse().get(); + messages + .addObject() // + .put(ROLE, TOOL) // + .put(TOOL_CALL_ID, fr.id().orElse(fr.name().orElse(CALL_0))) // + .put(CONTENT, MAPPER.writeValueAsString(fr.response().orElse(Map.of()))); + } + } + + private void appendCallParts(ArrayNode messages, List callParts) throws Exception { + ObjectNode msg = messages.addObject(); + msg.put(ROLE, ASSISTANT); + ArrayNode toolCalls = msg.putArray(TOOL_CALLS); + for (Part part : callParts) { + FunctionCall fc = part.functionCall().get(); + ObjectNode tc = toolCalls.addObject(); + tc.put(ID, fc.id().orElse(fc.name().orElse(CALL_0))); + tc.put(TYPE, FUNCTION); + tc.putObject(FUNCTION) // + .put(NAME, fc.name().orElse("")) // + .put(ARGUMENTS, MAPPER.writeValueAsString(fc.args().orElse(Map.of()))); + } + } + + private void appendToolDeclaration(ArrayNode toolsArray, FunctionDeclaration decl) { + try { + ObjectNode node = toolsArray.addObject().put(TYPE, FUNCTION).putObject(FUNCTION); + node.put(NAME, decl.name().orElse("")); + node.put(DESCRIPTION, decl.description().orElse("")); + if (decl.parameters().isPresent()) { + node.set( + PARAMETERS, normalizeSchemaTypes(MAPPER.readTree(decl.parameters().get().toJson()))); + } else { + node.putObject(PARAMETERS).put(TYPE, OBJECT); + } + } catch (Exception e) { + throw new IllegalArgumentException("Failed to serialize tool declaration: " + decl.name(), e); + } + } + + private JsonNode normalizeSchemaTypes(JsonNode node) { + if (node.isObject()) { + ObjectNode obj = (ObjectNode) node; + if (obj.has(TYPE)) { + obj.put(TYPE, obj.get(TYPE).asText().toLowerCase()); + } + obj.fields().forEachRemaining(entry -> normalizeSchemaTypes(entry.getValue())); + } else if (node.isArray()) { + node.forEach(child -> normalizeSchemaTypes(child)); + } + return node; + } +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/ResponseFunction.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/ResponseFunction.java new file mode 100644 index 000000000..450d013ad --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/ResponseFunction.java @@ -0,0 +1,54 @@ +package com.google.adk.base.serializer; + +import static com.google.adk.base.serializer.SerializerUtils.ARGUMENTS; +import static com.google.adk.base.serializer.SerializerUtils.CHOICES; +import static com.google.adk.base.serializer.SerializerUtils.CONTENT; +import static com.google.adk.base.serializer.SerializerUtils.FUNCTION; +import static com.google.adk.base.serializer.SerializerUtils.MAPPER; +import static com.google.adk.base.serializer.SerializerUtils.MESSAGE; +import static com.google.adk.base.serializer.SerializerUtils.NAME; +import static com.google.adk.base.serializer.SerializerUtils.TOOL_CALLS; +import static com.google.adk.base.serializer.SerializerUtils.content; +import static com.google.adk.base.serializer.SerializerUtils.finalTextResponse; +import static com.google.adk.base.serializer.SerializerUtils.parseJsonArgs; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.adk.models.LlmResponse; +import com.google.genai.types.Part; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public enum ResponseFunction implements Function { + INSTANCE; + + @Override + public LlmResponse apply(String input) { + try { + JsonNode root = MAPPER.readTree(input); + JsonNode message = root.path(CHOICES).path(0).path(MESSAGE); + JsonNode toolCalls = message.path(TOOL_CALLS); + if (toolCalls.isArray() && !toolCalls.isEmpty()) { + return toolCallsResponse(toolCalls); + } + return finalTextResponse(message.path(CONTENT).asText("")); + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + private LlmResponse toolCallsResponse(JsonNode toolCalls) throws Exception { + List parts = new ArrayList<>(); + for (JsonNode tc : toolCalls) { + String txt = tc.path(FUNCTION).path(ARGUMENTS).asText("{}"); + String name = tc.path(FUNCTION).path(NAME).asText(""); + Map args = parseJsonArgs(txt); + parts.add(Part.fromFunctionCall(name, args)); + } + return LlmResponse.builder() // + .content(content(parts)) // + .turnComplete(false) + .build(); // + } +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/SerializerUtils.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/SerializerUtils.java new file mode 100644 index 000000000..e26d10ce0 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/base/serializer/SerializerUtils.java @@ -0,0 +1,58 @@ +package com.google.adk.base.serializer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.models.LlmResponse; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.List; +import java.util.Map; + +public final class SerializerUtils { + + protected static final ObjectMapper MAPPER = new ObjectMapper(); + + protected static final String ID = "id"; + protected static final String ROLE = "role"; + protected static final String NAME = "name"; + protected static final String TYPE = "type"; + protected static final String USER = "user"; + protected static final String TOOL = "tool"; + protected static final String TOOLS = "tools"; + protected static final String MODEL = "model"; + protected static final String ERROR = "error"; + protected static final String DELTA = "delta"; + protected static final String INDEX = "index"; + protected static final String OBJECT = "object"; + protected static final String CALL_0 = "call_0"; + protected static final String STREAM = "stream"; + protected static final String SYSTEM = "system"; + protected static final String CHOICES = "choices"; + protected static final String CONTENT = "content"; + protected static final String MESSAGE = "message"; + protected static final String MESSAGES = "messages"; + protected static final String FUNCTION = "function"; + protected static final String ARGUMENTS = "arguments"; + protected static final String ASSISTANT = "assistant"; + protected static final String TOOL_CALLS = "tool_calls"; + protected static final String PARAMETERS = "parameters"; + protected static final String DESCRIPTION = "description"; + protected static final String TOOL_CALL_ID = "tool_call_id"; + + protected static LlmResponse finalTextResponse(String fullText) { + List parts = List.of(Part.fromText(fullText)); + return LlmResponse.builder() // + .content(content(parts)) // + .partial(false) // + .turnComplete(true) // + .build(); + } + + protected static Content content(List parts) { + return Content.builder().role(MODEL).parts(parts).build(); + } + + @SuppressWarnings("unchecked") + protected static Map parseJsonArgs(String json) throws Exception { + return MAPPER.readValue(json, Map.class); + } +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/ModelProvider.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/ModelProvider.java new file mode 100644 index 000000000..de7e76844 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/ModelProvider.java @@ -0,0 +1,57 @@ +package com.google.adk.models.spi; + +import com.google.adk.models.BaseLlm; + +/** + * SPI (Service Provider Interface) for pluggable LLM backends in Google ADK Java. + * + *

Purpose

+ * + *

This interface is the proposed extension point that would allow third-party providers (Groq, + * Ollama, OpenRouter, etc.) to register themselves with {@link com.google.adk.models.LlmRegistry} + * automatically - without any changes to {@code App.java} or ADK core. + * + *

How it works

+ * + *
    + *
  1. A provider library implements this interface. + *
  2. It declares the implementation in {@code + * META-INF/services/com.google.adk.models.spi.ModelProvider}. + *
  3. At startup, {@link ModelProviderRegistry#registerAll()} uses {@link + * java.util.ServiceLoader} to discover and register all providers on the classpath. + *
+ * + *

Proposed location in ADK core

+ * + *

This interface would live in {@code google-adk.jar} under {@code com.google.adk.models.spi}, + * making it part of ADK's public API surface + */ +public interface ModelProvider { + + /** + * Returns the regex pattern this provider handles. + * + *

The pattern is matched against the full {@code agent.model} value. Convention: prefix with + * provider name followed by {@code /} to avoid collisions. + * + *

Examples: + * + *

    + *
  • {@code "groq/.*"} - matches {@code groq/llama-3.1-8b-instant} + *
  • {@code "ollama/.*"} - matches {@code ollama/llama3} + *
  • {@code "openrouter/.*} - matches {@code openrouter/auto} + *
+ */ + String modelPattern(); + + /** + * Creates a {@link BaseLlm} instance for the given model name. + * + *

Called once per unique model name when first needed - not at startup. The full model name + * (including prefix) is passed; implementation should strip the prefix before passing to the + * underlying API. + * + * @param modelName the full model name, e.g. {@code "groq/llama-3.1-8b-instant"} + */ + BaseLlm create(String modelName); +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/ModelProviderRegistry.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/ModelProviderRegistry.java new file mode 100644 index 000000000..696a6b53a --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/ModelProviderRegistry.java @@ -0,0 +1,70 @@ +package com.google.adk.models.spi; + +import com.google.adk.models.LlmRegistry; +import java.util.ArrayList; +import java.util.List; +import java.util.ServiceLoader; + +/** + * Discovers and registers all {@link ModelProvider} implementations on the classpath using Java's + * {@link ServiceLoader} mechanism. + * + *

Context

+ * + *

ADK Java ships build-in support for Gemini. For other providers ADK already offers the + * official {@code google-adk-langchain4j} contrib bridge, which wraps any LangChain4j {@code + * ChatModel} as a {@code BaseLlm} and is passed directly to {@code + * LlmAgent.builder().model(langChain4jWrapper)}. That approach requires explicit per-agent + * construction code and does not use {@code LlmRegistry} at all. + * + *

This class addresses a different gap: there is no convention-based, zero-code + * discovery mechanism. Without it, model name strings such as {@code "groq/llama3} cannot be + * used in {@code .model("groq/...")} unless something has first called {@code + * LlmRegistry.registerLlm("groq/.*", ...)} in application code. + * + *

Proposed usage in ADK core

+ * + *

ADK's startup sequence would call {@link #registerAll()} once before the agent server starts. + * This single call replaces all manual registration calls that applications currently must make. + * + *

+ *  ModelProvderRegistry.registerAll();
+ *  
+ * + *

How providers are discovered

+ * + *

Any jar on the classpath that contains a {@code + * META-INF/services/com.google.adk.models.spi.ModelProvider} file listing its implementation class + * will be automatically picked up. No configuration needed. + * + *

Proposed location in ADK core

+ * + *

This class would live in {@code google-adk.jar}, called internally by {@code Runner}. {@code + * AdkWebServer.start()} in {@code google-adk-dev} would call {@code registerAll()} once, so + * application code never needs to call it directly. + */ +public final class ModelProviderRegistry { + + private ModelProviderRegistry() {} + + /** + * Loads all {@link ModelProvider} implementations via {@link ServiceLoader} and registers each + * one with {@link LlmRegistry}. + * + * @return the list of registered providers (useful for logging/diagnostics) + */ + public static List registerAll() { + return registerAll(ModelProviderRegistry.class.getClassLoader()); + } + + /** Same as {@link #registerAll()} but uses a specific {@link ClassLoader}. */ + public static List registerAll(ClassLoader classLoader) { + List registered = new ArrayList<>(); + ServiceLoader loader = ServiceLoader.load(ModelProvider.class, classLoader); + for (ModelProvider provider : loader) { + LlmRegistry.registerLlm(provider.modelPattern(), provider::create); + registered.add(provider); + } + return registered; + } +} diff --git a/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/OpenAiCompatibleLlm.java b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/OpenAiCompatibleLlm.java new file mode 100644 index 000000000..018c7ed23 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/main/java/com/google/adk/models/spi/OpenAiCompatibleLlm.java @@ -0,0 +1,94 @@ +package com.google.adk.models.spi; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.base.http.DefaultOpenAiHttpClient; +import com.google.adk.base.http.OpenAiHttpClient; +import com.google.adk.base.serializer.DefaultOpenAiMessageSerializer; +import com.google.adk.base.serializer.OpenAiMessageSerializer; +import com.google.adk.models.BaseLlm; +import com.google.adk.models.BaseLlmConnection; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import io.reactivex.rxjava3.core.BackpressureStrategy; +import io.reactivex.rxjava3.core.Flowable; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Base class for any OpenAI-compatible chat completion API (Groq, Ollama, OpenRouter, Together AI, + * Fireworks, ...). + * + *

Orchestrates two collaborators: + * + *

    + *
  • {@link OpenAiHttpClient} - handles HTTP transport + *
  • {@link OpenAiMessageSerializer} - handles JSON serialization / deserialization + *
+ * + *

A new provider needs only a one-liner subclass: + * + *

+ * public class GroqLlm extends OpenAiCompatibleLlm {
+ *   public GroqLlm(String modelName, String apiKey) {
+ *     super(modelName, "https://api.groq.com/openai/v1/chat/completions", apiKey);
+ *   }
+ * }
+ * 
+ * + *

The {@code protected} injection constructor accepts custom collaborators, which is useful for + * subclasses and unit tests. + */ +public class OpenAiCompatibleLlm extends BaseLlm { + + public OpenAiCompatibleLlm(String modelName, String apiUrl, Optional apiKey) { + this( + modelName, + new DefaultOpenAiHttpClient(apiUrl, apiKey), + new DefaultOpenAiMessageSerializer()); + } + + protected OpenAiCompatibleLlm( + String modelName, OpenAiHttpClient httpClient, OpenAiMessageSerializer serializer) { + super(checkNotNull(modelName, "modelName")); + this.httpClient = checkNotNull(httpClient, "httpClient"); + this.serializer = checkNotNull(serializer, "serializer"); + } + + private final OpenAiHttpClient httpClient; + private final OpenAiMessageSerializer serializer; + + @Override + public Flowable generateContent(LlmRequest request, boolean stream) { + if (stream) { + return streamContent(request); + } + return Flowable.fromCallable( + () -> { + String requestBody = serializer.serializeRequest(request, model(), false); + return serializer.deserializeResponse(httpClient.post(requestBody)); + }); + } + + private Flowable streamContent(LlmRequest request) { + return Flowable.create( + emitter -> { + try { + String requestBody = serializer.serializeRequest(request, model(), true); + try (Stream lines = httpClient.postStream(requestBody)) { + serializer.processStreamLines(lines, emitter); + } + emitter.onComplete(); + } catch (Exception e) { + emitter.onError(e); + } + }, + BackpressureStrategy.BUFFER); + } + + @Override + public BaseLlmConnection connect(LlmRequest llmRequest) { + throw new UnsupportedOperationException( + getClass().getSimpleName() + " does not support live streaming connections"); + } +} diff --git a/contrib/model-prism/model-prism-core/src/test/java/com/google/adk/base/serializer/DefaultOpenAiMessageSerializerTest.java b/contrib/model-prism/model-prism-core/src/test/java/com/google/adk/base/serializer/DefaultOpenAiMessageSerializerTest.java new file mode 100644 index 000000000..13b99d9eb --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/test/java/com/google/adk/base/serializer/DefaultOpenAiMessageSerializerTest.java @@ -0,0 +1,243 @@ +package com.google.adk.base.serializer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.adk.models.LlmResponse; +import io.reactivex.rxjava3.core.BackpressureStrategy; +import io.reactivex.rxjava3.core.Flowable; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DefaultOpenAiMessageSerializerTest { + + private DefaultOpenAiMessageSerializer serializer; + + @BeforeEach + void setUp() { + serializer = new DefaultOpenAiMessageSerializer(); + } + + // Helper: subscribes synchronously, collecting all emitted responses. + // processStreamLines() calls onNext but never onComplete (it is the caller's responsibility), + // so we collect values directly from the Flowable using blockingIterable. + private List collect(List lines) throws Exception { + List results = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + Flowable.create( + emitter -> { + try { + serializer.processStreamLines(lines.stream(), emitter); + } catch (Exception e) { + emitter.onError(e); + return; + } + emitter.onComplete(); + }, + BackpressureStrategy.BUFFER) + .blockingForEach(results::add); + + return results; + } + + // ----------------------------------------------------------------------- + // processStreamLines - text streaming + // ----------------------------------------------------------------------- + + @Test + void processStreamLines_textChunks_emitsPartialsThenFinal() throws Exception { + List lines = + List.of( + "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}", + "data: {\"choices\":[{\"delta\":{\"content\":\", world\"}}]}", + "data: [DONE]"); + + List responses = collect(lines); + + // Two partial tokens + one final assembled response + assertThat(responses).hasSize(3); + + // First two are partial + assertThat(responses.get(0).partial()).hasValue(true); + assertThat(responses.get(1).partial()).hasValue(true); + + // Final response has the full concatenated text and turnComplete=true + LlmResponse finalResponse = responses.get(2); + assertThat(finalResponse.partial()).hasValue(false); + assertThat(finalResponse.turnComplete()).hasValue(true); + String fullText = finalResponse.content().get().parts().get().get(0).text().orElse(""); + assertThat(fullText).isEqualTo("Hello, world"); + } + + @Test + void processStreamLines_emptyStream_emitsFinalEmptyResponse() throws Exception { + List lines = List.of("data: [DONE]"); + + List responses = collect(lines); + + assertThat(responses).hasSize(1); + LlmResponse finalResponse = responses.get(0); + assertThat(finalResponse.turnComplete()).hasValue(true); + } + + @Test + void processStreamLines_nonDataLinesIgnored() throws Exception { + List lines = + List.of( + ": keep-alive", + "", + "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}", + "data: [DONE]"); + + List responses = collect(lines); + + // One partial + one final + assertThat(responses).hasSize(2); + assertThat(responses.get(0).partial()).hasValue(true); + } + + // ----------------------------------------------------------------------- + // processStreamLines - tool call streaming (the bug we fixed) + // ----------------------------------------------------------------------- + + @Test + void processStreamLines_toolCall_assemblesArgumentsAcrossChunks() throws Exception { + // Arguments arrive fragmented across multiple chunks, as they do in real SSE streams + List lines = + List.of( + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\"," + + "\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"function\":{\"arguments\":\"{\\\"city\\\":\"}}]}}]}", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0," + + "\"function\":{\"arguments\":\"\\\"London\\\"}\"}}]}}]}", + "data: [DONE]"); + + List responses = collect(lines); + + // Single tool call response emitted at end + assertThat(responses).hasSize(1); + LlmResponse toolResponse = responses.get(0); + assertThat(toolResponse.turnComplete()).hasValue(false); + + var parts = toolResponse.content().get().parts().get(); + assertThat(parts).hasSize(1); + var functionCall = parts.get(0).functionCall(); + assertThat(functionCall).isPresent(); + assertThat(functionCall.get().name()).hasValue("get_weather"); + assertThat(functionCall.get().args().get()).containsKey("city"); + assertThat(functionCall.get().args().get().get("city")).isEqualTo("London"); + } + + @Test + void processStreamLines_multipleToolCalls_assemblesBothByIndex() throws Exception { + List lines = + List.of( + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + + "{\"index\":0,\"id\":\"call_a\",\"function\":{\"name\":\"tool_a\"," + + "\"arguments\":\"{\\\"x\\\":1}\"}}," + + "{\"index\":1,\"id\":\"call_b\",\"function\":{\"name\":\"tool_b\"," + + "\"arguments\":\"{\\\"y\\\":2}\"}}" + + "]}}]}", + "data: [DONE]"); + + List responses = collect(lines); + + var parts = responses.get(0).content().get().parts().get(); + assertThat(parts).hasSize(2); + assertThat(parts.get(0).functionCall().get().name()).hasValue("tool_a"); + assertThat(parts.get(1).functionCall().get().name()).hasValue("tool_b"); + } + + // ----------------------------------------------------------------------- + // processStreamLines - error handling + // ----------------------------------------------------------------------- + + @Test + void processStreamLines_errorChunk_throwsIllegalArgumentException() { + List lines = + List.of("data: {\"error\":{\"message\":\"Invalid API key\",\"type\":\"auth_error\"}}"); + + assertThatThrownBy(() -> collect(lines)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid API key"); + } + + @Test + void processStreamLines_malformedJsonChunk_skippedGracefully() throws Exception { + List lines = + List.of( + "data: {not valid json}", + "data: {\"choices\":[{\"delta\":{\"content\":\"OK\"}}]}", + "data: [DONE]"); + + List responses = collect(lines); + + // Malformed line is skipped; valid chunk still processed + assertThat(responses).isNotEmpty(); + // One partial ("OK") + one final + assertThat(responses).hasSize(2); + } + + // ----------------------------------------------------------------------- + // deserializeResponse - non-streaming + // ----------------------------------------------------------------------- + + @Test + void deserializeResponse_textContent_returnsLlmResponseWithText() throws Exception { + String body = + """ + { + "choices": [{ + "message": { + "role": "assistant", + "content": "The capital of France is Paris." + }, + "finish_reason": "stop" + }] + } + """; + + LlmResponse response = serializer.deserializeResponse(body); + + assertThat(response).isNotNull(); + String text = response.content().get().parts().get().get(0).text().orElse(""); + assertThat(text).isEqualTo("The capital of France is Paris."); + } + + @Test + void deserializeResponse_toolCallContent_returnsFunctionCallPart() throws Exception { + String body = + """ + { + "choices": [{ + "message": { + "role": "assistant", + "tool_calls": [{ + "id": "call_abc", + "function": { + "name": "get_weather", + "arguments": "{\\"city\\":\\"Paris\\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + } + """; + + LlmResponse response = serializer.deserializeResponse(body); + + assertThat(response).isNotNull(); + var parts = response.content().get().parts().get(); + assertThat(parts).hasSize(1); + var functionCall = parts.get(0).functionCall(); + assertThat(functionCall).isPresent(); + assertThat(functionCall.get().name()).hasValue("get_weather"); + assertThat(functionCall.get().args().get()).containsEntry("city", "Paris"); + } +} diff --git a/contrib/model-prism/model-prism-core/src/test/java/com/google/adk/models/spi/ModelProviderRegistryTest.java b/contrib/model-prism/model-prism-core/src/test/java/com/google/adk/models/spi/ModelProviderRegistryTest.java new file mode 100644 index 000000000..ebf093062 --- /dev/null +++ b/contrib/model-prism/model-prism-core/src/test/java/com/google/adk/models/spi/ModelProviderRegistryTest.java @@ -0,0 +1,179 @@ +package com.google.adk.models.spi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import com.google.adk.models.BaseLlm; +import com.google.adk.models.LlmRegistry; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Tests for {@link ModelProviderRegistry}. + * + *

Rather than fighting ServiceLoader's class-name-based instantiation with Mockito mocks (which + * use synthetic class names), we use a simple real implementation of {@link ModelProvider} and + * verify the interaction with {@link LlmRegistry} via Mockito static mocking. + */ +@ExtendWith(MockitoExtension.class) +class ModelProviderRegistryTest { + + // ----------------------------------------------------------------------- + // Lightweight real providers for ClassLoader-based ServiceLoader testing + // ----------------------------------------------------------------------- + + /** A real (non-mock) provider used to drive the ServiceLoader in tests. */ + public static final class GroqTestProvider implements ModelProvider { + @Override + public String modelPattern() { + return "groq/.*"; + } + + @Override + public BaseLlm create(String modelName) { + throw new UnsupportedOperationException("not needed in unit tests"); + } + } + + /** A second real provider. */ + public static final class OllamaTestProvider implements ModelProvider { + @Override + public String modelPattern() { + return "ollama/.*"; + } + + @Override + public BaseLlm create(String modelName) { + throw new UnsupportedOperationException("not needed in unit tests"); + } + } + + // ----------------------------------------------------------------------- + // registerAll(ClassLoader) tests + // ----------------------------------------------------------------------- + + @Test + void registerAll_singleProvider_registersPatternWithLlmRegistry() { + ClassLoader cl = + InMemoryServiceClassLoader.of(ModelProvider.class, List.of(GroqTestProvider.class)); + + try (MockedStatic registry = mockStatic(LlmRegistry.class)) { + List registered = ModelProviderRegistry.registerAll(cl); + + assertThat(registered).hasSize(1); + assertThat(registered.get(0).modelPattern()).isEqualTo("groq/.*"); + registry.verify(() -> LlmRegistry.registerLlm(anyString(), any()), times(1)); + } + } + + @Test + void registerAll_multipleProviders_registersAll() { + ClassLoader cl = + InMemoryServiceClassLoader.of( + ModelProvider.class, List.of(GroqTestProvider.class, OllamaTestProvider.class)); + + try (MockedStatic registry = mockStatic(LlmRegistry.class)) { + List registered = ModelProviderRegistry.registerAll(cl); + + assertThat(registered).hasSize(2); + assertThat(registered.stream().map(ModelProvider::modelPattern)) + .containsExactlyInAnyOrder("groq/.*", "ollama/.*"); + registry.verify(() -> LlmRegistry.registerLlm(anyString(), any()), times(2)); + } + } + + @Test + void registerAll_noProviders_returnsEmptyListAndNothingRegistered() { + ClassLoader cl = InMemoryServiceClassLoader.of(ModelProvider.class, List.of()); + + try (MockedStatic registry = mockStatic(LlmRegistry.class)) { + List registered = ModelProviderRegistry.registerAll(cl); + + assertThat(registered).isEmpty(); + registry.verify(() -> LlmRegistry.registerLlm(anyString(), any()), never()); + } + } + + @Test + void registerAll_calledTwice_registersEachTimeIndependently() { + ClassLoader cl = + InMemoryServiceClassLoader.of(ModelProvider.class, List.of(GroqTestProvider.class)); + + try (MockedStatic registry = mockStatic(LlmRegistry.class)) { + ModelProviderRegistry.registerAll(cl); + ModelProviderRegistry.registerAll(cl); + + // LlmRegistry.registerLlm called once per invocation × 2 invocations = 2 + registry.verify(() -> LlmRegistry.registerLlm(anyString(), any()), times(2)); + } + } + + // ----------------------------------------------------------------------- + // Helper: ClassLoader backed by an in-memory META-INF/services file + // ----------------------------------------------------------------------- + + /** + * Constructs 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 byte[] serviceFileContent; + + static InMemoryServiceClassLoader of( + Class serviceInterface, List> implementations) { + StringBuilder sb = new StringBuilder(); + for (Class impl : implementations) { + sb.append(impl.getName()).append("\n"); + } + return new InMemoryServiceClassLoader( + "META-INF/services/" + serviceInterface.getName(), + sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private InMemoryServiceClassLoader(String resourcePath, byte[] content) { + super(InMemoryServiceClassLoader.class.getClassLoader()); + this.resourcePath = resourcePath; + this.serviceFileContent = content; + } + + @Override + public java.util.Enumeration getResources(String name) + throws java.io.IOException { + if (resourcePath.equals(name)) { + byte[] bytes = serviceFileContent; + java.net.URL url = + new java.net.URL( + "mem", + null, + 0, + "/", + new java.net.URLStreamHandler() { + @Override + protected java.net.URLConnection openConnection(java.net.URL u) { + return new java.net.URLConnection(u) { + @Override + public void connect() {} + + @Override + public java.io.InputStream getInputStream() { + return new java.io.ByteArrayInputStream(bytes); + } + }; + } + }); + return java.util.Collections.enumeration(List.of(url)); + } + return super.getResources(name); + } + } +} diff --git a/contrib/model-prism/model-prism-demo/README.md b/contrib/model-prism/model-prism-demo/README.md new file mode 100644 index 000000000..f13d339ed --- /dev/null +++ b/contrib/model-prism/model-prism-demo/README.md @@ -0,0 +1,139 @@ +# adk-model-prism-demo + +Demo module for the ModelProvider SPI. +Eleven self-contained runnable classes, each +demonstrating a different aspect of the +ADK + ModelPrism SPI combination. + +--- + +## Demo Classes +| Class | What it shows | Extra prereqs | +|---|---|---| +| `DemoApp` | SPI auto-discovery via `ModelProviderRegistry.registerAll()` - prints registered providers and runs a single turn | - | +| `SessionDemoApp` | Multi-turn conversation memory - same `InMemoryRunner` + sessionId reused across three turns | - | +| `StreamingDemoApp` | SSE token-by-token streaming - partial events printed inline as they arrive | - | +| `ToolsDemoApp` | Three `FunctionTool`s (`getCurrentTime`, `getWeather`, `calculate`) wired to a live agent | - | +| `AgentToolDemoApp` | `GoogleSearchAgentTool`: Groq outer agent delegates live search to a Gemini sub-agent | GEMINI_API_KEY | +| `McpStdioDemoApp` | MCP filesystem tools via `@modelcontextprotocol/server-filesystem` (stdio) | Node.js + npx | +| `StructuredDemoApp` | `outputSchema` - agent extracts typed JSON (`title`, `director`, `year`, `genre`, `summary`) from a free-text movie blurb | - | +| `ParallelAgentDemoApp` | `ParallelAgent` - historian, scientist, and economist run concurrently on one topic | - | +| `MultiAgentDemoApp` | `SequentialAgent` + `LoopAgent` : researcher -> (writer <-> critic x2); shows `outputKey` + `Instruction.Provider` for inter-agent state passing | GEMINI_API_KEY | +| `CallbacksDemoApp` | `beforeModel`, `afterModel`, `beforeTool`, `afterTool` lifecycle callbacks; includes a guardrail that short-circuits the tool call | - | +| `WebServerDemoApp` | ADK Dev web server (`AdkWebServer`) - chat via browser at `http://localhost:8080` | - | + +--- + +## Prerequisites + +```powershell +# OpenRouter (used by all demos) +$env:OPENROUTER_API_KEY = "your_key_here" + +# Gemini (used in AgentToolDemoApp and MultiAgentDemoApp to utilize GoogleSearchTool) +$env:GEMINI_API_KEY = "your_key_here" + +# Groq (optional - swap model names in the demo classes to use it) +$env:GROQ_API_KEY = "your_key_here" + +# Ollama (optional - no key needed, just have it running) +ollama server +``` + +## Running +All commands run from the **repo root**. On Powershell, quote the `-D` value. + +```powershell +# SPI registration demo (prints registered providers, no interface call) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.DemoApp" + +# SessionDemo - multi-turn memory: three turns share one sessionId +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.SessionDemoApp" + +# Streaming demo - partial SSE tokens printed inline as they arrive +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.StreamingDemoApp" + +# Tool-calling demo - agent calls getCurrentTime, getWeather, and calculate +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.tools.ToolsDemoApp" + +# AgentTool-calling demo - Groq -> GoogleSearchAgentTool -> Gemini (requires GEMINI_API_KEY) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.agenttool.AgentToolDemoApp" + +# MCP demo via npx/filesystem (requires Node.js) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.mcp.McpStdioDemoApp" + +# Structured output demo - typed JSON extracted from a movie blurb +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.structured.StructuredOutputDemoApp" + +# Parallel Agent Demo - historian, scientist, economist run concurrently +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.parallel.ParallelDemoApp" + +# Multi-agent pipeline demo (SequentialAgent: researcher -> (writer <-> critic x2) +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.multiagent.MultiAgentDemoApp" + +# Callbacks demo - beforeModel, afterModel, beforeTool, afterTool; guardrail example +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.callbacks.CallbacksDemoApp" + +# ADK Dev Web server - open http://localhost:8080 in the ADK Dev UI +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.WebServerDemoApp" + +# Web server demo on a custom port +mvn -pl model-prism-demo exec:java "-Dexec.mainClass=com.google.adk.models.demo.WebServerDemoApp" "-Dexec.args=--server.port=9090" +``` + +On bash: + +```bash +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.DemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.SessionDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.StreamingDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.tools.ToolsDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.agenttool.AgentToolDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.mcp.McpStdioDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.structured.StructuredOutputDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.parallel.ParallelAgentDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.multiagent.MultiAgentDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.callbacks.CallbacksDemoApp +mvn -pl model-prism-demo exec:java -Dexec.mainClass=com.google.adk.models.demo.WebServerDemoApp +``` + +--- + +## Why `-parameters` in demo/pom.xml + +The `maven-compiler-plugin` is configured with `-parameters`: + +```xml + + -parameters + +``` + +This tells `javac` to embed method parameter names in the `.class` file. +Without it, Java reflection only sees generic names like `arg0`, `arg1`, `arg2`. +ADK's `FunctionalTool` uses reflection to build the JSON schema it sends to the model - +if it gets `arg0` instead of `city`, the model has no idea what the parameter means and +tool calling breaks. + +With the flag, reflection returns the real names (`city`, `a`, `b`, `operation`), so +the schema is meaningful to the model. The `@schema` annotations on each parameter in +`ToolsDemoApp` add human-readable description on top of that. + +--- + +## Dependency Structure +``` +adk-model-prism-demo + | + |--adk-model-prism-core (ModelProvider, ModelRegistry, OpenAiCompatibleLlm) + |--adk-model-prism-groq (GroqModelProvider + META-INF/services) + |--adk-model-prism-ollama (OllamaModelProvider + META-INF/services) + |--adk-model-prism-openrouter (OpenRouterModelProvider + META-INF/services) + |--adk-model-prism-core (transitive) +``` + +The three provider modules are independent of each other. Remove any one and the remaining providers +still register and work. The demo itself has no dependency on any provider class - it only imports `adk-model-prism-core` + +The MCP SDK (`io.modelcontextprotocol.sdk:mcp`) is a transitive dependency via `google-adk` - +no extra entry in `pom.xml` is needed to use `MCPToolset`. diff --git a/contrib/model-prism/model-prism-demo/pom.xml b/contrib/model-prism/model-prism-demo/pom.xml new file mode 100644 index 000000000..ced502320 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.3.1-SNAPSHOT + ../../../pom.xml + + + adk-model-prism-demo + Agent Development Kit - Model Prism Demo + + Demo application. Depends on model prism core module plus + whichever provider JARS are on the classpath - provides + self-register via META-INF/services, no wiring code required. + To add or remove a provider, simply add or remove its Maven dependency. + + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + google-adk-dev + ${project.version} + + + com.google.adk + adk-model-prism-core + ${project.version} + + + com.google.adk + adk-model-prism-groq + ${project.version} + + + com.google.adk + adk-model-prism-ollama + ${project.version} + + + com.google.adk + adk-model-prism-openrouter + ${project.version} + + + + jakarta.inject + jakarta.inject-api + + compile + + + + org.apache.commons + commons-lang3 + + compile + + + com.fasterxml.jackson.core + jackson-databind + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + -parameters + + + + + + \ No newline at end of file diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/DemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/DemoApp.java new file mode 100644 index 000000000..78af582c1 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/DemoApp.java @@ -0,0 +1,39 @@ +package com.google.adk.models.demo; + +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import java.util.List; + +// mvn exec:java -Dexec.mainClass=com.google.adk.models.demo.DemoApp + +public final class DemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + + LlmAgent agent = demoAgent(); + String prompt = "Reply in one sentence: what is the Java ServiceLoader pattern?"; + // prompt = "What is your name and what can you do?"; + showAgent(agent, prompt); + DemoRunner.run(agent, prompt); + } + + private static LlmAgent demoAgent() { + return LlmAgent.builder() + .name("demo-agent") // + .description("Helpful Assistant agent") // + .model(MODEL) + .instruction("You are a helpful assistant") // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/DemoRunner.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/DemoRunner.java new file mode 100644 index 000000000..662fd4f5b --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/DemoRunner.java @@ -0,0 +1,113 @@ +package com.google.adk.models.demo; + +import static org.apache.commons.lang3.StringUtils.isBlank; + +import com.google.adk.agents.BaseAgent; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; +import com.google.adk.events.Event; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.runner.InMemoryRunner; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +public class DemoRunner { + + private static final String PAT = " %-20s pattern: %s%n"; + + private DemoRunner() {} + + public static void run(BaseAgent agent, String prompt) { + run(agent, prompt, DemoRunner::printEvent); + } + + public static void runTurn( + InMemoryRunner runner, RunConfig config, String sessionId, String prompt, int turn) { + System.out.println("%nTurn %d - User: %s%n".formatted(turn, prompt)); + System.out.println("-".repeat(70)); + var content = Content.fromParts(Part.fromText(prompt)); + runner + .runAsync("demo-user", sessionId, content, config) + .blockingForEach(DemoRunner::printEvent); + } + + public static void run(BaseAgent agent, String prompt, Consumer handler) { + var runner = new InMemoryRunner(agent); + var session = UUID.randomUUID().toString(); + var content = Content.fromParts(Part.fromText(prompt)); + var config = RunConfig.builder().autoCreateSession(true).build(); + runner.runAsync("demo-user", session, content, config).blockingForEach(handler::accept); + } + + public static void printEvent(Event event) { + String author = event.author(); + String content = event.stringifyContent(); + boolean isPartial = event.partial().orElse(false); + boolean isTurnComplete = event.turnComplete().orElse(false); + if (isBlank(content)) { + return; + } + if (isPartial) { + System.out.println(content); + } else if (isTurnComplete) { + System.out.println(); + System.out.println("-".repeat(70)); + System.out.printf("[%s - final response]%n", author); + System.out.println(content); + } else { + System.out.printf("[%s] %s%n", author, content); + } + } + + public static void runStreaming(BaseAgent agent, String prompt, Consumer handler) { + var runner = new InMemoryRunner(agent); + var session = UUID.randomUUID().toString(); + var content = Content.fromParts(Part.fromText(prompt)); + var config = + RunConfig.builder() + .autoCreateSession(true) + .streamingMode(RunConfig.StreamingMode.SSE) + .build(); + runner.runAsync("demo-user", session, content, config).blockingForEach(handler::accept); + } + + public static void printStreamingEvent(Event event, AtomicInteger count) { + boolean isPartial = event.partial().orElse(false); + boolean isTurnComplete = event.turnComplete().orElse(false); + String content = event.stringifyContent(); + + if (isPartial && content != null && !content.isEmpty()) { + System.out.print(content); + if (content.contains("\n") || content.matches(".*[.!?,;:-]$")) { + System.out.println(); + System.out.flush(); + } + count.incrementAndGet(); + } else if (isTurnComplete) { + // end of streaming turn + System.out.flush(); + System.out.println(); + System.out.println(); + System.out.println("-".repeat(70)); + System.out.printf("[Turn complete - %d partial event received]%n", count.get()); + // token usage metadata (present when the provider includes usage in the final SSE chunk) + event.usageMetadata().ifPresent(meta -> System.out.println("[Usage metadata] " + meta)); + } + } + + public static void showAgent(LlmAgent agent, String prompt) { + System.out.println("\nAgent created: " + agent.name()); + System.out.println("Model: " + agent.model().map(Object::toString).orElse("none")); + System.out.println("\nUser: " + prompt); + System.out.println("-".repeat(60)); + } + + public static void showProviders(List registered) { + System.out.println("Registered providers:"); + registered.forEach(p -> System.out.printf(PAT, p.getClass().getSimpleName(), p.modelPattern())); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/SessionDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/SessionDemoApp.java new file mode 100644 index 000000000..5df0894a5 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/SessionDemoApp.java @@ -0,0 +1,75 @@ +package com.google.adk.models.demo; + +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.RunConfig; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import com.google.adk.runner.InMemoryRunner; +import java.util.List; +import java.util.UUID; + +public final class SessionDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + private static final String INSTRUCTION = + """ + You are a helpful travel planning assistant. + Remember everything the user tells you about their trip. + """; + + private static final String PROMPT1 = + """ + Hi! My name is Alice. I'm planning a 2-week trip to Italy + in October. I'm especially interested in history and old-master art, + museums, galleries, cathedrals, castles and good Italian food. + """; + + private static final String PROMPT2 = + """ + Given what I told you, which cities should I prioritise and + what's the weather like there during my travel month? + """; + + private static final String PROMPT3 = + """ + Can you give me a quick summary of everything we've discussed + about my trip so far? + """; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + + LlmAgent agent = demoAgent(); + showAgent(agent, PROMPT1); + + var runner = new InMemoryRunner(agent); + var session = UUID.randomUUID().toString(); + var config = RunConfig.builder().autoCreateSession(true).build(); + + // --- Turn 1------------------------------------------------------ + DemoRunner.runTurn(runner, config, session, PROMPT1, 1); + + // --- Turn 2------------------------------------------------------ + DemoRunner.runTurn(runner, config, session, PROMPT2, 2); + + // --- Turn 3------------------------------------------------------ + DemoRunner.runTurn(runner, config, session, PROMPT3, 3); + } + + private static LlmAgent demoAgent() { + return LlmAgent.builder() + .name("travel-assistant") // + .description("Travel Assistant agent") // + .model(MODEL) // + .instruction(INSTRUCTION) // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/StreamingDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/StreamingDemoApp.java new file mode 100644 index 000000000..45f19c4af --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/StreamingDemoApp.java @@ -0,0 +1,51 @@ +package com.google.adk.models.demo; + +import static com.google.adk.models.demo.DemoRunner.printStreamingEvent; +import static com.google.adk.models.demo.DemoRunner.runStreaming; +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public final class StreamingDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + private static final String INSTRUCTION = + """ + You are a creative writing assistant. + Be concise; + """; + + private static final String PROMPT = + """ + Write a short poem (4-6 lines) about the joy + of asynchronous programming. + """; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + LlmAgent agent = demoAgent(); + showAgent(agent, PROMPT); + System.out.println("Streaming response (each token printed as it arrives):"); + var count = new AtomicInteger(0); + runStreaming(agent, PROMPT, event -> printStreamingEvent(event, count)); + } + + private static LlmAgent demoAgent() { + return LlmAgent.builder() + .name("streaming-demo-agent") // + .description("Writing Assistant agent") // + .model(MODEL) + .instruction(INSTRUCTION) // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/WebServerDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/WebServerDemoApp.java new file mode 100644 index 000000000..3d00ced57 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/WebServerDemoApp.java @@ -0,0 +1,33 @@ +package com.google.adk.models.demo; + +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import com.google.adk.web.AdkWebServer; +import java.util.List; + +public final class WebServerDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + LlmAgent agent = demoAgent(); + AdkWebServer.start(agent); + } + + private static LlmAgent demoAgent() { + return LlmAgent.builder() + .name("demo-agent") // + .description("Helpful Assistant agent") // + .model(MODEL) + .instruction("You are a helpful assistant") // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/agenttool/AgentProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/agenttool/AgentProvider.java new file mode 100644 index 000000000..d554b8d13 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/agenttool/AgentProvider.java @@ -0,0 +1,43 @@ +package com.google.adk.models.demo.agenttool; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.BaseLlm; +import com.google.adk.models.LlmRegistry; +import com.google.adk.tools.AgentTool; +import com.google.adk.tools.GoogleSearchAgentTool; +import jakarta.inject.Provider; + +public final class AgentProvider implements Provider { + + private static final String RESEARCH_INSTRUCTION = + """ + You are a research analyst. When you need up-to-date + information, call the google_search_agent tool with + a precise search query. Synthesize the results into a + concise, well-structured answer. + """; + + public AgentProvider(String model) { + this.model = checkNotNull(model, "model"); + } + + private final String model; + + @Override + public LlmAgent get() { + return LlmAgent.builder() // + .name("research-analyst") + .model(model) // + .description("A research analyst that can search the web via a search sub-agent") // + .instruction(RESEARCH_INSTRUCTION) // + .tools(googleSearchTool()) // + .build(); + } + + private AgentTool googleSearchTool() { + BaseLlm llm = LlmRegistry.getLlm("gemini-2.5-flash"); + return GoogleSearchAgentTool.create(llm); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/agenttool/AgentToolDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/agenttool/AgentToolDemoApp.java new file mode 100644 index 000000000..24f620845 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/agenttool/AgentToolDemoApp.java @@ -0,0 +1,73 @@ +package com.google.adk.models.demo.agenttool; + +import static com.google.adk.models.demo.DemoRunner.run; +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; +import static org.apache.commons.lang3.StringUtils.isBlank; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.events.Event; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import com.google.adk.tools.GoogleSearchAgentTool; +import java.util.List; + +/** + * Demonstrates {@link GoogleSearchAgentTool} - an {@link com.google.adk.tools.AgentTool} that wraps + * a Gemini-powered search sub-agent and exposes it as a callable tool to an outer agent running on + * a completely different (non-Google) model. + * + *

The inner Gemini agent is invoked transparently via ADK's tool-dispatch mechanism - the outer + * Groq agent simply sees a function called {@code google_search_agent} in its tool declarations. + */ +public class AgentToolDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + private static final String PROMPT = + """ + What are the most significant large language model + releases from major AI labs so far in 2026? Give me + a brief summary of each with their key capabilities? + """; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + LlmAgent agent = new AgentProvider(MODEL).get(); + showAgent(agent, PROMPT); + run(agent, PROMPT, AgentToolDemoApp::printEvent); + } + + public static void printEvent(Event event) { + String author = event.author(); + String content = event.stringifyContent(); + boolean isPartial = event.partial().orElse(false); + boolean isTurnComplete = event.turnComplete().orElse(false); + if (isBlank(content)) { + return; + } + if (isPartial) { + System.out.println(content); + } else if (isTurnComplete) { + System.out.println(); + System.out.println("-".repeat(70)); + String label = + switch (author) { + case "google_search_agent" -> "[Search agent - results]"; + case "research-analyst" -> "[Research analyst - final answer]"; + default -> "[" + author + "]"; + }; + + System.out.println(label); + System.out.println(content); + System.out.printf("[%s - final response]%n", author); + System.out.println(content); + } else { + System.out.printf("[%s] %s%n", author, content); + } + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/CallbacksDemoAgent.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/CallbacksDemoAgent.java new file mode 100644 index 000000000..c1c80a12b --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/CallbacksDemoAgent.java @@ -0,0 +1,45 @@ +package com.google.adk.models.demo.callbacks; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.Callbacks; +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.demo.tools.DemoTools; +import com.google.adk.tools.FunctionTool; +import jakarta.inject.Provider; + +public final class CallbacksDemoAgent implements Provider { + + private static final String INSTRUCTION = + """ + You are a stock-price assistant. + Use the lookupStockPrice tool + to answer questions. + """; + + public CallbacksDemoAgent(String model) { + this.model = checkNotNull(model, "model"); + } + + private final String model; + + @Override + public LlmAgent get() { + Callbacks.BeforeModelCallbackSync beforeModel = DemoCallbacks::onBeforeModel; + Callbacks.AfterModelCallbackSync afterModel = DemoCallbacks::onAfterModel; + Callbacks.BeforeToolCallbackSync beforeTool = DemoCallbacks::onBeforeTool; + Callbacks.AfterToolCallbackSync afterTool = DemoCallbacks::onAfterTool; + + return LlmAgent.builder() + .name("callback-demo-agent") // + .model(model) // + .description("Stock Price Assistant") // + .instruction(INSTRUCTION) // + .tools(FunctionTool.create(DemoTools.class, "lookupStockPrice")) + .beforeModelCallbackSync(beforeModel) + .afterModelCallbackSync(afterModel) + .beforeToolCallbackSync(beforeTool) + .afterToolCallbackSync(afterTool) + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/CallbacksDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/CallbacksDemoApp.java new file mode 100644 index 000000000..4ddca7bae --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/CallbacksDemoApp.java @@ -0,0 +1,33 @@ +package com.google.adk.models.demo.callbacks; + +import static com.google.adk.models.demo.DemoRunner.run; +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import java.util.List; + +public final class CallbacksDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + LlmAgent agent = new CallbacksDemoAgent(MODEL).get(); + // -- Run 1: normal flow ------------------------------------- + String prompt1 = "What are the current prices of AAPL and MSFT?"; + showAgent(agent, prompt1); + run(agent, prompt1); + + // -- Run 2: guardrail blocks the tool call ------------------------------------- + String prompt2 = "What is the price of HACK?"; + showAgent(agent, prompt2); + run(agent, prompt2); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/DemoCallbacks.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/DemoCallbacks.java new file mode 100644 index 000000000..f471b1104 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/callbacks/DemoCallbacks.java @@ -0,0 +1,65 @@ +package com.google.adk.models.demo.callbacks; + +import com.google.adk.agents.CallbackContext; +import com.google.adk.agents.InvocationContext; +import com.google.adk.models.LlmRequest; +import com.google.adk.models.LlmResponse; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.ToolContext; +import java.util.Map; +import java.util.Optional; + +public class DemoCallbacks { + + protected static Optional onBeforeModel( + CallbackContext ctx, LlmRequest.Builder requestBuilder) { + String msg = " [CALLBACK] beforeModel -- agent=%s, userId=%s"; + System.out.println(msg.formatted(ctx.agentName(), ctx.userId())); + return Optional.empty(); + } + + protected static Optional onAfterModel(CallbackContext ctx, LlmResponse response) { + boolean isPartial = response.partial().orElse(false); + boolean turnComplete = response.turnComplete().orElse(false); + + if (!isPartial) { + String msg = " [tokens: prompt=%s, output=%s, total=%s]"; + String msg2 = " [CALLBACK] afterModel -- turnComplete=%s"; + String usage = + response + .usageMetadata() + .map( + m -> + msg.formatted( + m.promptTokenCount().orElse(0), + m.candidatesTokenCount().orElse(0), + m.totalTokenCount().orElse(0))) + .orElse(""); + System.out.println(msg2.formatted(turnComplete, usage)); + } + return Optional.empty(); + } + + protected static Optional> onBeforeTool( + InvocationContext ctx, BaseTool tool, Map args, ToolContext toolCtx) { + String msg1 = " [CALLBACK] beforeTool -- tool=%s args=%s"; + System.out.println(msg1.formatted(tool.name(), args)); + Object ticker = args.get("ticker"); + if ("HACK".equalsIgnoreCase(String.valueOf(ticker))) { + System.out.println(" [CALLBACK] beforeTool -- BLOCKED ticker: HACK (guardrail)"); + return Optional.of(Map.of("error", "Ticker 'HACK' is not permitted")); + } + return Optional.empty(); + } + + protected static Optional> onAfterTool( + InvocationContext ctx, + BaseTool tool, + Map args, + ToolContext toolCtx, + Object result) { + String msg = " [CALLBACK] afterTool -- tool=%s result=%s"; + System.out.println(msg.formatted(tool.name(), result)); + return Optional.empty(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/mcp/McpStdioDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/mcp/McpStdioDemoApp.java new file mode 100644 index 000000000..9f4251a5e --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/mcp/McpStdioDemoApp.java @@ -0,0 +1,66 @@ +package com.google.adk.models.demo.mcp; + +import static com.google.adk.models.demo.DemoRunner.run; +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; +import static java.lang.System.getProperty; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import com.google.adk.tools.mcp.McpToolset; +import com.google.adk.tools.mcp.StdioServerParameters; +import java.util.List; + +public final class McpStdioDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + private static final String INSTRUCTION = + """ + You are a helpful assistant with + access to filesystem tools. + Use them to answer questions accurately. + """; + + private static final String PROMPT = + """ + List the items in the root directory. + How many files vs directories are there? + """; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + String root = args.length > 0 ? args[0] : System.getProperty("user.home"); + System.out.println("MCP Demo - filesystem root: " + root); + McpToolset mcp = mcp(root); + LlmAgent agent = demoAgent(mcp); + showAgent(agent, PROMPT); + run(agent, PROMPT); + } + + private static McpToolset mcp(String root) { + String npx = getProperty("os.name").toLowerCase().contains("win") ? "npx.cmd" : "npx"; + var params = + StdioServerParameters.builder() // + .command(npx) // + .args(List.of("-y", "@modelcontextprotocol/server-filesystem", root)) // + .build() // + .toServerParameters(); + return new McpToolset(params); + } + + private static LlmAgent demoAgent(McpToolset mcp) { + return LlmAgent.builder() + .name("mcp-demo-agent") // + .description("Helpful Assistant agent") // + .model(MODEL) // + .instruction(INSTRUCTION) // + .tools(mcp) // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/ContentPipelineProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/ContentPipelineProvider.java new file mode 100644 index 000000000..311082d7a --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/ContentPipelineProvider.java @@ -0,0 +1,49 @@ +package com.google.adk.models.demo.multiagent; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.LoopAgent; +import com.google.adk.agents.SequentialAgent; +import com.google.adk.tools.GoogleSearchTool; +import jakarta.inject.Provider; + +public final class ContentPipelineProvider implements Provider { + + private static final String RESEARCH_INSTRUCTION = + """ + Yor are a research assistant. + Call the `google_search_tool` + for the given topic, then + produce 5-7 concise bullet + points covering the most important + facts, trends, and examples. + """; + + public ContentPipelineProvider(String model) { + this.model = checkNotNull(model, "model"); + } + + private final String model; + + @Override + public SequentialAgent get() { + LoopAgent refinementLoop = new RefinementLoopProvider(model).get(); + return SequentialAgent.builder() // + .name("content-pipeline") // + .description("Researcher -> (Writer <-> Critic loop) content creation pipeline.") // + .subAgents(researcher(), refinementLoop) // + .build(); + } + + private LlmAgent researcher() { + return LlmAgent.builder() + .name("researcher") // + .description("Researches a topic and produces structured notes") // + .model("gemini-2.5-flash") // + .instruction(RESEARCH_INSTRUCTION) // + .outputKey("research_notes") + .tools(new GoogleSearchTool()) + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/MultiAgentDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/MultiAgentDemoApp.java new file mode 100644 index 000000000..3e2f9d482 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/MultiAgentDemoApp.java @@ -0,0 +1,65 @@ +package com.google.adk.models.demo.multiagent; + +import static com.google.adk.models.demo.DemoRunner.run; +import static com.google.adk.models.demo.DemoRunner.showProviders; +import static org.apache.commons.lang3.StringUtils.isBlank; + +import com.google.adk.agents.SequentialAgent; +import com.google.adk.events.Event; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import java.util.List; + +public final class MultiAgentDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + private static final String TOPIC = "the future of renewable energy"; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + + SequentialAgent agent = new ContentPipelineProvider(MODEL).get(); + String prompt = "Write a briefing on this topic: %s".formatted(TOPIC); + show(prompt); + run(agent, prompt, e -> printEvent(e)); + } + + public static void show(String prompt) { + System.out.println("Multi-agent system Demo - researcher -> (writer <-> critic) x 2"); + System.out.println("-".repeat(70)); + System.out.println("Topic: " + TOPIC); + System.out.println("-".repeat(70)); + } + + private static void printEvent(Event event) { + String author = event.author(); + String content = event.stringifyContent(); + boolean isPartial = event.partial().orElse(false); + boolean isTurnComplete = event.turnComplete().orElse(false); + if (isBlank(content)) { + return; + } + if (isPartial) { + System.out.println(content); + } else if (isTurnComplete) { + System.out.println(); + System.out.println("-".repeat(60)); + String label = + switch (author) { + case "researcher" -> "[Researcher - notes complete]"; + case "writer" -> "[Writer - draft complete]"; + case "critic" -> "[Critic - feedback]"; + default -> "[" + author + " - complete]"; + }; + System.out.println(label); + System.out.println(content); + System.out.println("-".repeat(60)); + } else { + System.out.printf("[%s] %s%n", author, content); + } + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/RefinementLoopProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/RefinementLoopProvider.java new file mode 100644 index 000000000..4f5f3c73c --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/multiagent/RefinementLoopProvider.java @@ -0,0 +1,110 @@ +package com.google.adk.models.demo.multiagent; + +import static com.google.common.base.Preconditions.checkNotNull; +import static org.apache.commons.lang3.StringUtils.isBlank; + +import com.google.adk.agents.Callbacks; +import com.google.adk.agents.Instruction; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.LoopAgent; +import com.google.adk.agents.ReadonlyContext; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import jakarta.inject.Provider; + +public final class RefinementLoopProvider implements Provider { + + private static final String CRITIQUE_INSTRUCTION = + """ + Yor are an editor. Review the article + draft below and provide 2-3 specific, + actionable improvements (clarity, structure, word choice). + Be concise - bullet points only.\n\n + Draft:\n + %s. + """; + + private static final String WRITER_INSTRUCTION = + """ + Yor are a writer. Based on the research + notes below, write a polished 3-paragraph + article for a general audience. Stick to + the provided facts only.\n\n + Research notes:\n %s. + """; + + private static final String WRITER_INSTRUCTION_CRITIQUE = + """ + You are a writer revising your article. + Apply the critic's feedback and produce + an improved version.\n\n + Research notes:\n %s \n\n + Draft article:\n %s \n\n + Critic feedback:\n %s + """; + + public RefinementLoopProvider(String model) { + this.model = checkNotNull(model, "model"); + } + + private final String model; + + @Override + public LoopAgent get() { + return LoopAgent.builder() // + .name("refinement-loop") // + .description("Writer and critic iterate to refine the article") // + .subAgents(writer(), critic()) // + .maxIterations(2) // + .afterAgentCallback(publishFinalFromState("draft_article")) + .build(); + } + + private Callbacks.AfterAgentCallback publishFinalFromState(String srcStateKey) { + return callbackCtx -> { + Object stateVal = callbackCtx.invocationContext().session().state().get(srcStateKey); + if (stateVal instanceof String refinedArticle) { + String trimmed = refinedArticle.trim(); + if (!trimmed.isEmpty()) { + callbackCtx.invocationContext().session().state().put("final_article", trimmed); + } + } + return Maybe.empty(); + }; + } + + private LlmAgent writer() { + return LlmAgent.builder() + .name("writer") // + .description("Writes or revises a short article") // + .model(model) // + .instruction(new Instruction.Provider(ctx -> writerFunction(ctx))) // + .outputKey("draft_article") // + .build(); + } + + private Single writerFunction(ReadonlyContext ctx) { + String research = (String) ctx.state().getOrDefault("research_notes", ""); + String critique = (String) ctx.state().getOrDefault("critique_feedback", ""); + String draft = (String) ctx.state().getOrDefault("draft_article", ""); + if (isBlank(critique)) { + return Single.just(WRITER_INSTRUCTION.formatted(research)); + } else { + return Single.just(WRITER_INSTRUCTION_CRITIQUE.formatted(research, draft, critique)); + } + } + + private LlmAgent critic() { + return LlmAgent.builder() + .name("critic") // + .description("Reviews the draft and provides actionable feedback") // + .model(model) // + .instruction(new Instruction.Provider(ctx -> critiqueFunction(ctx))) // + .outputKey("critique_feedback") // + .build(); + } + + private Single critiqueFunction(ReadonlyContext ctx) { + String draft = (String) ctx.state().getOrDefault("draft_article", ""); + return Single.just(CRITIQUE_INSTRUCTION.formatted(draft)); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ParallelAgentDemo.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ParallelAgentDemo.java new file mode 100644 index 000000000..e6ffe1e60 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ParallelAgentDemo.java @@ -0,0 +1,50 @@ +package com.google.adk.models.demo.parallel; + +import static com.google.adk.models.demo.DemoRunner.printEvent; +import static com.google.adk.models.demo.DemoRunner.run; +import static com.google.adk.models.demo.DemoRunner.showProviders; +import static java.lang.Boolean.parseBoolean; +import static java.lang.System.getProperty; + +import com.google.adk.agents.SequentialAgent; +import com.google.adk.events.Event; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import java.util.List; + +public final class ParallelAgentDemo { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + private static final String TOPIC = "the global transition to renewable energy"; + private static final boolean doFilter = parseBoolean(getProperty("filter.event", "false")); + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + + SequentialAgent agent = new ReseachPipelineProvider(MODEL).get(); + String prompt = "Write a briefing on this topic: %s".formatted(TOPIC); + show(prompt); + + if (doFilter) { + run(agent, prompt, e -> filter(e)); + } else { + run(agent, prompt, e -> printEvent(e)); + } + } + + private static void filter(Event e) { + if ("synthesizer".equals(e.author())) { + printEvent(e); + } + } + + public static void show(String prompt) { + System.out.println("Multi-agent system Demo - filtered output (only synthesizer output shown)"); + System.out.println("Topic: " + TOPIC); + System.out.println("-".repeat(60)); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ReseachPipelineProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ReseachPipelineProvider.java new file mode 100644 index 000000000..9e5f07e85 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ReseachPipelineProvider.java @@ -0,0 +1,56 @@ +package com.google.adk.models.demo.parallel; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.Instruction; +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.ParallelAgent; +import com.google.adk.agents.ReadonlyContext; +import com.google.adk.agents.SequentialAgent; +import io.reactivex.rxjava3.core.Single; +import jakarta.inject.Provider; + +public class ReseachPipelineProvider implements Provider { + + private static final String INSTRUCTION = + """ + You are a senior analyst. Using only the expert notes below, + write a concise, well-structured 3-paragraph briefing. + Do not add information not present in the notes. + ## Historical context\n %s \n\n + ## Scientific principles\n %s \n\n + ## Economic outlook\n %s. + """; + + public ReseachPipelineProvider(String model) { + this.model = checkNotNull(model, "model"); + } + + private final String model; + + @Override + public SequentialAgent get() { + ParallelAgent researchPanel = new ResearchPanelProvider(model).get(); + return SequentialAgent.builder() // + .name("pipeline") // + .description("Parallel research + synthesis") // + .subAgents(researchPanel, synthesizer()) + .build(); + } + + private LlmAgent synthesizer() { + return LlmAgent.builder() + .name("synthesizer") // + .description("Combines expert notes into a final report") // + .model(model) // + .instruction(new Instruction.Provider(ctx -> function(ctx))) + .build(); + } + + private Single function(ReadonlyContext ctx) { + String history = (String) ctx.state().getOrDefault("history_notes", ""); + String science = (String) ctx.state().getOrDefault("science_notes", ""); + String economics = (String) ctx.state().getOrDefault("economics_notes", ""); + return Single.just(INSTRUCTION.formatted(history, science, economics)); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ResearchPanelProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ResearchPanelProvider.java new file mode 100644 index 000000000..729850d88 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/parallel/ResearchPanelProvider.java @@ -0,0 +1,60 @@ +package com.google.adk.models.demo.parallel; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.agents.ParallelAgent; +import com.google.adk.tools.GoogleSearchTool; +import jakarta.inject.Provider; + +public final class ResearchPanelProvider implements Provider { + + public ResearchPanelProvider(String model) { + this.model = checkNotNull(model, "model"); + } + + private final String model; + + @Override + public ParallelAgent get() { + return ParallelAgent.builder() // + .name("research-panel") // + .description("Parallel research: historian + scientist + economist") // + .subAgents(historian(), scientist(), economist()) // + .build(); + } + + private LlmAgent historian() { + return LlmAgent.builder() + .name("historian") // + .description("Historical context") // + .model(model) + .instruction( + "You are a historian. In 4-5 sentences give the historical context and the key milestones for the topic provided.") // + .outputKey("history_notes") + .build(); + } + + private LlmAgent scientist() { + return LlmAgent.builder() + .name("scientist") // + .description("Scientific principles") // + .model("gemini-2.5-flash") + .instruction( + "You are a scientiest. In 4-5 sentences explain the key scientific principles and current research on the topic.") // + .outputKey("science_notes") + .tools(new GoogleSearchTool()) + .build(); + } + + private LlmAgent economist() { + return LlmAgent.builder() + .name("economist") // + .description("Economic impact") // + .model(model) + .instruction( + "You are an economist. In 4-5 sentences describe the economic impact and near-term market outlook for the topic.") // + .outputKey("economics_notes") + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/MovieExtractorProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/MovieExtractorProvider.java new file mode 100644 index 000000000..c702ba1d2 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/MovieExtractorProvider.java @@ -0,0 +1,39 @@ +package com.google.adk.models.demo.structured; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.adk.agents.LlmAgent; +import com.google.genai.types.Schema; +import jakarta.inject.Provider; + +public final class MovieExtractorProvider implements Provider { + + private static final String INSTRUCTION = + """ + You are a data extraction assistant. + Extract the structured movie facts + from the text provided. Respond only + with the required JSON fields - no + extra commentary. + """; + + public MovieExtractorProvider(String model, Schema schema) { + this.model = checkNotNull(model, "model"); + this.schema = checkNotNull(schema, "schema"); + } + + private final String model; + private final Schema schema; + + @Override + public LlmAgent get() { + return LlmAgent.builder() + .name("movie-extractor") // + .model(model) // + .description("Data Extraction Assistant") // + .instruction(INSTRUCTION) // + .outputSchema(schema) // + .outputKey("movie_data") // + .build(); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/MovieSchemaProvider.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/MovieSchemaProvider.java new file mode 100644 index 000000000..dc0c123ef --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/MovieSchemaProvider.java @@ -0,0 +1,53 @@ +package com.google.adk.models.demo.structured; + +import com.google.genai.types.Schema; +import com.google.genai.types.Type; +import jakarta.inject.Provider; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class MovieSchemaProvider implements Provider { + + private static final String TITLE = "title"; + private static final String DIRECTOR = "director"; + private static final String YEAR = "year"; + private static final String GENRE = "genre"; + private static final String SUMMARY = "summary"; + private static final String CHARACTER = "character"; + private static final String MOTIVATION = "motivation"; + + @Override + public Schema get() { + return Schema.builder() + .type(new Type(Type.Known.OBJECT)) // + .description("Structured facts extracted from a movie description") // + .properties(properties()) // + .required(List.of(TITLE, DIRECTOR, YEAR, GENRE, SUMMARY)) // + .propertyOrdering(List.of(TITLE, DIRECTOR, YEAR, GENRE, SUMMARY, CHARACTER, MOTIVATION)) // + .build(); + } + + private Map properties() { + Type stype = new Type(Type.Known.STRING); + Type itype = new Type(Type.Known.INTEGER); + String ydesc = "Release year as a 4 digit integer"; + String gdesc = "Primary genre (e.g. science-fiction, drama, thriller"; + Schema title = Schema.builder().type(stype).description("The moview title").build(); + Schema director = Schema.builder().type(stype).description("The director's full name").build(); + Schema year = Schema.builder().type(itype).description(ydesc).build(); + Schema genre = Schema.builder().type(stype).description(gdesc).build(); + Schema summary = Schema.builder().type(stype).description("One-sentence plot summary").build(); + Schema character = Schema.builder().type(stype).description("Main character").build(); + Schema motiv = Schema.builder().type(stype).description("Main character motivation").build(); + Map map = new HashMap<>(); + map.put(TITLE, title); + map.put(DIRECTOR, director); + map.put(YEAR, year); + map.put(GENRE, genre); + map.put(SUMMARY, summary); + map.put(CHARACTER, character); + map.put(MOTIVATION, motiv); + return map; + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/StructuredOutputDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/StructuredOutputDemoApp.java new file mode 100644 index 000000000..c9d4d6277 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/structured/StructuredOutputDemoApp.java @@ -0,0 +1,42 @@ +package com.google.adk.models.demo.structured; + +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.demo.DemoRunner; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import com.google.genai.types.Schema; +import java.util.List; + +// mvn exec:java -Dexec.mainClass=com.google.adk.models.demo.structured.StructuredDemoApp + +public final class StructuredOutputDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + private static final String PROMPT = + """ + Inception, released in 2010 and directed by Christoper Nolan, + is a mind-bending science-fiction thriller about a skilled + thief who steals secrets from people's dreams and is offered + a chance to have his criminal record erased in exchange for + planting an idea in a target's mind. + """; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + + Schema schema = new MovieSchemaProvider().get(); + LlmAgent agent = new MovieExtractorProvider(MODEL, schema).get(); + System.out.println("Structured Output Demo - extract typed facts from free text"); + showAgent(agent, PROMPT); + System.out.println("Expected output: JSON with title, director, year, genre, summary"); + DemoRunner.run(agent, PROMPT); + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/tools/DemoTools.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/tools/DemoTools.java new file mode 100644 index 000000000..95455291d --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/tools/DemoTools.java @@ -0,0 +1,93 @@ +package com.google.adk.models.demo.tools; + +import com.google.adk.tools.Annotations.Schema; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +public class DemoTools { + + private static final String TOPIC = + """ + Global renewable capacity grew by 295 GW in 2022, + the fastest rate on record; + """; + + private DemoTools() {} + + // returns the current server time and timezone + @Schema(name = "getCurrentTime", description = "Get the current server time and timezone") + public static Map getCurrentTime() { + ZonedDateTime now = ZonedDateTime.now(); + Map map = new HashMap<>(); + map.put("time", now.format(DateTimeFormatter.RFC_1123_DATE_TIME)); + map.put("timezone", now.getZone().getId()); + return map; + } + + // returns current weather conditions for the requested city + // stubbed - replace with a real-weather API call in production + @Schema(name = "getWeather", description = "Get the weather for a given city") + public static Map getWeather( + @Schema(name = "city", description = "City name to get weather for") String city) { + Map map = new HashMap<>(); + map.put("city", city); + map.put("temperature_celsius", 38); + map.put("condition", "sunny"); + map.put("humidity_percent", 14); + return map; + } + + @Schema( + name = "calculate", + description = "Performs basic arithmetic: add, subtract, multiply, or divide") + public static Map calculate( + @Schema(name = "a", description = "First operand") double a, + @Schema(name = "b", description = "Second operand") double b, + @Schema(name = "operation", description = "One of: add, subract, multiply, divide") + String operation) { + double result = + switch (operation.toLowerCase()) { + case "add" -> a + b; + case "subtract" -> a - b; + case "multiply" -> a * b; + case "devide" -> b != 0 ? a / b : Double.NaN; + default -> throw new IllegalArgumentException("Unknown operation: " + operation); + }; + Map map = new HashMap<>(); + map.put("result", result); + map.put("expression", a + " " + operation + " " + b); + return map; + } + + // returns a stubbed stock price for the given ticket symbol + @Schema(name = "lookupStockPrice", description = "Get the weather for a given city") + public static Map lookupStockPrice( + @Schema(name = "ticker", description = "Stock ticker symbol, e.g. AAPL") String ticker) { + Map prices = new HashMap<>(); + prices.put("AAPL", 189.50); + prices.put("MSFT", 415.20); + prices.put("GOOG", 172.35); + prices.put("NVDA", 875.00); + double price = prices.getOrDefault(ticker.toUpperCase(), -1.0); + if (price < 0) { + return Map.of("error", "Unknown ticker: " + ticker); + } + Map map = new HashMap<>(); + map.put("ticker", ticker.toUpperCase()); + map.put("price_usd", price); + map.put("currency", "USD"); + return map; + } + + // returns a stubbed stock price for the given ticket symbol + @Schema(name = "topic", description = "The topic to look up facts about") + public static Map lookupFacts( + @Schema(name = "topic", description = "The topic to look up facts about") String topic) { + Map map = new HashMap<>(); + map.put("topic", topic); + map.put("facts", TOPIC); + return map; + } +} diff --git a/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/tools/ToolsDemoApp.java b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/tools/ToolsDemoApp.java new file mode 100644 index 000000000..fac917a31 --- /dev/null +++ b/contrib/model-prism/model-prism-demo/src/main/java/com/google/adk/models/demo/tools/ToolsDemoApp.java @@ -0,0 +1,60 @@ +package com.google.adk.models.demo.tools; + +import static com.google.adk.models.demo.DemoRunner.run; +import static com.google.adk.models.demo.DemoRunner.showAgent; +import static com.google.adk.models.demo.DemoRunner.showProviders; + +import com.google.adk.agents.LlmAgent; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.ModelProviderRegistry; +import com.google.adk.tools.BaseTool; +import com.google.adk.tools.FunctionTool; +import java.util.ArrayList; +import java.util.List; + +public final class ToolsDemoApp { + + // to be wired via application.properties, e.g. + // "ollama/llama3", "groq/llama-3.1-8b-instant", "gemini-2.5-flash", "openrouter/auto" + private static final String MODEL = "groq/llama-3.1-8b-instant"; + + private static final String PROMPT = + """ + What time is right now? + Also, what's the weather like in Phoenix? + And finally, what is 42 multiplied by 137? + """; + + private static final String INSTRUCTION = + """ + You are a helpful assistant. + Use the available tools to answer questions accurately. + """; + + public static void main(String[] args) { + // One call - discovers and registers ALL providers on the classpath + List registered = ModelProviderRegistry.registerAll(); + showProviders(registered); + LlmAgent agent = demoAgent(); + showAgent(agent, PROMPT); + run(agent, PROMPT); + } + + private static LlmAgent demoAgent() { + return LlmAgent.builder() + .name("tools-demo-agent") // + .description("Helpful Assistant agent with tools") // + .model(MODEL) // + .instruction(INSTRUCTION) // + .tools(tools()) // + .build(); + } + + private static List tools() { + List tools = new ArrayList<>(); + tools.add(FunctionTool.create(DemoTools.class, "getCurrentTime")); + tools.add(FunctionTool.create(DemoTools.class, "getWeather")); + tools.add(FunctionTool.create(DemoTools.class, "calculate")); + return tools; + } +} diff --git a/contrib/model-prism/model-prism-groq/README.md b/contrib/model-prism/model-prism-groq/README.md new file mode 100644 index 000000000..5251a2eae --- /dev/null +++ b/contrib/model-prism/model-prism-groq/README.md @@ -0,0 +1,65 @@ +# adk-model-prism-groq + +Groq provider JAR for Google ADK Java. +Drop this on the classpath and +`GroqModelProvider` auto-registers +via `META-INF/services` - +no application code changes required. + +--- + +## Usage + +Add the dependency and call `ModelProviderRegistry.registerAll()` +once at startup: + +```xml + + com.google.adk + adk-model-prism-groq + 0.1.0-SNAPSHOT + +``` + +```java +ModelProviderRegistry.registerAll(); +``` + +Then use any Groq model in your agents: + +```yaml +# agent.yaml +model: groq/llama-3.1-8b-instant +``` + +--- + +## Configuration + +| Environment variable | Required | Description | +|---|---|---| +|`GROQ_API_KEY` | Yes | API key from https://console.groq.com | + +--- + +## Model Names + +Prefix any Groq model name with `groq/`. + +Full model list: https://console.groq.com/docs/models + +--- + +## How It Works + +This JAR contains a single `META-INF/services` entry: + +``` +META-INF/services/com.google.adk.models.spi.ModelProvider + |--com.google.adk.models.groq.GroqModelProvider +``` + +When `ModelProviderRegistry.registerAll()` runs, `ServiceLoader` finds +this entry and registers pattern `groq/.*` with `LlmRegistry`, +`GroqModelProvider` delegates all HTTP and JSON work to +`OpenAiCompatibleLlm` in the `adk-model-prism-core` module. diff --git a/contrib/model-prism/model-prism-groq/pom.xml b/contrib/model-prism/model-prism-groq/pom.xml new file mode 100644 index 000000000..59e7f265d --- /dev/null +++ b/contrib/model-prism/model-prism-groq/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.3.1-SNAPSHOT + ../../../pom.xml + + + adk-model-prism-groq + Agent Development Kit - Model Prism Groq + + Groq Provider JAR. Drop this on the classpath + and GroqModelProvider auto-registers via + META-INF/services - no code changes required. + Supports model names matching "groq/*". + + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + adk-model-prism-core + ${project.version} + + + + jakarta.inject + jakarta.inject-api + compile + + + + org.apache.commons + commons-lang3 + compile + + + com.fasterxml.jackson.core + jackson-databind + + + \ No newline at end of file diff --git a/contrib/model-prism/model-prism-groq/src/main/java/com/google/adk/models/groq/GroqModelProvider.java b/contrib/model-prism/model-prism-groq/src/main/java/com/google/adk/models/groq/GroqModelProvider.java new file mode 100644 index 000000000..c0c68eb1b --- /dev/null +++ b/contrib/model-prism/model-prism-groq/src/main/java/com/google/adk/models/groq/GroqModelProvider.java @@ -0,0 +1,40 @@ +package com.google.adk.models.groq; + +import static java.util.Optional.ofNullable; + +import com.google.adk.models.BaseLlm; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.OpenAiCompatibleLlm; +import java.util.Optional; + +/** + * ModelProvider for the Groq API. + * + *

Activated when {@code agent.model} starts with {@code groq/}. Reads the API key from the + * {@code GROQ_API_KEY} environment variable. + * + *

Example: {@code agent.model=groq/llama-3.1-8b-instant} + * + *

Free tier available at console.groq.com. + * + *

Models with tool calling support: {@code llama-3.1-8b-instant}, {@code mixtral-8x7b-32768} + */ +public class GroqModelProvider implements ModelProvider { + + public GroqModelProvider() {} + + private static final String API_URL = "https://api.groq.com/openai/v1/chat/completions"; + private static final String PREFIX = "groq/"; + + @Override + public String modelPattern() { + return "groq/.*"; + } + + @Override + public BaseLlm create(String modelName) { + Optional apiKey = ofNullable(System.getenv("GROQ_API_KEY")); + String model = modelName.startsWith(PREFIX) ? modelName.substring(PREFIX.length()) : modelName; + return new OpenAiCompatibleLlm(model, API_URL, apiKey); + } +} diff --git a/contrib/model-prism/model-prism-groq/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider b/contrib/model-prism/model-prism-groq/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider new file mode 100644 index 000000000..a8c1ffc52 --- /dev/null +++ b/contrib/model-prism/model-prism-groq/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider @@ -0,0 +1 @@ +com.google.adk.models.groq.GroqModelProvider \ No newline at end of file diff --git a/contrib/model-prism/model-prism-ollama/README.md b/contrib/model-prism/model-prism-ollama/README.md new file mode 100644 index 000000000..a8e5a529b --- /dev/null +++ b/contrib/model-prism/model-prism-ollama/README.md @@ -0,0 +1,89 @@ +# adk-model-prism-ollama + +Ollama provider JAR for Google ADK Java. +Runs models locally - no API key, no +network, no cost. Drop this on the +classpath and `OllamaModelProvider` +auto-registers via `META-INF/services`. + +--- + +## Usage + +Add the dependency and call `ModelProviderRegistry.registerAll()` +once at startup: + +```xml + + com.google.adk + adk-model-prism-ollama + 0.1.0-SNAPSHOT + +``` + +```java +ModelProviderRegistry.registerAll(); +``` + +Then use any pulled Ollama model in your agents: + +```yaml +# agent.yaml +model: ollama/llama3 +``` + +## Setup + +1. Install Ollama: https://ollama.com +2. Pull a model: +```bash +ollama pull llama3 +``` +--- + +## Configuration + +| Environment variable | Required | Description | +|---|---|---| +|`OLLAMA_BASE_URL` | `http://localhost:11434` | Override if Ollama is on a different host/port | + +To use a remote Ollama instance: +``` +OLLAMA_BASE_URL=http://localhost:11434 +``` +No API key is required. The `Authorization` header is omitted entirely. + +--- + +## Model Names + +Prefix any Ollama model name with `ollama/`: + +| Model | Notes | +|---|---| +|`ollama/llama3` | `ollama pull llama3` | +|`ollama/mistral` | `ollama pull mistral` | +|`ollama/codellama` | `ollama pull codellama` | +|`ollama/phi3` | `ollama pull phi3` | + +Full model library: https://ollama.com/library + +>**Note:** Tool calling support varies by model. +>Larger models (13B+) are generally more reliable +>with structured tool calls. Smaller models may +>ignore or misformat them. +--- + +## How It Works + +This JAR contains a single `META-INF/services` entry: + +``` +META-INF/services/com.google.adk.models.spi.ModelProvider + |--com.google.adk.models.ollama.OllamaModelProvider +``` + +When `ModelProviderRegistry.registerAll()` runs, `ServiceLoader` finds +this entry and registers pattern `ollama/.*` with `LlmRegistry`, +`OllamaModelProvider` delegates all HTTP and JSON work to +`OpenAiCompatibleLlm` in the `adk-model-prism-core` module. diff --git a/contrib/model-prism/model-prism-ollama/pom.xml b/contrib/model-prism/model-prism-ollama/pom.xml new file mode 100644 index 000000000..b525327a6 --- /dev/null +++ b/contrib/model-prism/model-prism-ollama/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.3.1-SNAPSHOT + ../../../pom.xml + + + adk-model-prism-ollama + Agent Development Kit - Model Prism Ollama + + Ollama Provider JAR. Drop this on the classpath + and OllamaModelProvider auto-registers via + META-INF/services - no code changes required. + Supports model names matching "ollama/*". + + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + adk-model-prism-core + ${project.version} + + + + jakarta.inject + jakarta.inject-api + compile + + + + org.apache.commons + commons-lang3 + compile + + + com.fasterxml.jackson.core + jackson-databind + + + \ No newline at end of file diff --git a/contrib/model-prism/model-prism-ollama/src/main/java/com/google/adk/models/ollama/OllamaModelProvider.java b/contrib/model-prism/model-prism-ollama/src/main/java/com/google/adk/models/ollama/OllamaModelProvider.java new file mode 100644 index 000000000..37d8408e4 --- /dev/null +++ b/contrib/model-prism/model-prism-ollama/src/main/java/com/google/adk/models/ollama/OllamaModelProvider.java @@ -0,0 +1,40 @@ +package com.google.adk.models.ollama; + +import static java.util.Optional.empty; + +import com.google.adk.models.BaseLlm; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.OpenAiCompatibleLlm; + +/** + * ModelProvider for Ollama - fully local interface, no API key required. + * + *

Activated when {@code agent.model} starts with {@code ollama/}. Connects to a locally running + * Ollama server (default: {@code http://localhost:11434}). Override the base URL via the {@code + * OLLAMA_BASE_URL} environment variables. + * + *

Example: {@code agent.model=ollama/llama3} + * + *

Setup: install from ollama.com, then run {@code ollama pull + * llama3}. + */ +public class OllamaModelProvider implements ModelProvider { + + public OllamaModelProvider() {} + + private static final String DEFAULT_BASE_URL = "http://localhost:11434"; + private static final String PREFIX = "ollama/"; + + @Override + public String modelPattern() { + return "ollama/.*"; + } + + @Override + public BaseLlm create(String modelName) { + String model = modelName.startsWith(PREFIX) ? modelName.substring(PREFIX.length()) : modelName; + String baseUrl = System.getenv().getOrDefault("OLLAMA_BASE_URL", DEFAULT_BASE_URL); + String apiUrl = baseUrl.replaceAll("/$", "") + "/v1/chat/completions"; + return new OpenAiCompatibleLlm(model, apiUrl, empty()); + } +} diff --git a/contrib/model-prism/model-prism-ollama/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider b/contrib/model-prism/model-prism-ollama/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider new file mode 100644 index 000000000..ec286a43f --- /dev/null +++ b/contrib/model-prism/model-prism-ollama/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider @@ -0,0 +1 @@ +com.google.adk.models.ollama.OllamaModelProvider \ No newline at end of file diff --git a/contrib/model-prism/model-prism-openrouter/README.md b/contrib/model-prism/model-prism-openrouter/README.md new file mode 100644 index 000000000..77f7464ed --- /dev/null +++ b/contrib/model-prism/model-prism-openrouter/README.md @@ -0,0 +1,75 @@ +# adk-model-prism-openrouter + +OpenRouter provider JAR for Google ADK Java. +Aggregates hundreds of models from many +providers behind a single API - many with +a free tier. Drop this on the classpath and +`OpenRouterModelProvider` auto-registers +via `META-INF/services`. + +--- + +## Usage + +Add the dependency and call `ModelProviderRegistry.registerAll()` +once at startup: + +```xml + + com.google.adk + adk-model-prism-openrouter + 0.1.0-SNAPSHOT + +``` + +```java +ModelProviderRegistry.registerAll(); +``` + +Then use any OpenRouter model in your agents: + +```yaml +# agent.yaml +model: openrouter/auto +``` + +--- + +## Configuration + +| Environment variable | Required | Description | +|---|---|---| +|`OPENROUTER_API_KEY` | Yes | API key from https://openrouter.ai | + +--- + +## Model Names + +Prefix any OpenRouter model name with `openrouter/`. +Models ending in `:free` have no per-token cost (rate-limited): + +| Model | Notes | +|---|---| +|`openrouter/meta-llama/llama-3-8b-instruct:free` | Free tier, good general purpose | +|`openrouter/mistralai/mistral-7b-instruct:free` | Free tier, fast | +|`openrouter/google/gemma-3-27b-it:free` | Free tier, strong reasoning | +|`openrouter/meta-llama/llama-3.70b-instruct` | Paid, best Llama 3 quality | +|`openrouter/anthropic/claude-3-haiku` | Paid, fast Claude | + +Full model list with pricing: https://openrouter.ai/models + +--- + +## How It Works + +This JAR contains a single `META-INF/services` entry: + +``` +META-INF/services/com.google.adk.models.spi.ModelProvider + |--com.google.adk.models.openrouter.OpenRouterModelProvider +``` + +When `ModelProviderRegistry.registerAll()` runs, `ServiceLoader` finds +this entry and registers pattern `openrouter/.*` with `LlmRegistry`, +`OpenRouterModelProvider` delegates all HTTP and JSON work to +`OpenAiCompatibleLlm` in the `adk-model-prism-core` module. diff --git a/contrib/model-prism/model-prism-openrouter/pom.xml b/contrib/model-prism/model-prism-openrouter/pom.xml new file mode 100644 index 000000000..be39478f1 --- /dev/null +++ b/contrib/model-prism/model-prism-openrouter/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.3.1-SNAPSHOT + ../../../pom.xml + + + adk-model-prism-openrouter + Agent Development Kit - Model Prism OpenRouter + + OpenRouter Provider JAR. Drop this on the classpath + and OpenRouterModelProvider auto-registers via + META-INF/services - no code changes required. + Supports model names matching "openrouter/*". + + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + adk-model-prism-core + ${project.version} + + + + jakarta.inject + jakarta.inject-api + compile + + + + org.apache.commons + commons-lang3 + compile + + + com.fasterxml.jackson.core + jackson-databind + + + \ No newline at end of file diff --git a/contrib/model-prism/model-prism-openrouter/src/main/java/com/google/adk/models/openrouter/OpenRouterModelProvider.java b/contrib/model-prism/model-prism-openrouter/src/main/java/com/google/adk/models/openrouter/OpenRouterModelProvider.java new file mode 100644 index 000000000..b5046ed51 --- /dev/null +++ b/contrib/model-prism/model-prism-openrouter/src/main/java/com/google/adk/models/openrouter/OpenRouterModelProvider.java @@ -0,0 +1,37 @@ +package com.google.adk.models.openrouter; + +import com.google.adk.models.BaseLlm; +import com.google.adk.models.spi.ModelProvider; +import com.google.adk.models.spi.OpenAiCompatibleLlm; +import java.util.Optional; + +/** + * ModelProvider for the OpenRouter - aggregates many models through one OpenAI-compatible API. + * + *

Activated when {@code agent.model} starts with {@code openrouter/}. Reads the API key from the + * {@code OPENROUTER_API_KEY} environment variable. + * + *

Example: {@code agent.model=openrouter/auto} + * + *

Free tier models (suffix {@code :free}) require no credits. See openrouter.ai/models for the full list. + */ +public class OpenRouterModelProvider implements ModelProvider { + + public OpenRouterModelProvider() {} + + private static final String API_URL = "https://openrouter.ai/api/v1/chat/completions"; + private static final String PREFIX = "openrouter/"; + + @Override + public String modelPattern() { + return "openrouter/.*"; + } + + @Override + public BaseLlm create(String modelName) { + Optional apiKey = Optional.ofNullable(System.getenv("OPENROUTER_API_KEY")); + String model = modelName.startsWith(PREFIX) ? modelName.substring(PREFIX.length()) : modelName; + return new OpenAiCompatibleLlm(model, API_URL, apiKey); + } +} diff --git a/contrib/model-prism/model-prism-openrouter/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider b/contrib/model-prism/model-prism-openrouter/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider new file mode 100644 index 000000000..510581aee --- /dev/null +++ b/contrib/model-prism/model-prism-openrouter/src/main/resources/META-INF/services/com.google.adk.models.spi.ModelProvider @@ -0,0 +1 @@ +com.google.adk.models.openrouter.OpenRouterModelProvider \ No newline at end of file diff --git a/contrib/model-prism/pom.xml b/contrib/model-prism/pom.xml new file mode 100644 index 000000000..4d9db2528 --- /dev/null +++ b/contrib/model-prism/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + com.google.adk + google-adk-parent + 1.3.1-SNAPSHOT + ../../pom.xml + + + adk-model-prism + Agent Development Kit - Model Prism + + pom + + Proposal: Model Prism for Google ADK Java. + Demonstrates how pluggable model providers could work + via Java ServiceLoader, making it trivial to swap LLM + backends without modifying ADK core. + Each provider ships as its own JAR - drop one on the + classpath and it auto-registers. No code changes required. + + + model-prism-core + model-prism-groq + model-prism-ollama + model-prism-openrouter + model-prism-demo + + + + + + com.google.adk + google-adk + ${project.version} + + + com.google.adk + google-adk-dev + ${project.version} + + + + + jakarta.inject + jakarta.inject-api + + compile + + + + org.apache.commons + commons-lang3 + + compile + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + \ No newline at end of file