Multiple LLM Providers

Module 2 · ~10 min read
When you have multiple beans of the same type in a Spring context, you need to tell Spring which one to inject. The @Qualifier annotation is the standard solution. Combined with @Primary, it gives you a sensible default while still allowing precise selection when needed.

The @Qualifier Pattern

Power RAG registers multiple ChatClient beans — one per provider (Claude, Gemini, Ollama). Without disambiguation Spring would throw a NoUniqueBeanDefinitionException. The solution is to annotate each bean with a unique @Qualifier name and annotate the injection point with the same qualifier.

SpringAiConfig.java — Representative excerpt

SpringAiConfig.java View source ↗
@Configuration
public class SpringAiConfig {

    private static final String SYSTEM_PREAMBLE = """
            You are a knowledgeable, helpful, and fair assistant...
            """;

    @Bean @Primary @Qualifier("claudeSonnet")
    public ChatClient claudeSonnetClient(AnthropicChatModel model) {
        return ChatClient.builder(model)
                .defaultSystem(SYSTEM_PREAMBLE)
                .build();
    }

    @Bean @Qualifier("geminiFlash")
    public ChatClient geminiFlashClient(GoogleGenAiChatModel model) {
        return ChatClient.builder(model)
                .defaultSystem(SYSTEM_PREAMBLE)
                .build();
    }

    /** Input safety only — no default system prompt. */
    @Bean @Qualifier("geminiGuard")
    public ChatClient geminiGuardClient(GoogleGenAiChatModel model) {
        return ChatClient.builder(model).build();
    }

    @Bean @Qualifier("ollamaQwen")
    public ChatClient ollamaQwenClient(OllamaChatModel model) {
        return ChatClient.builder(model)
                .defaultSystem(SYSTEM_PREAMBLE)
                .build();
    }
}

Explanation

@Primary

The claudeSonnetClient bean is annotated with @Primary. This means: when a component injects ChatClient without any @Qualifier, Spring automatically selects this bean. It is the application default.

@Qualifier("geminiGuard")

The guardrail ChatClient is built without a default system prompt. That keeps the safety-classification user message separate from the main RAG assistant preamble. The model id for each call is set in GuardrailService via GoogleGenAiChatOptions (default gemini-2.5-flash).

Constructor parameter injection

Each bean method receives a provider-specific model type (AnthropicChatModel, GoogleGenAiChatModel, OllamaChatModel). These are auto-created by the respective provider starters. Spring injects them by type — there is no ambiguity because each type is unique.

@Primary means this bean is injected when no @Qualifier is specified. Always mark exactly one bean as @Primary to avoid NoUniqueBeanDefinitionException in components that inject without a qualifier.

Registered Beans in Power RAG

QualifierProviderDefault ModelPurpose
claudeSonnet (Primary) Anthropic claude-sonnet-4-6 Main RAG chat responses
geminiFlash Google GenAI gemini-2.5-flash RAG chat; image generation fallback
geminiGuard Google GenAI powerrag.guardrails.input-model-id (default gemini-2.5-flash) Input safety classification only
ollamaQwen / ollamaDeepSeek Ollama configured chat models Local chat when user selects Ollama

Injecting a Specific Bean

To inject the Gemini client specifically in a service:

Injecting with @Qualifier in a Spring component
@Service
public class SomeService {
    private final ChatClient geminiClient;

    public SomeService(@Qualifier("geminiFlash") ChatClient geminiClient) {
        this.geminiClient = geminiClient;
    }
}