Skip to content

Spring Boot 4 Spring AI 2.0 Migration

Alexander Hein-Heifetz edited this page May 30, 2026 · 4 revisions

Spring Boot 4 / Spring AI 2.0 Migration

Comprehensive write-up of the platform upgrade that brought the agent stack from Spring Boot 3.x onto the Boot 4 line.

Branch: 2.0.0  |  Scope: embabel-agent reactor + embabel-build BOM  |  Status: build green; one runtime regression under investigation (see Known issues)


1. Stack diff

Dependency Before After Notes
Spring Boot 3.x 4.0.6 Many starters split into smaller modules (jackson, security, observation, webmvc-test)
Spring Framework 6.x 7.x (via Boot) JSpecify @NullMarked tightens generics; core.retry.RetryTemplate introduced
Spring AI 1.x 2.0.0-M8 Hand-rolled OpenAiApi / AnthropicApi removed in favour of vendor SDKs
Jackson 2.x 3.x Package rebrand com.fasterxml.jackson.*tools.jackson.*; ObjectMapper is now immutable
Kotlin 2.1.10 2.2.21 (K2) Stricter K2 type-inference around JSpecify
JUnit 5.x 6.x assertThrows<X?> no longer accepts nullable type args
spring-retry from Boot BOM 2.0.12 (direct dep) Boot 4 dropped it from the BOM; kept the library to avoid migrating to core.retry
openai-java 4.16.1 (test) 4.36.0 (compile) Now used on the main classpath since Spring AI exposes OpenAIClient directly
anthropic-java 2.34.0 (via Spring AI) First-class dep introduced by Spring AI 2.0
MCP SDK 1.x 2.x McpSyncClientCustomizer + McpAsyncClientCustomizer unified into McpClientCustomizer

2. Commit timeline

The migration landed across the embabel-agent 2.0.0 branch in five commits (plus one prep commit in embabel-build):

embabel-agent (newest → oldest):
  f681b4c  Spring AI 2.0 — rewrite OpenAI + Anthropic factories on native SDKs
  dd432fa  Spring Boot 4 + AI 2.0 follow-ups across autoconfigure modules
  3db2a60  Spring Boot 4 + AI 2.0 follow-ups: webmvc-test dep, MCP customizer
  a176c1c  upgrade to Spring Boot 4.0.6 + Spring AI 2.0.0-M8 + Jackson 3   ← foundational

embabel-build (BOM):
  dca045e  Spring Boot 4 / AI 2.0 build deps: openai-java 4.36 + JSpecify ignore flag
  abe1034  Update Kotlin to version 2.2.21
  7d45753  Upgrade to Spring AI 2.0.0-M8
  68f650d  Update Spring Boot Version to 4.0.6

Total scope across the four embabel-agent commits: ~35 files, +376 / −220 lines (excluding the foundational commit, which is larger).


3. Major themes

3.1 Spring AI 2.0 model-factory rewrites

Spring AI 2.0 deleted its hand-rolled provider facades (OpenAiApi, AnthropicApi, OpenAiModerationApi) and now delegates to the vendor SDKs (openai-java, anthropic-java) directly. This is the single most invasive change: every chat-model factory in the platform was rewritten.

Builder API renames (apply to all *ChatModel.Builder types in Spring AI 2.0):

Spring AI 1.x Spring AI 2.0
.openAiApi(OpenAiApi) .openAiClient(OpenAIClient)
.anthropicApi(AnthropicApi) .anthropicClient(AnthropicClient)
.defaultOptions(ChatOptions) .options(ChatOptions)
.retryTemplate(spring-retry RetryTemplate) removed (core.retry.RetryTemplate accepted on some, but we drop the call)

Retry policy note. We no longer pass a retry template to the model builder — retries are wrapped one layer up at ChatClientLlmOperations via spring-retry, which is the same place async streaming retries are already handled.

Per-provider rewrite map

