Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Setting Up Spring AI with OpenAI

A complete Spring Boot + OpenAI setup: dependencies, API key management, model options, timeouts, retries and the five errors every developer hits on the first run.

Beginner6 min readUpdated
On this page

This is the setup that everything else in the Spring AI phase builds on. It takes about ten minutes, and the last section covers the five errors that account for most of the time people lose on it.

Step 1 — Add the dependencies

Spring AI's BOM manages the versions of every module together. Declare it once.

pom.xml (Maven)
<properties>
  <java.version>21</java.version>
  <!-- Pin an exact version. This ecosystem moves fast. -->
  <spring-ai.version>1.0.0</spring-ai.version>
</properties>
 
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>${spring-ai.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
 
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
  </dependency>
</dependencies>
build.gradle.kts (Gradle)
dependencies {
    implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.ai:spring-ai-starter-model-openai")
}

Step 2 — Handle the API key properly

There is one correct way to do this and several ways that end in a rotated key.

Set the key in your shell
export OPENAI_API_KEY="sk-proj-..."
application.yml
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}

For local development, an untracked .env or an IDE run configuration is fine. In production use your platform's secret manager — AWS Secrets Manager, Azure Key Vault, Kubernetes secrets mounted as environment variables, or Spring Cloud Config with encryption.

Step 3 — Configure model options

application.yml
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          # Start with a small, fast model. Move up only when quality demands it.
          model: gpt-4o-mini
          # 0.0-0.2 for extraction, classification, tool calling.
          # 0.7+ only when you genuinely want variety.
          temperature: 0.2
          # A hard ceiling on response length — and therefore on cost per call.
          max-tokens: 1000
      embedding:
        options:
          # Configured separately. Must match your vector store's dimensions.
          model: text-embedding-3-small
    retry:
      max-attempts: 3
      backoff:
        initial-interval: 2s
        multiplier: 2
        max-interval: 30s
      # 429 and 5xx are retried. 4xx client errors are not.
      on-client-errors: false
 
logging:
  level:
    # Uncomment while debugging to see the outgoing request bodies.
    # org.springframework.ai: DEBUG

Choosing a model

Start smaller than you think you need. The cheap, fast models handle summarisation, classification, extraction and routine tool calling well. Reserve the expensive ones for genuinely hard reasoning, and measure the difference on your own inputs rather than assuming it.

A useful pattern is a property per use case:

app:
  ai:
    models:
      fast: gpt-4o-mini      # classification, routing, extraction
      strong: gpt-4o         # multi-step reasoning, code analysis
// Override per call — the application default stays untouched.
String analysis = chatClient.prompt()
        .user(complexQuestion)
        .options(ChatOptions.builder()
                .model(properties.models().strong())
                .temperature(0.1)
                .build())
        .call()
        .content();

Step 4 — Make the call

OpenAiChatService.java
package com.javaagentic.demo.chat;
 
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
 
@Service
public class OpenAiChatService {
 
    private final ChatClient chatClient;
    private final MeterRegistry meterRegistry;
 
    public OpenAiChatService(ChatClient.Builder builder, MeterRegistry meterRegistry) {
        this.chatClient = builder
                .defaultSystem("You are a helpful assistant for Java developers.")
                .build();
        this.meterRegistry = meterRegistry;
    }
 
    public String ask(String question) {
        // chatResponse() rather than content() so the usage metadata is available.
        ChatResponse response = chatClient.prompt()
                .user(question)
                .call()
                .chatResponse();
 
        recordUsage(response);
        return response.getResult().getOutput().getText();
    }
 
    /**
     * Token usage is the only reliable early warning for a cost problem.
     * Recording it from the first day costs nothing and has saved many teams
     * an unpleasant conversation about the monthly bill.
     */
    private void recordUsage(ChatResponse response) {
        var usage = response.getMetadata().getUsage();
        if (usage == null) {
            return;
        }
        meterRegistry.counter("ai.tokens", "type", "prompt")
                .increment(usage.getPromptTokens());
        meterRegistry.counter("ai.tokens", "type", "completion")
                .increment(usage.getCompletionTokens());
    }
}

Step 5 — Add timeouts

Spring AI's retry configuration handles transient failures, but it will not save you from a call that simply never returns. Set an explicit timeout on the underlying HTTP client.

AiHttpConfig.java
package com.javaagentic.demo.config;
 
