Skip to content

Commit 44777c8

Browse files
temporal-spring-ai: per-model ActivityOptions registry
SpringAiPlugin now accepts an optional Map<String, ActivityOptions> keyed by chat-model bean name. On construction the plugin publishes the map to a new package-public static registry SpringAiPluginOptions, which ActivityChatModel.forModel(name) and forDefault() consult when building the activity stub. Entries resolve by bean name; the reserved key SpringAiPlugin.DEFAULT_MODEL_NAME ("default") covers forDefault(). Callers who pass explicit ActivityOptions via forModel(name, options) or forDefault(options) bypass the registry entirely — explicit options always win. The registry has no effect on the (timeout, maxAttempts) convenience factory either; that still builds options from its args. Auto-configuration picks up a user bean named "chatModelActivityOptions" (constant SpringAiTemporalAutoConfiguration.CHAT_MODEL_ACTIVITY_OPTIONS_BEAN) of type Map<String, ActivityOptions>. The explicit bean-name qualifier avoids Spring's collection-of-beans auto-wiring for Map<String, T>. Tests: PerModelActivityOptionsTest covers the three cases called out in the plan — registry hit, registry miss (falls back to default 2-minute timeout), and explicit options bypass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79d7067 commit 44777c8

5 files changed

Lines changed: 375 additions & 12 deletions

File tree

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
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;
67
import org.springframework.beans.factory.ObjectProvider;
78
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.beans.factory.annotation.Qualifier;
810
import org.springframework.boot.autoconfigure.AutoConfiguration;
911
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
1012
import org.springframework.context.annotation.Bean;
@@ -28,9 +30,34 @@
2830
name = {"org.springframework.ai.chat.model.ChatModel", "io.temporal.worker.Worker"})
2931
public class SpringAiTemporalAutoConfiguration {
3032

33+
/** Bean name users declare their per-model {@link ActivityOptions} map under. */
34+
public static final String CHAT_MODEL_ACTIVITY_OPTIONS_BEAN = "chatModelActivityOptions";
35+
36+
/**
37+
* Builds the {@link SpringAiPlugin}. Picks up an optional user bean named {@value
38+
* #CHAT_MODEL_ACTIVITY_OPTIONS_BEAN} of type {@code Map<String, ActivityOptions>} and forwards it
39+
* as per-model overrides. The bean name is required (rather than auto-wiring by type) to avoid
40+
* Spring's collection-of-beans autowiring behavior for {@code Map<String, ActivityOptions>}.
41+
*
42+
* <p>Example user config:
43+
*
44+
* <pre>{@code
45+
* @Bean(SpringAiTemporalAutoConfiguration.CHAT_MODEL_ACTIVITY_OPTIONS_BEAN)
46+
* public Map<String, ActivityOptions> chatModelActivityOptions() {
47+
* return Map.of(
48+
* "reasoning", ActivityOptions.newBuilder(ActivityChatModel.defaultActivityOptions())
49+
* .setStartToCloseTimeout(Duration.ofMinutes(15))
50+
* .build());
51+
* }
52+
* }</pre>
53+
*/
3154
@Bean
3255
public SpringAiPlugin springAiPlugin(
33-
@Autowired Map<String, ChatModel> chatModels, ObjectProvider<ChatModel> primaryChatModel) {
34-
return new SpringAiPlugin(chatModels, primaryChatModel.getIfUnique());
56+
@Autowired Map<String, ChatModel> chatModels,
57+
ObjectProvider<ChatModel> primaryChatModel,
58+
@Qualifier(CHAT_MODEL_ACTIVITY_OPTIONS_BEAN)
59+
ObjectProvider<Map<String, ActivityOptions>> perModelOptions) {
60+
return new SpringAiPlugin(
61+
chatModels, primaryChatModel.getIfUnique(), perModelOptions.getIfAvailable(Map::of));
3562
}
3663
}

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import io.temporal.activity.ActivityOptions;
44
import io.temporal.common.RetryOptions;
55
import io.temporal.springai.activity.ChatModelActivity;
6+
import io.temporal.springai.plugin.SpringAiPlugin;
7+
import io.temporal.springai.plugin.SpringAiPluginOptions;
68
import io.temporal.workflow.Workflow;
79
import java.net.URI;
810
import java.net.URISyntaxException;
@@ -96,16 +98,29 @@ private ActivityChatModel(
9698
}
9799