Module What changed
embabel-agent-openai OpenAiCompatibleModelFactory rewritten; builds OpenAIClient via OpenAIOkHttpClient.builder(). Ctor parameters restClientBuilder / webClientBuilder / completionsPath / embeddingsPath / retryTemplate kept as @Suppress("UNUSED_PARAMETER") no-ops for source compatibility with the 5 downstream subclasses. OpenAiEmbeddingModel switched to the new 4-arg ctor (OpenAIClient, MetadataMode, Options, ObservationRegistry).
embabel-agent-anthropic-autoconfigure AnthropicModelFactory rewritten; builds AnthropicClient via AnthropicOkHttpClient.builder() (chosen over AnthropicSetup.setupSyncClient to avoid the SDK's implicit ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY env-var precedence — our factory's caller has already resolved env vs. property).
embabel-agent-dockermodels-autoconfigure DockerLocalModelsConfig rewritten; same migration off OpenAiApi.Builder() to OpenAIOkHttpClient.builder(). Uses apiKey("no-auth") placeholder since the openai-java SDK rejects null/blank keys but the local Docker server doesn't require auth.
embabel-agent-bedrock-autoconfigure BedrockModelsConfig switched from Jackson 2 ObjectMapper to Jackson 3 tools.jackson.databind.json.JsonMapper.builder().build().
embabel-agent-deepseek-autoconfigure Dropped redundant .retryTemplate(RetryTemplate) builder call (Spring AI 2.0 expects org.springframework.core.retry.RetryTemplate now; we wrap with spring-retry elsewhere).
embabel-agent-google-genai-autoconfigure Pass-through RetryTemplate arg now an empty org.springframework.core.retry.RetryTemplate() (positional, can't drop).
embabel-agent-mistral-ai-autoconfigure Dropped redundant .retryTemplate(...) (same as deepseek).

Provider-specific package promotions

Spring AI 2.0 promoted several types out of their old .api subpackage:

Old New
org.springframework.ai.anthropic.api.AnthropicCacheTtl org.springframework.ai.anthropic.AnthropicCacheTtl
org.springframework.ai.anthropic.api.AnthropicCacheOptions org.springframework.ai.anthropic.AnthropicCacheOptions
org.springframework.ai.anthropic.api.AnthropicCacheStrategy org.springframework.ai.anthropic.AnthropicCacheStrategy

Provider-specific config rewrites

  • Anthropic thinking config. AnthropicApi.ChatCompletionRequest.ThinkingConfig(ThinkingType.ENABLED, budget) is gone. Use the first-class builder methods: AnthropicChatOptions.builder().thinkingEnabled(tokenBudget.toLong()) or .thinkingDisabled().
  • Anthropic native usage. AnthropicApi.Usage (with int accessors) → com.anthropic.models.messages.Usage (with Optional<Long> accessors). UsageExtensions preserves the Int? return shape via .orElse(null)?.toInt() so call sites are unchanged.
  • OpenAI moderation. OpenAiModerationApi is gone — moderation goes through OpenAiModerationModel.builder().openAiClient(client).build().
  • OpenAI HTTP overrides. completionsPath / embeddingsPath are no longer honoured by the SDK — bake the path into baseUrl instead. The factory logs a warning if these are set.
  • OpenAI custom headers. OpenAiChatOptions.Builder.httpHeaders(Map) renamed to .customHeaders(Map).

3.2 Spring Boot 4 module splits

Boot 4 split several umbrella starters into smaller modules. Each split required an explicit dep declaration where we used the symbol:

Symbol Previously Now in module Where we added the dep
JacksonAutoConfiguration spring-boot-autoconfigure spring-boot-jackson embabel-agent-bedrock-autoconfigure + embabel-agent-openai-autoconfigure test scope
ObservationRegistryCustomizer spring-boot-actuator-autoconfigure spring-boot-micrometer-observation embabel-agent-observability-autoconfigure
SecurityAutoConfiguration spring-boot-autoconfigure spring-boot-security embabel-agent-mcpserver-security-autoconfigure (test) — note no .servlet segment in the new FQN
OAuth2ResourceServerAutoConfiguration spring-boot-autoconfigure spring-boot-security-oauth2-resource-server embabel-agent-mcpserver-security-autoconfigure (test)
@AutoConfigureMockMvc spring-boot-test-autoconfigure spring-boot-webmvc-test embabel-agent-webmvc + embabel-agent-a2a (test)
Jackson2ObjectMapperBuilder available by default removed Build mapper via jacksonObjectMapper() directly
aspectjweaver pulled by spring-boot-starter-aop direct dep Boot 4 renamed the starter

3.3 Jackson 2 → Jackson 3 migration

Jackson 3 is a hard fork with two breaking themes:

Package rebrand. com.fasterxml.jackson.databind/module/dataformat/...tools.jackson.databind/module/dataformat/.... Annotations stayed at com.fasterxml.jackson.annotation.* (per Jackson team's note: "Annotations remain at Jackson 2.x group id").

Immutability. ObjectMapper is no longer mutable — you can't call .registerModule(...) on a constructed instance to chain modules. Use JsonMapper.builder().addModule(...).build() (or call helpers like jacksonObjectMapper() that return a pre-configured mapper).

Concern Migration
Kotlin module jacksonObjectMapper() from tools.jackson.module.kotlin already wires up KotlinModule — drop .registerKotlinModule()
Java Time (jsr310) Built into databind 3 — drop the jackson-datatype-jsr310 dep and the .registerModule(JavaTimeModule()) call
Custom deserializers JsonDeserializerValueDeserializer; ContextualDeserializer merged in
Field/property names fieldName() / fieldNames()propertyName() / propertyNames()
Feature relocations WRITE_DATES_AS_TIMESTAMPS moved to DateTimeFeature
Lenient mapper Build via JsonMapper.builder().lenientMode(true).build() pattern (no in-place .rebuild())
Exception types Unserializable graphs now throw IllegalArgumentException / JacksonException instead of IOException — loosened test assertions
ArrayNode iteration ArrayNode.asString() semantics changed; flattened in two test sites
Schema generation victools/jsonschema-generator bumped to 5.0.0 (Jackson 3 native, pulled transitively by Spring AI 2.0); ObjectNode.deepCopy() returns ObjectNode directly (no type arg); schema required field may now nest arrays — flattened in two test sites

3.4 Spring Framework 7 / Kotlin K2 / JSpecify

Spring 7, Spring AI 2.0, Jackson 3, and JUnit 6 all moved to @NullMarked JSpecify packages. With Kotlin 2.2 K2's strict type-inference, this cascades non-null T : Any upper bounds into every override of those APIs — breaking the agent platform's many <T> / <O> signatures.

Decision: suppress JSpecify enforcement rather than do an Any-bound sweep.

<!-- embabel-build/pom.xml, kotlin-maven-plugin args -->
<arg>-Xjspecify-annotations=ignore</arg>

This keeps the existing <T> / <O> signatures everywhere. Where the boundary couldn't be avoided (Spring AI's StructuredOutputConverter etc.), we kept <T : Any> bounds in 7 converter classes and added explicit Class<Any> casts at the 4 converter construction sites that build them from unbounded <O> enclosing methods.

Other small Spring 7 fallouts: AutoRegistration.ApplicationListener<ContextRefreshedEvent> dropped its redundant ? per Spring 7's stricter signatures.

3.5 Spring AI 2.0 ChatClient plumbing

  • ChatClient.options() now takes ChatOptions.Builder<*> — we bake chatOptions into the Prompt itself (preserves the ToolCallingChatOptions subtype through the advisor chain).
  • Tool callbacks are embedded via ToolCallingChatOptions.Builder so they survive the chat-model-defaults merge in addition to the spec-level .toolCallbacks() call (two paths to be safe).
  • Generation.getResult() / Message.getText() are now nullable — added the required !! / ?: dereferences at call sites.
  • Usage is non-null in Spring AI 2.0 — updated test mocks.
  • StructuredOutputConverter added getJsonSchema()JacksonOutputConverter exposes it via a private val.
  • ModelOptionsUtils.jsonToMap removed — replaced with objectMapper.readValue.
  • Tool-callback assertions: 3 tests had to be relaxed because Spring AI 2.0 routes spec toolCallbacks through ToolCallAdvisor internally rather than mirroring them onto the final prompt.options seen by ChatModel.call().

3.6 MCP SDK 2.0

  • McpSyncClientCustomizer + McpAsyncClientCustomizer unified into McpClientCustomizer (single interface, sync/async via separate methods).
  • McpSchema.Resource ctor gained title / size / meta params — SyncResourceSpecificationFactory passes name as title, 0L size, null meta.
  • clientCapabilities is non-null in test mocks now.

3.7 BOM hygiene

  • openai-java version bumped 4.16.14.36.0 (matches what spring-ai-openai 2.0.0-M8 is compiled against) and moved from test scope to default (compile) scope so OpenAIClient is available on the main classpath.
  • openai-java-client-okhttp added to dependencyManagement (same version).
  • Fixed a ${openai-java.version&gt;} HTML-escape typo in embabel-build-dependencies/pom.xml that broke variable resolution.

4. Per-module breakdown

The table below summarizes everything touched. Modules not listed compiled without changes.

Core platform

Module Changes
embabel-agent-api ChatClientLlmOperations: spring-retry pattern preserved (.execute<X, DatabindException>, RetrySynchronizationManager.getContext()?.retryCount + 1); chatOptions baked into Prompt + chain .toolCallbacks(springAiToolCallbacks); Class<Any> casts on converter chains. SpringAiLlmMessageStreamer, StreamingChatClientOperations: same dual-path tool callback pattern. 7 converter files keep <T : Any> bounds. spring-retry 2.0.12 added as direct dep.
embabel-agent-openai OpenAiCompatibleModelFactory fully rewritten — see §3.1. POM pins openai-java + openai-java-client-okhttp 4.36.0 compile-scope and adds spring-webflux back (Spring AI 2.0 dropped its webflux dep).
embabel-agent-mcp/embabel-agent-mcpserver SyncResourceSpecificationFactory: McpSchema.Resource ctor — added name as title, 0L size, null meta.

Autoconfigure

Module Changes
embabel-agent-platform-autoconfigure AgentPlatformConfiguration.embabelJacksonObjectMapper() returns jacksonObjectMapper() directly (Jackson2ObjectMapperBuilder removed in Boot 4); dropped defaultCandidate = false for autowire.
embabel-agent-shell-autoconfigure AgentShellProperties: javax.validationjakarta.validation (3 imports).
embabel-agent-observability-autoconfigure MicrometerTracingAutoConfiguration: ObservationRegistryCustomizer import → o.s.b.micrometer.observation.autoconfigure.*; added spring-boot-micrometer-observation dep; gated defaultTracingObservationHandler with @ConditionalOnBean(Tracer.class). HttpBodyCachingFilter: ContentCachingRequestWrapper(request, Integer.MAX_VALUE) 2-arg ctor (prod + 3 tests).
embabel-agent-mcpserver-security-autoconfigure Added spring-boot-security + spring-boot-security-oauth2-resource-server test deps. AgentMcpServerSecurityAutoConfigurationTest imports: o.s.b.security.autoconfigure.SecurityAutoConfiguration (no .servlet) + o.s.b.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration.
embabel-agent-a2a Added spring-boot-webmvc-test test dep. A2AWebIntegrationTest: @AutoConfigureMockMvc import relocated to o.s.b.webmvc.test.autoconfigure.
embabel-agent-webmvc Added spring-boot-webmvc-test test dep. 5 test files: same MockMvc import relocation.

Model autoconfigure

Module Changes
embabel-agent-openai-autoconfigure Calls go through rewritten OpenAiCompatibleModelFactory. Added spring-boot-jackson test dep for JacksonAutoConfiguration relocation. LLMOpenAiGuardRailsIntegrationIT: OpenAiModerationApi removed → OpenAiModerationModel.builder().openAiClient(...). Two ITs: JacksonAutoConfiguration import relocated.
embabel-agent-anthropic-autoconfigure AnthropicModelFactory + AnthropicModelsConfig + AnthropicCachingConfig + UsageExtensions rewritten (see §3.1). Test fixtures: AnthropicOptionsConverterTest updated for ThinkingConfigParam.isEnabled() / isDisabled() as functions; UsageExtensionsTest gets an anthropicUsage(...) helper that wraps Usage.builder() to keep the prior positional ergonomics.
embabel-agent-bedrock-autoconfigure BedrockModelsConfig: ObjectMapperJsonMapper. Added spring-boot-jackson test dep. AgentBedrockAutoConfigurationIT: JacksonAutoConfiguration import relocated.
embabel-agent-dockermodels-autoconfigure DockerLocalModelsConfig rewritten off OpenAiApi.Builder().
embabel-agent-deepseek-autoconfigure Dropped redundant .retryTemplate(properties.retryTemplate("deepseek-${modelDef.modelId}")) from DeepSeekChatModel.builder() chain.
embabel-agent-google-genai-autoconfigure Pass-through RetryTemplate arg now empty o.s.core.retry.RetryTemplate() (positional).
embabel-agent-mistral-ai-autoconfigure Dropped redundant .retryTemplate(...) (same as deepseek).

Reactor wiring

File Change
Root pom.xml Re-enabled embabel-agent-openai (briefly disabled mid-migration while the OpenAI rewrite was outstanding).
embabel-agent-autoconfigure/pom.xml Re-enabled 7 model autoconfigure modules disabled mid-migration: openai*, openai-custom*, anthropic*, lmstudio*, gemini*, dockermodels*, minimax*.
embabel-agent-starters/pom.xml Re-enabled the 7 matching starter modules.

BOM (embabel-build)

File Change
embabel-build-dependencies/pom.xml openai-java.version 4.16.1 → 4.36.0; dropped test scope; added openai-java-client-okhttp managed entry; fixed &gt; HTML-escape typo.
embabel-build/pom.xml (parent) Added -Xjspecify-annotations=ignore to kotlin-maven-plugin args.

5. Behavioural notes & gotchas

5.1 Retry policy is now in one place

Before Spring AI 2.0 we had two retry layers:

  1. The Spring AI *ChatModel.Builder.retryTemplate(...) — pinned via RetryProperties.retryTemplate("<provider>-<model>").
  2. The ChatClientLlmOperations wrapper — RetryTemplate.execute<X, DatabindException>.

Spring AI 2.0 dropped layer 1 (or changed its type to core.retry.RetryTemplate, which we don't want to migrate to). The RetryProperties config keys are still honoured — they just now configure layer 2 only. Effective retry behaviour is unchanged.

5.2 No more Spring RestClient / WebClient in the OpenAI / Anthropic HTTP path

The openai-java and anthropic-java SDKs use OkHttp internally. Spring's RestClient.Builder / WebClient.Builder are no longer wired into the HTTP path — they're accepted as ctor params on OpenAiCompatibleModelFactory and AnthropicModelFactory purely for source compatibility with the 5 + 1 downstream subclasses that pass them positionally. The params are @Suppress("UNUSED_PARAMETER") no-ops.

If you were using these to install custom interceptors, request factories, or observation registries on the LLM HTTP path, that integration is gone for these two providers (other providers — Mistral, DeepSeek, Bedrock — still use Spring HTTP clients).

5.3 OpenAI completionsPath / embeddingsPath no longer honoured

The openai-java SDK uses fixed endpoint paths (/v1/chat/completions, /v1/embeddings). The completionsPath / embeddingsPath ctor params are still accepted but log a warning at startup and are ignored. If you were targeting an OpenAI-compatible provider with a non-standard endpoint path (e.g. Groq's /openai/v1/chat/completions), bake the full path into baseUrl instead.

5.4 OpenAI no-auth placeholder

The openai-java SDK rejects null/blank API keys at build time even if the backing server doesn't require auth. OpenAiCompatibleModelFactory passes the placeholder string "no-auth" when apiKey is null, so local OpenAI-compatible servers (LM Studio, Docker Models) still work.

5.5 ObjectMapper is immutable in Jackson 3

Any code that does ObjectMapper().registerModule(...) or mapper.registerKotlinModule() post-construction will not compile in Jackson 3. Use one of:

// Pre-configured with Kotlin module
import tools.jackson.module.kotlin.jacksonObjectMapper
val om = jacksonObjectMapper()

// Or build explicitly
import tools.jackson.databind.json.JsonMapper
val om = JsonMapper.builder()
    .addModule(myCustomModule)
    .build()

JSR-310 Java Time support is built into Jackson 3 databind — no separate jackson-datatype-jsr310 dep, no .registerModule(JavaTimeModule()).

5.6 Tool callback assertions in tests may need relaxing

Spring AI 2.0 routes spec-level .toolCallbacks(...) through ToolCallAdvisor internally rather than mirroring them onto the final prompt.options.toolCallbacks visible at ChatModel.call(). Three test assertions had to be relaxed with comments. If you have similar tests, verify intent and either:

  • check callbacks via the spec recorder, or
  • assert on the call's effect rather than the prompt's shape.

5.7 assertThrows<X?> no longer compiles

JUnit 6 with @NullMarked rejects nullable type arguments on assertThrows. Drop the ?:

// Before
assertThrows<IllegalStateException?> { ... }
// After
assertThrows<IllegalStateException> { ... }

5.8 Jackson 3 Kotlin module: missing primitives no longer get zero-defaulted

Jackson 2's kotlin-module-kotlin quietly defaulted missing Int / Long / Double (etc.) constructor parameters to their primitive zero values via KotlinValueInstantiator's lenient lookup. Jackson 3 is strict: any non-nullable Kotlin parameter that's missing from the input AND lacks an explicit default throws:

MismatchedInputException: Missing required creator property 'birthYear' (index 3)

If you relied on the old behaviour, the fix is per-class: add an explicit default to the data class.

// Jackson 2: missing birthYear silently became 0
data class Person(val name: String, val birthYear: Int)

// Jackson 3: add the default explicitly
data class Person(val name: String, val birthYear: Int = 0)

NamedEntityHydrationTest.toTypedInstance uses default for missing primitive field was renamed and updated to verify the new graceful-null contract — the production NamedEntityData.toTypedInstance already caught the exception and returned null.

5.9 Micrometer Tracing: observation names no longer kebab-cased

Micrometer Tracing 1.x's OtelTracer bridge normalized observation names to kebab-case before handing them to OpenTelemetry ("ChatClient" → span name "chat-client"). Micrometer Tracing 1.15+ (the version Spring Boot 4 brings in) dropped this normalization — observation names now flow through to OTel span names verbatim.

If you have tests asserting on span names by hard-coded kebab-case, update them to the raw observation name. ObservabilityToolCallbackIntegrationTest in embabel-agent-observability-autoconfigure was one such site.

5.10 Spring AI 2.0 Bedrock proxy chat autoconfig requires a ToolCallingManager bean

Spring AI 2.0's org.springframework.ai.model.bedrock.converse.autoconfigure.BedrockConverseProxyChatAutoConfiguration adds a ToolCallingManager autowire to its bedrockProxyChatModel factory method (parameter 4). Spring AI 2.0 ships no autoconfig that creates a default ToolCallingManager bean — apps either expose one explicitly or fall back to building one inline per call site (which embabel does in every model factory).

AgentBedrockAutoConfiguration now exposes a default @Bean @ConditionalOnMissingBean ToolCallingManager, satisfying the Spring AI Bedrock proxy chat autoconfig (and any other Spring AI bean that wants to wire in the same shared manager). Downstream apps that already provide their own ToolCallingManager bean will keep using theirs — the @ConditionalOnMissingBean defers to user config. If a future Spring AI release ships a default ToolCallingManager autoconfig, we'll drop this bean.

5.11 Kotlin null-checks fire on @NullMarked Java getters that actually return null

-Xjspecify-annotations=ignore (see §3.4) silences JSpecify warnings but doesn't change Kotlin K2's type inference for @NullMarked packages. Methods like OpenAiChatOptions.getTemperature() are bytecode-typed java.lang.Double but Kotlin treats them as non-null Double because the containing package is @NullMarked. Direct property access on a runtime-null return throws:

java.lang.NullPointerException: getTemperature(...) must not be null

Two simple workarounds — both safe, both bypass the inferred-non-null check:

// 1) Read through a nullable local
val temperature: Double? = options.temperature
assertNull(temperature)

// 2) Or cast explicitly
assertNull(options.temperature as Double?)

Gpt5ChatOptionsConverterTest.ignores temperature and respects non-temperature options use option 1.

5.12 openai-java 4.36 eagerly validates baseUrl

OpenAIOkHttpClient.builder().baseUrl(s).build() in openai-java 4.36 (used by Spring AI 2.0) parses the URL eagerly and routes through AzureUrlCategory.categorizeBaseUrl, which NPEs (host must not be null) on strings that don't have a valid host segment. Spring AI 1.x's OpenAiApi.Builder accepted any string. If you have tests passing sentinel strings like "foobar" to verify ctor wiring, switch them to a syntactically-valid URL like http://foobar.example (the URL doesn't need to be reachable, just parseable).

5.13 openai-java 4.x endpoint routing in mock servers

The openai-java SDK may route requests to either /v1/chat/completions or /v1/responses (the new "Responses API") depending on which method on the client you call (client.chat().completions().create(...) vs. client.responses().create(...)). Spring AI 2.0's OpenAiChatModel uses chat completions, but mock-HTTP tests that rely on a specific path are brittle to internal SDK routing changes. Prefer a wildcard root handler:

server.createContext("/") { exchange ->
    // respond to any path the SDK chose
}

OpenAiCompatibleModelFactoryBuildValidatedTest uses this pattern after seeing 404s from /v1/chat/completions-specific handlers under openai-java 4.36.

5.14 anthropic-java Usage.Builder requires every nested Optional field be touched

com.anthropic.models.messages.Usage.Builder.build() calls Check.checkRequired(name, ...) on every nested object field (not just the scalars). That means even fields typed Optional<...> must be explicitly assigned — default-uninitialized is a build-time failure:

java.lang.IllegalStateException: `cacheCreation` is required, but was not set
    at com.anthropic.core.Check.checkRequired(Check.kt:12)
    at com.anthropic.models.messages.Usage$Builder.build(Usage.kt:465)

Optional.empty() satisfies the check — the SDK distinguishes "never set" from "set to absent":

AnthropicSdkUsage.builder()
    .inputTokens(100L)
    .outputTokens(50L)
    .cacheCreationInputTokens(0L)
    .cacheReadInputTokens(0L)
    // Required even though the type is Optional<...>:
    .cacheCreation(Optional.empty())
    .inferenceGeo(Optional.empty())
    .serverToolUse(Optional.empty())
    .serviceTier(Optional.empty())
    .build()

UsageExtensionsTest.anthropicUsage(...) follows this pattern. Any other code constructing a test/fixture Usage needs the same treatment.

5.15 Spring 7 RestClient Kotlin body<T>() adds a .hint(KType, …) step

Spring 7's org.springframework.web.client.body reified-Kotlin extension now invokes .hint(KType, T) on ResponseSpec before calling .body(...), so HTTP message converters can pick up the Kotlin reified-generic type (useful for things like body<List<MyType>>() which lose the inner type to Java erasure otherwise).

Production code is unaffected — the runtime SDK still does the right thing. But MockK-style test chains that stub retrieve()body(...) will break because the un-stubbed .hint(...) call returns null and the chain collapses. Add a single line to keep the chain alive:

every { mockResponseSpec.hint(any(), any()) } returns mockResponseSpec

OllamaModelsConfigTest had five failing tests for this reason; the stub above fixes all of them.

5.16 Bedrock uses JsonMapper

BedrockModelsConfig now uses tools.jackson.databind.json.JsonMapper.builder().build() explicitly. Spring AI 2.0's Bedrock client expects a Jackson 3 JsonMapper, not a generic Jackson 2 ObjectMapper.


6. Migration cheat sheet for downstream apps

If you're an app on top of embabel-agent that just consumes the platform, you'll typically need:

Symptom Fix
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration missing Add spring-boot-jackson dep; import becomes o.s.b.jackson.autoconfigure.JacksonAutoConfiguration
javax.validation.* not found Switch to jakarta.validation.* (Boot 3 → 4 cleanup completion)
com.fasterxml.jackson.databind.ObjectMapper not found Switch to tools.jackson.databind.ObjectMapper (or just call jacksonObjectMapper() from tools.jackson.module.kotlin)
ObjectMapper.registerKotlinModule() returns void, can't chain Replace with jacksonObjectMapper() — the helper returns a pre-configured mapper
JavaTimeModule import unresolved Drop it — Jackson 3 builds JSR-310 support in
OpenAiApi / AnthropicApi not found Use the vendor SDK directly: OpenAIOkHttpClient.builder() / AnthropicOkHttpClient.builder()
OpenAiChatModel.Builder.defaultOptions(...) not found Rename to .options(...)
AnthropicApi.ChatCompletionRequest.ThinkingConfig not found Use AnthropicChatOptions.Builder.thinkingEnabled(tokens.toLong()) / thinkingDisabled()
org.springframework.ai.anthropic.api.AnthropicCache* not found Drop the .api segment from the import
assertThrows<X?> won't compile Drop the ?
Tracer bean not found at startup Add a tracing bridge (micrometer-tracing-bridge-brave or micrometer-tracing-bridge-otel) OR rely on the new @ConditionalOnBean(Tracer.class) on defaultTracingObservationHandler
@AutoConfigureMockMvc import unresolved in tests Add spring-boot-webmvc-test dep; import moves to o.s.b.webmvc.test.autoconfigure
McpSyncClientCustomizer / McpAsyncClientCustomizer unresolved Replace with unified McpClientCustomizer

7. Known issues

7.1 AnthropicModelsConfig runtime constructor exception

When examples-java is launched with the anthropic-models Maven profile active (ANTHROPIC_API_KEY set), startup fails:

Error creating bean with name 'com.embabel.agent.config.models.anthropic.AnthropicModelsConfig':
  Failed to instantiate: Constructor threw exception

The truncated log doesn't include the Caused by chain. Diagnosis is open (task #37). Run with --debug and capture the full stack trace; the most likely candidates are an SDK-level validation in AnthropicOkHttpClient.build() or an unintended eager init path on the autoconfigure class.

7.2 Disabled-then-restored modules

The middle commit (dd432fa) temporarily disabled the following modules from the reactor while the OpenAI / Anthropic rewrites were outstanding:

  • embabel-agent-openai
  • embabel-agent-{openai,openai-custom,anthropic,lmstudio,gemini,dockermodels,minimax}-autoconfigure
  • The matching embabel-agent-starter-* modules

All are re-enabled in the final commit (f681b4c). If you cherry-pick commits out of order, you may need to re-enable manually — search the parent POMs for TEMPORARILY DISABLED markers (none should remain on 2.0.0 HEAD).

7.3 Spring BeanPostProcessorChecker warnings at startup

Boot 4 / Spring 7 surfaced several pre-existing post-processor-ordering warnings that were silent before. Examples:

Bean 'embeddingTrackingConfiguration' is not eligible for getting processed by all BeanPostProcessors
Bean 'org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration' is not eligible ...

These are noisy but not fatal. Fix by declaring the offending BeanPostProcessor factory methods as static (per the warning's hint) or marking the bean with BeanDefinition.ROLE_INFRASTRUCTURE.


8. References

Clone this wiki locally