import java.time.Duration;
 
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
 
@Configuration
public class AiHttpConfig {
 
    /**
     * Without this, a stalled model call holds a request thread indefinitely.
     * Read timeout must exceed your slowest legitimate generation — long
     * completions on a large model can genuinely take 60 seconds.
     */
    @Bean
    RestClientCustomizer aiRestClientCustomizer() {
        return restClientBuilder -> {
            var factory = new SimpleClientHttpRequestFactory();
            factory.setConnectTimeout(Duration.ofSeconds(10));
            factory.setReadTimeout(Duration.ofSeconds(90));
            restClientBuilder.requestFactory(factory);
        };
    }
}

Step 6 — Verify it works

ChatSmokeTest.java
package com.javaagentic.demo;
 
import static org.assertj.core.api.Assertions.assertThat;
 
import com.javaagentic.demo.chat.OpenAiChatService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
 
@SpringBootTest
// Guarded so CI without a key does not fail. Real calls cost money — keep
// these few and mock the model in your ordinary unit tests.
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
class ChatSmokeTest {
 
    @Autowired
    OpenAiChatService chatService;
 
    @Test
    void answersASimpleQuestion() {
        String answer = chatService.ask("Reply with exactly the word: OK");
        assertThat(answer).containsIgnoringCase("OK");
    }
}

Note the assertion style. containsIgnoringCase rather than isEqualTo, because the output is probabilistic. Writing exact-match assertions against a language model produces a test suite that fails for no reason. There is a full treatment in testing AI applications.

The five errors everyone hits

401 Unauthorized

The key is not reaching the application. Check, in order: the environment variable name, whether your IDE run configuration exports it, whether application.yml uses ${OPENAI_API_KEY} or a literal, and whether the key has stray whitespace or quotes from copy-paste.

429 Too Many Requests

Two different causes wear the same status code. If it says "quota", you have no billing set up or have hit a spend cap — no amount of retrying fixes that. If it is genuine rate limiting, Spring AI's retry backoff handles it; raise max-attempts and lengthen the interval before you ask for a limit increase.

NoSuchBeanDefinitionException: ChatClient.Builder

Auto-configuration did not run. Usually the starter is missing, or the API key property is absent so the conditional bean never activated. Run with --debug and check the auto-configuration report for OpenAiChatAutoConfiguration.

Context length exceeded

Your prompt plus expected response exceeds the model's context window. Long conversation history and over-large retrieved chunks are the usual culprits. Bound both — see tokenization and context windows.

Dimension mismatch on a vector store

expected 1536 dimensions, got 3072 means the embedding model does not match the store schema. The dimension is fixed when the table is created; changing embedding model requires re-creating the store and re-ingesting everything.

What you have now

A Spring Boot application that calls OpenAI with managed keys, sane model defaults, retries, timeouts and token metrics. That is a genuinely production-shaped foundation, not a demo.

Next: the Spring AI ChatClient API, which covers prompt templates, streaming responses and advisors — the parts that turn a single call into a feature.

Frequently Asked Questions

Why do I get a 401 Unauthorized from OpenAI in Spring Boot?
Almost always the key is not reaching the application. Confirm the environment variable name matches exactly, that your IDE run configuration exports it, and that application.yml uses ${OPENAI_API_KEY} rather than a literal. A key with surrounding whitespace or quotes from a copy-paste will also fail.
How do I change the OpenAI model in Spring AI?
Set spring.ai.openai.chat.options.model in application.yml for the application default, or pass ChatOptions on an individual call to override it per request. The embedding model is configured separately under spring.ai.openai.embedding.options.model.
How do I use Azure OpenAI instead of OpenAI?
Swap the starter for spring-ai-starter-model-azure-openai and configure spring.ai.azure.openai.api-key and endpoint. Your ChatClient code does not change, because it depends on the ChatModel interface rather than the provider.
Can I point Spring AI at a local model?
Yes. Either use the Ollama starter, or keep the OpenAI starter and set spring.ai.openai.base-url to any OpenAI-compatible server such as vLLM or LM Studio. The second option is useful when you want identical code across local and hosted environments.
What does spring.ai.retry actually retry?
Transient failures — HTTP 429 rate limits and 5xx responses — with exponential backoff. It does not retry 4xx client errors such as an invalid key or a malformed request, and it does not retry a response you consider semantically wrong.

Related tutorials