Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions core/src/main/java/com/google/adk/models/LlmRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down
128 changes: 128 additions & 0 deletions core/src/main/java/com/google/adk/models/OrcaRouterLlm.java
Original file line number Diff line number Diff line change
@@ -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 <a
* href="https://www.orcarouter.ai">OrcaRouter</a> model gateway.
*
* <p>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.
*
* <p>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.
*
* <p>Model names must use the {@code orcarouter/<model>} 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.
*
* <p>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/<model>}
* 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<LlmResponse> 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/<model>: " + modelName);
}
return modelName;
}
}
162 changes: 162 additions & 0 deletions core/src/test/java/com/google/adk/models/OrcaRouterLlmTest.java
Original file line number Diff line number Diff line change
@@ -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<LlmRequest> 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<LlmRequest> 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<LlmRequest> 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/<model>: 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();
}
}