98100
/**
99-
* Creates an ActivityChatModel for the default chat model with the plugin's default {@link
100-
* ActivityOptions} (2-minute start-to-close timeout, 3 attempts, clearly permanent AI errors
101-
* marked non-retryable).
101+
* Creates an ActivityChatModel for the default chat model.
102+
*
103+
* <p>Options resolution order:
104+
*
105+
* <ol>
106+
* <li>An entry registered on {@link SpringAiPlugin} under {@link
107+
* SpringAiPlugin#DEFAULT_MODEL_NAME} in the per-model {@code ActivityOptions} map, if any.
108+
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
109+
* clearly permanent AI errors marked non-retryable).
110+
* </ol>
111+
*
112+
* <p>Callers who want to set explicit options should use {@link #forDefault(ActivityOptions)} —
113+
* explicit options bypass the registry entirely.
102114
*
103115
* <p><strong>Must be called from workflow code.</strong>
104116
*
105117
* @return an ActivityChatModel for the default chat model
106118
*/
107119
public static ActivityChatModel forDefault() {
108-
return forDefault(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
120+
ActivityOptions options =
121+
SpringAiPluginOptions.optionsFor(SpringAiPlugin.DEFAULT_MODEL_NAME)
122+
.orElseGet(ActivityChatModel::defaultActivityOptions);
123+
return forDefault(options);
109124
}
110125

111126
/**
@@ -124,8 +139,19 @@ public static ActivityChatModel forDefault(ActivityOptions options) {
124139
}
125140

126141
/**
127-
* Creates an ActivityChatModel for a specific chat model by bean name with the plugin's default
128-
* {@link ActivityOptions}.
142+
* Creates an ActivityChatModel for a specific chat model by bean name.
143+
*
144+
* <p>Options resolution order:
145+
*
146+
* <ol>
147+
* <li>An entry registered on {@link SpringAiPlugin} under {@code modelName} in the per-model
148+
* {@code ActivityOptions} map, if any.
149+
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
150+
* clearly permanent AI errors marked non-retryable).
151+
* </ol>
152+
*
153+
* <p>Callers who want to set explicit options should use {@link #forModel(String,
154+
* ActivityOptions)} — explicit options bypass the registry entirely.
129155
*
130156
* <p><strong>Must be called from workflow code.</strong>
131157
*
@@ -134,7 +160,10 @@ public static ActivityChatModel forDefault(ActivityOptions options) {
134160
* @throws IllegalArgumentException if no model with that name exists (at activity runtime)
135161
*/
136162
public static ActivityChatModel forModel(String modelName) {
137-
return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
163+
ActivityOptions options =
164+
SpringAiPluginOptions.optionsFor(modelName)
165+
.orElseGet(ActivityChatModel::defaultActivityOptions);
166+
return forModel(modelName, options);
138167
}
139168

140169
/**

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

Lines changed: 29 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.worker.Worker;
@@ -55,9 +56,7 @@ public class SpringAiPlugin extends SimplePlugin {
5556
* @param chatModel the Spring AI chat model to wrap as an activity
5657
*/
5758
public SpringAiPlugin(ChatModel chatModel) {
58-
super("io.temporal.spring-ai");
59-
this.chatModels = Map.of(DEFAULT_MODEL_NAME, chatModel);
60-
this.defaultModelName = DEFAULT_MODEL_NAME;
59+
this(Map.of(DEFAULT_MODEL_NAME, chatModel), null, Map.of());
6160
}
6261

6362
/**
@@ -67,6 +66,25 @@ public SpringAiPlugin(ChatModel chatModel) {
6766
* @param primaryChatModel the primary chat model (used to determine default), or null
6867
*/
6968
public SpringAiPlugin(Map<String, ChatModel> chatModels, @Nullable ChatModel primaryChatModel) {
69+
this(chatModels, primaryChatModel, Map.of());
70+
}
71+
72+
/**
73+
* Creates a new SpringAiPlugin with multiple ChatModels and per-model {@link ActivityOptions}.
74+
*
75+
* <p>Entries in {@code perModelOptions} are keyed by chat-model bean name and consulted by {@link
76+
* io.temporal.springai.model.ActivityChatModel#forModel(String)} (and by {@link
77+
* io.temporal.springai.model.ActivityChatModel#forDefault()} via {@link #DEFAULT_MODEL_NAME}).
78+
* Callers who pass explicit {@code ActivityOptions} to a factory bypass this map entirely.
79+
*
80+
* @param chatModels map of bean names to ChatModel instances
81+
* @param primaryChatModel the primary chat model (used to determine default), or null
82+
* @param perModelOptions per-model-name ActivityOptions overrides; may be empty
83+
*/
84+
public SpringAiPlugin(
85+
Map<String, ChatModel> chatModels,
86+
@Nullable ChatModel primaryChatModel,
87+
Map<String, ActivityOptions> perModelOptions) {
7088
super("io.temporal.spring-ai");
7189

7290
if (chatModels == null || chatModels.isEmpty()) {
@@ -87,13 +105,21 @@ public SpringAiPlugin(Map<String, ChatModel> chatModels, @Nullable ChatModel pri
87105
this.defaultModelName = chatModels.keySet().iterator().next();
88106
}
89107

108+
SpringAiPluginOptions.register(perModelOptions);
109+
90110
if (chatModels.size() > 1) {
91111
log.info(
92112
"Registered {} chat models: {} (default: {})",
93113
chatModels.size(),
94114
chatModels.keySet(),
95115
defaultModelName);
96116
}
117+
if (perModelOptions != null && !perModelOptions.isEmpty()) {
118+
log.info(
119+
"Registered per-model ActivityOptions overrides for {} model(s): {}",
120+
perModelOptions.size(),
121+
perModelOptions.keySet());
122+
}
97123
}
98124

99125
@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)