diff --git a/README.md b/README.md
index 9613078dd..f459b44f3 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,41 @@ Same as the beloved Python Development UI.
A built-in development UI to help you test, evaluate, debug, and showcase your agent(s).
+### Custom OpenAI-Compatible Endpoints
+
+ADK-Java supports any OpenAI-compatible LLM endpoint (Groq, Ollama, Azure OpenAI, Perplexity, etc.) via `OpenAiCompatibleLlm`:
+
+```java
+import com.google.adk.models.OpenAiCompatibleLlm;
+import com.google.common.collect.ImmutableMap;
+
+// Register custom endpoint
+OpenAiCompatibleLlm.builder()
+ .baseUrl("https://api.groq.com/openai/v1/")
+ .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+ .modelName("groq-llama3-70b")
+ .build()
+ .registerWithPattern("groq-.*");
+
+// Use in agent
+LlmAgent agent = LlmAgent.builder()
+ .model("groq-llama3-70b")
+ .instruction("You are a helpful assistant.")
+ .build();
+```
+
+**Supported Providers:** Groq, Ollama, Azure OpenAI, Perplexity, or any service implementing OpenAI's Chat Completions API.
+
+**Local Development with Ollama:**
+```java
+OpenAiCompatibleLlm.builder()
+ .baseUrl("http://localhost:11434/v1/")
+ .headers(ImmutableMap.of())
+ .modelName("ollama-llama2")
+ .build()
+ .registerWithPattern("ollama-.*");
+```
+
### Evaluate Agents
Coming soon...
diff --git a/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java b/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java
new file mode 100644
index 000000000..09637152c
--- /dev/null
+++ b/core/src/main/java/com/google/adk/models/OpenAiCompatibleLlm.java
@@ -0,0 +1,225 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.models;
+
+import com.google.adk.models.chat.ChatCompletionsHttpClient;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.HttpOptions;
+import io.reactivex.rxjava3.core.Flowable;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Represents any OpenAI-compatible LLM endpoint.
+ *
+ *
This class enables ADK agents to connect to any LLM provider that implements the OpenAI Chat + * Completions API format, including: + * + *
Example usage with Groq: + * + *
{@code
+ * OpenAiCompatibleLlm groq = OpenAiCompatibleLlm.builder()
+ * .baseUrl("https://api.groq.com/openai/v1/")
+ * .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+ * .modelName("groq-llama3-70b")
+ * .build();
+ * groq.registerWithPattern("groq-.*");
+ *
+ * LlmAgent agent = LlmAgent.builder()
+ * .model("groq-llama3-70b")
+ * .instruction("You are a helpful assistant.")
+ * .build();
+ * }
+ *
+ * Example usage with Ollama (local): + * + *
{@code
+ * OpenAiCompatibleLlm ollama = OpenAiCompatibleLlm.builder()
+ * .baseUrl("http://localhost:11434/v1/")
+ * .headers(ImmutableMap.of())
+ * .modelName("ollama-llama2")
+ * .build();
+ * ollama.registerWithPattern("ollama-.*");
+ * }
+ *
+ * Note: Streaming support depends on {@link ChatCompletionsHttpClient} implementation.
+ * Currently non-streaming only. Live bidirectional connections are not supported as the OpenAI Chat
+ * Completions API does not provide this capability.
+ */
+public class OpenAiCompatibleLlm extends BaseLlm {
+
+ private final ChatCompletionsHttpClient client;
+
+ /**
+ * Constructs a new OpenAiCompatibleLlm instance.
+ *
+ * @param modelName The model name for registry identification
+ * @param client The HTTP client configured for the endpoint
+ */
+ private OpenAiCompatibleLlm(String modelName, ChatCompletionsHttpClient client) {
+ super(modelName);
+ this.client = Objects.requireNonNull(client, "client cannot be null");
+ }
+
+ /**
+ * Creates a new builder for configuring an OpenAI-compatible LLM.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ public Flowable This allows agents to reference the model by name pattern. For example:
+ *
+ * The base URL should end with the API version path (e.g., "/v1/"). The client will
+ * automatically append "/chat/completions" to form the complete endpoint URL.
+ *
+ * Examples:
+ *
+ * Typically used for authorization headers. Example:
+ *
+ * Defaults to 300,000ms (5 minutes) if not specified. Set to 0 for infinite timeout (not
+ * recommended for production).
+ *
+ * @param millis timeout in milliseconds
+ * @return this builder
+ */
+ public Builder timeoutMillis(int millis) {
+ this.timeoutMillis = millis;
+ return this;
+ }
+
+ /**
+ * Sets the model name used for registry pattern matching.
+ *
+ * @param name the model name (e.g., "groq-llama3-70b")
+ * @return this builder
+ */
+ public Builder modelName(String name) {
+ this.modelName = name;
+ return this;
+ }
+
+ /**
+ * Builds the {@link OpenAiCompatibleLlm} instance.
+ *
+ * @return a configured OpenAiCompatibleLlm instance
+ * @throws IllegalArgumentException if baseUrl or modelName is not set, or if baseUrl is invalid
+ */
+ public OpenAiCompatibleLlm build() {
+ Objects.requireNonNull(baseUrl, "baseUrl must be set");
+ Objects.requireNonNull(modelName, "modelName must be set");
+
+ HttpOptions httpOptions =
+ HttpOptions.builder().baseUrl(baseUrl).headers(headers).timeout(timeoutMillis).build();
+
+ ChatCompletionsHttpClient httpClient = new ChatCompletionsHttpClient(httpOptions);
+
+ return new OpenAiCompatibleLlm(modelName, httpClient);
+ }
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/ManualGroqTest.java b/core/src/test/java/com/google/adk/models/ManualGroqTest.java
new file mode 100644
index 000000000..cf1a0db8c
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/ManualGroqTest.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.models;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+
+/**
+ * Manual test for OpenAiCompatibleLlm with Groq.
+ *
+ * This is NOT an automated test - it's for manual verification with a real API key.
+ *
+ * Usage:
+ *
+ * Or pass API key directly:
+ *
+ * These tests require external services to be running. To separate from fast unit tests, run
+ * these manually or configure your build to skip them in CI.
+ *
+ * Prerequisites:
+ *
+ * {@code
+ * llm.registerWithPattern("groq-.*");
+ * // Now agents can use model names like "groq-llama3-70b", "groq-mixtral", etc.
+ * }
+ *
+ * @param pattern regex pattern for model name matching (e.g., "groq-.*", "ollama-.*")
+ */
+ public void registerWithPattern(String pattern) {
+ LlmRegistry.registerLlm(pattern, modelName -> this);
+ }
+
+ /** Builder for {@link OpenAiCompatibleLlm}. */
+ public static class Builder {
+ private String baseUrl;
+ private ImmutableMap
+ *
+ *
+ * @param url the base URL (required)
+ * @return this builder
+ */
+ public Builder baseUrl(String url) {
+ this.baseUrl = url;
+ return this;
+ }
+
+ /**
+ * Sets custom HTTP headers to include in all requests.
+ *
+ * {@code
+ * .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+ * }
+ *
+ * @param headers map of header names to values
+ * @return this builder
+ */
+ public Builder headers(ImmutableMap
+ * # Set your Groq API key
+ * export GROQ_API_KEY="your-api-key-here"
+ *
+ * # Run the test
+ * cd /Users/sm0704/Documents/projects/ghc/shaamam/adk-java
+ * ./mvnw -f core/pom.xml exec:java -Dexec.mainClass="com.google.adk.models.ManualGroqTest" \
+ * -Dexec.classpathScope=test
+ *
+ *
+ *
+ * ./mvnw -f core/pom.xml exec:java -Dexec.mainClass="com.google.adk.models.ManualGroqTest" \
+ * -Dexec.classpathScope=test -Dexec.args="your-groq-api-key"
+ *
+ */
+public class ManualGroqTest {
+
+ public static void main(String[] args) {
+ // Get API key from args or environment
+ String apiKey = null;
+ if (args.length > 0) {
+ apiKey = args[0];
+ } else {
+ apiKey = System.getenv("GROQ_API_KEY");
+ }
+
+ if (apiKey == null || apiKey.isEmpty()) {
+ System.err.println("ERROR: No API key provided!");
+ System.err.println("Usage:");
+ System.err.println(" 1. Set GROQ_API_KEY environment variable");
+ System.err.println(" 2. Or pass API key as first argument");
+ System.exit(1);
+ }
+
+ System.out.println("=== OpenAiCompatibleLlm Manual Test with Groq ===\n");
+
+ try {
+ // Test 1: Direct OpenAiCompatibleLlm usage
+ System.out.println("[Test 1] Direct OpenAiCompatibleLlm usage");
+ System.out.println("-----------------------------------------");
+
+ OpenAiCompatibleLlm groq =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl("https://api.groq.com/openai/v1/")
+ .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+ .modelName("llama-3.3-70b-versatile")
+ .timeoutMillis(30_000)
+ .build();
+
+ System.out.println("✓ Created OpenAiCompatibleLlm for Groq");
+ System.out.println(" Model: " + groq.model());
+
+ LlmRequest request =
+ LlmRequest.builder()
+ .model(groq.model())
+ .contents(
+ ImmutableList.of(
+ Content.fromParts(Part.fromText("Say 'Hello from Groq!' and nothing else."))))
+ .build();
+
+ System.out.println("\nSending request to Groq...");
+ LlmResponse response = groq.generateContent(request, false).blockingFirst();
+
+ if (response.content().isPresent()
+ && response.content().get().parts().isPresent()
+ && !response.content().get().parts().get().isEmpty()) {
+ String responseText = response.content().get().parts().get().get(0).text().orElse("");
+ System.out.println("✓ Got response: " + responseText);
+ } else {
+ System.out.println("✗ No response content");
+ System.exit(1);
+ }
+
+ // Test 2: Registry integration
+ System.out.println("\n[Test 2] Registry integration");
+ System.out.println("-----------------------------");
+
+ groq.registerWithPattern("groq-.*");
+ System.out.println("✓ Registered pattern 'groq-.*'");
+
+ BaseLlm resolvedLlm = LlmRegistry.getLlm("groq-llama-3.3-70b-versatile");
+ if (resolvedLlm != null) {
+ System.out.println("✓ Successfully resolved model from registry");
+ } else {
+ System.out.println("✗ Failed to resolve model from registry");
+ System.exit(1);
+ }
+
+ // Test 3: Multi-turn conversation
+ System.out.println("\n[Test 3] Multi-turn conversation");
+ System.out.println("---------------------------------");
+
+ LlmRequest multiTurnRequest =
+ LlmRequest.builder()
+ .model(groq.model())
+ .contents(
+ ImmutableList.of(
+ Content.fromParts(Part.fromText("My name is Shaamam.")),
+ Content.fromParts(Part.fromText("Nice to meet you, Shaamam!")),
+ Content.fromParts(Part.fromText("What's my name?"))))
+ .build();
+
+ System.out.println("Sending multi-turn conversation...");
+ LlmResponse multiTurnResponse = groq.generateContent(multiTurnRequest, false).blockingFirst();
+
+ if (multiTurnResponse.content().isPresent()
+ && multiTurnResponse.content().get().parts().isPresent()
+ && !multiTurnResponse.content().get().parts().get().isEmpty()) {
+ String multiTurnText =
+ multiTurnResponse.content().get().parts().get().get(0).text().orElse("");
+ System.out.println("✓ Got response: " + multiTurnText);
+
+ if (multiTurnText.toLowerCase().contains("shaamam")) {
+ System.out.println("✓ Correctly remembered name from conversation!");
+ } else {
+ System.out.println("⚠ Warning: Model didn't remember name from context");
+ }
+ }
+
+ // Success summary
+ System.out.println("\n" + "=".repeat(50));
+ System.out.println("✓ ALL TESTS PASSED");
+ System.out.println("=".repeat(50));
+ System.out.println("\nOpenAiCompatibleLlm is working correctly with Groq!");
+ System.out.println("Safe to commit and create PR.");
+
+ } catch (Exception e) {
+ System.err.println("\n✗ TEST FAILED");
+ System.err.println("Error: " + e.getMessage());
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmIntegrationTest.java b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmIntegrationTest.java
new file mode 100644
index 000000000..3a6434f47
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmIntegrationTest.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.models;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Integration tests for {@link OpenAiCompatibleLlm} against real endpoints.
+ *
+ *
+ *
+ */
+@RunWith(JUnit4.class)
+public final class OpenAiCompatibleLlmIntegrationTest {
+
+ private static final String OLLAMA_BASE_URL = "http://localhost:11434/v1/";
+ private static final String OLLAMA_MODEL = "llama2";
+
+ @Before
+ public void checkOllamaAvailable() {
+ assumeTrue(
+ "Ollama is not running on localhost:11434. "
+ + "Start Ollama and pull a model (e.g., 'ollama pull llama2') to run this test.",
+ isOllamaRunning());
+ }
+
+ @Test
+ public void testOllamaSimpleCompletion() {
+ OpenAiCompatibleLlm ollama =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl(OLLAMA_BASE_URL)
+ .headers(ImmutableMap.of()) // Ollama doesn't require auth for local
+ .modelName(OLLAMA_MODEL)
+ .timeoutMillis(30_000) // 30 seconds for slower local models
+ .build();
+
+ LlmRequest request =
+ LlmRequest.builder()
+ .contents(
+ ImmutableList.of(Content.fromParts(Part.fromText("Say 'hello' and nothing else."))))
+ .build();
+
+ LlmResponse response = ollama.generateContent(request, false).blockingFirst();
+
+ assertThat(response.content()).isPresent();
+ assertThat(response.content().get().parts()).isPresent();
+ assertThat(response.content().get().parts().get()).isNotEmpty();
+ String responseText =
+ response.content().get().parts().get().get(0).text().orElse("").toLowerCase();
+ assertThat(responseText).contains("hello");
+ }
+
+ @Test
+ public void testOllamaMultiTurnConversation() {
+ OpenAiCompatibleLlm ollama =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl(OLLAMA_BASE_URL)
+ .headers(ImmutableMap.of())
+ .modelName(OLLAMA_MODEL)
+ .timeoutMillis(30_000)
+ .build();
+
+ LlmRequest request =
+ LlmRequest.builder()
+ .contents(
+ ImmutableList.of(
+ Content.fromParts(Part.fromText("What is 2+2?")),
+ Content.fromParts(Part.fromText("2+2 equals 4.")),
+ Content.fromParts(Part.fromText("What about 3+3?"))))
+ .build();
+
+ LlmResponse response = ollama.generateContent(request, false).blockingFirst();
+
+ assertThat(response.content()).isPresent();
+ assertThat(response.content().get().parts()).isPresent();
+ assertThat(response.content().get().parts().get()).isNotEmpty();
+ String responseText = response.content().get().parts().get().get(0).text().orElse("");
+ assertThat(responseText).containsMatch("[6|six]"); // Could be "6" or "six"
+ }
+
+ @Test
+ public void testOllamaWithCustomTimeout() {
+ OpenAiCompatibleLlm ollama =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl(OLLAMA_BASE_URL)
+ .headers(ImmutableMap.of())
+ .modelName(OLLAMA_MODEL)
+ .timeoutMillis(60_000) // 1 minute for slower prompts
+ .build();
+
+ LlmRequest request =
+ LlmRequest.builder()
+ .contents(
+ ImmutableList.of(
+ Content.fromParts(Part.fromText("List 3 colors, comma separated."))))
+ .build();
+
+ LlmResponse response = ollama.generateContent(request, false).blockingFirst();
+
+ assertThat(response.content()).isPresent();
+ assertThat(response.content().get().parts()).isPresent();
+ assertThat(response.content().get().parts().get()).isNotEmpty();
+ }
+
+ @Test
+ public void testOllamaRegistryIntegration() {
+ OpenAiCompatibleLlm ollama =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl(OLLAMA_BASE_URL)
+ .headers(ImmutableMap.of())
+ .modelName("ollama-test")
+ .timeoutMillis(30_000)
+ .build();
+
+ ollama.registerWithPattern("ollama-test-.*");
+
+ // Verify registry resolution works
+ BaseLlm resolved = LlmRegistry.getLlm("ollama-test-model");
+ assertThat(resolved).isNotNull();
+ assertThat(resolved).isInstanceOf(OpenAiCompatibleLlm.class);
+
+ // Verify the resolved model can make requests
+ LlmRequest request =
+ LlmRequest.builder()
+ .contents(ImmutableList.of(Content.fromParts(Part.fromText("Say 'test'."))))
+ .build();
+
+ LlmResponse response = resolved.generateContent(request, false).blockingFirst();
+ assertThat(response.content()).isPresent();
+ }
+
+ /**
+ * Checks if Ollama is accessible at the expected URL.
+ *
+ * @return true if Ollama is running and responsive, false otherwise
+ */
+ private static boolean isOllamaRunning() {
+ try {
+ URL url = new URL("http://localhost:11434/api/tags");
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("GET");
+ connection.setConnectTimeout(2000);
+ connection.setReadTimeout(2000);
+ int responseCode = connection.getResponseCode();
+ return responseCode == 200;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java
new file mode 100644
index 000000000..2500e87ee
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/OpenAiCompatibleLlmTest.java
@@ -0,0 +1,229 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.models;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class OpenAiCompatibleLlmTest {
+
+ @Test
+ public void builderRequiresBaseUrl() {
+ NullPointerException exception =
+ assertThrows(
+ NullPointerException.class,
+ () ->
+ OpenAiCompatibleLlm.builder()
+ .modelName("test-model")
+ .headers(ImmutableMap.of("Authorization", "Bearer token"))
+ .build());
+
+ assertThat(exception.getMessage()).contains("baseUrl");
+ }
+
+ @Test
+ public void builderAcceptsValidBaseUrl() {
+ OpenAiCompatibleLlm llm =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl("https://api.example.com/v1/")
+ .modelName("test-model")
+ .build();
+
+ assertThat(llm.model()).isEqualTo("test-model");
+ }
+
+ @Test
+ public void builderRejectsInvalidBaseUrl() {
+ IllegalArgumentException exception =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ OpenAiCompatibleLlm.builder()
+ .baseUrl("not-a-valid-url")
+ .modelName("test-model")
+ .build());
+
+ assertThat(exception.getMessage()).contains("not a valid HTTP(S) URL");
+ }
+
+ @Test
+ public void builderAcceptsCustomTimeout() {
+ // Just verify the builder accepts timeout without error
+ OpenAiCompatibleLlm llm =
+ OpenAiCompatibleLlm.builder()
+ .baseUrl("https://api.example.com/v1/")
+ .modelName("test-model")
+ .timeoutMillis(60_000)
+ .build();
+
+ assertThat(llm.model()).isEqualTo("test-model");
+ }
+
+ @Test
+ public void builderAcceptsHeadersAsImmutableMap() {
+ ImmutableMap