Spring AI with Open Ai

🔄 Updated: September 2026  ·  Rewritten for Spring AI 2.0.1
The starter artifact, the configuration properties and the client class have all changed since 2024
Integrating OpenAI ChatGPT with Spring AI

Spring AI gives Java developers a clean, Spring-idiomatic way to call OpenAI's models — chat, streaming, structured output, tool calling and embeddings — without hand-rolling HTTP clients or JSON mapping. This guide walks through the whole setup, from getting an API key to building endpoints that do real work.

Getting Started: Obtaining an OpenAI API Key

You need an API key before anything else:

  1. Sign up at platform.openai.com (note: the API dashboard, not the ChatGPT consumer site).
  2. Add a payment method and some credit. API access is billed separately from a ChatGPT Plus subscription — having Plus does not give you API credit.
  3. Go to the API Keys page and create a new secret key.
  4. Copy it immediately. OpenAI shows the key exactly once and you cannot retrieve it later.
Keep the key out of your code

Never hardcode the key, never commit it, and never put it in an application.properties file that is tracked in git. Leaked OpenAI keys are actively scraped from public repositories and run up real charges within hours. Export it as an environment variable instead:

export SPRING_AI_OPENAI_API_KEY=<INSERT KEY HERE>

Spring Boot's relaxed binding maps that environment variable onto the spring.ai.openai.api-key property automatically, so nothing else is needed. For production, use your platform's secret manager — AWS Secrets Manager, Vault, Kubernetes secrets — rather than a plain environment variable.

Dependency Management

Enabling Auto-configuration

Add the OpenAI starter. Note the artifact ID — every Spring AI starter was renamed to the spring-ai-starter-model-* pattern in 1.0 GA:

<!-- Maven -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
// Gradle
dependencies {
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'
}

Import the Spring AI BOM so you never specify individual module versions. You no longer need to add the Spring Milestone repository — Spring AI has been on Maven Central since 1.0:

<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.1</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>

Configuration

All Spring AI configuration lives under the spring.ai.* prefix. Connection settings sit on spring.ai.openai, chat settings on spring.ai.openai.chat, and retry behaviour on the shared spring.ai.retry namespace.

application.properties


# ---- Connection ----
# Usually supplied via the SPRING_AI_OPENAI_API_KEY environment variable instead
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.base-url=https://api.openai.com

# ---- Chat model ----
# Pick a current model - see the note below on why this is not hardcoded here
spring.ai.openai.chat.model=gpt-4.1
spring.ai.openai.chat.temperature=0.7
spring.ai.openai.chat.max-tokens=2048

# ---- Retry (shared across all Spring AI models) ----
spring.ai.retry.max-attempts=3
spring.ai.retry.backoff.initial-interval=2s
spring.ai.retry.backoff.multiplier=2
spring.ai.retry.backoff.max-interval=60s

# ---- Enable / disable the chat auto-configuration ----
# 'openai' by default; set to 'none' to switch it off
spring.ai.model.chat=openai

application.yml


spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      base-url: https://api.openai.com
      chat:
        model: gpt-4.1
        temperature: 0.7
        max-tokens: 2048
    retry:
      max-attempts: 3
      backoff:
        initial-interval: 2s
        multiplier: 2
        max-interval: 60s
A note on the .options. prefix

Until Spring AI 2.0, chat settings carried an extra segment: spring.ai.openai.chat.options.model. That form was flattened to spring.ai.openai.chat.model. The old keys are deprecated rather than removed, so existing config still binds — but they will be dropped eventually, and every current example uses the flat form.

A note on model names

Do not treat any model name in a blog post — including this one — as current. OpenAI retires models on a rolling schedule: gpt-3.5-turbo, gpt-4 and gpt-4-turbo all have announced API shutdown dates, and calling a retired model returns a 404 model_not_found. Check the OpenAI models page and the deprecations page before you pick one.

Practically, this means: keep the model name in configuration (never in code), pin a dated snapshot version in production so a silent upgrade cannot change your outputs, and set a calendar reminder to check the deprecation page every few months.

ChatClient: the recommended way to call the model

ChatClient is the high-level fluent API and is what you should reach for in application code. The starter auto-configures a ChatClient.Builder, so you inject that and build from it:


package codeKatha;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class AssistantService {

    private final ChatClient chatClient;

    public AssistantService(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("You are a helpful assistant for Java developers. "
                        + "Answer concisely and include code examples where useful.")
                .build();
    }

    public String ask(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

A system prompt set once via defaultSystem() applies to every call through that client — useful for fixing tone, format and scope in one place rather than repeating instructions in every user prompt.

Sample Controller

A controller with both a blocking and a streaming endpoint:


package codeKatha;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;

@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/ai/generate")
    public String generate(
            @RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }

    /**
     * Streams tokens as the model produces them.
     * TEXT_EVENT_STREAM_VALUE lets the browser render the response progressively
     * instead of waiting for the whole thing.
     */
    @GetMapping(value = "/ai/generateStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> generateStream(
            @RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

The streaming endpoint needs spring-boot-starter-webflux on the classpath for Flux. Streaming matters more than it looks: a long answer can take fifteen seconds to generate, and users tolerate that far better when text appears immediately.

If you need the raw response — token usage, finish reason, model metadata — drop down to OpenAiChatModel:


package codeKatha;

import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Service;

@Service
public class LowLevelService {

    private final OpenAiChatModel chatModel;

    public LowLevelService(OpenAiChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public String ask(String question) {
        ChatResponse response = chatModel.call(new Prompt(question));

        // Token usage - worth logging, this is what you get billed on
        var usage = response.getMetadata().getUsage();
        System.out.println("prompt=" + usage.getPromptTokens()
                + " completion=" + usage.getCompletionTokens());

        // NOTE: getText(), not getContent() - renamed in 1.0
        return response.getResult().getOutput().getText();
    }

    public String askPrecisely(String question) {
        // Per-request options override application.properties
        Prompt prompt = new Prompt(question,
                OpenAiChatOptions.builder()
                        .temperature(0.0)
                        .maxTokens(500)
                        .build());

        return chatModel.call(prompt).getResult().getOutput().getText();
    }
}

Structured Outputs

Parsing free-form model text with regex is a losing game. Spring AI can generate a JSON Schema from a Java type, send it with the request, and convert the response straight back into an object:


package codeKatha;

public record MovieReview(
        String title,
        int year,
        int ratingOutOfTen,
        String oneLineVerdict) {
}

MovieReview review = chatClient.prompt()
        .user("Review the movie Inception in one line")
        .call()
        .entity(MovieReview.class);

System.out.println(review.ratingOutOfTen());

That single .entity() call handles schema generation, the API request and deserialisation. It also works with collections:


List<MovieReview> reviews = chatClient.prompt()
        .user("Review three Christopher Nolan films")
        .call()
        .entity(new ParameterizedTypeReference<List<MovieReview>>() {});

This is the feature that makes an LLM usable as a backend component rather than a chat toy — you get a typed object you can validate, persist and test against.

Tool Calling (formerly Function Calling)

Tool calling lets the model ask your application to run a method and then use the result in its answer. The model never executes anything itself — it returns a structured request, Spring AI runs your code, and the result goes back into the conversation.

The terminology changed. What was called "function calling" in 2024 is now "tool calling" throughout Spring AI. FunctionCallback became ToolCallback, .functions(...) became .tools(...), and the old API was removed entirely in 2.0.

Example Implementation

Declare tools as methods annotated with @Tool. The description is not decoration — it is how the model decides whether to call it, so write it carefully:


package codeKatha;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.time.LocalDate;

public class OrderTools {

    private final OrderRepository orderRepository;

    public OrderTools(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Tool(description = "Get today's date in ISO format")
    public String currentDate() {
        return LocalDate.now().toString();
    }

    @Tool(description = "Look up the delivery status of a customer order by its ID")
    public String orderStatus(
            @ToolParam(description = "The order ID, e.g. ORD-12345") String orderId) {

        return orderRepository.findById(orderId)
                .map(order -> order.getStatus().name())
                .orElse("No order found with that ID");
    }
}

Attach them to the call and Spring AI handles the whole loop — the model requests a tool, your method runs, the result is fed back, and the model produces a final answer:


String answer = chatClient.prompt()
        .user("Where is my order ORD-12345, and how long ago did I place it?")
        .tools(new OrderTools(orderRepository))
        .call()
        .content();

An important 2.0 change: in Spring AI 1.x the tool-execution loop lived inside ChatModel. In 2.0 it was removed from every ChatModel implementation and moved into ChatClient's auto-registered ToolCallingAdvisor. Call chatModel.call(prompt) directly with tools attached and you get back the model's unexecuted request. Use ChatClient and it just works.

Two practical cautions. First, a tool that touches your database is now reachable by anything the model decides to do with user input — validate arguments and scope permissions exactly as you would for a public endpoint. Second, every tool definition is sent with every request and costs tokens, so ten tools attached to a chatbot is ten tools' worth of prompt on every single call.

Chat Memory

The API is stateless: each request knows nothing about the last one. To hold a conversation you must send the history back every time, and Spring AI packages that as an advisor:


package codeKatha;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.stereotype.Service;

@Service
public class ConversationService {

    private final ChatClient chatClient;

    public ConversationService(ChatClient.Builder builder) {
        ChatMemory memory = MessageWindowChatMemory.builder()
                .maxMessages(20)
                .build();

        this.chatClient = builder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
                .build();
    }

    public String chat(String sessionId, String message) {
        return chatClient.prompt()
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
                .call()
                .content();
    }
}

Note that the conversation ID is required in 2.0 — the old ChatMemory.DEFAULT_CONVERSATION_ID constant was removed, and omitting the parameter throws an IllegalArgumentException. Use the session or user ID.

Watch the cost here: the whole window is resent on every turn, so a 20-message history means you pay for 20 messages' worth of input tokens on each request. That is exactly why MessageWindowChatMemory caps the count rather than keeping everything.

Azure OpenAI users: the module is gone

If your project used spring-ai-azure-openai, that module — along with its starter and auto-configuration — was removed in Spring AI 2.0. Migrate to the standard spring-ai-openai module: drop the Azure prefix from the class names and move your configuration across to the spring.ai.openai.* properties, pointing base-url at your Azure endpoint.

Conclusion

Spring AI turns OpenAI's API into an ordinary Spring dependency — injected, configured through properties, and testable like anything else. The pieces worth building on are structured output, which gives you typed objects instead of strings to parse, and tool calling, which lets the model reach into your own data. Chat and streaming are the easy part.

If you are preparing for interviews, be ready to explain what tool calling actually does (the model requests, your application executes), why the API being stateless forces you to resend history, and where the token cost in an AI feature actually comes from. Those questions come up far more often than framework syntax.

Also Read:  Spring Boot with Ollama

Comments

Popular Posts on Code Katha

Java Interview Questions for 10 Years Experience

Sql Interview Questions for 10 Years Experience

Spring Boot Interview Questions for 10 Years Experience

Java interview questions - Must to know concepts

Visual Studio Code setup for Java and Spring with GitHub Copilot

Spring AI with Ollama

Data Structures & Algorithms Tutorial with Coding Interview Questions

Spring Data JPA

Bit Manipulation and Bit Masking Concepts

Topological Sort in Graph