Skip to content

Commit a85ac4e

Browse files
Merge branch 'master' into spring-ai/provider-options-passthrough
2 parents 48d54c9 + 79c9fe5 commit a85ac4e

8 files changed

Lines changed: 556 additions & 30 deletions

File tree

temporal-spring-ai/README.md

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ ActivityChatModel chatModel = ActivityChatModel.forDefault(
6464
.build());
6565
```
6666

67+
For configuration-driven per-model overrides, declare a `ChatModelActivityOptions` bean and auto-configuration wires the map into the plugin. A key equal to `ChatModelTypes.DEFAULT_MODEL_NAME` (the literal `"default"`) acts as a global catch-all: any chat model that lacks a bean-name-specific entry — including models contributed by third-party starters that your application did not declare directly — picks up that entry.
68+
69+
```java
70+
@Bean
71+
ChatModelActivityOptions chatModelActivityOptions() {
72+
ActivityOptions fiveMinute = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
73+
.setStartToCloseTimeout(Duration.ofMinutes(5))
74+
.build();
75+
return new ChatModelActivityOptions(Map.of(
76+
ChatModelTypes.DEFAULT_MODEL_NAME, fiveMinute, // global baseline
77+
"claude", ActivityOptions.newBuilder(fiveMinute).setTaskQueue("claude-heavy").build())); // override
78+
}
79+
```
80+
81+
Keys that neither match a registered ChatModel bean name nor equal `"default"` cause plugin construction to fail, so a typo surfaces at startup.
82+
6783
`ActivityMcpClient.create()` / `create(ActivityOptions)` work the same way with a 30-second default timeout.
6884

6985
The Temporal UI labels chat and MCP rows with a short Summary (`chat: <model>`, `mcp: <client>.<tool>`). `ActivityChatModel` and `ActivityMcpClient` are constructed only via these factories — there is no public constructor, so users can't accidentally end up in a code path that skips UI labels. Prompt text is deliberately not included in chat summaries to avoid leaking user input (which may contain PII, credentials, or other sensitive data) into workflow history and server logs.
@@ -187,24 +203,6 @@ Raw `byte[]` media gets serialized into every chat activity's input *and* result
187203

188204
Override the cap by setting the system property `io.temporal.springai.maxMediaBytes` before your worker starts (pass a positive integer; `0` disables the check). For anything larger than a small thumbnail, the URI route is the right answer — have an activity write the bytes to blob storage, then pass only the URL into the conversation.
189205

190-
## Activity options and retry behavior
191-
192-
`ActivityChatModel.forDefault()` and `ActivityChatModel.forModel(name)` create the chat activity stub with sensible defaults: a 2-minute start-to-close timeout, 3 attempts, and `org.springframework.ai.retry.NonTransientAiException` + `java.lang.IllegalArgumentException` classified as non-retryable so a bad API key or invalid prompt fails fast.
193-
194-
Override with `ActivityChatModel.forModel(name, ActivityOptions)`:
195-
196-
```java
197-
ActivityOptions opts = ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
198-
.setStartToCloseTimeout(Duration.ofMinutes(10))
199-
.setTaskQueue("reasoning-models")
200-
.build();
201-
ActivityChatModel chatModel = ActivityChatModel.forModel("reasoning", opts);
202-
```
203-
204-
For repeated per-model overrides, declare a `ChatModelActivityOptions` bean and auto-configuration wires the map into the plugin. See that class's javadoc for the pattern.
205-
206-
`ActivityMcpClient.create()` / `create(ActivityOptions)` behave the same way with a 30-second default timeout.
207-
208206
## Known limitations
209207

210208
- **Streaming (`chatClient.stream(...)`)** — not currently supported. Use `.call()` instead.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.temporal.springai.autoconfigure;
2+
3+
import io.temporal.activity.ActivityOptions;
4+
import io.temporal.springai.model.ActivityChatModel;
5+
import io.temporal.springai.plugin.SpringAiPlugin;
6+
import java.util.Map;
7+
8+
/**
9+
* Spring-bean wrapper for the per-model {@link ActivityOptions} map consumed by {@link
10+
* SpringAiPlugin}. Inject this type into your config by declaring a bean that returns an instance
11+
* of it:
12+
*
13+
* <pre>{@code
14+
* @Bean
15+
* ChatModelActivityOptions chatModelActivityOptions() {
16+
* return new ChatModelActivityOptions(Map.of(
17+
* "reasoning", ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
18+
* .setStartToCloseTimeout(Duration.ofMinutes(15))
19+
* .build()));
20+
* }
21+
* }</pre>
22+
*
23+
* <p>The wrapper exists so auto-configuration can inject your options by <em>type</em>, not by bean
24+
* name. Spring's default behavior for {@code Map<String, T>} injection is to collect every bean of
25+
* type {@code T} into a map keyed by bean name — which for a generic type like {@code Map<String,
26+
* ActivityOptions>} would sweep in any unrelated {@link ActivityOptions} bean you have in the
27+
* context. Having a dedicated wrapper type avoids that collision entirely.
28+
*
29+
* <p>Keys are chat-model bean names; values are the full {@link ActivityOptions} to use when {@link
30+
* ActivityChatModel#forModel(String)} / {@link ActivityChatModel#forDefault()} build the stub for
31+
* that model. Use {@link ActivityChatModel#defaultActivityOptions()} as the baseline so the
32+
* plugin's non-retryable-error classification is preserved.
33+
*
34+
* <p>A key equal to {@link io.temporal.springai.model.ChatModelTypes#DEFAULT_MODEL_NAME} (the
35+
* literal {@code "default"}) acts as a global catch-all: any chat model that lacks a
36+
* bean-name-specific entry — including models contributed by third-party starters that your
37+
* application did not declare directly — picks up the {@code "default"} entry. Keys that neither
38+
* match a registered ChatModel bean nor equal {@code "default"} cause plugin construction to fail.
39+
*
40+
* @param byModelName per-model-bean-name {@link ActivityOptions} overrides; may be empty
41+
*/
42+
public record ChatModelActivityOptions(Map<String, ActivityOptions> byModelName) {
43+
44+
public ChatModelActivityOptions {
45+
byModelName = byModelName == null ? Map.of() : Map.copyOf(byModelName);
46+
}
47+
48+
/** Returns an empty registry. */
49+
public static ChatModelActivityOptions empty() {
50+
return new ChatModelActivityOptions(Map.of());
51+
}
52+
}

temporal-spring-ai/src/main/java/io/temporal/springai/autoconfigure/SpringAiTemporalAutoConfiguration.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.temporal.springai.autoconfigure;
22

3+
import io.temporal.activity.ActivityOptions;
34
import io.temporal.springai.plugin.SpringAiPlugin;
45
import java.util.Map;
56
import org.springframework.ai.chat.model.ChatModel;
@@ -28,9 +29,32 @@
2829
name = {"org.springframework.ai.chat.model.ChatModel", "io.temporal.worker.Worker"})
2930
public class SpringAiTemporalAutoConfiguration {
3031

32+
/**
33+
* Builds the {@link SpringAiPlugin}. Picks up an optional {@link ChatModelActivityOptions} bean
34+
* and forwards its map to the plugin as per-model {@link ActivityOptions} overrides. The wrapper
35+
* type exists so this auto-config can inject user options <em>by type</em> — trying to inject
36+
* {@code Map<String, ActivityOptions>} directly would trigger Spring's collection-of-beans
37+
* autowiring and sweep in any unrelated {@link ActivityOptions} bean in the context.
38+
*
39+
* <p>Example user config:
40+
*
41+
* <pre>{@code
42+
* @Bean
43+
* ChatModelActivityOptions chatModelActivityOptions() {
44+
* return new ChatModelActivityOptions(Map.of(
45+
* "reasoning", ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
46+
* .setStartToCloseTimeout(Duration.ofMinutes(15))
47+
* .build()));
48+
* }
49+
* }</pre>
50+
*/
3151
@Bean
3252
public SpringAiPlugin springAiPlugin(
33-
@Autowired Map<String, ChatModel> chatModels, ObjectProvider<ChatModel> primaryChatModel) {
34-
return new SpringAiPlugin(chatModels, primaryChatModel.getIfUnique());
53+
@Autowired Map<String, ChatModel> chatModels,
54+
ObjectProvider<ChatModel> primaryChatModel,
55+
ObjectProvider<ChatModelActivityOptions> perModelOptions) {
56+
ChatModelActivityOptions options =
57+
perModelOptions.getIfAvailable(ChatModelActivityOptions::empty);
58+
return new SpringAiPlugin(chatModels, primaryChatModel.getIfUnique(), options.byModelName());
3559
}
3660
}

temporal-spring-ai/src/main/java/io/temporal/springai/model/ActivityChatModel.java

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import io.temporal.activity.ActivityOptions;
66
import io.temporal.common.RetryOptions;
77
import io.temporal.springai.activity.ChatModelActivity;
8+
import io.temporal.springai.plugin.SpringAiPlugin;
9+
import io.temporal.springai.plugin.SpringAiPluginOptions;
810
import io.temporal.workflow.Workflow;
911
import java.net.URI;
1012
import java.net.URISyntaxException;
@@ -130,16 +132,29 @@ private ActivityChatModel(
130132
}
131133

132134
/**
133-
* Creates an ActivityChatModel for the default chat model with the plugin's default {@link
134-
* ActivityOptions} (2-minute start-to-close timeout, 3 attempts, clearly permanent AI errors
135-
* marked non-retryable).
135+
* Creates an ActivityChatModel for the default chat model.
136+
*
137+
* <p>Options resolution order:
138+
*
139+
* <ol>
140+
* <li>An entry registered on {@link SpringAiPlugin} under {@link
141+
* ChatModelTypes#DEFAULT_MODEL_NAME} in the per-model {@code ActivityOptions} map, if any.
142+
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
143+
* clearly permanent AI errors marked non-retryable).
144+
* </ol>
145+
*
146+
* <p>Callers who want to set explicit options should use {@link #forDefault(ActivityOptions)} —
147+
* explicit options bypass the registry entirely.
136148
*
137149
* <p><strong>Must be called from workflow code.</strong>
138150
*
139151
* @return an ActivityChatModel for the default chat model
140152
*/
141153
public static ActivityChatModel forDefault() {
142-
return forDefault(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
154+
ActivityOptions options =
155+
SpringAiPluginOptions.optionsFor(ChatModelTypes.DEFAULT_MODEL_NAME)
156+
.orElseGet(ActivityChatModel::defaultActivityOptions);
157+
return forDefault(options);
143158
}
144159

145160
/**
@@ -158,8 +173,21 @@ public static ActivityChatModel forDefault(ActivityOptions options) {
158173
}
159174

160175
/**
161-
* Creates an ActivityChatModel for a specific chat model by bean name with the plugin's default
162-
* {@link ActivityOptions}.
176+
* Creates an ActivityChatModel for a specific chat model by bean name.
177+
*
178+
* <p>Options resolution order:
179+
*
180+
* <ol>
181+
* <li>An entry registered on {@link SpringAiPlugin} under {@code modelName} in the per-model
182+
* {@code ActivityOptions} map, if any.
183+
* <li>An entry registered under {@link ChatModelTypes#DEFAULT_MODEL_NAME} in the per-model map,
184+
* which acts as a user-declared catch-all for models without a specific entry.
185+
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
186+
* clearly permanent AI errors marked non-retryable).
187+
* </ol>
188+
*
189+
* <p>Callers who want to set explicit options should use {@link #forModel(String,
190+
* ActivityOptions)} — explicit options bypass the registry entirely.
163191
*
164192
* <p><strong>Must be called from workflow code.</strong>
165193
*
@@ -168,7 +196,11 @@ public static ActivityChatModel forDefault(ActivityOptions options) {
168196
* @throws IllegalArgumentException if no model with that name exists (at activity runtime)
169197
*/
170198
public static ActivityChatModel forModel(String modelName) {
171-
return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
199+
ActivityOptions options =
200+
SpringAiPluginOptions.optionsFor(modelName)
201+
.or(() -> SpringAiPluginOptions.optionsFor(ChatModelTypes.DEFAULT_MODEL_NAME))
202+
.orElseGet(ActivityChatModel::defaultActivityOptions);
203+
return forModel(modelName, options);
172204
}
173205

174206
/**

temporal-spring-ai/src/main/java/io/temporal/springai/plugin/SpringAiPlugin.java

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.temporal.springai.plugin;
22

3+
import io.temporal.activity.ActivityOptions;
34
import io.temporal.common.SimplePlugin;
45
import io.temporal.springai.activity.ChatModelActivityImpl;
56
import io.temporal.springai.model.ChatModelTypes;
@@ -53,9 +54,7 @@ public class SpringAiPlugin extends SimplePlugin {
5354
* @param chatModel the Spring AI chat model to wrap as an activity
5455
*/
5556
public SpringAiPlugin(ChatModel chatModel) {
56-
super("io.temporal.spring-ai");
57-
this.chatModels = Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel);
58-
this.defaultModelName = ChatModelTypes.DEFAULT_MODEL_NAME;
57+
this(Map.of(ChatModelTypes.DEFAULT_MODEL_NAME, chatModel), null, Map.of());
5958
}
6059

6160
/**
@@ -65,6 +64,31 @@ public SpringAiPlugin(ChatModel chatModel) {
6564
* @param primaryChatModel the primary chat model (used to determine default), or null
6665
*/
6766
public SpringAiPlugin(Map<String, ChatModel> chatModels, @Nullable ChatModel primaryChatModel) {
67+
this(chatModels, primaryChatModel, Map.of());
68+
}
69+
70+
/**
71+
* Creates a new SpringAiPlugin with multiple ChatModels and per-model {@link ActivityOptions}.
72+
*
73+
* <p>Entries in {@code perModelOptions} are keyed by chat-model bean name and consulted by {@link
74+
* io.temporal.springai.model.ActivityChatModel#forModel(String)} and {@link
75+
* io.temporal.springai.model.ActivityChatModel#forDefault()}. An entry whose key equals {@link
76+
* ChatModelTypes#DEFAULT_MODEL_NAME} acts as a global catch-all: models without a
77+
* bean-name-specific entry pick it up, including models contributed by third-party starters that
78+
* the application did not declare directly. Keys that neither match a bean nor equal the
79+
* catch-all name cause plugin construction to fail, so a typo'd model name surfaces early.
80+
* Callers who pass explicit {@code ActivityOptions} to a factory bypass this map entirely.
81+
*
82+
* @param chatModels map of bean names to ChatModel instances
83+
* @param primaryChatModel the primary chat model (used to determine default), or null
84+
* @param perModelOptions per-model-name ActivityOptions overrides; may be empty
85+
* @throws IllegalArgumentException if a key in {@code perModelOptions} neither matches a
86+
* registered ChatModel bean name nor equals {@link ChatModelTypes#DEFAULT_MODEL_NAME}
87+
*/
88+
public SpringAiPlugin(
89+
Map<String, ChatModel> chatModels,
90+
@Nullable ChatModel primaryChatModel,
91+
Map<String, ActivityOptions> perModelOptions) {
6892
super("io.temporal.spring-ai");
6993

7094
if (chatModels == null || chatModels.isEmpty()) {
@@ -85,13 +109,35 @@ public SpringAiPlugin(Map<String, ChatModel> chatModels, @Nullable ChatModel pri
85109
this.defaultModelName = chatModels.keySet().iterator().next();
86110
}
87111

112+
if (perModelOptions != null) {
113+
for (String key : perModelOptions.keySet()) {
114+
if (!ChatModelTypes.DEFAULT_MODEL_NAME.equals(key) && !this.chatModels.containsKey(key)) {
115+
throw new IllegalArgumentException(
116+
"perModelOptions key '"
117+
+ key
118+
+ "' does not match any ChatModel bean. Registered models: "
119+
+ this.chatModels.keySet()
120+
+ ". Use '"
121+
+ ChatModelTypes.DEFAULT_MODEL_NAME
122+
+ "' as a catch-all for models without a specific entry.");
123+
}
124+
}
125+
}
126+
SpringAiPluginOptions.register(perModelOptions);
127+
88128
if (chatModels.size() > 1) {
89129
log.info(
90130
"Registered {} chat models: {} (default: {})",
91131
chatModels.size(),
92132
chatModels.keySet(),
93133
defaultModelName);
94134
}
135+
if (perModelOptions != null && !perModelOptions.isEmpty()) {
136+
log.info(
137+
"Registered per-model ActivityOptions overrides for {} model(s): {}",
138+
perModelOptions.size(),
139+
perModelOptions.keySet());
140+
}
95141
}
96142

97143
@Override
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package io.temporal.springai.plugin;
2+
3+
import io.temporal.activity.ActivityOptions;
4+
import java.util.Map;
5+
import java.util.Optional;
6+
import java.util.concurrent.atomic.AtomicReference;
7+
8+
/**
9+
* Process-scoped registry of per-chat-model {@link ActivityOptions}, populated by {@link
10+
* SpringAiPlugin} at worker construction and consulted by {@link
11+
* io.temporal.springai.model.ActivityChatModel#forModel(String)} when building the activity stub.
12+
*
13+
* <p>The registry is a static singleton because the plugin is a worker-side object but the lookup
14+
* happens in workflow code that runs on the same JVM. Populating a shared static map before any
15+
* workflow executes is the cleanest way to bridge that without teaching workflow code about plugin
16+
* instances.
17+
*
18+
* <p>Limitations:
19+
*
20+
* <ul>
21+
* <li>Only one set of per-model options per JVM. Running multiple plugins in the same worker
22+
* process with different per-model options is not supported — the last registration wins.
23+
* <li>Callers who invoke {@link io.temporal.springai.model.ActivityChatModel#forModel(String,
24+
* ActivityOptions)} or {@link
25+
* io.temporal.springai.model.ActivityChatModel#forDefault(ActivityOptions)} bypass the
26+
* registry — explicit options always win.
27+
* </ul>
28+
*/
29+
public final class SpringAiPluginOptions {
30+
31+
private static final AtomicReference<Map<String, ActivityOptions>> REGISTRY =
32+
new AtomicReference<>(Map.of());
33+
34+
private SpringAiPluginOptions() {}
35+
36+
/**
37+
* Installs the given per-model-name {@link ActivityOptions}, replacing any previous entries.
38+
* Called by {@link SpringAiPlugin}. A null or empty map clears the registry.
39+
*/
40+
public static void register(Map<String, ActivityOptions> options) {
41+
REGISTRY.set(options == null || options.isEmpty() ? Map.of() : Map.copyOf(options));
42+
}
43+
44+
/**
45+
* Returns the options registered for the given model name, or empty if none. A null {@code
46+
* modelName} always returns empty.
47+
*/
48+
public static Optional<ActivityOptions> optionsFor(String modelName) {
49+
if (modelName == null) {
50+
return Optional.empty();
51+
}
52+
return Optional.ofNullable(REGISTRY.get().get(modelName));
53+
}
54+
}

0 commit comments

Comments
 (0)