jdaugherty opened a new pull request, #16599:
URL: https://github.com/apache/grails-core/pull/16599
Target: `8.1.x`. Adds a Spring dependency (`spring-ai-bom` 2.0.1), so the
longer review policy for Spring dependency changes applies.
## Why a Grails module rather than "just add Spring AI"
Spring AI is already a Spring Boot integration, and a Grails 8 application
can add a provider starter today and inject a `ChatClient.Builder` into a
service. This PR is about the places where that stops being true in a Grails
application, and about the conventions Grails adds that Spring AI leaves to the
host framework.
1. **Streaming did not work from a controller.** Every Spring AI example
streams a `Flux` from `stream()`. Grails actions go through
`UrlMappingsInfoHandlerAdapter`, which handles a map, a `ModelAndView` or null;
a returned `Flux` rendered nothing. Spring MVC's return value handlers are not
involved.
2. **Unit tests could not use it.** Spring AI ships no stub `ChatModel`, and
`GrailsApplicationBuilder` loads only `org.grails.*` auto-configurations in
unit tests, so a service injecting `ChatClient.Builder` failed under
`ServiceUnitTest`. Integration tests needed a real API key to boot.
3. **Version management landed on each application.** Spring AI's BOM
manages only its own artifacts, and its Boot baseline moves every minor (2.0 is
Boot 4.0/4.1, 2.1 is already Boot 4.2). Importing the BOM into `grails-bom`
makes "which Spring AI goes with my Grails" a project decision, and
`validateDependencyVersions` and `buildHealth` police the transitives once.
4. **Defaults suited to a web application.** Spring AI retries a failed call
ten times with backoff up to three minutes, and logs neither prompts nor
completions. Grails environments let the plugin ship three retries everywhere
and prompt logging in development only, which Spring profiles do not express as
naturally.
The streaming support is general and lives in grails-async, not grails-ai:
any Reactive Streams `Publisher` returned from an action streams as server-sent
events, and grails-ai only teaches it to render chat responses.
The plugin stays provider-neutral. Each Spring AI provider is its own module
with its own SDK, auto-configuration and credentials, and every OpenAI model
kind auto-configures by default, so bundling one provider would force every
other application to turn it off per model kind. Forge picks the provider at
creation time instead.
## What is included
- **grails-async**: `PublisherActionResultTransformer` streams a returned
`Publisher` through Spring MVC's `DeferredResult` lifecycle (same timeouts and
thread release as a returned `Promise`), item by item with back-pressure.
`grails.async.web.StreamItemRenderer` SPI with text and Jackson defaults;
Spring `ServerSentEvent` honoured for ids, names and retry; a terminal
`complete` event so `EventSource` clients do not reconnect; failures reported
as an `error` event on the committed response. Settings
`grails.async.streaming.enabled` and `grails.async.streaming.timeout`,
documented in the properties reference.
- **grails-ai**: the chat client and the auto-configuration every provider
shares, `plugin.yml` defaults, and a renderer that turns streamed
`ChatResponse`s into text. Thin by design.
- **grails-testing-support-ai**: `AiUnitTest` trait with a scripted chat
model, a deterministic embedding model and an in-memory vector store, wired
through a Spock global extension like the JSON views trait; in the test
environment the stubs become the primary models and `spring.ai.model.chat`,
`spring.ai.model.embedding` and `spring.ai.vectorstore.type` default to `none`,
so no key and no database are needed. `grails.ai.stub.enabled=false` opts back
into the real provider.
- **Forge**: an `AI` category with `grails-ai`, `spring-ai-openai`,
`spring-ai-anthropic` and `spring-ai-ollama`. Provider features write key
placeholders such as `${OPENAI_API_KEY}`, never values.
- **Docs**: a "Using AI in Your Application" chapter (installation, every
model provider with its starter and Spring AI reference link, configuration,
chat client, streaming, testing), kept distinct from and cross-linked with the
Agent Skills section; a "Streaming Responses" page in the async chapter; what's
new entries; feature lists on the command line pages. Spring AI links use a
version attribute derived from the BOM, so they follow the pinned line.
- **grails-test-examples/ai**: functional tests against a running server
with the Ollama starter stubbed out.
## Behaviour change for existing applications
An action that already returned a `Publisher` used to render its view; it
now streams. Set `grails.async.streaming.enabled: false` to restore the old
behaviour. No other existing behaviour changes.
## Dependencies and licensing
`spring-ai-bom` 2.0.1 is imported as a platform. StringTemplate and the
ANTLR 3 runtime, pulled in by Spring AI's prompt templates, declare "The BSD
License" with the antlr.org 3-clause text, so `SbomPlugin` maps them to
BSD-3-Clause like the existing jline entries.
## Verification
| Suite | Result |
|---|---|
| grails-async | 82 passed |
| grails-ai | 15 passed |
| grails-testing-support-ai | 44 passed |
| grails-test-examples/ai, unit and functional over Tomcat | 14 passed |
| Forge `GrailsAiSpec` | 10 passed |
| codeStyle (modules and Forge), validateDependencyVersions,
validateBomProperties, RAT, repository conventions, guide build | clean |
The functional tests cover emission from a timer thread after the container
thread returned, JSON and `ServerSentEvent` items, the `error` and `complete`
events, a chat answer through the stub synchronously and streamed, the
`plugin.yml` defaults reaching Spring AI's `SpringAiRetryProperties`, and the
Ollama provider backing off without a server. The full aggregate gate was left
to CI.
## Follow-ups, not in this PR
Data-source bridges so pgvector and JDBC chat memory find the GORM data
source; a GORM-backed chat memory repository; constraints to JSON schema for
tool parameters and structured output; `@Tool` discovery on services with the
security context in `ToolContext`; GSP prompt templates; MCP exposure of
services. Open question for reviewers: keep the plugin provider-neutral and add
a startup warning when no `ChatModel` bean exists, or bundle the OpenAI starter.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]