From 8afb6ae3576c0b1ad9852bd8c745d41f81f48a58 Mon Sep 17 00:00:00 2001 From: XiaoHuo888-hue Date: Thu, 20 Aug 2026 13:38:44 +0000 Subject: [PATCH] feat(models): add OrcaRouter as a named model provider Adds OrcaRouterLlm, a BaseLlm that calls the OrcaRouter model gateway's OpenAI-compatible /v1/chat/completions endpoint via ChatCompletionsHttpClient, supporting both non-streaming and streaming responses. The API key is read from ORCAROUTER_API_KEY (default base URL https://api.orcarouter.ai/v1, overridable via ORCAROUTER_BASE_URL). Registers the orcarouter/.* pattern in LlmRegistry so agents can use models like orcarouter/auto or orcarouter/fusion directly. Includes unit tests mirroring the existing ApigeeLlm chat-completions coverage and a short README section. Signed-off-by: XiaoHuo888-hue --- README.md | 17 ++ .../com/google/adk/models/LlmRegistry.java | 1 + .../com/google/adk/models/OrcaRouterLlm.java | 128 ++++++++++++++ .../google/adk/models/OrcaRouterLlmTest.java | 162 ++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 core/src/main/java/com/google/adk/models/OrcaRouterLlm.java create mode 100644 core/src/test/java/com/google/adk/models/OrcaRouterLlmTest.java 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(); + } +}