diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchAutoConfiguration.java new file mode 100644 index 0000000000..562f38c54e --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchAutoConfiguration.java @@ -0,0 +1,113 @@ +/* + * 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.anthropic.autoconfigure; + +import java.util.List; + +import com.anthropic.client.AnthropicClient; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.anthropic.AnthropicBatchModel; +import org.springframework.ai.anthropic.AnthropicBatchObservationConvention; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.DefaultAnthropicBatchModel; +import org.springframework.ai.anthropic.http.okhttp.AnthropicHttpClientBuilderCustomizer; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * {@link AutoConfiguration Auto-configuration} for the Anthropic Message Batches model. + * + *

+ * Opt-in: the bean is only created when {@code spring.ai.anthropic.batch.enabled=true}, + * so applications that do not submit batches do not pay for a second HTTP client. + * Connection settings ({@code spring.ai.anthropic.*}) and model defaults + * ({@code spring.ai.anthropic.chat.*}) are shared with + * {@link AnthropicChatAutoConfiguration}, so a batch reuses the same credentials, base + * URL, timeout, retries, proxy, custom headers and HTTP client customizers as realtime + * calls. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +@AutoConfiguration(after = AnthropicChatAutoConfiguration.class) +@EnableConfigurationProperties({ AnthropicConnectionProperties.class, AnthropicChatProperties.class, + AnthropicBatchProperties.class }) +@ConditionalOnClass(AnthropicClient.class) +@ConditionalOnProperty(prefix = AnthropicBatchProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") +public class AnthropicBatchAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public AnthropicBatchModel anthropicBatchModel(AnthropicConnectionProperties connectionProperties, + AnthropicChatProperties chatProperties, AnthropicBatchProperties batchProperties, + ToolCallingManager toolCallingManager, ObjectProvider observationRegistry, + ObjectProvider meterRegistry, + ObjectProvider observationConvention, + ObjectProvider httpClientBuilderCustomizers) { + + AnthropicChatOptions.Builder builder = chatProperties.toOptions().mutate(); + if (connectionProperties.getApiKey() != null) { + builder.apiKey(connectionProperties.getApiKey()); + } + if (connectionProperties.getBaseUrl() != null) { + builder.baseUrl(connectionProperties.getBaseUrl()); + } + if (connectionProperties.getTimeout() != null) { + builder.timeout(connectionProperties.getTimeout()); + } + if (connectionProperties.getMaxRetries() != null) { + builder.maxRetries(connectionProperties.getMaxRetries()); + } + if (connectionProperties.getProxy() != null) { + builder.proxy(connectionProperties.getProxy()); + } + if (!connectionProperties.getCustomHeaders().isEmpty()) { + builder.customHeaders(connectionProperties.getCustomHeaders()); + } + // Batch-specific overrides of the shared chat defaults. + if (batchProperties.getModel() != null) { + builder.model(batchProperties.getModel()); + } + if (batchProperties.getMaxTokens() != null) { + builder.maxTokens(batchProperties.getMaxTokens()); + } + AnthropicChatOptions options = builder.build(); + + List customizers = httpClientBuilderCustomizers.orderedStream().toList(); + + DefaultAnthropicBatchModel batchModel = DefaultAnthropicBatchModel.builder() + .options(options) + .toolCallingManager(toolCallingManager) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .meterRegistry(chatProperties.isConnectionPoolMetricsEnabled() ? meterRegistry.getIfAvailable() : null) + .httpClientBuilderCustomizers(customizers) + .build(); + + observationConvention.ifAvailable(batchModel::setObservationConvention); + + return batchModel; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchProperties.java new file mode 100644 index 0000000000..637d317600 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchProperties.java @@ -0,0 +1,84 @@ +/* + * 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.anthropic.autoconfigure; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Anthropic Message Batches autoconfiguration properties. + * + *

+ * The batch model is opt-in: set {@code spring.ai.anthropic.batch.enabled=true} to get an + * {@link org.springframework.ai.anthropic.AnthropicBatchModel} bean. Connection settings + * ({@code spring.ai.anthropic.*}) and the model defaults + * ({@code spring.ai.anthropic.chat.*}) are shared with the chat model; the two properties + * here only override the model and output-token ceiling for batch entries when batches + * need to differ from realtime calls. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +@ConfigurationProperties(AnthropicBatchProperties.CONFIG_PREFIX) +public class AnthropicBatchProperties { + + public static final String CONFIG_PREFIX = "spring.ai.anthropic.batch"; + + /** + * Whether to expose an Anthropic batch model bean. Disabled by default so that + * applications that do not use batches do not pay for a second HTTP client. + */ + private boolean enabled = false; + + /** + * Model to use for batch entries. Falls back to + * {@code spring.ai.anthropic.chat.model}. + */ + private @Nullable String model; + + /** + * Maximum number of tokens to generate per batch entry. Falls back to + * {@code spring.ai.anthropic.chat.max-tokens}. + */ + private @Nullable Integer maxTokens; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getModel() { + return this.model; + } + + public void setModel(@Nullable String model) { + this.model = model; + } + + public @Nullable Integer getMaxTokens() { + return this.maxTokens; + } + + public void setMaxTokens(@Nullable Integer maxTokens) { + this.maxTokens = maxTokens; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 3242a6f7a4..eb7a3faf3d 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ org.springframework.ai.model.anthropic.autoconfigure.AnthropicChatAutoConfiguration +org.springframework.ai.model.anthropic.autoconfigure.AnthropicBatchAutoConfiguration diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchAutoConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchAutoConfigurationTests.java new file mode 100644 index 0000000000..d4802291b1 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-anthropic/src/test/java/org/springframework/ai/model/anthropic/autoconfigure/AnthropicBatchAutoConfigurationTests.java @@ -0,0 +1,148 @@ +/* + * 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.anthropic.autoconfigure; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.ai.anthropic.AnthropicBatch; +import org.springframework.ai.anthropic.AnthropicBatchModel; +import org.springframework.ai.anthropic.AnthropicBatchRequest; +import org.springframework.ai.anthropic.AnthropicBatchResult; +import org.springframework.ai.anthropic.DefaultAnthropicBatchModel; +import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AnthropicBatchAutoConfiguration}: the batch model must be opt-in, must + * back off when the application defines its own bean, and must inherit the shared + * Anthropic connection and chat defaults. + * + * @author Ricken Bazolo + */ +class AnthropicBatchAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class, + AnthropicChatAutoConfiguration.class, AnthropicBatchAutoConfiguration.class)) + .withPropertyValues("spring.ai.anthropic.api-key=test-key"); + + @Test + void batchModelIsNotCreatedByDefault() { + this.contextRunner.run(context -> assertThat(context).doesNotHaveBean(AnthropicBatchModel.class)); + } + + @Test + void batchModelIsNotCreatedWhenExplicitlyDisabled() { + this.contextRunner.withPropertyValues("spring.ai.anthropic.batch.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(AnthropicBatchModel.class)); + } + + @Test + void batchModelIsCreatedWhenEnabled() { + this.contextRunner.withPropertyValues("spring.ai.anthropic.batch.enabled=true").run(context -> { + assertThat(context).hasSingleBean(AnthropicBatchModel.class); + assertThat(context.getBean(AnthropicBatchModel.class)).isInstanceOf(DefaultAnthropicBatchModel.class); + }); + } + + @Test + void batchModelInheritsConnectionAndChatDefaults() { + this.contextRunner + .withPropertyValues("spring.ai.anthropic.batch.enabled=true", "spring.ai.anthropic.base-url=https://proxy", + "spring.ai.anthropic.timeout=45s", "spring.ai.anthropic.max-retries=5", + "spring.ai.anthropic.chat.model=claude-sonnet-4-5", "spring.ai.anthropic.chat.max-tokens=1234") + .run(context -> { + var options = context.getBean(DefaultAnthropicBatchModel.class).getOptions(); + assertThat(options.getApiKey()).isEqualTo("test-key"); + assertThat(options.getBaseUrl()).isEqualTo("https://proxy"); + assertThat(options.getTimeout()).isEqualTo(Duration.ofSeconds(45)); + assertThat(options.getMaxRetries()).isEqualTo(5); + assertThat(options.getModel()).isEqualTo("claude-sonnet-4-5"); + assertThat(options.getMaxTokens()).isEqualTo(1234); + }); + } + + @Test + void batchPropertiesOverrideChatDefaults() { + this.contextRunner + .withPropertyValues("spring.ai.anthropic.batch.enabled=true", + "spring.ai.anthropic.chat.model=claude-sonnet-4-5", "spring.ai.anthropic.chat.max-tokens=1234", + "spring.ai.anthropic.batch.model=claude-haiku-4-5", "spring.ai.anthropic.batch.max-tokens=64") + .run(context -> { + var options = context.getBean(DefaultAnthropicBatchModel.class).getOptions(); + assertThat(options.getModel()).isEqualTo("claude-haiku-4-5"); + assertThat(options.getMaxTokens()).isEqualTo(64); + }); + } + + @Test + void applicationDefinedBatchModelWins() { + this.contextRunner.withPropertyValues("spring.ai.anthropic.batch.enabled=true") + .withUserConfiguration(CustomBatchModelConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(AnthropicBatchModel.class); + assertThat(context.getBean(AnthropicBatchModel.class)) + .isNotInstanceOf(DefaultAnthropicBatchModel.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomBatchModelConfiguration { + + @Bean + AnthropicBatchModel customBatchModel() { + return new AnthropicBatchModel() { + + @Override + public AnthropicBatch submit(List requests) { + throw new UnsupportedOperationException(); + } + + @Override + public AnthropicBatch retrieve(String batchId) { + throw new UnsupportedOperationException(); + } + + @Override + public Flux results(String batchId) { + return Flux.empty(); + } + + @Override + public AnthropicBatch cancel(String batchId) { + throw new UnsupportedOperationException(); + } + + @Override + public void delete(String batchId) { + } + + }; + } + + } + +} diff --git a/models/spring-ai-anthropic/README.md b/models/spring-ai-anthropic/README.md index a04992fab6..ab37855db9 100644 --- a/models/spring-ai-anthropic/README.md +++ b/models/spring-ai-anthropic/README.md @@ -32,6 +32,7 @@ This module supports: - **Citations** - Document-grounded responses with source attribution - **Prompt Caching** - Reduce costs for repeated context with configurable strategies - **Structured Output** - JSON schema-constrained responses with effort control +- **Message Batches** - Asynchronous bulk processing at reduced cost, correlated by `custom_id` - **Per-Request HTTP Headers** - Custom headers per API call for tracking, beta features, and routing - **Observability** - Micrometer-based metrics and tracing @@ -266,6 +267,66 @@ var options = AnthropicChatOptions.builder() ChatResponse response = chatModel.call(new Prompt("Hello", options)); ``` +## Message Batches + +Submit many prompts at once for asynchronous processing. Batch results are not returned in +submission order, so every entry carries a `customId` used to correlate its result. + +Processing can take up to 24 hours. Spring AI deliberately performs **no polling, no +persistence and no scheduling**: it exposes the five provider operations, and the application +owns the orchestration. + +```java +AnthropicBatchModel batchModel = AnthropicBatchModel.builder() + .options(AnthropicChatOptions.builder().model("claude-haiku-4-5").maxTokens(1024).build()) + .build(); + +// 1. Submit — returns as soon as Anthropic accepts the batch +AnthropicBatch batch = batchModel.submit(List.of( + AnthropicBatchRequest.of("invoice-1", "Summarize invoice 1"), + AnthropicBatchRequest.of("invoice-2", new Prompt("Summarize invoice 2", perRequestOptions)))); + +// Persist batch.id() and the customIds so polling survives a restart +String batchId = batch.id(); + +// 2. Poll on your own schedule +if (batchModel.retrieve(batchId).isEnded()) { + + // 3. Stream the results — never buffered wholesale + batchModel.results(batchId) + .doOnNext(result -> { + switch (result.status()) { + case SUCCEEDED -> store(result.customId(), result.getText(), result.usage()); + case ERRORED -> logFailure(result.customId(), result.error()); + case CANCELED, EXPIRED, UNKNOWN -> requeue(result.customId()); + } + }) + .blockLast(); +} + +// Optional lifecycle control +batchModel.cancel(batchId); +batchModel.delete(batchId); +``` + +In Spring Boot the batch model is opt-in, so applications that never submit batches do not +pay for a second HTTP client: + +```properties +spring.ai.anthropic.batch.enabled=true +``` + +Connection settings (`spring.ai.anthropic.*`) and model defaults +(`spring.ai.anthropic.chat.*`) are shared with the chat model; `spring.ai.anthropic.batch.model` +and `spring.ai.anthropic.batch.max-tokens` override them for batch entries only. + +Requests go through the same `Prompt` conversion as `AnthropicChatModel.call(...)`, and a +succeeded result is converted back into the same `ChatResponse` shape. + +> **Tool calls are not executed.** Tool definitions are sent, but a batch response containing +> `tool_use` blocks is returned as-is — a batch entry cannot be continued mid-flight. Read the +> tool calls off the assistant message and submit a follow-up batch for the next turn. + ## Logging Enable SDK logging by setting the environment variable: diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatch.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatch.java new file mode 100644 index 0000000000..789b4c81a2 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatch.java @@ -0,0 +1,78 @@ +/* + * 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.anthropic; + +import java.time.OffsetDateTime; + +import com.anthropic.models.messages.batches.MessageBatch; +import org.jspecify.annotations.Nullable; + +/** + * State of an Anthropic message batch, as returned by + * {@link AnthropicBatchModel#submit(java.util.List)}, + * {@link AnthropicBatchModel#retrieve(String)} and + * {@link AnthropicBatchModel#cancel(String)}. + * + *

+ * Batch processing is asynchronous and can take up to 24 hours. Applications are expected + * to persist {@link #id()} and poll {@link AnthropicBatchModel#retrieve(String)} on their + * own schedule — Spring AI performs no polling. + * + * @param id the batch identifier, to be persisted by the application for later polling + * and result retrieval + * @param status the current processing status + * @param requestCounts per-outcome request counters + * @param createdAt when the batch was created + * @param expiresAt when the batch expires; requests not completed by then are reported as + * {@link AnthropicBatchResultStatus#EXPIRED} + * @param endedAt when processing finished, or {@code null} while still in progress + * @param cancelInitiatedAt when cancellation was requested, or {@code null} + * @param archivedAt when the batch was archived, or {@code null} + * @param resultsUrl the URL of the JSONL results, or {@code null} until processing ends; + * prefer {@link AnthropicBatchModel#results(String)} over fetching it directly + * @author Ricken Bazolo + * @since 2.0.0 + */ +public record AnthropicBatch(String id, AnthropicBatchStatus status, AnthropicBatchRequestCounts requestCounts, + OffsetDateTime createdAt, OffsetDateTime expiresAt, @Nullable OffsetDateTime endedAt, + @Nullable OffsetDateTime cancelInitiatedAt, @Nullable OffsetDateTime archivedAt, @Nullable String resultsUrl) { + + /** + * Whether processing has finished and results can be read. + * @return {@code true} when the status is {@link AnthropicBatchStatus#ENDED} + */ + public boolean isEnded() { + return this.status == AnthropicBatchStatus.ENDED; + } + + /** + * Whether cancellation has been requested for this batch. + * @return {@code true} when the status is {@link AnthropicBatchStatus#CANCELING} + */ + public boolean isCanceling() { + return this.status == AnthropicBatchStatus.CANCELING; + } + + static AnthropicBatch from(MessageBatch messageBatch) { + return new AnthropicBatch(messageBatch.id(), AnthropicBatchStatus.from(messageBatch.processingStatus()), + AnthropicBatchRequestCounts.from(messageBatch.requestCounts()), messageBatch.createdAt(), + messageBatch.expiresAt(), messageBatch.endedAt().orElse(null), + messageBatch.cancelInitiatedAt().orElse(null), messageBatch.archivedAt().orElse(null), + messageBatch.resultsUrl().orElse(null)); + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchError.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchError.java new file mode 100644 index 0000000000..ca217561b7 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchError.java @@ -0,0 +1,115 @@ +/* + * 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.anthropic; + +import com.anthropic.core.JsonValue; +import com.anthropic.models.ApiErrorObject; +import com.anthropic.models.AuthenticationError; +import com.anthropic.models.BillingError; +import com.anthropic.models.ErrorObject; +import com.anthropic.models.ErrorResponse; +import com.anthropic.models.GatewayTimeoutError; +import com.anthropic.models.InvalidRequestError; +import com.anthropic.models.NotFoundError; +import com.anthropic.models.OverloadedError; +import com.anthropic.models.PermissionError; +import com.anthropic.models.RateLimitError; +import org.jspecify.annotations.Nullable; + +/** + * Failure of a single request inside an Anthropic message batch. + * + *

+ * Individual failures are surfaced per request rather than thrown, so that a single bad + * entry does not hide the results of the rest of the batch. + * + * @param type the Anthropic error type, for example {@code invalid_request_error}, + * {@code rate_limit_error} or {@code overloaded_error} + * @param message the human-readable error message + * @param requestId the Anthropic request identifier, when reported + * @author Ricken Bazolo + * @since 2.0.0 + * @see Anthropic error types + */ +public record AnthropicBatchError(String type, String message, @Nullable String requestId) { + + private static final String UNKNOWN_TYPE = "unknown_error"; + + static AnthropicBatchError from(ErrorResponse errorResponse) { + String requestId = errorResponse.requestId().orElse(null); + AnthropicBatchError error = errorResponse.error().accept(new ErrorObject.Visitor() { + @Override + public AnthropicBatchError visitInvalidRequestError(InvalidRequestError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitAuthenticationError(AuthenticationError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitBillingError(BillingError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitPermissionError(PermissionError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitNotFoundError(NotFoundError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitRateLimitError(RateLimitError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitTimeoutError(GatewayTimeoutError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitApiError(ApiErrorObject error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError visitOverloadedError(OverloadedError error) { + return of(error._type(), error.message(), requestId); + } + + @Override + public AnthropicBatchError unknown(@Nullable JsonValue json) { + return new AnthropicBatchError(UNKNOWN_TYPE, String.valueOf(json), requestId); + } + }); + return error; + } + + private static AnthropicBatchError of(JsonValue type, String message, @Nullable String requestId) { + // JsonValue extends the raw JsonField type, so asString() erases to + // Optional. + Object typeValue = type.asString().orElse(null); + return new AnthropicBatchError(typeValue instanceof String text ? text : UNKNOWN_TYPE, message, requestId); + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchModel.java new file mode 100644 index 0000000000..255769fda9 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchModel.java @@ -0,0 +1,129 @@ +/* + * 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.anthropic; + +import java.util.List; + +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; + +/** + * Anthropic Message + * Batches abstraction: submit many prompts at once, poll the batch, then read the + * per-request results. + * + *

+ * Batch processing is asynchronous and can take up to 24 hours, so this is + * deliberately not a {@link ChatModel}: {@link #submit(List)} has no immediate + * {@link ChatResponse} to return. The control operations are synchronous; only + * {@link #results(String)} is reactive, so a batch with a very large number of entries + * never has to be held in memory. + * + *

+ * Spring AI provides the provider access, not the orchestration. There is no + * automatic polling, no persistence, no scheduling and no business retry: how often to + * call {@link #retrieve(String)}, where to store the batch id and the {@code customId} + * correlations, when to notify and how to account for cost all stay with the application. + * + *

+ * Requests are mapped exactly as they would be for {@link ChatModel#call(Prompt)}, and a + * succeeded result is converted back into a {@link ChatResponse} with the same + * generations, metadata and usage — see {@link DefaultAnthropicBatchModel} for the + * details and for the tool-calling limitation. + * + *

+ * Results are unordered. Correlate them through + * {@link AnthropicBatchResult#customId()}, never by position. + * + * @author Ricken Bazolo + * @since 2.0.0 + * @see AnthropicBatchRequest + * @see AnthropicBatchResult + * @see DefaultAnthropicBatchModel + */ +public interface AnthropicBatchModel { + + /** + * Creates a builder for the default {@link AnthropicBatchModel} implementation. + * @return a new builder instance + */ + static DefaultAnthropicBatchModel.Builder builder() { + return DefaultAnthropicBatchModel.builder(); + } + + /** + * Submits a batch of prompts for asynchronous processing. + * + *

+ * Returns as soon as Anthropic accepts the batch; no request has been processed yet. + * Persist {@link AnthropicBatch#id()} together with the {@code customId} of every + * entry so that polling and correlation survive an application restart. + * @param requests the batch entries; must be non-empty and carry distinct + * {@code customId} values + * @return the accepted batch, in state {@link AnthropicBatchStatus#IN_PROGRESS} + * @throws IllegalArgumentException if the list is empty or a {@code customId} is + * duplicated + */ + AnthropicBatch submit(List requests); + + /** + * Retrieves the current state of a batch. + * + *

+ * Call this on the application's own schedule; Spring AI performs no polling. Results + * become readable once {@link AnthropicBatch#isEnded()} is {@code true}. + * @param batchId the batch identifier returned by {@link #submit(List)} + * @return the current batch state + */ + AnthropicBatch retrieve(String batchId); + + /** + * Streams the results of an ended batch. + * + *

+ * Results arrive in an unspecified order: key them by + * {@link AnthropicBatchResult#customId()}. Individual failures are emitted as + * {@link AnthropicBatchResultStatus#ERRORED} items rather than thrown, so one bad + * entry never hides the rest. + * @param batchId the batch identifier + * @return a lazily-populated flux of per-request results + */ + Flux results(String batchId); + + /** + * Requests cancellation of a batch. + * + *

+ * Cancellation is not immediate: the batch moves to + * {@link AnthropicBatchStatus#CANCELING} and requests that already completed keep + * their result, while the remaining ones end up as + * {@link AnthropicBatchResultStatus#CANCELED}. + * @param batchId the batch identifier + * @return the batch state after the cancellation request + */ + AnthropicBatch cancel(String batchId); + + /** + * Deletes a batch. Only batches whose processing has ended can be deleted. + * @param batchId the batch identifier + */ + void delete(String batchId); + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationContext.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationContext.java new file mode 100644 index 0000000000..18e801fa38 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationContext.java @@ -0,0 +1,206 @@ +/* + * 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.anthropic; + +import io.micrometer.observation.Observation; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; + +/** + * Context used to store metadata for Anthropic Message Batches operations. + * + *

+ * Deliberately carries no batch identifier and no prompt or generated content: batch ids + * are unbounded in cardinality and prompts must not leak into metric tags. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +public class AnthropicBatchObservationContext extends Observation.Context { + + private final Operation operation; + + private final String provider; + + private final @Nullable String requestModel; + + private final @Nullable Integer requestCount; + + private @Nullable AnthropicBatch batch; + + private @Nullable AnthropicBatchRequestCounts requestCounts; + + AnthropicBatchObservationContext(Operation operation, String provider, @Nullable String requestModel, + @Nullable Integer requestCount) { + this.operation = operation; + this.provider = provider; + this.requestModel = requestModel; + this.requestCount = requestCount; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * The batch operation being observed. + * @return the operation + */ + public Operation getOperation() { + return this.operation; + } + + /** + * The model provider as identified by the client instrumentation. + * @return the provider + */ + public String getProvider() { + return this.provider; + } + + /** + * The model requested for the batch entries, when a single model applies to all of + * them. + * @return the model, or {@code null} + */ + public @Nullable String getRequestModel() { + return this.requestModel; + } + + /** + * The number of requests submitted, for {@link Operation#CREATE}. + * @return the request count, or {@code null} + */ + public @Nullable Integer getRequestCount() { + return this.requestCount; + } + + /** + * The batch returned by the operation, when it returns one. + * @return the batch, or {@code null} + */ + public @Nullable AnthropicBatch getBatch() { + return this.batch; + } + + public void setBatch(@Nullable AnthropicBatch batch) { + this.batch = batch; + if (batch != null) { + this.requestCounts = batch.requestCounts(); + } + } + + /** + * The per-outcome counters observed for this operation. Set from the batch for + * control-plane operations, and accumulated while streaming for + * {@link Operation#RESULTS}. + * @return the counters, or {@code null} + */ + public @Nullable AnthropicBatchRequestCounts getRequestCounts() { + return this.requestCounts; + } + + public void setRequestCounts(@Nullable AnthropicBatchRequestCounts requestCounts) { + this.requestCounts = requestCounts; + } + + /** + * The Anthropic Message Batches operations that Spring AI observes. + */ + public enum Operation { + + /** + * Batch submission, {@code POST /v1/messages/batches}. + */ + CREATE("batch_create"), + + /** + * Status lookup, {@code GET /v1/messages/batches/{id}}. + */ + RETRIEVE("batch_retrieve"), + + /** + * Result streaming, {@code GET /v1/messages/batches/{id}/results}. + */ + RESULTS("batch_results"), + + /** + * Cancellation, {@code POST /v1/messages/batches/{id}/cancel}. + */ + CANCEL("batch_cancel"), + + /** + * Deletion, {@code DELETE /v1/messages/batches/{id}}. + */ + DELETE("batch_delete"); + + private final String value; + + Operation(String value) { + this.value = value; + } + + public String value() { + return this.value; + } + + } + + public static final class Builder { + + private @Nullable Operation operation; + + private @Nullable String provider; + + private @Nullable String requestModel; + + private @Nullable Integer requestCount; + + private Builder() { + } + + public Builder operation(Operation operation) { + this.operation = operation; + return this; + } + + public Builder provider(String provider) { + this.provider = provider; + return this; + } + + public Builder requestModel(@Nullable String requestModel) { + this.requestModel = requestModel; + return this; + } + + public Builder requestCount(@Nullable Integer requestCount) { + this.requestCount = requestCount; + return this; + } + + public AnthropicBatchObservationContext build() { + Assert.state(this.operation != null, "Operation must not be null"); + Assert.state(this.provider != null, "Provider must not be null"); + return new AnthropicBatchObservationContext(this.operation, this.provider, this.requestModel, + this.requestCount); + } + + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationConvention.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationConvention.java new file mode 100644 index 0000000000..c352577bff --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationConvention.java @@ -0,0 +1,36 @@ +/* + * 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.anthropic; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Interface for an {@link ObservationConvention} for Anthropic Message Batches + * operations. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +public interface AnthropicBatchObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof AnthropicBatchObservationContext; + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationDocumentation.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationDocumentation.java new file mode 100644 index 0000000000..81fe6f38fe --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchObservationDocumentation.java @@ -0,0 +1,178 @@ +/* + * 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.anthropic; + +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +import org.springframework.ai.observation.conventions.AiObservationAttributes; + +/** + * Documented conventions for Anthropic Message Batches observations. + * + *

+ * Neither the batch identifier nor any prompt or generated content is exposed as a tag: + * batch ids are unbounded in cardinality and prompt content must not reach a metrics + * backend. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +public enum AnthropicBatchObservationDocumentation implements ObservationDocumentation { + + /** + * Observation emitted around each Anthropic Message Batches operation. + */ + BATCH_MODEL_OPERATION { + @Override + public Class> getDefaultConvention() { + return DefaultAnthropicBatchObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + }; + + /** + * Low-cardinality observation key names for batch operations. + */ + public enum LowCardinalityKeyNames implements KeyName { + + /** + * The batch operation being performed: {@code batch_create}, + * {@code batch_retrieve}, {@code batch_results}, {@code batch_cancel} or + * {@code batch_delete}. + */ + AI_OPERATION_TYPE { + @Override + public String asString() { + return AiObservationAttributes.AI_OPERATION_TYPE.value(); + } + }, + + /** + * The model provider as identified by the client instrumentation. + */ + AI_PROVIDER { + @Override + public String asString() { + return AiObservationAttributes.AI_PROVIDER.value(); + } + }, + + /** + * The name of the model the batch entries target, or {@code none} when the + * entries do not share a single model. + */ + REQUEST_MODEL { + @Override + public String asString() { + return AiObservationAttributes.REQUEST_MODEL.value(); + } + }, + + /** + * The processing status of the batch: {@code in_progress}, {@code canceling}, + * {@code ended}, or {@code none} when the operation returns no batch. + */ + BATCH_STATUS { + @Override + public String asString() { + return "spring.ai.anthropic.batch.status"; + } + } + + } + + /** + * High-cardinality observation key names for batch operations. + */ + public enum HighCardinalityKeyNames implements KeyName { + + /** + * The number of requests submitted with the batch. + */ + BATCH_REQUEST_COUNT { + @Override + public String asString() { + return "spring.ai.anthropic.batch.request.count"; + } + }, + + /** + * The number of requests still being processed. + */ + BATCH_PROCESSING_COUNT { + @Override + public String asString() { + return "spring.ai.anthropic.batch.counts.processing"; + } + }, + + /** + * The number of requests that completed successfully. + */ + BATCH_SUCCEEDED_COUNT { + @Override + public String asString() { + return "spring.ai.anthropic.batch.counts.succeeded"; + } + }, + + /** + * The number of requests that failed. + */ + BATCH_ERRORED_COUNT { + @Override + public String asString() { + return "spring.ai.anthropic.batch.counts.errored"; + } + }, + + /** + * The number of requests canceled before completion. + */ + BATCH_CANCELED_COUNT { + @Override + public String asString() { + return "spring.ai.anthropic.batch.counts.canceled"; + } + }, + + /** + * The number of requests that expired before completion. + */ + BATCH_EXPIRED_COUNT { + @Override + public String asString() { + return "spring.ai.anthropic.batch.counts.expired"; + } + } + + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchRequest.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchRequest.java new file mode 100644 index 0000000000..e71559d046 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchRequest.java @@ -0,0 +1,93 @@ +/* + * 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.anthropic; + +import java.util.regex.Pattern; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.util.Assert; + +/** + * A single entry of a batch submitted through {@link AnthropicBatchModel}. + * + *

+ * The {@code customId} is the only way to correlate a result with its request: the + * Anthropic API does not guarantee that results come back in submission order. Use + * an identifier your application can resolve back to its own domain entity, and keep it + * stored alongside the batch id so that correlation survives a restart. + * + *

+ * The {@link Prompt} is mapped exactly like it would be for + * {@link AnthropicChatModel#call(Prompt)} — including system messages, conversation + * history, images and PDF documents, prompt caching, thinking, structured output and tool + * definitions. Per-request model and options are taken from {@link Prompt#getOptions()} + * when it carries an {@link AnthropicChatOptions}; otherwise the batch model's default + * options apply. + * + * @param customId the caller-defined correlation identifier; 1 to 64 characters, limited + * to letters, digits, underscores and hyphens + * @param prompt the prompt to run + * @author Ricken Bazolo + * @since 2.0.0 + */ +public record AnthropicBatchRequest(String customId, Prompt prompt) { + + /** + * The identifier format accepted by the Anthropic Message Batches API. + */ + private static final Pattern CUSTOM_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]{1,64}$"); + + public AnthropicBatchRequest { + Assert.hasText(customId, "customId must not be empty"); + Assert.isTrue(CUSTOM_ID_PATTERN.matcher(customId).matches(), + () -> "customId must match " + CUSTOM_ID_PATTERN.pattern() + " but was: '" + customId + "'"); + Assert.notNull(prompt, "prompt must not be null"); + } + + /** + * Creates a batch request from a correlation identifier and a prompt. + * @param customId the caller-defined correlation identifier + * @param prompt the prompt to run + * @return the batch request + */ + public static AnthropicBatchRequest of(String customId, Prompt prompt) { + return new AnthropicBatchRequest(customId, prompt); + } + + /** + * Creates a batch request from a correlation identifier and a plain user message. + * @param customId the caller-defined correlation identifier + * @param userText the user message content + * @return the batch request, using the batch model's default options + */ + public static AnthropicBatchRequest of(String customId, String userText) { + return new AnthropicBatchRequest(customId, new Prompt(userText)); + } + + /** + * Creates a batch request from a correlation identifier, a plain user message and + * per-request options. + * @param customId the caller-defined correlation identifier + * @param userText the user message content + * @param options the Anthropic options for this entry + * @return the batch request + */ + public static AnthropicBatchRequest of(String customId, String userText, AnthropicChatOptions options) { + return new AnthropicBatchRequest(customId, new Prompt(userText, options)); + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchRequestCounts.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchRequestCounts.java new file mode 100644 index 0000000000..c923e369b7 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchRequestCounts.java @@ -0,0 +1,60 @@ +/* + * 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.anthropic; + +import com.anthropic.models.messages.batches.MessageBatchRequestCounts; + +/** + * Per-outcome request counters of an Anthropic message batch. + * + *

+ * While a batch is {@link AnthropicBatchStatus#IN_PROGRESS in progress} these counters + * let an application report progress without reading the (potentially large) result + * stream. + * + * @param processing number of requests still being processed + * @param succeeded number of requests that completed successfully + * @param errored number of requests that failed + * @param canceled number of requests canceled before completion + * @param expired number of requests that expired before completion + * @author Ricken Bazolo + * @since 2.0.0 + */ +public record AnthropicBatchRequestCounts(long processing, long succeeded, long errored, long canceled, long expired) { + + /** + * Returns the total number of requests tracked by this batch. + * @return the sum of every counter + */ + public long total() { + return this.processing + this.succeeded + this.errored + this.canceled + this.expired; + } + + /** + * Returns the number of requests that reached a terminal outcome. + * @return the sum of every counter except {@link #processing()} + */ + public long completed() { + return this.succeeded + this.errored + this.canceled + this.expired; + } + + static AnthropicBatchRequestCounts from(MessageBatchRequestCounts counts) { + return new AnthropicBatchRequestCounts(counts.processing(), counts.succeeded(), counts.errored(), + counts.canceled(), counts.expired()); + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchResult.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchResult.java new file mode 100644 index 0000000000..509106e277 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchResult.java @@ -0,0 +1,112 @@ +/* + * 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.anthropic; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; + +/** + * Result of a single request inside an Anthropic message batch, correlated to its request + * through {@link #customId()}. + * + *

+ * Results are not returned in submission order. Key them by {@code customId} + * rather than by position in the stream. + * + *

+ * Exactly one of {@link #chatResponse()} / {@link #error()} is populated: a succeeded + * result carries the {@link ChatResponse}, an errored one carries the + * {@link AnthropicBatchError}, and canceled or expired results carry neither. + * + * @param customId the correlation identifier supplied on the matching + * {@link AnthropicBatchRequest} + * @param status the terminal outcome of this request + * @param chatResponse the response, converted exactly as + * {@link AnthropicChatModel#call(org.springframework.ai.chat.prompt.Prompt)} would; only + * present when the status is {@link AnthropicBatchResultStatus#SUCCEEDED} + * @param error the failure detail; only present when the status is + * {@link AnthropicBatchResultStatus#ERRORED} + * @author Ricken Bazolo + * @since 2.0.0 + */ +public record AnthropicBatchResult(String customId, AnthropicBatchResultStatus status, + @Nullable ChatResponse chatResponse, @Nullable AnthropicBatchError error) { + + /** + * Creates a succeeded result. + * @param customId the correlation identifier + * @param chatResponse the converted response + * @return the result + */ + static AnthropicBatchResult succeeded(String customId, ChatResponse chatResponse) { + return new AnthropicBatchResult(customId, AnthropicBatchResultStatus.SUCCEEDED, chatResponse, null); + } + + /** + * Creates an errored result. + * @param customId the correlation identifier + * @param error the failure detail + * @return the result + */ + static AnthropicBatchResult errored(String customId, AnthropicBatchError error) { + return new AnthropicBatchResult(customId, AnthropicBatchResultStatus.ERRORED, null, error); + } + + /** + * Creates a result with no payload, for canceled, expired or unrecognised outcomes. + * @param customId the correlation identifier + * @param status the terminal outcome + * @return the result + */ + static AnthropicBatchResult of(String customId, AnthropicBatchResultStatus status) { + return new AnthropicBatchResult(customId, status, null, null); + } + + /** + * Whether this request completed successfully. + * @return {@code true} when the status is + * {@link AnthropicBatchResultStatus#SUCCEEDED} + */ + public boolean isSucceeded() { + return this.status == AnthropicBatchResultStatus.SUCCEEDED; + } + + /** + * Returns the token usage reported for this request, for cost accounting. + * @return the usage, or {@code null} when this request produced no response + */ + public @Nullable Usage usage() { + if (this.chatResponse == null || this.chatResponse.getMetadata() == null) { + return null; + } + return this.chatResponse.getMetadata().getUsage(); + } + + /** + * Returns the aggregated text of the response. + * @return the response text, or {@code null} when this request produced no response + */ + public @Nullable String getText() { + if (this.chatResponse == null || this.chatResponse.getResult() == null) { + return null; + } + return this.chatResponse.getResult().getOutput().getText(); + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchResultStatus.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchResultStatus.java new file mode 100644 index 0000000000..d1e328aff2 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchResultStatus.java @@ -0,0 +1,57 @@ +/* + * 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.anthropic; + +/** + * Terminal outcome of a single request inside an Anthropic message batch. + * + *

+ * Outcomes are per request, not per batch: one errored entry does not prevent the other + * entries of the same batch from succeeding. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +public enum AnthropicBatchResultStatus { + + /** + * The request completed and a message is available. + */ + SUCCEEDED, + + /** + * The request failed; see {@link AnthropicBatchResult#error()} for the reason. + */ + ERRORED, + + /** + * The request was canceled before completion, following a + * {@link AnthropicBatchModel#cancel(String)} call. + */ + CANCELED, + + /** + * The request did not complete before the batch expired. + */ + EXPIRED, + + /** + * An outcome returned by the API that this version of Spring AI does not know about. + */ + UNKNOWN + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchStatus.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchStatus.java new file mode 100644 index 0000000000..340079685c --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicBatchStatus.java @@ -0,0 +1,83 @@ +/* + * 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.anthropic; + +import com.anthropic.models.messages.batches.MessageBatch; + +/** + * Processing status of an Anthropic message batch. + * + * @author Ricken Bazolo + * @since 2.0.0 + * @see Anthropic + * Message Batches API + */ +public enum AnthropicBatchStatus { + + /** + * The batch has been accepted and its requests are being processed. + */ + IN_PROGRESS("in_progress"), + + /** + * Cancellation has been requested; requests already completed keep their result while + * the remaining ones are canceled. + */ + CANCELING("canceling"), + + /** + * Processing has finished. Results are available for reading, and every request has a + * terminal outcome (succeeded, errored, canceled or expired). + */ + ENDED("ended"), + + /** + * A status returned by the API that this version of Spring AI does not know about. + */ + UNKNOWN("unknown"); + + private final String value; + + AnthropicBatchStatus(String value) { + this.value = value; + } + + /** + * Returns the wire value used by the Anthropic API. + * @return the wire value + */ + public String getValue() { + return this.value; + } + + /** + * Maps the SDK processing status onto this enum, returning {@link #UNKNOWN} for + * values added by the API after this release. + * @param processingStatus the SDK processing status + * @return the corresponding status + */ + static AnthropicBatchStatus from(MessageBatch.ProcessingStatus processingStatus) { + String value = processingStatus.asString(); + for (AnthropicBatchStatus status : values()) { + if (status.value.equals(value)) { + return status; + } + } + return UNKNOWN; + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java index 6b3ea00ec7..2dbea2d15f 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatModel.java @@ -591,7 +591,7 @@ public ChatResponse internalCall(Prompt prompt, @Nullable ChatResponse previousC return response; } - private static AnthropicChatOptions resolveAnthropicOptions(Prompt prompt) { + static AnthropicChatOptions resolveAnthropicOptions(Prompt prompt) { ChatOptions options = prompt.getOptions(); return options instanceof AnthropicChatOptions anthropicOptions ? anthropicOptions : AnthropicChatOptions.builder().build(); @@ -615,6 +615,23 @@ private static RequestOptions requestOptionsFor(Prompt prompt) { * @return the constructed request parameters */ MessageCreateParams createRequest(Prompt prompt, boolean stream) { + return createRequest(prompt, this.toolCallingManager, this.options.getSkillContainer()); + } + + /** + * Same mapping as {@link #createRequest(Prompt, boolean)}, with the collaborators + * passed in explicitly so that other Anthropic models in this module — notably + * {@link AnthropicBatchModel} — reuse the exact same {@link Prompt} to + * {@link MessageCreateParams} conversion instead of duplicating it. + * @param prompt the prompt with message history and options + * @param toolCallingManager resolves the tool definitions to advertise + * @param defaultSkillContainer fallback skill container when the prompt options carry + * none; may be {@code null} + * @return the constructed request parameters + * @since 2.0.0 + */ + static MessageCreateParams createRequest(Prompt prompt, ToolCallingManager toolCallingManager, + @Nullable AnthropicSkillContainer defaultSkillContainer) { MessageCreateParams.Builder builder = MessageCreateParams.builder(); @@ -856,9 +873,9 @@ else if (message.getMessageType() == MessageType.TOOL) { List allTools = new ArrayList<>(); // Add user-defined tool definitions - List toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions); + List toolDefinitions = toolCallingManager.resolveToolDefinitions(requestOptions); if (!CollectionUtils.isEmpty(toolDefinitions)) { - List tools = toolDefinitions.stream().map(this::toAnthropicTool).toList(); + List tools = toolDefinitions.stream().map(AnthropicChatModel::toAnthropicTool).toList(); // Apply cache control to the last tool if caching strategy includes tools CacheControlEphemeral toolCacheControl = cacheResolver.resolveToolCacheControl(); @@ -907,8 +924,8 @@ else if (Boolean.TRUE.equals(requestOptions.getDisableParallelToolUse())) { // Skills support AnthropicSkillContainer skillContainer = requestOptions.getSkillContainer(); - if (skillContainer == null && this.options.getSkillContainer() != null) { - skillContainer = this.options.getSkillContainer(); + if (skillContainer == null && defaultSkillContainer != null) { + skillContainer = defaultSkillContainer; } if (skillContainer != null) { // Add container with skills config @@ -953,7 +970,7 @@ else if (Boolean.TRUE.equals(requestOptions.getDisableParallelToolUse())) { * @param lastUserIndex the index of the last user message (inclusive) * @return the combined text of eligible messages */ - private String combineEligibleMessagesText(List messages, + private static String combineEligibleMessagesText(List messages, int lastUserIndex) { StringBuilder combined = new StringBuilder(); for (int i = 0; i <= lastUserIndex && i < messages.size(); i++) { @@ -965,7 +982,7 @@ private String combineEligibleMessagesText(List responses) { + private static String combineToolResponsesText(List responses) { StringBuilder combined = new StringBuilder(); for (ToolResponseMessage.ToolResponse response : responses) { String data = response.responseData(); @@ -984,7 +1001,7 @@ private String combineToolResponsesText(List r * @param webSearchAccumulator collects web search results found in response * @return list of generations with text, tool calls, and/or thinking content */ - private List buildGenerations(Message message, List citationAccumulator, + static List buildGenerations(Message message, List citationAccumulator, List webSearchAccumulator) { List generations = new ArrayList<>(); @@ -1067,7 +1084,7 @@ else if (block.isContainerUpload() || block.isServerToolUse() || block.isBashCod * @param usage the usage information * @return the chat response metadata */ - private ChatResponseMetadata from(Message message, Usage usage, List citations, + static ChatResponseMetadata from(Message message, Usage usage, List citations, List webSearchResults, RateLimit rateLimit) { Assert.notNull(message, "Anthropic Message must not be null"); ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder() @@ -1090,7 +1107,7 @@ private ChatResponseMetadata from(Message message, Usage usage, List c * @param usage the Anthropic SDK usage * @return the Spring AI usage */ - private Usage getDefaultUsage(com.anthropic.models.messages.Usage usage) { + static Usage getDefaultUsage(com.anthropic.models.messages.Usage usage) { if (usage == null) { return new EmptyUsage(); } @@ -1103,7 +1120,7 @@ private Usage getDefaultUsage(com.anthropic.models.messages.Usage usage) { Integer.valueOf(Math.toIntExact(inputTokens + outputTokens)), usage, cacheRead, cacheWrite); } - private @Nullable Citation convertTextCitation(TextCitation textCitation) { + private static @Nullable Citation convertTextCitation(TextCitation textCitation) { if (textCitation.isCharLocation()) { return fromCharLocation(textCitation.asCharLocation()); } @@ -1119,7 +1136,7 @@ else if (textCitation.isWebSearchResultLocation()) { return null; } - private @Nullable Citation convertStreamingCitation(CitationsDelta.Citation citation) { + private static @Nullable Citation convertStreamingCitation(CitationsDelta.Citation citation) { if (citation.isCharLocation()) { return fromCharLocation(citation.asCharLocation()); } @@ -1135,22 +1152,22 @@ else if (citation.isWebSearchResultLocation()) { return null; } - private Citation fromCharLocation(CitationCharLocation loc) { + private static Citation fromCharLocation(CitationCharLocation loc) { return Citation.ofCharLocation(loc.citedText(), (int) loc.documentIndex(), loc.documentTitle().orElse(null), (int) loc.startCharIndex(), (int) loc.endCharIndex()); } - private Citation fromPageLocation(CitationPageLocation loc) { + private static Citation fromPageLocation(CitationPageLocation loc) { return Citation.ofPageLocation(loc.citedText(), (int) loc.documentIndex(), loc.documentTitle().orElse(null), (int) loc.startPageNumber(), (int) loc.endPageNumber()); } - private Citation fromContentBlockLocation(CitationContentBlockLocation loc) { + private static Citation fromContentBlockLocation(CitationContentBlockLocation loc) { return Citation.ofContentBlockLocation(loc.citedText(), (int) loc.documentIndex(), loc.documentTitle().orElse(null), (int) loc.startBlockIndex(), (int) loc.endBlockIndex()); } - private Citation fromWebSearchResultLocation(CitationsWebSearchResultLocation loc) { + private static Citation fromWebSearchResultLocation(CitationsWebSearchResultLocation loc) { return Citation.ofWebSearchResultLocation(loc.citedText(), loc.url(), loc.title().orElse(null)); } @@ -1162,7 +1179,7 @@ private Citation fromWebSearchResultLocation(CitationsWebSearchResultLocation lo * @return a valid JSON string * @throws RuntimeException if serialization fails */ - private String convertJsonValueToString(JsonValue jsonValue) { + private static String convertJsonValueToString(JsonValue jsonValue) { try { var jsonMapper = tools.jackson.databind.json.JsonMapper.builder().build(); // Convert to native Java objects first, then serialize with Jackson @@ -1180,7 +1197,7 @@ private String convertJsonValueToString(JsonValue jsonValue) { * @param jsonValue the SDK's JsonValue to convert * @return the equivalent native Java object, or null for JSON null */ - private @Nullable Object convertJsonValueToNative(JsonValue jsonValue) { + private static @Nullable Object convertJsonValueToNative(JsonValue jsonValue) { return jsonValue.accept(new JsonValue.Visitor<@Nullable Object>() { @Override public @Nullable Object visitNull() { @@ -1232,7 +1249,7 @@ public Object visitObject(java.util.Map values) { * @param argumentsJson the JSON string containing tool call arguments * @return a ToolUseBlockParam.Input with the parsed arguments */ - private ToolUseBlockParam.Input buildToolInput(String argumentsJson) { + private static ToolUseBlockParam.Input buildToolInput(String argumentsJson) { ToolUseBlockParam.Input.Builder inputBuilder = ToolUseBlockParam.Input.builder(); if (argumentsJson != null && !argumentsJson.isEmpty()) { try { @@ -1267,7 +1284,7 @@ private ToolUseBlockParam.Input buildToolInput(String argumentsJson) { * @throws RuntimeException if the JSON schema cannot be parsed */ @SuppressWarnings("unchecked") - private Tool toAnthropicTool(ToolDefinition toolDefinition) { + private static Tool toAnthropicTool(ToolDefinition toolDefinition) { try { // Parse the JSON schema string into a Map var jsonMapper = tools.jackson.databind.json.JsonMapper.builder().build(); @@ -1314,7 +1331,7 @@ private Tool toAnthropicTool(ToolDefinition toolDefinition) { * @param webSearchTool the web search configuration * @return the SDK web search tool */ - private WebSearchTool20260209 toSdkWebSearchTool(AnthropicWebSearchTool webSearchTool) { + private static WebSearchTool20260209 toSdkWebSearchTool(AnthropicWebSearchTool webSearchTool) { WebSearchTool20260209.Builder sdkBuilder = WebSearchTool20260209.builder(); if (webSearchTool.getAllowedDomains() != null) { @@ -1355,7 +1372,7 @@ private WebSearchTool20260209 toSdkWebSearchTool(AnthropicWebSearchTool webSearc * @return the appropriate ContentBlockParam (ImageBlockParam or DocumentBlockParam) * @throws IllegalArgumentException if the media type is unsupported */ - private ContentBlockParam getContentBlockParamByMedia(Media media) { + private static ContentBlockParam getContentBlockParamByMedia(Media media) { MimeType mimeType = media.getMimeType(); String data = fromMediaData(media.getData()); @@ -1374,7 +1391,7 @@ else if (isPdfMedia(mimeType)) { * @param mimeType the MIME type to check * @return true if the type is image/* */ - private boolean isImageMedia(MimeType mimeType) { + private static boolean isImageMedia(MimeType mimeType) { return "image".equals(mimeType.getType()); } @@ -1383,7 +1400,7 @@ private boolean isImageMedia(MimeType mimeType) { * @param mimeType the MIME type to check * @return true if the type is application/pdf */ - private boolean isPdfMedia(MimeType mimeType) { + private static boolean isPdfMedia(MimeType mimeType) { return "application".equals(mimeType.getType()) && "pdf".equals(mimeType.getSubtype()); } @@ -1394,7 +1411,7 @@ private boolean isPdfMedia(MimeType mimeType) { * @return base64-encoded string or URL string * @throws IllegalArgumentException if data type is unsupported */ - private String fromMediaData(Object mediaData) { + private static String fromMediaData(Object mediaData) { if (mediaData instanceof byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); } @@ -1411,7 +1428,7 @@ else if (mediaData instanceof String text) { * @param data base64-encoded image data or HTTPS URL * @return the ImageBlockParam wrapped in ContentBlockParam */ - private ContentBlockParam createImageBlockParam(MimeType mimeType, String data) { + private static ContentBlockParam createImageBlockParam(MimeType mimeType, String data) { ImageBlockParam.Source source; if (data.startsWith("https://")) { source = ImageBlockParam.Source.ofUrl(UrlImageSource.builder().url(data).build()); @@ -1428,7 +1445,7 @@ private ContentBlockParam createImageBlockParam(MimeType mimeType, String data) * @param data base64-encoded PDF data or HTTPS URL * @return the DocumentBlockParam wrapped in ContentBlockParam */ - private ContentBlockParam createDocumentBlockParam(String data) { + private static ContentBlockParam createDocumentBlockParam(String data) { DocumentBlockParam.Source source; if (data.startsWith("https://")) { source = DocumentBlockParam.Source.ofUrl(UrlPdfSource.builder().url(data).build()); @@ -1445,7 +1462,7 @@ private ContentBlockParam createDocumentBlockParam(String data) { * @return the SDK media type enum value * @throws IllegalArgumentException if the image type is unsupported */ - private Base64ImageSource.MediaType toSdkImageMediaType(MimeType mimeType) { + private static Base64ImageSource.MediaType toSdkImageMediaType(MimeType mimeType) { String subtype = mimeType.getSubtype(); return switch (subtype) { case "png" -> Base64ImageSource.MediaType.IMAGE_PNG; @@ -1461,7 +1478,7 @@ private Base64ImageSource.MediaType toSdkImageMediaType(MimeType mimeType) { * Applies {@code disableParallelToolUse} to an existing {@link ToolChoice} by * rebuilding the appropriate subtype with the flag set to {@code true}. */ - private ToolChoice applyDisableParallelToolUse(ToolChoice toolChoice) { + private static ToolChoice applyDisableParallelToolUse(ToolChoice toolChoice) { if (toolChoice.isAuto()) { return ToolChoice.ofAuto(toolChoice.asAuto().toBuilder().disableParallelToolUse(true).build()); } diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/DefaultAnthropicBatchModel.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/DefaultAnthropicBatchModel.java new file mode 100644 index 0000000000..5d580798d0 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/DefaultAnthropicBatchModel.java @@ -0,0 +1,623 @@ +/* + * 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.anthropic; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicLong; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.core.RequestOptions; +import com.anthropic.core.http.Headers; +import com.anthropic.core.http.StreamResponse; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.MessageCreateParams; +import com.anthropic.models.messages.batches.BatchCreateParams; +import com.anthropic.models.messages.batches.MessageBatchIndividualResponse; +import com.anthropic.models.messages.batches.MessageBatchResult; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +import org.springframework.ai.anthropic.http.okhttp.AnthropicHttpClientBuilderCustomizer; +import org.springframework.ai.chat.metadata.EmptyRateLimit; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Default {@link AnthropicBatchModel} implementation, built on the official + * Anthropic Java SDK. + * + *

+ * Requests are mapped exactly like realtime ones. Each + * {@link AnthropicBatchRequest} carries a {@link Prompt} that goes through the very same + * {@code Prompt} to {@code MessageCreateParams} conversion used by + * {@link AnthropicChatModel#call(Prompt)}, so system messages, conversation history, + * images and PDF documents, prompt caching, thinking, structured output and tool + * definitions behave the same on both paths. Likewise, a succeeded result is converted + * into a {@link ChatResponse} with the same generations, metadata keys and usage as a + * realtime call. + * + *

+ * Tool calls are not executed. Tool definitions are sent, but a batch response + * containing {@code tool_use} blocks is returned as-is: there is no interactive + * tool-execution loop, because a batch entry cannot be continued mid-flight. Surface the + * tool calls from {@link org.springframework.ai.chat.model.Generation#getOutput() the + * assistant message} and submit a follow-up batch if you need a second turn. + * + *

+ * Results are unordered. Correlate them through + * {@link AnthropicBatchResult#customId()}, never by position. + * + *

+ * Typical usage: + * + *

{@code
+ * AnthropicBatch batch = batchModel.submit(List.of(
+ *         AnthropicBatchRequest.of("invoice-1", "Summarize invoice 1"),
+ *         AnthropicBatchRequest.of("invoice-2", "Summarize invoice 2")));
+ *
+ * // later, on the application's own schedule
+ * if (batchModel.retrieve(batch.id()).isEnded()) {
+ *     batchModel.results(batch.id())
+ *         .doOnNext(result -> store(result.customId(), result))
+ *         .blockLast();
+ * }
+ * }
+ * + * @author Ricken Bazolo + * @since 2.0.0 + * @see AnthropicBatchRequest + * @see AnthropicBatchResult + * @see AnthropicChatModel + */ +public final class DefaultAnthropicBatchModel implements AnthropicBatchModel { + + private static final Log logger = LogFactory.getLog(DefaultAnthropicBatchModel.class); + + private static final AnthropicBatchObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAnthropicBatchObservationConvention(); + + private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build(); + + private final AnthropicClient anthropicClient; + + private final AnthropicChatOptions options; + + private final ToolCallingManager toolCallingManager; + + private final ObservationRegistry observationRegistry; + + private AnthropicBatchObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + private DefaultAnthropicBatchModel(@Nullable AnthropicClient anthropicClient, + @Nullable AnthropicChatOptions options, @Nullable ToolCallingManager toolCallingManager, + @Nullable ObservationRegistry observationRegistry, @Nullable MeterRegistry meterRegistry, + @Nullable ExecutorService dispatcherExecutor, + List httpClientCustomizers) { + + this.options = options != null ? options : AnthropicChatOptions.builder().build(); + this.observationRegistry = Objects.requireNonNullElse(observationRegistry, ObservationRegistry.NOOP); + this.toolCallingManager = Objects.requireNonNullElse(toolCallingManager, DEFAULT_TOOL_CALLING_MANAGER); + + this.anthropicClient = Objects.requireNonNullElseGet(anthropicClient, + () -> AnthropicSetup.setupSyncClient(this.options.getBaseUrl(), this.options.getApiKey(), + this.options.getTimeout(), this.options.getMaxRetries(), this.options.getProxy(), + this.options.getCustomHeaders(), this.observationRegistry, meterRegistry, dispatcherExecutor, + httpClientCustomizers)); + } + + /** + * Creates a new builder for {@link AnthropicBatchModel}. + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Gets the default options applied to batch entries whose prompt carries none. + * @return the default options + */ + public AnthropicChatOptions getOptions() { + return this.options; + } + + /** + * Returns the underlying synchronous Anthropic SDK client, for accessing SDK features + * this model does not expose (for example listing batches). + * @return the sync client + */ + public AnthropicClient getAnthropicClient() { + return this.anthropicClient; + } + + /** + * Submits a batch of prompts for asynchronous processing. + * + *

+ * Returns as soon as Anthropic accepts the batch; no request has been processed yet. + * Persist {@link AnthropicBatch#id()} together with the {@code customId} of every + * entry so that polling and correlation survive an application restart. + * @param requests the batch entries; must be non-empty and carry distinct + * {@code customId} values + * @return the accepted batch, in state {@link AnthropicBatchStatus#IN_PROGRESS} + * @throws IllegalArgumentException if the list is empty or a {@code customId} is + * duplicated + * @throws com.anthropic.errors.AnthropicServiceException if the API rejects the batch + */ + @Override + public AnthropicBatch submit(List requests) { + Assert.notEmpty(requests, "requests must not be empty"); + + Set customIds = new LinkedHashSet<>(); + List sdkRequests = new ArrayList<>(requests.size()); + Map> additionalHeaders = new LinkedHashMap<>(); + Set requestModels = new LinkedHashSet<>(); + + for (AnthropicBatchRequest request : requests) { + Assert.isTrue(customIds.add(request.customId()), () -> "Duplicate customId in batch: '" + request.customId() + + "'. Results are correlated by customId, so it must be unique within a batch."); + + MessageCreateParams params = AnthropicChatModel.createRequest(buildRequestPrompt(request.prompt()), + this.toolCallingManager, this.options.getSkillContainer()); + + requestModels.add(params.model().asString()); + collectAdditionalHeaders(params._additionalHeaders(), additionalHeaders); + + sdkRequests.add(BatchCreateParams.Request.builder() + .customId(request.customId()) + .params(toBatchRequestParams(params)) + .build()); + } + + BatchCreateParams.Builder builder = BatchCreateParams.builder().requests(sdkRequests); + // Per-request headers cannot be expressed per batch entry, so entry-level headers + // (for example the beta headers implied by a skill container) are merged onto the + // batch request itself. + additionalHeaders.forEach(builder::replaceAdditionalHeaders); + + AnthropicBatchObservationContext observationContext = AnthropicBatchObservationContext.builder() + .operation(AnthropicBatchObservationContext.Operation.CREATE) + .provider(AiProvider.ANTHROPIC.value()) + .requestModel(requestModels.size() == 1 ? requestModels.iterator().next() : null) + .requestCount(requests.size()) + .build(); + + return observe(observationContext, () -> { + AnthropicBatch batch = AnthropicBatch + .from(this.anthropicClient.messages().batches().create(builder.build(), requestOptions())); + observationContext.setBatch(batch); + if (logger.isDebugEnabled()) { + logger.debug("Submitted Anthropic message batch " + batch.id() + " with " + requests.size() + + " request(s)."); + } + return batch; + }); + } + + /** + * Retrieves the current state of a batch. + * + *

+ * Call this on the application's own schedule; Spring AI performs no polling. Results + * become readable once {@link AnthropicBatch#isEnded()} is {@code true}. + * @param batchId the batch identifier returned by {@link #submit(List)} + * @return the current batch state + * @throws com.anthropic.errors.NotFoundException if no such batch exists + */ + @Override + public AnthropicBatch retrieve(String batchId) { + Assert.hasText(batchId, "batchId must not be empty"); + + AnthropicBatchObservationContext observationContext = observationContext( + AnthropicBatchObservationContext.Operation.RETRIEVE); + + return observe(observationContext, () -> { + AnthropicBatch batch = AnthropicBatch + .from(this.anthropicClient.messages().batches().retrieve(batchId, requestOptions())); + observationContext.setBatch(batch); + return batch; + }); + } + + /** + * Streams the results of an ended batch. + * + *

+ * The underlying JSONL stream is consumed lazily and the SDK stream is closed when + * the returned {@link Flux} terminates or is cancelled, so a batch with a very large + * number of entries never has to be held in memory. Because the SDK exposes a + * blocking stream, items are emitted on {@link Schedulers#boundedElastic()}. + * + *

+ * Results arrive in an unspecified order: key them by + * {@link AnthropicBatchResult#customId()}. Individual failures are emitted as + * {@link AnthropicBatchResultStatus#ERRORED} items rather than thrown, so one bad + * entry never hides the rest. + * @param batchId the batch identifier + * @return a lazily-populated flux of per-request results + */ + @Override + public Flux results(String batchId) { + Assert.hasText(batchId, "batchId must not be empty"); + + return Flux.defer(() -> { + AnthropicBatchObservationContext observationContext = observationContext( + AnthropicBatchObservationContext.Operation.RESULTS); + Observation observation = AnthropicBatchObservationDocumentation.BATCH_MODEL_OPERATION.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); + observation.start(); + + ResultCounters counters = new ResultCounters(); + + return Flux + .using(() -> this.anthropicClient.messages().batches().resultsStreaming(batchId, requestOptions()), + streamResponse -> Flux.fromStream(streamResponse.stream()).map(response -> { + AnthropicBatchResult result = toBatchResult(response); + counters.record(result.status()); + return result; + }), StreamResponse::close) + .doOnError(observation::error) + .doFinally(signal -> { + observationContext.setRequestCounts(counters.snapshot()); + observation.stop(); + }) + .subscribeOn(Schedulers.boundedElastic()); + }); + } + + /** + * Requests cancellation of a batch. + * + *

+ * Cancellation is not immediate: the batch moves to + * {@link AnthropicBatchStatus#CANCELING} and requests that already completed keep + * their result, while the remaining ones end up as + * {@link AnthropicBatchResultStatus#CANCELED}. + * @param batchId the batch identifier + * @return the batch state after the cancellation request + */ + @Override + public AnthropicBatch cancel(String batchId) { + Assert.hasText(batchId, "batchId must not be empty"); + + AnthropicBatchObservationContext observationContext = observationContext( + AnthropicBatchObservationContext.Operation.CANCEL); + + return observe(observationContext, () -> { + AnthropicBatch batch = AnthropicBatch + .from(this.anthropicClient.messages().batches().cancel(batchId, requestOptions())); + observationContext.setBatch(batch); + return batch; + }); + } + + /** + * Deletes a batch. Only batches whose processing has ended can be deleted. + * @param batchId the batch identifier + */ + @Override + public void delete(String batchId) { + Assert.hasText(batchId, "batchId must not be empty"); + + AnthropicBatchObservationContext observationContext = observationContext( + AnthropicBatchObservationContext.Operation.DELETE); + + AnthropicBatchObservationDocumentation.BATCH_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> this.anthropicClient.messages().batches().delete(batchId, requestOptions())); + } + + /** + * Use the provided convention for reporting observation data. + * @param observationConvention the provided convention + */ + public void setObservationConvention(AnthropicBatchObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + + private T observe(AnthropicBatchObservationContext observationContext, java.util.function.Supplier action) { + T result = AnthropicBatchObservationDocumentation.BATCH_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(action); + Assert.state(result != null, "Anthropic batch operation returned no result"); + return result; + } + + private AnthropicBatchObservationContext observationContext(AnthropicBatchObservationContext.Operation operation) { + return AnthropicBatchObservationContext.builder() + .operation(operation) + .provider(AiProvider.ANTHROPIC.value()) + .build(); + } + + /** + * Applies this model's default options when the prompt carries none, mirroring + * {@link AnthropicChatModel}'s behaviour. + */ + private Prompt buildRequestPrompt(Prompt prompt) { + return prompt.getOptions() == null ? prompt.mutate().chatOptions(this.options).build() : prompt; + } + + private RequestOptions requestOptions() { + Duration timeout = this.options.getTimeout(); + return timeout != null ? RequestOptions.builder().timeout(timeout).build() : RequestOptions.none(); + } + + private static void collectAdditionalHeaders(Headers headers, Map> target) { + for (String name : headers.names()) { + target.computeIfAbsent(name, key -> new LinkedHashSet<>()).addAll(headers.values(name)); + } + } + + /** + * Copies a realtime {@link MessageCreateParams} onto the batch entry parameter shape. + * The two SDK types are structurally identical but nominally distinct, so every field + * is transferred explicitly; {@code stream} is deliberately never set, as batch + * entries cannot stream. + */ + private static BatchCreateParams.Request.Params toBatchRequestParams(MessageCreateParams params) { + BatchCreateParams.Request.Params.Builder builder = BatchCreateParams.Request.Params.builder() + .maxTokens(params._maxTokens()) + .messages(params._messages()) + .model(params._model()); + + params.cacheControl().ifPresent(builder::cacheControl); + params.container().ifPresent(builder::container); + params.inferenceGeo().ifPresent(builder::inferenceGeo); + params.metadata().ifPresent(builder::metadata); + params.outputConfig().ifPresent(builder::outputConfig); + params.serviceTier() + .ifPresent(serviceTier -> builder + .serviceTier(BatchCreateParams.Request.Params.ServiceTier.of(serviceTier.asString()))); + params.stopSequences().ifPresent(builder::stopSequences); + params.system().ifPresent(system -> { + if (system.isString()) { + builder.system(BatchCreateParams.Request.Params.System.ofString(system.asString())); + } + else if (system.isTextBlockParams()) { + builder.system(BatchCreateParams.Request.Params.System.ofTextBlockParams(system.asTextBlockParams())); + } + }); + params.temperature().ifPresent(builder::temperature); + params.thinking().ifPresent(builder::thinking); + params.toolChoice().ifPresent(builder::toolChoice); + params.tools().ifPresent(builder::tools); + params.topK().ifPresent(builder::topK); + params.topP().ifPresent(builder::topP); + + // Carries anything set through putAdditionalBodyProperty(), notably the skills + // container. + params._additionalBodyProperties().forEach(builder::putAdditionalProperty); + + return builder.build(); + } + + /** + * Converts one JSONL entry into a Spring AI result, reusing the realtime response + * conversion so that a batched message yields the same {@link ChatResponse} shape as + * {@link AnthropicChatModel#call(Prompt)}. + */ + private static AnthropicBatchResult toBatchResult(MessageBatchIndividualResponse response) { + String customId = response.customId(); + MessageBatchResult result = response.result(); + + if (result.isSucceeded()) { + Message message = result.asSucceeded().message(); + List citations = new ArrayList<>(); + List webSearchResults = new ArrayList<>(); + List generations = AnthropicChatModel.buildGenerations(message, citations, webSearchResults); + Usage usage = AnthropicChatModel.getDefaultUsage(message.usage()); + // Batch results carry no per-request rate-limit headers. + ChatResponse chatResponse = new ChatResponse(generations, + AnthropicChatModel.from(message, usage, citations, webSearchResults, new EmptyRateLimit())); + return AnthropicBatchResult.succeeded(customId, chatResponse); + } + if (result.isErrored()) { + return AnthropicBatchResult.errored(customId, AnthropicBatchError.from(result.asErrored().error())); + } + if (result.isCanceled()) { + return AnthropicBatchResult.of(customId, AnthropicBatchResultStatus.CANCELED); + } + if (result.isExpired()) { + return AnthropicBatchResult.of(customId, AnthropicBatchResultStatus.EXPIRED); + } + if (logger.isWarnEnabled()) { + logger.warn("Unrecognised batch result type for customId '" + customId + "': " + result); + } + return AnthropicBatchResult.of(customId, AnthropicBatchResultStatus.UNKNOWN); + } + + /** + * Accumulates per-outcome counters while the result stream is consumed, so the + * {@link AnthropicBatchObservationContext.Operation#RESULTS} observation can report + * them without buffering the results themselves. + */ + private static final class ResultCounters { + + private final AtomicLong succeeded = new AtomicLong(); + + private final AtomicLong errored = new AtomicLong(); + + private final AtomicLong canceled = new AtomicLong(); + + private final AtomicLong expired = new AtomicLong(); + + void record(AnthropicBatchResultStatus status) { + switch (status) { + case SUCCEEDED -> this.succeeded.incrementAndGet(); + case ERRORED -> this.errored.incrementAndGet(); + case CANCELED -> this.canceled.incrementAndGet(); + case EXPIRED -> this.expired.incrementAndGet(); + case UNKNOWN -> { + } + } + } + + AnthropicBatchRequestCounts snapshot() { + return new AnthropicBatchRequestCounts(0, this.succeeded.get(), this.errored.get(), this.canceled.get(), + this.expired.get()); + } + + } + + /** + * Builder for {@link AnthropicBatchModel}. Accepts the same connection and + * observability configuration as {@link AnthropicChatModel.Builder}, so a batch model + * reuses the application's Anthropic credentials, base URL, timeout, retries, proxy, + * custom headers and HTTP client customizers. + */ + public static final class Builder { + + private @Nullable AnthropicClient anthropicClient; + + private @Nullable AnthropicChatOptions options; + + private @Nullable ToolCallingManager toolCallingManager; + + private @Nullable ObservationRegistry observationRegistry; + + private @Nullable MeterRegistry meterRegistry; + + private @Nullable ExecutorService dispatcherExecutor; + + private List httpClientCustomizers = new ArrayList<>(); + + private Builder() { + } + + /** + * Sets a pre-configured Anthropic SDK client. When supplied, the + * connection-related options are ignored. + * @param anthropicClient the client + * @return this builder + */ + public Builder anthropicClient(AnthropicClient anthropicClient) { + this.anthropicClient = anthropicClient; + return this; + } + + /** + * Sets the default options applied to batch entries whose prompt carries none, + * and the connection settings used when no client is supplied. + * @param options the options + * @return this builder + */ + public Builder options(AnthropicChatOptions options) { + this.options = options; + return this; + } + + /** + * Sets the tool calling manager used to resolve the tool definitions advertised + * to the model. Tool calls returned by a batch are never executed; see the + * class-level documentation. + * @param toolCallingManager the tool calling manager + * @return this builder + */ + public Builder toolCallingManager(ToolCallingManager toolCallingManager) { + this.toolCallingManager = toolCallingManager; + return this; + } + + /** + * Sets the observation registry batch operations report to. + * @param observationRegistry the observation registry + * @return this builder + */ + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + /** + * Sets the meter registry OkHttp connection-pool gauges are bound to. + * @param meterRegistry the meter registry, or {@code null} to disable the gauges + * @return this builder + */ + public Builder meterRegistry(@Nullable MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + return this; + } + + /** + * Sets the OkHttp dispatcher executor. The caller owns its lifecycle. + * @param dispatcherExecutor the executor, or {@code null} for the library default + * @return this builder + */ + public Builder dispatcherExecutor(@Nullable ExecutorService dispatcherExecutor) { + this.dispatcherExecutor = dispatcherExecutor; + return this; + } + + /** + * Adds a customizer applied to the underlying OkHttp client builder. + * @param customizer the customizer + * @return this builder + */ + public Builder httpClientBuilderCustomizer(AnthropicHttpClientBuilderCustomizer customizer) { + Assert.notNull(customizer, "customizer cannot be null"); + this.httpClientCustomizers.add(customizer); + return this; + } + + /** + * Replaces the customizers applied to the underlying OkHttp client builder. + * @param customizers the customizers + * @return this builder + */ + public Builder httpClientBuilderCustomizers(List customizers) { + Assert.notNull(customizers, "customizers cannot be null"); + this.httpClientCustomizers = CollectionUtils.isEmpty(customizers) ? new ArrayList<>() + : new ArrayList<>(customizers); + return this; + } + + /** + * Builds the batch model. + * @return a new {@link DefaultAnthropicBatchModel} + */ + public DefaultAnthropicBatchModel build() { + return new DefaultAnthropicBatchModel(this.anthropicClient, this.options, this.toolCallingManager, + this.observationRegistry, this.meterRegistry, this.dispatcherExecutor, this.httpClientCustomizers); + } + + } + +} diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/DefaultAnthropicBatchObservationConvention.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/DefaultAnthropicBatchObservationConvention.java new file mode 100644 index 0000000000..6dde49a0f3 --- /dev/null +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/DefaultAnthropicBatchObservationConvention.java @@ -0,0 +1,105 @@ +/* + * 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.anthropic; + +import io.micrometer.common.KeyValues; + +import org.springframework.ai.anthropic.AnthropicBatchObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.ai.anthropic.AnthropicBatchObservationDocumentation.LowCardinalityKeyNames; + +/** + * Default conventions to populate observations for Anthropic Message Batches operations. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +public class DefaultAnthropicBatchObservationConvention implements AnthropicBatchObservationConvention { + + public static final String DEFAULT_NAME = "spring.ai.anthropic.batch.operation"; + + private static final String KEY_VALUE_NONE = "none"; + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public String getContextualName(AnthropicBatchObservationContext context) { + return "%s %s".formatted(context.getOperation().value(), context.getProvider()); + } + + @Override + public KeyValues getLowCardinalityKeyValues(AnthropicBatchObservationContext context) { + KeyValues keyValues = KeyValues.empty(); + keyValues = aiOperationType(keyValues, context); + keyValues = aiProvider(keyValues, context); + keyValues = requestModel(keyValues, context); + keyValues = batchStatus(keyValues, context); + return keyValues; + } + + @Override + public KeyValues getHighCardinalityKeyValues(AnthropicBatchObservationContext context) { + KeyValues keyValues = KeyValues.empty(); + keyValues = requestCount(keyValues, context); + keyValues = requestCounts(keyValues, context); + return keyValues; + } + + private KeyValues aiOperationType(KeyValues keyValues, AnthropicBatchObservationContext context) { + return keyValues.and(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), context.getOperation().value()); + } + + private KeyValues aiProvider(KeyValues keyValues, AnthropicBatchObservationContext context) { + return keyValues.and(LowCardinalityKeyNames.AI_PROVIDER.asString(), context.getProvider()); + } + + private KeyValues requestModel(KeyValues keyValues, AnthropicBatchObservationContext context) { + String requestModel = context.getRequestModel(); + return keyValues.and(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + requestModel != null ? requestModel : KEY_VALUE_NONE); + } + + private KeyValues batchStatus(KeyValues keyValues, AnthropicBatchObservationContext context) { + AnthropicBatch batch = context.getBatch(); + return keyValues.and(LowCardinalityKeyNames.BATCH_STATUS.asString(), + batch != null ? batch.status().getValue() : KEY_VALUE_NONE); + } + + private KeyValues requestCount(KeyValues keyValues, AnthropicBatchObservationContext context) { + Integer requestCount = context.getRequestCount(); + if (requestCount == null) { + return keyValues; + } + return keyValues.and(HighCardinalityKeyNames.BATCH_REQUEST_COUNT.asString(), String.valueOf(requestCount)); + } + + private KeyValues requestCounts(KeyValues keyValues, AnthropicBatchObservationContext context) { + AnthropicBatchRequestCounts counts = context.getRequestCounts(); + if (counts == null) { + return keyValues; + } + return keyValues + .and(HighCardinalityKeyNames.BATCH_PROCESSING_COUNT.asString(), String.valueOf(counts.processing())) + .and(HighCardinalityKeyNames.BATCH_SUCCEEDED_COUNT.asString(), String.valueOf(counts.succeeded())) + .and(HighCardinalityKeyNames.BATCH_ERRORED_COUNT.asString(), String.valueOf(counts.errored())) + .and(HighCardinalityKeyNames.BATCH_CANCELED_COUNT.asString(), String.valueOf(counts.canceled())) + .and(HighCardinalityKeyNames.BATCH_EXPIRED_COUNT.asString(), String.valueOf(counts.expired())); + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchIT.java new file mode 100644 index 0000000000..953704d4ac --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchIT.java @@ -0,0 +1,268 @@ +/* + * 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.anthropic; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.errors.AnthropicServiceException; +import com.anthropic.models.messages.Model; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Integration tests for the Anthropic + * Message Batches + * API support: full submit → poll → read lifecycle, cancellation, and error handling + * against the real API. + * + *

+ * Gating. Two switches guard this test, because a batch consumes tokens and its + * completion time is not bounded by the API contract: + *

    + *
  • {@code ANTHROPIC_API_KEY} must be set — the repository-wide convention for provider + * integration tests.
  • + *
  • {@code ANTHROPIC_BATCH_IT_DISABLED=true} turns it off even when a key is present, + * for runs that must not spend batch quota.
  • + *
+ * Anthropic ITs are also excluded from the {@code ci-fast-integration-tests} profile, so + * this only runs under {@code -Pintegration-tests}. + * + *

+ * Expect minutes, not seconds. Batches are asynchronous by design and the API + * allows up to 24 hours, though a small batch normally ends within a few minutes of + * queueing. {@link #submitPollAndReadResults()} is the only test that waits, and it logs + * every poll with the status and counters so a slow queue is visibly a slow queue rather + * than a hang. Past {@link #COMPLETION_TIMEOUT} — 5 minutes by default, override with + * {@code ANTHROPIC_BATCH_IT_TIMEOUT_MINUTES} — it {@link Assumptions#abort aborts} + * instead of failing, because a slow queue on Anthropic's side is not a Spring AI + * regression. + * + * @author Ricken Bazolo + * @since 2.0.0 + */ +@SpringBootTest(classes = AnthropicBatchIT.Config.class) +@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+") +@DisabledIfEnvironmentVariable(named = "ANTHROPIC_BATCH_IT_DISABLED", matches = "(?i)true") +class AnthropicBatchIT { + + private static final Log logger = LogFactory.getLog(AnthropicBatchIT.class); + + private static final Duration COMPLETION_TIMEOUT = completionTimeout(); + + private static final Duration POLL_INTERVAL = Duration.ofSeconds(20); + + private static final Duration RESULTS_TIMEOUT = Duration.ofMinutes(2); + + @Autowired + private AnthropicBatchModel batchModel; + + private final List createdBatchIds = new ArrayList<>(); + + private static Duration completionTimeout() { + String minutes = System.getenv("ANTHROPIC_BATCH_IT_TIMEOUT_MINUTES"); + return StringUtils.hasText(minutes) ? Duration.ofMinutes(Long.parseLong(minutes.trim())) + : Duration.ofMinutes(5); + } + + @AfterEach + void deleteCreatedBatches() { + for (String batchId : this.createdBatchIds) { + cancelQuietly(batchId); + deleteQuietly(batchId); + } + this.createdBatchIds.clear(); + } + + @Test + void submitPollAndReadResults() { + AnthropicBatch submitted = submit(List.of( + AnthropicBatchRequest.of("spring-ai-batch-it-1", "Reply with exactly one word: ONE"), + AnthropicBatchRequest.of("spring-ai-batch-it-2", + new Prompt(List.of(new SystemMessage("Answer with a single uppercase word and nothing else."), + new UserMessage("Reply with exactly one word: TWO")))), + AnthropicBatchRequest.of("spring-ai-batch-it-3", "Reply with exactly one word: THREE", + AnthropicChatOptions.builder().model(Model.CLAUDE_HAIKU_4_5).maxTokens(64).build()))); + + assertThat(submitted.id()).isNotBlank(); + assertThat(submitted.status()).isIn(AnthropicBatchStatus.IN_PROGRESS, AnthropicBatchStatus.ENDED); + assertThat(submitted.expiresAt()).isAfter(submitted.createdAt()); + + AnthropicBatch ended = awaitEnded(submitted.id()); + assertThat(ended.requestCounts().total()).isEqualTo(3); + assertThat(ended.requestCounts().processing()).isZero(); + assertThat(ended.resultsUrl()).isNotBlank(); + assertThat(ended.endedAt()).isNotNull(); + + Map byCustomId = this.batchModel.results(submitted.id()) + .collectMap(AnthropicBatchResult::customId) + .block(RESULTS_TIMEOUT); + + // Correlation is by customId only: the API does not preserve submission order. + assertThat(byCustomId).containsOnlyKeys("spring-ai-batch-it-1", "spring-ai-batch-it-2", "spring-ai-batch-it-3"); + + byCustomId.forEach((customId, result) -> { + assertThat(result.status()).as("%s outcome, error was %s", customId, result.error()) + .isEqualTo(AnthropicBatchResultStatus.SUCCEEDED); + assertThat(result.error()).isNull(); + assertThat(result.getText()).as("%s response text", customId).isNotBlank(); + // A batched message must carry the same metadata and usage as a realtime + // call. + assertThat(result.chatResponse()).isNotNull(); + assertThat(result.chatResponse().getMetadata().getId()).isNotBlank(); + assertThat(result.chatResponse().getMetadata().getModel()).isNotBlank(); + assertThat(result.chatResponse().getResult().getMetadata().getFinishReason()).isNotBlank(); + assertThat(result.usage()).isNotNull(); + assertThat(result.usage().getPromptTokens()).isPositive(); + assertThat(result.usage().getCompletionTokens()).isPositive(); + assertThat(result.usage().getTotalTokens()).isPositive(); + }); + + // Same batch, fresh subscription: taking one element must not drain the whole + // JSONL stream. Asserted here so the suite waits for a batch only once. + AnthropicBatchResult first = this.batchModel.results(submitted.id()).next().block(RESULTS_TIMEOUT); + assertThat(first).isNotNull(); + assertThat(first.customId()).startsWith("spring-ai-batch-it-"); + } + + @Test + void cancelIsAcknowledged() { + AnthropicBatch submitted = submit(List.of(AnthropicBatchRequest.of("spring-ai-batch-it-cancel", + "Write a detailed multi-paragraph essay about the history of gardening."))); + + AnthropicBatch canceling = this.batchModel.cancel(submitted.id()); + + assertThat(canceling.id()).isEqualTo(submitted.id()); + // A tiny batch can finish before the cancellation request lands. + assertThat(canceling.status()).isIn(AnthropicBatchStatus.CANCELING, AnthropicBatchStatus.ENDED); + if (canceling.isCanceling()) { + assertThat(canceling.cancelInitiatedAt()).isNotNull(); + } + } + + @Test + void retrievingAnUnknownBatchFailsWithAClientError() { + assertThatExceptionOfType(AnthropicServiceException.class) + .isThrownBy(() -> this.batchModel.retrieve("msgbatch_01SpringAiNoSuchBatch00")) + .satisfies(ex -> assertThat(ex.statusCode()).isBetween(400, 499)); + } + + private AnthropicBatch submit(List requests) { + AnthropicBatch batch = this.batchModel.submit(requests); + this.createdBatchIds.add(batch.id()); + return batch; + } + + /** + * Polls until the batch ends, logging every attempt. + *

+ * The logging is not decoration: a batch is asynchronous by design, so a silent wait + * is indistinguishable from a hung test. Each line reports the elapsed time, the + * processing status and the per-outcome counters, so a slow queue is visibly a slow + * queue. + */ + private AnthropicBatch awaitEnded(String batchId) { + long startedAt = System.nanoTime(); + logger.info("Waiting up to %s for batch %s to end (polling every %s)".formatted(COMPLETION_TIMEOUT, batchId, + POLL_INTERVAL)); + try { + Awaitility.await() + .atMost(COMPLETION_TIMEOUT) + .pollInterval(POLL_INTERVAL) + .pollDelay(Duration.ofSeconds(2)) + .until(() -> { + AnthropicBatch current = this.batchModel.retrieve(batchId); + logger.info(" [%3ds] batch %s status=%s counts=%s".formatted( + Duration.ofNanos(System.nanoTime() - startedAt).toSeconds(), batchId, + current.status().getValue(), current.requestCounts())); + return current.isEnded(); + }); + } + catch (ConditionTimeoutException ex) { + Assumptions.abort( + "Batch %s had not ended after %s. The API allows up to 24 hours, so a slow queue on Anthropic's side is not a Spring AI failure; raise ANTHROPIC_BATCH_IT_TIMEOUT_MINUTES to wait longer." + .formatted(batchId, COMPLETION_TIMEOUT)); + } + AnthropicBatch ended = this.batchModel.retrieve(batchId); + assertThat(ended.isEnded()).isTrue(); + return ended; + } + + private void cancelQuietly(String batchId) { + try { + this.batchModel.cancel(batchId); + } + catch (RuntimeException ex) { + // Best-effort cleanup: an already-ended batch cannot be canceled. + } + } + + private void deleteQuietly(String batchId) { + try { + this.batchModel.delete(batchId); + } + catch (RuntimeException ex) { + // Best-effort cleanup: a batch that has not ended yet cannot be deleted. + } + } + + @SpringBootConfiguration + public static class Config { + + @Bean + public AnthropicClient anthropicClient() { + String apiKey = System.getenv("ANTHROPIC_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name ANTHROPIC_API_KEY"); + } + return AnthropicSetup.setupSyncClient(null, apiKey, null, null, null, null); + } + + @Bean + public AnthropicBatchModel anthropicBatchModel(AnthropicClient client) { + return AnthropicBatchModel.builder() + .anthropicClient(client) + .options(AnthropicChatOptions.builder().model(Model.CLAUDE_HAIKU_4_5).maxTokens(256).build()) + .build(); + } + + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchModelTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchModelTests.java new file mode 100644 index 0000000000..3c10a79575 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchModelTests.java @@ -0,0 +1,414 @@ +/* + * 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.anthropic; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import com.anthropic.client.AnthropicClient; +import com.anthropic.core.RequestOptions; +import com.anthropic.core.http.StreamResponse; +import com.anthropic.models.ErrorObject; +import com.anthropic.models.ErrorResponse; +import com.anthropic.models.InvalidRequestError; +import com.anthropic.models.messages.ContentBlock; +import com.anthropic.models.messages.Message; +import com.anthropic.models.messages.Model; +import com.anthropic.models.messages.StopReason; +import com.anthropic.models.messages.TextBlock; +import com.anthropic.models.messages.Usage; +import com.anthropic.models.messages.batches.BatchCreateParams; +import com.anthropic.models.messages.batches.DeletedMessageBatch; +import com.anthropic.models.messages.batches.MessageBatch; +import com.anthropic.models.messages.batches.MessageBatchErroredResult; +import com.anthropic.models.messages.batches.MessageBatchIndividualResponse; +import com.anthropic.models.messages.batches.MessageBatchRequestCounts; +import com.anthropic.models.messages.batches.MessageBatchResult; +import com.anthropic.models.messages.batches.MessageBatchSucceededResult; +import com.anthropic.services.blocking.MessageService; +import com.anthropic.services.blocking.messages.BatchService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link DefaultAnthropicBatchModel}. Exercises SDK parameter + * construction, batch and result mapping, out-of-order results, per-request errors and + * stream cleanup with a mocked SDK client — no API key and no network access required. + * + * @author Ricken Bazolo + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AnthropicBatchModelTests { + + @Mock + private AnthropicClient anthropicClient; + + @Mock + private MessageService messageService; + + @Mock + private BatchService batchService; + + private AnthropicBatchModel batchModel; + + @BeforeEach + void setUp() { + given(this.anthropicClient.messages()).willReturn(this.messageService); + given(this.messageService.batches()).willReturn(this.batchService); + + this.batchModel = AnthropicBatchModel.builder() + .anthropicClient(this.anthropicClient) + .options(AnthropicChatOptions.builder().model("claude-haiku-4-5").maxTokens(256).build()) + .build(); + } + + @Test + void submitMapsEachPromptThroughTheRealtimeRequestConversion() { + MessageBatch accepted = mockMessageBatch("msgbatch_1", MessageBatch.ProcessingStatus.IN_PROGRESS); + given(this.batchService.create(any(BatchCreateParams.class), any(RequestOptions.class))).willReturn(accepted); + + AnthropicBatch batch = this.batchModel.submit(List.of( + AnthropicBatchRequest.of("req-1", + new Prompt(List.of(new SystemMessage("Be brief."), new UserMessage("Hello")))), + AnthropicBatchRequest.of("req-2", "World"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BatchCreateParams.class); + verify(this.batchService).create(captor.capture(), any(RequestOptions.class)); + + List requests = captor.getValue().requests(); + assertThat(requests).hasSize(2); + assertThat(requests.stream().map(BatchCreateParams.Request::customId)).containsExactly("req-1", "req-2"); + + BatchCreateParams.Request.Params first = requests.get(0).params(); + assertThat(first.model().asString()).isEqualTo("claude-haiku-4-5"); + assertThat(first.maxTokens()).isEqualTo(256L); + assertThat(first.system().orElseThrow().asString()).isEqualTo("Be brief."); + assertThat(first.messages()).hasSize(1); + // Batch entries cannot stream. + assertThat(first._additionalProperties()).doesNotContainKey("stream"); + + assertThat(batch.id()).isEqualTo("msgbatch_1"); + assertThat(batch.status()).isEqualTo(AnthropicBatchStatus.IN_PROGRESS); + assertThat(batch.isEnded()).isFalse(); + } + + @Test + void submitHonoursPerRequestOptions() { + MessageBatch accepted = mockMessageBatch("msgbatch_2", MessageBatch.ProcessingStatus.IN_PROGRESS); + given(this.batchService.create(any(BatchCreateParams.class), any(RequestOptions.class))).willReturn(accepted); + + this.batchModel.submit(List.of(AnthropicBatchRequest.of("req-1", "Hello", + AnthropicChatOptions.builder().model("claude-opus-4-5").maxTokens(64).temperature(0.2).build()))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BatchCreateParams.class); + verify(this.batchService).create(captor.capture(), any(RequestOptions.class)); + + BatchCreateParams.Request.Params params = captor.getValue().requests().get(0).params(); + assertThat(params.model().asString()).isEqualTo("claude-opus-4-5"); + assertThat(params.maxTokens()).isEqualTo(64L); + assertThat(params.temperature()).contains(0.2); + } + + @Test + void submitRejectsAnEmptyBatch() { + assertThatIllegalArgumentException().isThrownBy(() -> this.batchModel.submit(List.of())) + .withMessageContaining("requests must not be empty"); + } + + @Test + void submitRejectsDuplicateCustomIds() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.batchModel + .submit(List.of(AnthropicBatchRequest.of("same", "a"), AnthropicBatchRequest.of("same", "b")))) + .withMessageContaining("Duplicate customId"); + } + + @Test + void retrieveMapsStatusCountersAndTimestamps() { + MessageBatch messageBatch = mockMessageBatch("msgbatch_3", MessageBatch.ProcessingStatus.ENDED); + given(this.batchService.retrieve(eq("msgbatch_3"), any(RequestOptions.class))).willReturn(messageBatch); + + AnthropicBatch batch = this.batchModel.retrieve("msgbatch_3"); + + assertThat(batch.status()).isEqualTo(AnthropicBatchStatus.ENDED); + assertThat(batch.isEnded()).isTrue(); + assertThat(batch.requestCounts()).isEqualTo(new AnthropicBatchRequestCounts(1, 2, 1, 0, 0)); + assertThat(batch.requestCounts().total()).isEqualTo(4); + assertThat(batch.requestCounts().completed()).isEqualTo(3); + assertThat(batch.resultsUrl()).isEqualTo("https://api.anthropic.com/v1/messages/batches/msgbatch_3/results"); + assertThat(batch.endedAt()).isNotNull(); + } + + @Test + void retrieveMapsUnknownStatusesWithoutFailing() { + MessageBatch messageBatch = mockMessageBatch("msgbatch_x", + MessageBatch.ProcessingStatus.of("brand_new_status")); + given(this.batchService.retrieve(eq("msgbatch_x"), any(RequestOptions.class))).willReturn(messageBatch); + + assertThat(this.batchModel.retrieve("msgbatch_x").status()).isEqualTo(AnthropicBatchStatus.UNKNOWN); + } + + @Test + void resultsCorrelateByCustomIdEvenWhenReturnedOutOfOrder() { + StreamResponse streamResponse = mockResults( + succeededResponse("req-2", "second"), succeededResponse("req-1", "first")); + given(this.batchService.resultsStreaming(eq("msgbatch_4"), any(RequestOptions.class))) + .willReturn(streamResponse); + + Map byCustomId = this.batchModel.results("msgbatch_4") + .collectMap(AnthropicBatchResult::customId) + .block(); + + assertThat(byCustomId).containsOnlyKeys("req-1", "req-2"); + assertThat(byCustomId.get("req-1").getText()).isEqualTo("first"); + assertThat(byCustomId.get("req-2").getText()).isEqualTo("second"); + assertThat(byCustomId.get("req-1").isSucceeded()).isTrue(); + verify(streamResponse).close(); + } + + @Test + void resultsConvertSucceededMessagesLikeARealtimeCall() { + StreamResponse streamResponse = mockResults( + succeededResponse("req-1", "Hello there")); + given(this.batchService.resultsStreaming(eq("msgbatch_5"), any(RequestOptions.class))) + .willReturn(streamResponse); + + AnthropicBatchResult result = this.batchModel.results("msgbatch_5").blockFirst(); + + assertThat(result).isNotNull(); + assertThat(result.status()).isEqualTo(AnthropicBatchResultStatus.SUCCEEDED); + assertThat(result.error()).isNull(); + assertThat(result.chatResponse()).isNotNull(); + assertThat(result.chatResponse().getResult().getOutput().getText()).isEqualTo("Hello there"); + assertThat(result.chatResponse().getResult().getMetadata().getFinishReason()) + .isEqualTo(StopReason.END_TURN.toString()); + assertThat(result.chatResponse().getMetadata().getId()).isEqualTo("msg_batch_req-1"); + assertThat(result.usage()).isNotNull(); + assertThat(result.usage().getPromptTokens()).isEqualTo(10); + assertThat(result.usage().getCompletionTokens()).isEqualTo(20); + assertThat(result.usage().getTotalTokens()).isEqualTo(30); + } + + @Test + void resultsSurfaceIndividualErrorsWithoutHidingTheOtherEntries() { + StreamResponse streamResponse = mockResults( + erroredResponse("req-1", "max_tokens must be positive"), succeededResponse("req-2", "fine"), + terminalResponse("req-3", TerminalKind.CANCELED), terminalResponse("req-4", TerminalKind.EXPIRED)); + given(this.batchService.resultsStreaming(eq("msgbatch_6"), any(RequestOptions.class))) + .willReturn(streamResponse); + + List results = this.batchModel.results("msgbatch_6").collectList().block(); + + assertThat(results).hasSize(4); + AnthropicBatchResult errored = results.get(0); + assertThat(errored.status()).isEqualTo(AnthropicBatchResultStatus.ERRORED); + assertThat(errored.chatResponse()).isNull(); + assertThat(errored.error()).isNotNull(); + assertThat(errored.error().type()).isEqualTo("invalid_request_error"); + assertThat(errored.error().message()).isEqualTo("max_tokens must be positive"); + assertThat(errored.error().requestId()).isEqualTo("req_abc"); + + assertThat(results.get(1).status()).isEqualTo(AnthropicBatchResultStatus.SUCCEEDED); + assertThat(results.get(2).status()).isEqualTo(AnthropicBatchResultStatus.CANCELED); + assertThat(results.get(3).status()).isEqualTo(AnthropicBatchResultStatus.EXPIRED); + assertThat(results.get(2).chatResponse()).isNull(); + assertThat(results.get(3).error()).isNull(); + } + + @Test + void resultsCloseTheSdkStreamWhenTheSubscriberCancels() { + StreamResponse streamResponse = mockResults(succeededResponse("req-1", "a"), + succeededResponse("req-2", "b")); + given(this.batchService.resultsStreaming(eq("msgbatch_7"), any(RequestOptions.class))) + .willReturn(streamResponse); + + AnthropicBatchResult first = this.batchModel.results("msgbatch_7").next().block(); + + assertThat(first).isNotNull(); + verify(streamResponse).close(); + } + + @Test + void resultsAreLazyAndDoNotCallTheApiUntilSubscribed() { + this.batchModel.results("msgbatch_8"); + + verify(this.batchService, never()).resultsStreaming(any(String.class), any(RequestOptions.class)); + } + + @Test + void cancelDelegatesToTheProvider() { + MessageBatch canceling = mockMessageBatch("msgbatch_9", MessageBatch.ProcessingStatus.CANCELING); + given(this.batchService.cancel(eq("msgbatch_9"), any(RequestOptions.class))).willReturn(canceling); + + AnthropicBatch batch = this.batchModel.cancel("msgbatch_9"); + + assertThat(batch.status()).isEqualTo(AnthropicBatchStatus.CANCELING); + assertThat(batch.isCanceling()).isTrue(); + verify(this.batchService).cancel(eq("msgbatch_9"), any(RequestOptions.class)); + } + + @Test + void deleteDelegatesToTheProvider() { + DeletedMessageBatch deleted = mock(DeletedMessageBatch.class); + given(this.batchService.delete(eq("msgbatch_10"), any(RequestOptions.class))).willReturn(deleted); + + this.batchModel.delete("msgbatch_10"); + + verify(this.batchService).delete(eq("msgbatch_10"), any(RequestOptions.class)); + } + + @Test + void controlOperationsRejectBlankBatchIds() { + assertThatIllegalArgumentException().isThrownBy(() -> this.batchModel.retrieve(" ")); + assertThatIllegalArgumentException().isThrownBy(() -> this.batchModel.cancel("")); + assertThatIllegalArgumentException().isThrownBy(() -> this.batchModel.delete("")); + assertThatIllegalArgumentException().isThrownBy(() -> this.batchModel.results("")); + } + + // --- fixtures --- + + private static MessageBatch mockMessageBatch(String id, MessageBatch.ProcessingStatus status) { + MessageBatchRequestCounts counts = mock(MessageBatchRequestCounts.class); + given(counts.processing()).willReturn(1L); + given(counts.succeeded()).willReturn(2L); + given(counts.errored()).willReturn(1L); + given(counts.canceled()).willReturn(0L); + given(counts.expired()).willReturn(0L); + + OffsetDateTime now = OffsetDateTime.parse("2026-07-30T10:15:30Z"); + + MessageBatch messageBatch = mock(MessageBatch.class); + given(messageBatch.id()).willReturn(id); + given(messageBatch.processingStatus()).willReturn(status); + given(messageBatch.requestCounts()).willReturn(counts); + given(messageBatch.createdAt()).willReturn(now); + given(messageBatch.expiresAt()).willReturn(now.plusDays(1)); + given(messageBatch.endedAt()).willReturn(Optional.of(now.plusHours(2))); + given(messageBatch.cancelInitiatedAt()).willReturn(Optional.empty()); + given(messageBatch.archivedAt()).willReturn(Optional.empty()); + given(messageBatch.resultsUrl()) + .willReturn(Optional.of("https://api.anthropic.com/v1/messages/batches/" + id + "/results")); + return messageBatch; + } + + @SuppressWarnings("unchecked") + private static StreamResponse mockResults( + MessageBatchIndividualResponse... responses) { + StreamResponse streamResponse = mock(StreamResponse.class); + given(streamResponse.stream()).willReturn(Stream.of(responses)); + return streamResponse; + } + + private static MessageBatchIndividualResponse succeededResponse(String customId, String text) { + Message message = mockMessage("msg_batch_" + customId, text); + MessageBatchSucceededResult succeeded = mock(MessageBatchSucceededResult.class); + given(succeeded.message()).willReturn(message); + + MessageBatchResult result = mock(MessageBatchResult.class); + given(result.isSucceeded()).willReturn(true); + given(result.asSucceeded()).willReturn(succeeded); + + return individualResponse(customId, result); + } + + private static MessageBatchIndividualResponse erroredResponse(String customId, String message) { + ErrorResponse errorResponse = ErrorResponse.builder() + .error(ErrorObject.ofInvalidRequestError(InvalidRequestError.builder().message(message).build())) + .requestId("req_abc") + .build(); + MessageBatchErroredResult errored = mock(MessageBatchErroredResult.class); + given(errored.error()).willReturn(errorResponse); + + MessageBatchResult result = mock(MessageBatchResult.class); + given(result.isSucceeded()).willReturn(false); + given(result.isErrored()).willReturn(true); + given(result.asErrored()).willReturn(errored); + + return individualResponse(customId, result); + } + + private enum TerminalKind { + + CANCELED, EXPIRED + + } + + private static MessageBatchIndividualResponse terminalResponse(String customId, TerminalKind kind) { + MessageBatchResult result = mock(MessageBatchResult.class); + given(result.isSucceeded()).willReturn(false); + given(result.isErrored()).willReturn(false); + given(result.isCanceled()).willReturn(kind == TerminalKind.CANCELED); + given(result.isExpired()).willReturn(kind == TerminalKind.EXPIRED); + + return individualResponse(customId, result); + } + + private static MessageBatchIndividualResponse individualResponse(String customId, MessageBatchResult result) { + MessageBatchIndividualResponse response = mock(MessageBatchIndividualResponse.class); + given(response.customId()).willReturn(customId); + given(response.result()).willReturn(result); + return response; + } + + private static Message mockMessage(String id, String text) { + TextBlock textBlock = mock(TextBlock.class); + given(textBlock.text()).willReturn(text); + given(textBlock.citations()).willReturn(Optional.empty()); + + ContentBlock contentBlock = mock(ContentBlock.class); + given(contentBlock.isText()).willReturn(true); + given(contentBlock.asText()).willReturn(textBlock); + + Usage usage = mock(Usage.class); + given(usage.inputTokens()).willReturn(10L); + given(usage.outputTokens()).willReturn(20L); + given(usage.cacheReadInputTokens()).willReturn(Optional.empty()); + given(usage.cacheCreationInputTokens()).willReturn(Optional.empty()); + + Message message = mock(Message.class); + given(message.id()).willReturn(id); + given(message.model()).willReturn(Model.CLAUDE_HAIKU_4_5); + given(message.content()).willReturn(List.of(contentBlock)); + given(message.stopReason()).willReturn(Optional.of(StopReason.END_TURN)); + given(message.usage()).willReturn(usage); + return message; + } + +} diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchRequestTests.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchRequestTests.java new file mode 100644 index 0000000000..2486f9f383 --- /dev/null +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicBatchRequestTests.java @@ -0,0 +1,78 @@ +/* + * 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.anthropic; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.ai.chat.prompt.Prompt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link AnthropicBatchRequest} correlation-identifier validation. A malformed + * {@code customId} is rejected client-side rather than costing a round trip, because it + * is the only handle the caller has to match a result back to its request. + * + * @author Ricken Bazolo + */ +class AnthropicBatchRequestTests { + + @Test + void acceptsValidCustomIds() { + assertThat(AnthropicBatchRequest.of("invoice_42-A", "hello").customId()).isEqualTo("invoice_42-A"); + assertThat(AnthropicBatchRequest.of("a".repeat(64), "hello").customId()).hasSize(64); + } + + @Test + void carriesThePromptAndItsOptions() { + AnthropicChatOptions options = AnthropicChatOptions.builder().model("claude-haiku-4-5").maxTokens(32).build(); + + AnthropicBatchRequest request = AnthropicBatchRequest.of("req-1", "hello", options); + + assertThat(request.prompt().getOptions()).isSameAs(options); + assertThat(request.prompt().getContents()).contains("hello"); + } + + @Test + void acceptsAnExplicitPrompt() { + Prompt prompt = new Prompt("hello"); + + assertThat(AnthropicBatchRequest.of("req-1", prompt).prompt()).isSameAs(prompt); + } + + @ParameterizedTest + @ValueSource(strings = { " ", "with space", "with/slash", "with:colon", "accentué" }) + void rejectsMalformedCustomIds(String customId) { + assertThatIllegalArgumentException().isThrownBy(() -> AnthropicBatchRequest.of(customId, "hello")); + } + + @Test + void rejectsAnEmptyCustomId() { + assertThatIllegalArgumentException().isThrownBy(() -> AnthropicBatchRequest.of("", "hello")) + .withMessageContaining("customId must not be empty"); + } + + @Test + void rejectsACustomIdLongerThanTheApiLimit() { + assertThatIllegalArgumentException().isThrownBy(() -> AnthropicBatchRequest.of("a".repeat(65), "hello")) + .withMessageContaining("customId must match"); + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index bfdb21c478..66057fcde6 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -19,6 +19,7 @@ **** xref:api/chat/comparison.adoc[Chat Models Comparison] **** xref:api/chat/bedrock-converse.adoc[Amazon Bedrock Converse] **** xref:api/chat/anthropic-chat.adoc[Anthropic] +***** xref:api/chat/anthropic-batch.adoc[Message Batches] **** xref:api/chat/azure-openai-chat.adoc[Azure OpenAI] **** xref:api/chat/deepseek-chat.adoc[DeepSeek] **** xref:api/chat/dmr-chat.adoc[Docker Model Runner] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-batch.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-batch.adoc new file mode 100644 index 0000000000..85a9201a5d --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-batch.adoc @@ -0,0 +1,177 @@ +[[anthropic-batch]] += Anthropic Message Batches + +The link:https://platform.claude.com/docs/en/api/messages/batches[Message Batches API] processes a large number of prompts asynchronously at reduced cost. +Spring AI exposes it through `AnthropicBatchModel`, a dedicated abstraction rather than a `ChatModel`: `submit(...)` returns as soon as Anthropic accepts the batch, and there is no `ChatResponse` to return yet. + +This page covers the batch model only. +See xref:api/chat/anthropic-chat.adoc[Anthropic Chat] for the dependency, the Boot starter and the `spring.ai.anthropic.*` connection properties, which the batch model shares. + +[IMPORTANT] +==== +**Spring AI provides the provider access, not the orchestration.** + +There is no automatic polling, no persistence, no scheduling and no business retry. +Deciding how often to call `retrieve(...)`, where to store the batch id and the `customId` correlations, when to notify and how to account for cost all stay with your application. +==== + +== Dependency and auto-configuration + +The batch model ships in the same `spring-ai-starter-model-anthropic` starter as the chat model, but it is **opt-in** so that applications which never submit batches do not pay for a second HTTP client: + +[source,properties] +---- +spring.ai.anthropic.batch.enabled=true +---- + +Connection settings (`spring.ai.anthropic.*`) and model defaults (`spring.ai.anthropic.chat.*`) are shared with the chat model, so a batch reuses the same credentials, base URL, timeout, retries, proxy, custom headers and `AnthropicHttpClientBuilderCustomizer` beans as realtime calls. + +[cols="3,4"] +|==== +| Property | Description + +| `spring.ai.anthropic.batch.enabled` +| Whether to expose an `AnthropicBatchModel` bean. Default `false`. + +| `spring.ai.anthropic.batch.model` +| Model for batch entries. Falls back to `spring.ai.anthropic.chat.model`. + +| `spring.ai.anthropic.batch.max-tokens` +| Output-token ceiling per batch entry. Falls back to `spring.ai.anthropic.chat.max-tokens`. +|==== + +Outside Spring Boot, build one directly — the builder accepts the same connection and observability configuration as `AnthropicChatModel.builder()`: + +[source,java] +---- +AnthropicBatchModel batchModel = AnthropicBatchModel.builder() + .options(AnthropicChatOptions.builder() + .model("claude-haiku-4-5") + .maxTokens(1024) + .build()) + .observationRegistry(observationRegistry) + .build(); +---- + +== Submitting a batch + +Every entry pairs a `customId` with a `Prompt`. +The prompt is mapped through the *same* conversion used by `AnthropicChatModel.call(...)`, so system messages, conversation history, images and PDF documents, prompt caching, thinking, structured output and tool definitions all behave identically on both paths. +Per-entry options are taken from the prompt when it carries `AnthropicChatOptions`; otherwise the batch model's defaults apply. + +[source,java] +---- +AnthropicBatch batch = batchModel.submit(List.of( + AnthropicBatchRequest.of("invoice-1", "Summarize invoice 1"), + AnthropicBatchRequest.of("invoice-2", "Summarize invoice 2", + AnthropicChatOptions.builder().model("claude-sonnet-4-6").maxTokens(2048).build()), + AnthropicBatchRequest.of("invoice-3", new Prompt(List.of( + new SystemMessage("Answer in one sentence."), + new UserMessage("Summarize invoice 3")))))); + +// Persist these before returning: they are the only handle on the work in flight +String batchId = batch.id(); +---- + +`customId` must be 1–64 characters limited to letters, digits, underscores and hyphens, and must be unique within the batch. +Both rules are enforced client-side, so a malformed identifier fails fast instead of costing a round trip. + +== Checking status + +Batch processing is asynchronous and **can take up to 24 hours**. +Poll on your own schedule: + +[source,java] +---- +AnthropicBatch current = batchModel.retrieve(batchId); + +if (current.isEnded()) { + // results can now be read +} + +// Progress reporting without reading the result stream +AnthropicBatchRequestCounts counts = current.requestCounts(); +log.info("{}/{} done ({} errored)", counts.completed(), counts.total(), counts.errored()); +---- + +`AnthropicBatch` also exposes `createdAt()`, `expiresAt()`, `endedAt()`, `cancelInitiatedAt()`, `archivedAt()` and `resultsUrl()`. +`status()` is an `AnthropicBatchStatus`: `IN_PROGRESS`, `CANCELING`, `ENDED`, or `UNKNOWN` for a status added by the API after your Spring AI release. + +== Reading results + +`results(...)` returns a `Flux` that consumes the JSONL stream lazily and closes the underlying SDK stream when the flux terminates or is cancelled, so a batch with a very large number of entries is never held in memory. + +[IMPORTANT] +==== +**Results are not returned in submission order.** Key them by `customId`, never by position in the stream. +==== + +Individual failures are emitted as `ERRORED` items rather than thrown, so one bad entry never hides the rest of the batch: + +[source,java] +---- +batchModel.results(batchId) + .doOnNext(result -> { + switch (result.status()) { + case SUCCEEDED -> { + ChatResponse response = result.chatResponse(); + store(result.customId(), response.getResult().getOutput().getText()); + recordCost(result.customId(), result.usage()); + } + case ERRORED -> { + AnthropicBatchError error = result.error(); + log.warn("{} failed: {} — {}", result.customId(), error.type(), error.message()); + } + case CANCELED, EXPIRED, UNKNOWN -> requeue(result.customId()); + } + }) + .blockLast(); +---- + +A succeeded result carries a full `ChatResponse` — same generations, same metadata keys (including `anthropic-response`, citations and web-search results) and same `Usage` as a realtime call — so downstream code that already consumes `ChatResponse` needs no change. +`AnthropicBatchResult` adds `getText()` and `usage()` shortcuts for the common cases. + +== Cancelling and deleting + +[source,java] +---- +// Cancellation is not immediate: the batch moves to CANCELING, entries that already +// completed keep their result, and the rest end up CANCELED. +AnthropicBatch canceling = batchModel.cancel(batchId); + +// Only batches whose processing has ended can be deleted. +batchModel.delete(batchId); +---- + +== Correlating results with your domain + +`customId` is the only correlation handle the API offers, and results outlive the JVM that submitted them. +Persist the batch id together with the `customId` of every entry, mapped to your own identifiers, before `submit(...)` returns to the caller — otherwise a restart mid-flight leaves results that cannot be attributed. + +[source,java] +---- +// customId built from a domain identifier so correlation survives a restart +AnthropicBatchRequest.of("invoice-" + invoice.getId(), prompt(invoice)); +---- + +== Tool calling limitation + +Tool definitions resolved from the request options *are* sent with each entry, but a batch response containing `tool_use` blocks is returned as-is: there is **no interactive tool-execution loop**, because a batch entry cannot be continued mid-flight. +Read the tool calls off the assistant message and submit a follow-up batch if you need a second turn: + +[source,java] +---- +AssistantMessage output = result.chatResponse().getResult().getOutput(); +if (!output.getToolCalls().isEmpty()) { + // execute locally, then submit a follow-up batch with the tool results appended +} +---- + +== Observability + +Every batch operation emits a `spring.ai.anthropic.batch.operation` observation whose `gen_ai.operation.name` tag is `batch_create`, `batch_retrieve`, `batch_results`, `batch_cancel` or `batch_delete`, alongside `gen_ai.system`, `gen_ai.request.model` and `spring.ai.anthropic.batch.status`. +The submitted request count and the per-outcome counters are recorded as high-cardinality tags. + +Neither the batch id nor any prompt or generated content is exposed as a tag: batch ids are unbounded in cardinality, and prompt content must not reach a metrics backend. + +Supply your own `AnthropicBatchObservationConvention` bean to customise the tags. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc index 89b7f60285..6de1c45f19 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/anthropic-chat.adoc @@ -1642,6 +1642,13 @@ spring.ai.anthropic.chat.web-search-tool.user-location.city=San Francisco spring.ai.anthropic.chat.web-search-tool.user-location.country=US ---- +== Message Batches + +Anthropic can process a large number of prompts asynchronously at reduced cost through the Message Batches API. +Spring AI exposes it as a dedicated `AnthropicBatchModel` rather than a `ChatModel`, because a submitted batch has no immediate response to return. + +See xref:api/chat/anthropic-batch.adoc[Anthropic Message Batches] for submitting a batch, polling it, reading results, and the auto-configuration properties. + == Observability Spring AI emits Micrometer observations at two layers for every Anthropic call: