+ * Enabled only when {@code spring.ai.openai.batch.enabled=true} is set. Wires together
+ * the {@link OpenAiBatchApi}, {@link OpenAiBatchModel}, registered
+ * {@link BatchRequestHandler}s, and {@link OpenAiBatchListener}s.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+@AutoConfiguration
+@EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiBatchProperties.class })
+@ConditionalOnProperty(name = "spring.ai.openai.batch.enabled", havingValue = "true")
+public class OpenAiBatchAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public OpenAiBatchApi openAiBatchApi(OpenAiConnectionProperties commonProperties,
+ OpenAiBatchProperties batchProperties) {
+ OpenAiAutoConfigurationUtil.ResolvedConnectionProperties resolved = OpenAiAutoConfigurationUtil
+ .resolveConnectionProperties(commonProperties, batchProperties);
+
+ OpenAIClient openAIClient = this.openAiClient(resolved);
+
+ return new OpenAiBatchApi(openAIClient, new com.fasterxml.jackson.databind.ObjectMapper());
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public BatchExecutionRepository batchExecutionRepository() {
+ return new InMemoryBatchExecutionRepository();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public OpenAiBatchModel openAiBatchModel(OpenAiBatchProperties batchProperties, OpenAiBatchApi batchApi,
+ BatchExecutionRepository executionRepository, ObjectProvider
+ * Configuration properties are available under the {@code spring.ai.openai.batch} prefix.
+ * All batch-specific settings (rate limits, token budgets, retry policies) are
+ * configurable, eliminating the need for hardcoded values.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+@ConfigurationProperties(OpenAiBatchProperties.CONFIG_PREFIX)
+public class OpenAiBatchProperties extends AbstractOpenAiOptions {
+
+ public static final String CONFIG_PREFIX = "spring.ai.openai.batch";
+
+ /**
+ * Whether the OpenAI Batch API support is enabled.
+ */
+ private boolean enabled = false;
+
+ @NestedConfigurationProperty
+ private final OpenAiBatchOptions options = OpenAiBatchOptions.builder().build();
+
+ public boolean isEnabled() {
+ return this.enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public OpenAiBatchOptions getOptions() {
+ return this.options;
+ }
+
+}
diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 0f89c1f89d..7e6713082b 100644
--- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -19,3 +19,4 @@ org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration
org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration
org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration
org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration
+org.springframework.ai.model.openai.autoconfigure.OpenAiBatchAutoConfiguration
diff --git a/models/spring-ai-openai-batch-repository-jdbc/pom.xml b/models/spring-ai-openai-batch-repository-jdbc/pom.xml
new file mode 100644
index 0000000000..1bb0147d2a
--- /dev/null
+++ b/models/spring-ai-openai-batch-repository-jdbc/pom.xml
@@ -0,0 +1,78 @@
+
+
+
+
+ * This entity is managed by a {@link BatchExecutionRepository} and is updated as the
+ * batch progresses through its lifecycle.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+public class BatchExecution {
+
+ private final String batchId;
+
+ private final String endpoint;
+
+ private final int requestCount;
+
+ private final String inputFileId;
+
+ private final Instant createdAt;
+
+ private BatchExecutionStatus status;
+
+ private Instant updatedAt;
+
+ /**
+ * Creates a new batch execution record.
+ * @param batchId the OpenAI batch ID
+ * @param endpoint the API endpoint this batch targets
+ * @param status the initial status
+ * @param requestCount the number of requests in the batch
+ * @param inputFileId the OpenAI file ID of the uploaded JSONL input
+ */
+ public BatchExecution(String batchId, String endpoint, BatchExecutionStatus status, int requestCount,
+ String inputFileId) {
+ Assert.hasText(batchId, "batchId must not be blank");
+ Assert.hasText(endpoint, "endpoint must not be blank");
+ Assert.notNull(status, "status must not be null");
+ Assert.isTrue(requestCount > 0, "requestCount must be positive");
+ Assert.hasText(inputFileId, "inputFileId must not be blank");
+ this.batchId = batchId;
+ this.endpoint = endpoint;
+ this.status = status;
+ this.requestCount = requestCount;
+ this.inputFileId = inputFileId;
+ this.createdAt = Instant.now();
+ this.updatedAt = this.createdAt;
+ }
+
+ public String getBatchId() {
+ return this.batchId;
+ }
+
+ public String getEndpoint() {
+ return this.endpoint;
+ }
+
+ public BatchExecutionStatus getStatus() {
+ return this.status;
+ }
+
+ public void setStatus(BatchExecutionStatus status) {
+ Assert.notNull(status, "status must not be null");
+ this.status = status;
+ this.updatedAt = Instant.now();
+ }
+
+ public int getRequestCount() {
+ return this.requestCount;
+ }
+
+ public String getInputFileId() {
+ return this.inputFileId;
+ }
+
+ public Instant getCreatedAt() {
+ return this.createdAt;
+ }
+
+ public Instant getUpdatedAt() {
+ return this.updatedAt;
+ }
+
+ @Override
+ public String toString() {
+ return "BatchExecution{batchId='" + this.batchId + "', endpoint='" + this.endpoint + "', status=" + this.status
+ + ", requestCount=" + this.requestCount + '}';
+ }
+
+}
diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/batch/BatchExecutionRepository.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/batch/BatchExecutionRepository.java
new file mode 100644
index 0000000000..1ef59f73be
--- /dev/null
+++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/batch/BatchExecutionRepository.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.openai.batch;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Repository interface for persisting and querying {@link BatchExecution} records.
+ *
+ * Provides an out-of-the-box {@link InMemoryBatchExecutionRepository} for simple
+ * use-cases and development. For production, users may implement this interface with a
+ * database-backed store (e.g., Spring Data JPA, JDBC) to survive application restarts.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ * @see InMemoryBatchExecutionRepository
+ * @see BatchExecution
+ */
+public interface BatchExecutionRepository {
+
+ /**
+ * Saves a batch execution record. If a record with the same batch ID already exists,
+ * it is replaced.
+ * @param execution the batch execution to save
+ */
+ void save(BatchExecution execution);
+
+ /**
+ * Finds a batch execution by its OpenAI batch ID.
+ * @param batchId the OpenAI batch ID
+ * @return the batch execution, or empty if not found
+ */
+ Optional
+ * The {@code ::} delimiter is chosen over single characters (e.g., {@code _} or
+ * {@code -}) to avoid collisions with identifiers that may naturally contain those
+ * characters.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+public record BatchRequestCustomId(String entityId, String handlerId) {
+
+ /**
+ * Delimiter used to separate the entity ID and handler ID in the serialized custom ID
+ * string.
+ */
+ public static final String DELIMITER = "::";
+
+ public BatchRequestCustomId {
+ Assert.hasText(entityId, "entityId must not be blank");
+ Assert.hasText(handlerId, "handlerId must not be blank");
+ Assert.isTrue(!entityId.contains(DELIMITER), "entityId must not contain the delimiter '" + DELIMITER + "'");
+ Assert.isTrue(!handlerId.contains(DELIMITER), "handlerId must not contain the delimiter '" + DELIMITER + "'");
+ }
+
+ /**
+ * Parses a custom ID string into a {@link BatchRequestCustomId}.
+ * @param customId the custom ID string in the format {@code entityId::handlerId}
+ * @return the parsed custom ID
+ * @throws IllegalArgumentException if the string does not contain exactly two parts
+ */
+ public static BatchRequestCustomId parse(String customId) {
+ Assert.hasText(customId, "customId must not be blank");
+ String[] parts = customId.split(DELIMITER, -1);
+ if (parts.length != 2) {
+ throw new IllegalArgumentException(
+ "Invalid custom ID format: expected 'entityId" + DELIMITER + "handlerId', got: " + customId);
+ }
+ return new BatchRequestCustomId(parts[0], parts[1]);
+ }
+
+ @Override
+ public String toString() {
+ return this.entityId + DELIMITER + this.handlerId;
+ }
+
+}
diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/batch/BatchRequestHandler.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/batch/BatchRequestHandler.java
new file mode 100644
index 0000000000..989da2e77d
--- /dev/null
+++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/batch/BatchRequestHandler.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.openai.batch;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Interface for batch request handlers that know how to generate OpenAI API request
+ * bodies from domain-specific input data.
+ *
+ * Implementations are responsible for:
+ *
+ * The handler follows the hybrid storage approach: domain-specific input data is
+ * stored in the database, and the full OpenAI API envelope (model, reasoning_effort,
+ * response_format, messages, etc.) is generated on demand at execution time. This means
+ * configuration changes (e.g., fixing a wrong model or reasoning_effort) automatically
+ * apply to all pending requests without data cleanup.
+ *
+ * @param the type of domain-specific input data
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+public interface BatchRequestHandler {
+
+ /**
+ * Returns the unique identifier for this handler. Used as the {@code handlerId}
+ * component of {@link BatchRequestCustomId}.
+ * @return the handler identifier, must be non-blank and not contain
+ * {@link BatchRequestCustomId#DELIMITER}
+ */
+ String getHandlerId();
+
+ /**
+ * Returns the OpenAI API endpoint this handler targets.
+ * @return the endpoint URL (e.g., {@code /v1/chat/completions},
+ * {@code /v1/embeddings})
+ */
+ String getEndpoint();
+
+ /**
+ * Generates the OpenAI API request body from the given domain input data. This method
+ * is called at batch execution time, allowing the request to reflect the latest
+ * handler configuration (model, prompts, parameters, etc.).
+ * @param input the domain-specific input data
+ * @return the request body as a map of parameters suitable for the target endpoint
+ */
+ Map
+ * Each line follows the format:
+ *
+ *
+ * Each line follows the format:
+ *
+ *
+ * Suitable for development, testing, and single-instance deployments where batch
+ * execution tracking does not need to survive application restarts. For production
+ * use-cases with multiple instances or restart resilience, provide a database-backed
+ * implementation.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+public final class InMemoryBatchExecutionRepository implements BatchExecutionRepository {
+
+ private final Map
+ * Provides methods for:
+ *
+ * Implementations can react to batch creation, completion, failure, expiration, and
+ * individual request results. A no-op default is provided for all methods so
+ * implementations only need to override the events they care about.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ */
+public interface OpenAiBatchListener {
+
+ /**
+ * Called after a batch has been successfully created on the OpenAI API.
+ * @param batch the created batch
+ * @param requestCount the number of requests in the batch
+ */
+ default void onBatchCreated(Batch batch, int requestCount) {
+ }
+
+ /**
+ * Called when a batch has completed successfully.
+ * @param batch the completed batch
+ */
+ default void onBatchCompleted(Batch batch) {
+ }
+
+ /**
+ * Called when a batch has failed.
+ * @param batch the failed batch
+ */
+ default void onBatchFailed(Batch batch) {
+ }
+
+ /**
+ * Called when a batch has expired before completion.
+ * @param batch the expired batch
+ */
+ default void onBatchExpired(Batch batch) {
+ }
+
+ /**
+ * Called when a batch has been cancelled.
+ * @param batch the cancelled batch
+ */
+ default void onBatchCancelled(Batch batch) {
+ }
+
+ /**
+ * Called after individual response lines from a completed batch have been processed.
+ * @param batch the batch that produced these results
+ * @param successLines response lines that completed successfully
+ * @param errorLines response lines that failed
+ */
+ default void onBatchResultsProcessed(Batch batch, List
+ * This model provides three main lifecycle phases:
+ *
+ * Following the spring-ai pattern, this class uses a builder for construction and can
+ * optionally auto-create the OpenAI client from options if one is not explicitly
+ * provided.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ * @see BatchRequestHandler
+ * @see OpenAiBatchApi
+ * @see OpenAiBatchListener
+ */
+public final class OpenAiBatchModel {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenAiBatchModel.class);
+
+ /**
+ * Metadata key for the handler version stored with each batch.
+ */
+ static final String METADATA_HANDLER_VERSION = "handler-version";
+
+ private final OpenAiBatchApi batchApi;
+
+ private final OpenAiBatchOptions options;
+
+ private final List>> handlers,
+ ObjectProvider
> listeners) {
+ return OpenAiBatchModel.builder()
+ .batchApi(batchApi)
+ .options(batchProperties.getOptions())
+ .executionRepository(executionRepository)
+ .handlers(handlers.getIfAvailable(List::of))
+ .listeners(listeners.getIfAvailable(List::of))
+ .build();
+ }
+
+ private OpenAIClient openAiClient(AbstractOpenAiOptions resolved) {
+ return OpenAiSetup.setupSyncClient(resolved.getBaseUrl(), resolved.getApiKey(), resolved.getCredential(),
+ resolved.getMicrosoftDeploymentName(), resolved.getMicrosoftFoundryServiceVersion(),
+ resolved.getOrganizationId(), resolved.isMicrosoftFoundry(), resolved.isGitHubModels(),
+ resolved.getModel(), resolved.getTimeout(), resolved.getMaxRetries(), resolved.getProxy(),
+ resolved.getCustomHeaders());
+ }
+
+}
diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiBatchProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiBatchProperties.java
new file mode 100644
index 0000000000..08615a51dc
--- /dev/null
+++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiBatchProperties.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.model.openai.autoconfigure;
+
+import org.springframework.ai.openai.AbstractOpenAiOptions;
+import org.springframework.ai.openai.batch.OpenAiBatchOptions;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
+
+/**
+ * OpenAI SDK Batch API autoconfiguration properties.
+ *
+ *
+ *
+ *
+ * {
+ * "custom_id": "entity-123::my-handler",
+ * "method": "POST",
+ * "url": "/v1/chat/completions",
+ * "body": { ... }
+ * }
+ *
+ *
+ * The {@code body} is a generic map that the handler populates with the appropriate API
+ * request parameters. This keeps the batch framework endpoint-agnostic.
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ * @see OpenAI Batch
+ * API
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record BatchRequestLine(@JsonProperty("custom_id") String customId, @JsonProperty("method") String method,
+ @JsonProperty("url") String url, @JsonProperty("body") Map
+ * {
+ * "id": "batch_req_abc123",
+ * "custom_id": "entity-123::my-handler",
+ * "response": {
+ * "status_code": 200,
+ * "request_id": "req_abc123",
+ * "body": { ... }
+ * },
+ * "error": {
+ * "code": "...",
+ * "message": "..."
+ * }
+ * }
+ *
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ * @see OpenAI Batch
+ * API
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record BatchResponseLine(@JsonProperty("id") @Nullable String id,
+ @JsonProperty("custom_id") @Nullable String customId, @JsonProperty("response") @Nullable Response response,
+ @JsonProperty("error") @Nullable Error error) {
+
+ /**
+ * Returns whether this response line indicates a successful response.
+ */
+ public boolean isSuccess() {
+ return this.response != null && this.response.statusCode() != null && this.response.statusCode() == 200
+ && this.error == null;
+ }
+
+ /**
+ * The response envelope containing status code and body.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record Response(@JsonProperty("status_code") @Nullable Integer statusCode,
+ @JsonProperty("request_id") @Nullable String requestId,
+ @JsonProperty("body") @Nullable Map
+ *
+ *
+ * @author Yasin Akbas
+ * @since 2.0.0
+ * @see OpenAI Batch
+ * API
+ */
+public class OpenAiBatchApi {
+
+ private static final Logger logger = LoggerFactory.getLogger(OpenAiBatchApi.class);
+
+ private final OpenAIClient openAiClient;
+
+ private final ObjectMapper objectMapper;
+
+ public OpenAiBatchApi(OpenAIClient openAiClient, ObjectMapper objectMapper) {
+ Assert.notNull(openAiClient, "openAiClient must not be null");
+ Assert.notNull(objectMapper, "objectMapper must not be null");
+ this.openAiClient = openAiClient;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Uploads a JSONL file containing batch request lines and creates a batch execution.
+ * @param requestLines the batch request lines to include
+ * @param endpoint the target API endpoint (e.g., {@code /v1/chat/completions})
+ * @param completionWindow the completion window (e.g., {@code 24h})
+ * @param metadata optional metadata to attach to the batch
+ * @return the created {@link Batch} object
+ */
+ public Batch createBatch(List
+ *
+ *
+ *