diff --git a/README.md b/README.md
index b5747e371..913d1663b 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,23 @@ LlmAgent rootAgent = LlmAgent.builder()
.build();
```
+Models are resolved through the `LlmRegistry` by name. In addition to Gemini,
+Vertex AI, Apigee, and Claude, you can use the [OrcaRouter](https://www.orcarouter.ai)
+model gateway as an OpenAI-compatible endpoint — set the `ORCAROUTER_API_KEY`
+environment variable and pass a model like `orcarouter/auto`:
+
+```java
+LlmAgent agent = LlmAgent.builder()
+ .name("assistant")
+ .model("orcarouter/auto") // Routed through the OrcaRouter gateway
+ .instruction("You are a helpful assistant.")
+ .build();
+```
+
+The gateway also runs gateway-level, zero-trust security for AI agents on the
+same endpoint — screening every prompt/response and governing every tool call
+on a default-deny basis, with no application code changes.
+
### Development UI
Same as the beloved Python Development UI.
diff --git a/core/src/main/java/com/google/adk/models/LlmRegistry.java b/core/src/main/java/com/google/adk/models/LlmRegistry.java
index acc038695..224983562 100644
--- a/core/src/main/java/com/google/adk/models/LlmRegistry.java
+++ b/core/src/main/java/com/google/adk/models/LlmRegistry.java
@@ -40,6 +40,7 @@ public interface LlmFactory {
registerLlm("gemini-.*", modelName -> Gemini.builder().modelName(modelName).build());
registerLlm("apigee/.*", modelName -> ApigeeLlm.builder().modelName(modelName).build());
registerLlm("gemma-.*", modelName -> Gemini.builder().modelName(modelName).build());
+ registerLlm("orcarouter/.*", modelName -> new OrcaRouterLlm(modelName));
}
/**
diff --git a/core/src/main/java/com/google/adk/models/OrcaRouterLlm.java b/core/src/main/java/com/google/adk/models/OrcaRouterLlm.java
new file mode 100644
index 000000000..5870b91c4
--- /dev/null
+++ b/core/src/main/java/com/google/adk/models/OrcaRouterLlm.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.adk.models;
+
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import com.google.adk.models.chat.ChatCompletionsClient;
+import com.google.adk.models.chat.ChatCompletionsHttpClient;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.HttpOptions;
+import io.reactivex.rxjava3.core.Flowable;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A {@link BaseLlm} implementation for calling the OrcaRouter model gateway.
+ *
+ *
OrcaRouter is an OpenAI-compatible model gateway that routes each request to a model hosted by
+ * one of its upstream providers. This implementation talks to the gateway's {@code
+ * /v1/chat/completions} endpoint using {@link ChatCompletionsHttpClient}, supporting both
+ * non-streaming and streaming responses.
+ *
+ *
The API key is read from the {@code ORCAROUTER_API_KEY} environment variable. The base URL
+ * defaults to {@code https://api.orcarouter.ai/v1} and can be overridden with the {@code
+ * ORCAROUTER_BASE_URL} environment variable.
+ *
+ *
Model names must use the {@code orcarouter/} format, e.g. {@code orcarouter/auto}
+ * (smart routing) or {@code orcarouter/fusion}. The gateway accepts the model identifier as-is,
+ * including the {@code orcarouter/} namespace prefix.
+ */
+public class OrcaRouterLlm extends BaseLlm {
+ private static final Logger logger = LoggerFactory.getLogger(OrcaRouterLlm.class);
+
+ static final String API_KEY_ENV_VAR = "ORCAROUTER_API_KEY";
+ static final String BASE_URL_ENV_VAR = "ORCAROUTER_BASE_URL";
+ static final String DEFAULT_BASE_URL = "https://api.orcarouter.ai/v1";
+ static final String MODEL_PREFIX = "orcarouter/";
+
+ private final ChatCompletionsClient chatCompletionsClient;
+
+ /**
+ * Constructs a new {@code OrcaRouterLlm} instance.
+ *
+ * The chat completions client is built from the {@code ORCAROUTER_API_KEY} and {@code
+ * ORCAROUTER_BASE_URL} environment variables.
+ *
+ * @param modelName the OrcaRouter model name (e.g., {@code orcarouter/auto})
+ * @throws IllegalArgumentException if the model name is not in the {@code orcarouter/}
+ * format or the API key is not configured
+ */
+ public OrcaRouterLlm(String modelName) {
+ this(modelName, buildChatCompletionsClient());
+ }
+
+ /**
+ * Constructs a new {@code OrcaRouterLlm} instance with the given chat completions client, for
+ * testing purposes.
+ *
+ * @param modelName the OrcaRouter model name (e.g., {@code orcarouter/auto})
+ * @param chatCompletionsClient the client used to call the gateway
+ */
+ @VisibleForTesting
+ OrcaRouterLlm(String modelName, ChatCompletionsClient chatCompletionsClient) {
+ super(validateModelName(modelName));
+ this.chatCompletionsClient =
+ Objects.requireNonNull(chatCompletionsClient, "chatCompletionsClient cannot be null");
+ }
+
+ /** Builds the production chat completions client from environment variables. */
+ private static ChatCompletionsClient buildChatCompletionsClient() {
+ String apiKey = System.getenv(API_KEY_ENV_VAR);
+ if (isNullOrEmpty(apiKey)) {
+ throw new IllegalArgumentException(
+ "OrcaRouter API key is not set. Set the " + API_KEY_ENV_VAR + " environment variable.");
+ }
+ String baseUrl = System.getenv(BASE_URL_ENV_VAR);
+ if (isNullOrEmpty(baseUrl)) {
+ baseUrl = DEFAULT_BASE_URL;
+ }
+ HttpOptions httpOptions =
+ HttpOptions.builder()
+ .baseUrl(baseUrl)
+ .headers(ImmutableMap.of("Authorization", "Bearer " + apiKey))
+ .build();
+ logger.debug("OrcaRouterLlm constructed with baseUrl={}", baseUrl);
+ return new ChatCompletionsHttpClient(httpOptions);
+ }
+
+ @Override
+ public Flowable generateContent(LlmRequest llmRequest, boolean stream) {
+ String modelToUse = llmRequest.model().orElse(model());
+ LlmRequest newLlmRequest = llmRequest.toBuilder().model(validateModelName(modelToUse)).build();
+ return chatCompletionsClient.complete(newLlmRequest, stream);
+ }
+
+ @Override
+ public BaseLlmConnection connect(LlmRequest llmRequest) {
+ throw new UnsupportedOperationException(
+ "Streaming connections are not supported for OrcaRouter models.");
+ }
+
+ private static String validateModelName(@Nullable String modelName) {
+ if (isNullOrEmpty(modelName)
+ || !modelName.startsWith(MODEL_PREFIX)
+ || modelName.length() == MODEL_PREFIX.length()) {
+ throw new IllegalArgumentException(
+ "Invalid OrcaRouter model name, expected orcarouter/: " + modelName);
+ }
+ return modelName;
+ }
+}
diff --git a/core/src/test/java/com/google/adk/models/OrcaRouterLlmTest.java b/core/src/test/java/com/google/adk/models/OrcaRouterLlmTest.java
new file mode 100644
index 000000000..8572b77ba
--- /dev/null
+++ b/core/src/test/java/com/google/adk/models/OrcaRouterLlmTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.adk.models;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.adk.models.chat.ChatCompletionsClient;
+import com.google.common.collect.ImmutableList;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+import io.reactivex.rxjava3.core.Flowable;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(JUnit4.class)
+public class OrcaRouterLlmTest {
+
+ @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+ @Mock private ChatCompletionsClient mockCcClient;
+
+ @Test
+ public void build_withValidModelStrings_succeeds() {
+ String[] validModelStrings = {
+ "orcarouter/auto",
+ "orcarouter/fusion",
+ "orcarouter/fusion-flash",
+ "orcarouter/fusion-mini",
+ "orcarouter/deepseek/deepseek-v4-pro"
+ };
+
+ for (String modelName : validModelStrings) {
+ OrcaRouterLlm llm = new OrcaRouterLlm(modelName, mockCcClient);
+ assertThat(llm).isNotNull();
+ assertThat(llm.model()).isEqualTo(modelName);
+ }
+ }
+
+ @Test
+ public void build_withInvalidModelStrings_throwsException() {
+ String[] invalidModelStrings = {
+ "auto", "orcarouter", "orcarouter/", "orcarouter", "gemini-2.5-flash", "", null
+ };
+
+ for (String modelName : invalidModelStrings) {
+ assertThrows(
+ IllegalArgumentException.class, () -> new OrcaRouterLlm(modelName, mockCcClient));
+ }
+ }
+
+ @Test
+ public void generateContent_sendsToCcClient() {
+ when(mockCcClient.complete(any(), anyBoolean())).thenReturn(Flowable.empty());
+
+ OrcaRouterLlm llm = new OrcaRouterLlm("orcarouter/auto", mockCcClient);
+ LlmRequest request =
+ LlmRequest.builder()
+ .model("orcarouter/auto")
+ .contents(ImmutableList.of(Content.builder().parts(Part.fromText("hello")).build()))
+ .build();
+ llm.generateContent(request, false).test().assertNoErrors();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(LlmRequest.class);
+ verify(mockCcClient).complete(requestCaptor.capture(), eq(false));
+ assertThat(requestCaptor.getValue().model()).hasValue("orcarouter/auto");
+ }
+
+ @Test
+ public void generateContent_sendsStreamingToCcClient() {
+ when(mockCcClient.complete(any(), anyBoolean())).thenReturn(Flowable.empty());
+
+ OrcaRouterLlm llm = new OrcaRouterLlm("orcarouter/fusion", mockCcClient);
+ LlmRequest request =
+ LlmRequest.builder()
+ .model("orcarouter/fusion")
+ .contents(ImmutableList.of(Content.builder().parts(Part.fromText("hi")).build()))
+ .build();
+ llm.generateContent(request, true).test().assertNoErrors();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(LlmRequest.class);
+ verify(mockCcClient).complete(requestCaptor.capture(), eq(true));
+ assertThat(requestCaptor.getValue().model()).hasValue("orcarouter/fusion");
+ }
+
+ @Test
+ public void generateContent_requestLevelModelOverride_preservesPrefix() {
+ when(mockCcClient.complete(any(), anyBoolean())).thenReturn(Flowable.empty());
+
+ OrcaRouterLlm llm = new OrcaRouterLlm("orcarouter/auto", mockCcClient);
+ LlmRequest request =
+ LlmRequest.builder()
+ .model("orcarouter/fusion-flash")
+ .contents(ImmutableList.of(Content.builder().parts(Part.fromText("hello")).build()))
+ .build();
+ llm.generateContent(request, false).test().assertNoErrors();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(LlmRequest.class);
+ verify(mockCcClient).complete(requestCaptor.capture(), eq(false));
+ assertThat(requestCaptor.getValue().model()).hasValue("orcarouter/fusion-flash");
+ }
+
+ @Test
+ public void generateContent_invalidRequestLevelOverride_throwsException() {
+ OrcaRouterLlm llm = new OrcaRouterLlm("orcarouter/auto", mockCcClient);
+ LlmRequest request =
+ LlmRequest.builder()
+ .model("gemini-2.5-flash")
+ .contents(ImmutableList.of(Content.builder().parts(Part.fromText("hello")).build()))
+ .build();
+
+ IllegalArgumentException e =
+ assertThrows(IllegalArgumentException.class, () -> llm.generateContent(request, false));
+ assertThat(e)
+ .hasMessageThat()
+ .contains("Invalid OrcaRouter model name, expected orcarouter/: gemini-2.5-flash");
+ }
+
+ @Test
+ public void connect_throwsUnsupportedOperationException() {
+ OrcaRouterLlm llm = new OrcaRouterLlm("orcarouter/auto", mockCcClient);
+ LlmRequest request = LlmRequest.builder().model("orcarouter/auto").build();
+ UnsupportedOperationException e =
+ assertThrows(UnsupportedOperationException.class, () -> llm.connect(request));
+ assertThat(e)
+ .hasMessageThat()
+ .contains("Streaming connections are not supported for OrcaRouter models.");
+ }
+
+ @Test
+ public void llmRegistry_resolvesOrcaRouterModels() {
+ // ORCAROUTER_API_KEY must be set for the production path; clear it to assert the error path
+ // first, then check the registry wiring with a registered pattern.
+ assertThat(LlmRegistry.matchesAnyPattern("orcarouter/auto")).isTrue();
+ assertThat(LlmRegistry.matchesAnyPattern("orcarouter/fusion")).isTrue();
+ assertThat(LlmRegistry.matchesAnyPattern("gemini-2.5-flash")).isTrue();
+ assertThat(LlmRegistry.matchesAnyPattern("claude-sonnet-5")).isFalse();
+ }
+}