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
735 changes: 325 additions & 410 deletions core/src/main/java/com/google/adk/models/BedrockBaseLM.java

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions core/src/main/java/com/google/adk/models/Claude.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.genai.types.*;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -151,11 +152,17 @@ public Flowable<LlmResponse> generateContent(LlmRequest llmRequest, boolean stre
paramsBuilder.toolChoice(toolChoice);
}

var message = this.anthropicClient.messages().create(paramsBuilder.build());

logger.debug("Claude response: {}", message);

return Flowable.just(convertAnthropicResponseToLlmResponse(message));
MessageCreateParams params = paramsBuilder.build();

// Defer the blocking Anthropic SDK call until subscription so it is cancellable and bounded by
// any downstream timeout, enabling latency-based failover in FailoverLlm.
return Flowable.defer(
() -> {
var message = this.anthropicClient.messages().create(params);
logger.debug("Claude response: {}", message);
return Flowable.just(convertAnthropicResponseToLlmResponse(message));
})
.subscribeOn(Schedulers.io());
}

private Role toClaudeRole(String role) {
Expand Down
95 changes: 94 additions & 1 deletion core/src/main/java/com/google/adk/models/LlmRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@

package com.google.adk.models;

import com.google.adk.models.failover.FailoverConfig;
import com.google.adk.models.failover.FailoverLlm;
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;

/** Central registry for managing Large Language Model (LLM) instances. */
public final class LlmRegistry {
Expand All @@ -41,6 +46,16 @@ public interface LlmFactory {
*/
private static final LinkedHashMap<String, LlmFactory> llmFactories = new LinkedHashMap<>();

/**
* Regex patterns to ordered fallback model-name chains, in registration order. When a resolved
* model name matches one of these patterns, {@link #getLlm} wraps the created model in a {@link
* FailoverLlm} that fails over to the listed models. First match wins.
*/
private static final LinkedHashMap<String, List<String>> fallbackChains = new LinkedHashMap<>();

/** Config applied to {@link FailoverLlm} instances built by the registry. */
private static volatile FailoverConfig defaultFailoverConfig = FailoverConfig.defaults();

/** Registers default LLM factories, e.g. for Gemini models. */
static {
registerLlm("gemini-.*", modelName -> Gemini.builder().modelName(modelName).build());
Expand Down Expand Up @@ -95,7 +110,85 @@ static boolean matchesAnyPattern(String modelName) {
* @throws IllegalArgumentException If no factory matches the model name.
*/
public static BaseLlm getLlm(String modelName) {
return instances.computeIfAbsent(modelName, LlmRegistry::createLlm);
return instances.computeIfAbsent(modelName, LlmRegistry::createLlmWithFallback);
}

/**
* Registers an ordered fallback chain for model names matching the given regex pattern. Models
* resolved through {@link #getLlm} whose name matches the pattern are wrapped in a {@link
* FailoverLlm} that tries the matched model first, then each fallback model in order on a
* classified, retryable failure or timeout.
*
* <p>Fallback model names are resolved lazily (only on failure) through the registered factories,
* so a chain may mix providers, e.g. {@code ["gemini-2.5-flash-lite", "Bedrock|...claude..."]}.
*
* @param modelNamePattern Regex pattern for matching primary model names.
* @param fallbackModelNames Ordered fallback model names (excluding the primary).
*/
public static void registerFallback(String modelNamePattern, List<String> fallbackModelNames) {
synchronized (factoryLock) {
fallbackChains.put(modelNamePattern, new ArrayList<>(fallbackModelNames));
}
// Drop cached instances so subsequent lookups pick up the new (un)wrapped form.
instances.keySet().removeIf(name -> name.matches(modelNamePattern));
}

/** Sets the {@link FailoverConfig} used for registry-built {@link FailoverLlm} instances. */
public static void setDefaultFailoverConfig(FailoverConfig config) {
defaultFailoverConfig = config == null ? FailoverConfig.defaults() : config;
// Existing wrapped instances captured the old config; drop them so they rebuild.
if (!fallbackChains.isEmpty()) {
instances.clear();
}
}

/**
* Evicts cached LLM instances whose model name matches the given regex pattern. Use this to
* switch a model at runtime: change the factory/config, then evict so the next {@link #getLlm}
* rebuilds the instance.
*
* @param modelNamePattern Regex pattern for matching cached model names.
*/
public static void evict(String modelNamePattern) {
instances.keySet().removeIf(name -> name.matches(modelNamePattern));
}

/** Clears all cached LLM instances. The next {@link #getLlm} call rebuilds them. */
public static void clearInstances() {
instances.clear();
}

private static BaseLlm createLlmWithFallback(String modelName) {
BaseLlm primary = createLlm(modelName);
List<String> fallbacks = findFallbackChain(modelName);
if (fallbacks == null || fallbacks.isEmpty()) {
return primary;
}
List<Supplier<BaseLlm>> suppliers = new ArrayList<>();
suppliers.add(() -> primary);
for (String fallbackName : fallbacks) {
if (fallbackName == null || fallbackName.isEmpty() || fallbackName.equals(modelName)) {
continue;
}
// Resolve fallbacks via the raw factory path (not getLlm) to avoid re-wrapping/recursion;
// FailoverLlm memoizes each supplier so the instance is built at most once, only on failure.
suppliers.add(() -> createLlm(fallbackName));
}
if (suppliers.size() == 1) {
return primary;
}
return new FailoverLlm(suppliers, defaultFailoverConfig);
}

private static List<String> findFallbackChain(String modelName) {
synchronized (factoryLock) {
for (Map.Entry<String, List<String>> entry : fallbackChains.entrySet()) {
if (modelName.matches(entry.getKey())) {
return entry.getValue();
}
}
}
return null;
}

/**
Expand Down
33 changes: 30 additions & 3 deletions core/src/main/java/com/google/adk/models/azure/AzureConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.google.adk.models.azure;

import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -26,10 +28,13 @@ public final class AzureConfig {

private static final Logger logger = LoggerFactory.getLogger(AzureConfig.class);

public static final String MODEL_ENDPOINT_ENV = "AZURE_MODEL_ENDPOINT";
public static final String RESPONSE_ENDPOINT_ENV = "AZURE_RESPONSE_ENDPOINT";
public static final String REALTIME_ENDPOINT_ENV = "AZURE_REALTIME_ENDPOINT";
public static final String TRANSLATE_ENDPOINT_ENV = "AZURE_TRANSLATE_ENDPOINT";

private static final Map<String, String> runtimeOverrides = new ConcurrentHashMap<>();

public static final String API_KEY_ENV = "AZURE_OPENAI_API_KEY";
public static final String VOICE_ENV = "AZURE_REALTIME_VOICE";
public static final String TRANSLATE_TARGET_LANGUAGE_ENV = "AZURE_TRANSLATE_TARGET_LANGUAGE";
Expand Down Expand Up @@ -82,6 +87,17 @@ private AzureConfig(
this.translateTargetLanguage = translateTargetLanguage;
}

/**
* Sets a runtime configuration value that takes precedence over environment variables. Used by
* host applications (e.g. RAE) to inject values from {@code application.properties} or AWS
* Secrets Manager at startup.
*/
public static void setRuntimeOverride(String envKey, String value) {
if (value != null && !value.isBlank()) {
runtimeOverrides.put(envKey, value.replaceAll("/+$", ""));
}
}

public static AzureConfig fromEnvironment(String modelName) {
String responseEndpoint = resolveContractEndpoint(RESPONSE_ENDPOINT_ENV, "Responses API");
String realtimeEndpoint = resolveContractEndpoint(REALTIME_ENDPOINT_ENV, "Realtime voice API");
Expand Down Expand Up @@ -217,6 +233,9 @@ static String normalizeTranslateWebSocketUrl(String raw, String modelName) {

private static String resolveContractEndpoint(String specificEnv, String label) {
String val = resolveOptionalEnv(specificEnv);
if ((val == null || val.isBlank()) && RESPONSE_ENDPOINT_ENV.equals(specificEnv)) {
val = resolveOptionalEnv(MODEL_ENDPOINT_ENV);
}
if (val == null || val.isBlank()) {
throw new IllegalStateException(
"Azure " + label + " endpoint not configured. Set " + specificEnv);
Expand Down Expand Up @@ -256,23 +275,31 @@ private static String toWebSocketUrl(String url) {
}

private static String resolveRequired(String envVar) {
String val = System.getenv(envVar);
String val = resolveEnvOrOverride(envVar);
if (val == null || val.isBlank()) {
throw new IllegalStateException(envVar + " environment variable is not set.");
}
return val.replaceAll("/+$", "");
}

private static String resolveOptional(String envVar, String defaultValue) {
String val = System.getenv(envVar);
String val = resolveEnvOrOverride(envVar);
return (val != null && !val.isBlank()) ? val : defaultValue;
}

private static String resolveOptionalEnv(String envVar) {
String val = System.getenv(envVar);
String val = resolveEnvOrOverride(envVar);
return (val != null && !val.isBlank()) ? val.replaceAll("/+$", "") : null;
}

private static String resolveEnvOrOverride(String envVar) {
String override = runtimeOverrides.get(envVar);
if (override != null && !override.isBlank()) {
return override;
}
return System.getenv(envVar);
}

private static String maskEndpoint(String url) {
if (url == null) {
return "unset";
Expand Down
Loading