Spring AI Model Portability: Switch Providers Without Rewriting Your App

July 11, 2026 · Spring AI for Enterprise Java (part 4)

▶ Watch on YouTube & subscribe to The Stack Underflow

A procurement team asks a reasonable question: “If OpenAI raises prices next quarter, or the security team decides model traffic can’t leave the building, how much code do we have to touch?” On most AI stacks, the honest answer is “the request-building logic, the response-parsing logic, and every prompt template that assumed a specific vendor’s SDK shape.” That’s not a config change — it’s a rewrite disguised as a vendor swap.

Spring AI was built to make that question boring. The ChatClient code you write against one provider is the same ChatClient code that runs against a different one, because your code was never actually talking to OpenAI, Anthropic, or Amazon Bedrock. It was talking to an interface. The vendor sits behind a seam, and the seam is where the portability lives.

If you’ve already swapped a DataSource implementation without touching your DAO layer, or swapped a JPA provider without touching your entities, you already understand this pattern. Spring AI just applies it to models.

The one-sentence version: ChatClient is built against the ChatModel interface, not a vendor SDK, so swapping providers is a config property or a @Qualifier — never a rewrite of your business logic.

The seam: ChatClient talks to an interface, not a vendor

Every ChatClient you build wraps a ChatModelorg.springframework.ai.chat.model.ChatModel. That interface is the entire contract. Your fluent-API calls (.prompt(), .user(), .call(), .content() — the same chain from the last episode) never reference OpenAI, Anthropic, or anything vendor-specific:

@Service
public class ExplainerService {

    private final ChatClient chatClient;

    public ExplainerService(ChatModel chatModel) {
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    public String explain(String topic) {
        return chatClient.prompt()
            .user("Explain %s in two sentences.".formatted(topic))
            .call()
            .content();
    }
}

Nothing in explain() changes no matter which ChatModel Spring injects. That’s the seam:

┌────────────────────────────────────────────────────────┐
│  YOUR CODE (unchanged, every provider)                  │
│  chatClient.prompt().user("...").call().content();      │
└───────────────────────────┬──────────────────────────────┘

┌────────────────────────────▼─────────────────────────────┐
│  ChatModel  — org.springframework.ai.chat.model.ChatModel │
│  the interface. The whole trick lives here.                │
└────────────────────────────┬─────────────────────────────┘

        ┌──────────┬─────────┼─────────┬──────────┐
        ▼          ▼         ▼         ▼          ▼
  OpenAiChatModel  Anthropic  Bedrock   Google      Ollama
                   ChatModel  Proxy     GenAi       ChatModel
                              ChatModel ChatModel   (local)

The ChatModel implementation gets wired in as a Spring bean, ChatClient.builder(chatModel) accepts whatever ChatModel the container hands it, and your service code has no idea which one it got.

Five implementations, one contract

Spring AI 2.0.0 ships ChatModel implementations for five providers. All five satisfy the identical interface, so all five are drop-in replacements for each other from your code’s point of view:

ProviderChatModel classPackage
OpenAIOpenAiChatModelorg.springframework.ai.openai
AnthropicAnthropicChatModelorg.springframework.ai.anthropic
Amazon Bedrock (Converse API)BedrockProxyChatModelorg.springframework.ai.bedrock.converse
Google GenAI (Gemini)GoogleGenAiChatModelorg.springframework.ai.google.genai
Ollama (runs on your own hardware)OllamaChatModelorg.springframework.ai.ollama

Each provider ships as its own Spring Boot starter, added independently to your build. The verified 2.0.0 coordinate for OpenAI is:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

with version 2.0.0 managed by the Spring AI BOM. The sibling starters follow the same spring-ai-starter-model-<provider> naming pattern (spring-ai-starter-model-anthropic, spring-ai-starter-model-ollama, spring-ai-starter-model-bedrock-converse, spring-ai-starter-model-google-genai) — confirm the exact coordinate against the live Spring AI BOM or start.spring.io before you pin a version, since Spring AI has renamed a provider module before (see below).

One naming delta worth knowing if you learned Spring AI before 2.0.0: the Gemini integration used to be VertexAiGeminiChatModel under org.springframework.ai.vertexai.gemini. In 2.0.0 that class was renamed and consolidated into GoogleGenAiChatModel under org.springframework.ai.google.genai. If you see VertexAiGeminiChatModel in an older tutorial or the training course material, treat it as superseded — it is not the current 2.0.0 class.

Setup requirement while you’re here: Spring AI 2.0.0 runs on Spring Boot 4.0 and requires Java 21 to build (Java 17 is only Boot 4’s runtime floor, not the build requirement). If your project targets Java 17, upgrade the build toolchain before adding these starters.

Mechanism one: flip a config property

The simplest swap is a single property. spring.ai.model.chat=<provider> tells Spring AI’s auto-configuration which provider’s ChatModel bean to activate:

# application.properties — cloud
spring.ai.model.chat=openai
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-4o-mini
# application.properties — same app, local model, nothing else changes
spring.ai.model.chat=ollama
spring.ai.ollama.chat.model=llama3.2

Change the value, restart the app, and ExplainerService.explain() from earlier now runs against a completely different vendor. No line of ChatClient code was touched.

This works because each provider’s auto-configuration class is conditional on that exact property. OllamaChatAutoConfiguration is enabled when spring.ai.model.chat is set to ollama — or when the property is absent at all, since Ollama is the default. AnthropicChatAutoConfiguration follows the identical pattern for anthropic. Every provider’s auto-configuration uses the same @ConditionalOnProperty(name = "spring.ai.model.<kind>", havingValue = "<provider>", matchIfMissing = true) shape. That symmetry is why dropping two provider starters on the classpath at once, without setting the property, produces two competing ChatModel beans — which brings up mechanism two.

Mechanism two: run two providers side by side

Sometimes you don’t want to flip between providers — you want both running at once. A common enterprise shape: cloud model for general traffic, local Ollama model for anything that can’t leave the building. Standard Spring dependency injection handles this with @Primary and @Qualifier, nothing Spring-AI-specific:

@Configuration
public class ChatModelConfig {

    @Bean
    @Primary
    public ChatClient primaryChatClient(
            @Qualifier("openAiChatModel") ChatModel cloudModel) {
        return ChatClient.builder(cloudModel).build();
    }

    @Bean
    public ChatClient onPremChatClient(
            @Qualifier("ollamaChatModel") ChatModel localModel) {
        return ChatClient.builder(localModel).build();
    }
}

Inject primaryChatClient anywhere you’d normally autowire the unqualified ChatClient; inject onPremChatClient explicitly wherever data residency requires it. Both call .prompt().user("...").call().content() — the identical chain — one just never leaves your infrastructure. Confirm the actual auto-configured bean name (openAiChatModel, ollamaChatModel, etc.) in your own build before wiring @Qualifier, since bean names are an implementation detail of the auto-configuration, not part of the public contract.

The enterprise case: cost, latency, and data residency

The technical trick (one interface, five vendors) is worth knowing on its own, but the reason it matters at the enterprise level is leverage:

ConcernWhat changes by providerWhat Spring AI gives you
CostPer-token pricing varies by vendor and model tierSwap without a re-architecture if pricing shifts
LatencyNetwork hop to a cloud API vs. a local callRoute latency-sensitive paths to Ollama via @Qualifier
Data residencyCloud providers process data off your infrastructureOllama keeps prompts and responses entirely on-box
ProcurementVendor contracts, SLAs, renewal termsNo code-level lock-in tying you to one vendor’s terms

None of this is a demo trick. It’s a real answer the next time security asks “can this data leave the building,” and a real lever the next time procurement asks “what’s our switching cost.”

How to apply this right now

  1. Confirm your build target. Spring AI 2.0.0 needs Spring Boot 4.0 and Java 21 to build. Upgrade the toolchain first if you’re still on Java 17.
  2. Add exactly one provider starter to start. Pick spring-ai-starter-model-openai or whichever provider you already have credentials for, and confirm the version against the current Spring AI BOM.
  3. Inject ChatModel, not a vendor class, everywhere in your service code. Build your ChatClient with ChatClient.builder(chatModel).build() and never import a provider-specific chat model type into your business logic.
  4. Set spring.ai.model.chat=<provider> explicitly in application.properties, even if it matches the default — it documents which provider is active and makes the swap a one-line diff later.
  5. Add a second provider starter (e.g. Ollama) to a non-production profile and prove the swap works by flipping the property and re-running your existing tests unchanged.
  6. If you need both providers concurrently, wire @Primary + @Qualifier per the pattern above, and verify the actual bean names auto-configuration produces in your version before hardcoding a qualifier string.

Common misconceptions

“Switching AI vendors means rewriting my prompt/request code.” Only if your code was directly coupled to a vendor SDK. Code built against ChatModel/ChatClient doesn’t change — only which bean gets injected changes.

spring.ai.model.chat and spring.ai.openai.chat.model configure the same thing.” They don’t. spring.ai.model.chat selects which provider’s auto-configuration activates. spring.ai.openai.chat.model sets which model to use within the already-selected OpenAI provider (e.g. gpt-4o-mini). Confusing the two is the most common misconfiguration when adding a second provider.

“Running two model providers at once requires a custom abstraction layer.” It requires @Primary and @Qualifier — plain Spring dependency injection that predates Spring AI entirely. No factory pattern, no custom registry needed.

“Gemini support in Spring AI is VertexAiGeminiChatModel.” That was true in the pre-2.0.0 course material. As of 2.0.0 GA, the current, verified class is GoogleGenAiChatModel under org.springframework.ai.google.genai. Treat VertexAiGeminiChatModel as a superseded name, not a current alternative.

Frequently asked questions

What’s the actual difference between ChatClient and ChatModel? ChatModel is the low-level interface a specific provider implements (OpenAiChatModel, OllamaChatModel, and so on). ChatClient is the fluent API you build on top of a ChatModel — it’s what your service code actually calls. You should almost never call a ChatModel directly in application code; build a ChatClient around it and code against that.

How do I switch from OpenAI to Ollama without touching code? Add the Ollama starter to your build, set spring.ai.model.chat=ollama in place of openai, set spring.ai.ollama.chat.model to the local model you’ve pulled (e.g. llama3.2), and restart. No ChatClient call site changes.

Can I run OpenAI and Ollama in the same Spring Boot application at once? Yes — that’s exactly what @Primary + @Qualifier on separate ChatModel/ChatClient beans is for. Both starters can coexist on the classpath; the qualifiers tell Spring which bean goes where.

Do I need Java 21 for Spring AI 2.0.0? Yes, to build. Spring AI 2.0.0 runs on Spring Boot 4.0 / Spring Framework 7.0, which requires Java 21 as the build baseline (Java 17 is only Boot 4’s minimum runtime version, not the build requirement).

What happened to VertexAiGeminiChatModel? It was renamed and consolidated into GoogleGenAiChatModel (org.springframework.ai.google.genai) as part of the 2.0.0 changes. If you’re migrating older Spring AI code, this is the rename to check for first — it’s the highest-risk naming surface in the provider lineup.

Which starter dependency do I add for each provider? Each provider has its own spring-ai-starter-model-<provider> artifact under org.springframework.ai (e.g. spring-ai-starter-model-openai, confirmed at 2.0.0 on Maven Central). Add only the starters for the providers you actually use — adding two without setting spring.ai.model.chat will produce competing ChatModel beans and a startup failure unless you qualify them.

Where this fits in the series

This is Episode 4 of Spring AI for Enterprise Java. The previous episode covered ChatClient’s fluent API and response metadata against a single provider — this episode opened up what sits underneath it. The next episode, Structured Output to Java POJOs, covers turning that free-text response into a typed Java record.

Runnable code for this episode, including both the config-flip and the @Primary/@Qualifier setup, lives in the 04-model-portability module of spring-ai-enterprise-examples on GitHub.

If you want the video walkthrough of the same material, subscribe to @TheStackUnderflow on YouTube — new Spring AI episodes post as the series continues.

Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.

Subscribe on YouTube →