Skip to content

Commit 79c9fe5

Browse files
temporal-spring-ai: per-model ActivityOptions registry (#2855)
* temporal-spring-ai: plan — activity summaries for debuggability Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: amend plan — limit summaries to chat + MCP Narrows the activity-summaries scope to cases where the plugin itself owns activity-stub creation. Activity-backed tool calls, Nexus tool calls, and @SideEffectTool calls are now explicitly out of scope; the first two would require per-call option overrides on user-owned stubs (no clean API), and the third writes MarkerRecorded events which have no Summary field. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: add ActivitySummaryTest (chat path) Runs a workflow that drives a single chat call through ActivityChatModel, fetches the resulting history, and asserts the ActivityTaskScheduled event for callChatModel carries a userMetadata Summary that starts with "chat: default" and includes the user prompt. Intentionally fails against unmodified chat code — the implementation follows in a subsequent commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: attach activity summaries for chat and MCP calls ActivityChatModel.forModel() now stores the ActivityOptions it built and, on each chat call, rebuilds the stub with a per-call Summary of the form "chat: <model> · <first 60 chars of user prompt>". When a caller passes a pre-built stub directly via the public constructors, behavior is unchanged (no options known → no summary overlay). ActivityMcpClient.create() does the same and adds a callTool(clientName, request, summary) overload. McpToolCallback passes "mcp: <client>.<tool>". Also fixes the activity-type-name casing in ActivitySummaryTest — Temporal capitalizes the first character of method-name-derived activity types, so the event carries "CallChatModel", not "callChatModel". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: drop PLAN.md Planning scratchpad — not part of the shipped artifact. Removed before merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: drop user prompt from chat activity Summary The Summary now carries only the model label ("chat: <model>") instead of "chat: <model> · <first 60 chars of user prompt>". Including even a truncated prompt leaks whatever the prompt contains — PII, secrets, internal identifiers — into workflow history, server logs, and the Temporal UI, which is a surprising default for an observability label. An opt-in API for callers who explicitly want the prompt in the Summary can be added later if there's demand. ActivitySummaryTest.chatActivity_carriesModelOnlySummary_neverLeaksUserPrompt asserts the Summary equals "chat: default" exactly and defensively checks that no part of the prompt leaked in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: wrap nullable fields/params in Optional<> Addresses review feedback (thread on #2852) preferring typesafe Optional<T> over nullable fields and raw null delegation. Three sites changed: - ActivityChatModel: modelName and baseOptions fields + private ctor params are now Optional<String> / Optional<ActivityOptions>. getModelName() returns Optional<String>. Public ctors and factories still accept nullable String modelName at the API boundary (matches prior javadoc: "null for default"); they normalize via Optional.ofNullable before storing. Internal readers use .map / .orElse instead of null checks. - ActivityMcpClient: the 3-arg callTool's summary parameter is now Optional<String>. The 2-arg convenience overload passes Optional.empty(). The rebuild-with-summary branch uses .isPresent() + .get() instead of null checks. - McpToolCallback.call(...) wraps its generated summary string in Optional.of(...) before passing to callTool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: use @nullable annotations instead of Optional<> Reverses the previous Optional<> commit in favor of @nullable on the nullable fields/params. Matches the dominant convention in temporal-sdk (431+ @nullable usages on public API surface) and avoids a thicker runtime story for what is fundamentally a documentation / IDE-hint concern (no NullAway in the build either way). - ActivityChatModel: modelName and baseOptions fields + private ctor params are now @nullable String / @nullable ActivityOptions. Public ctors/factories accept nullable String modelName at the API boundary directly. getModelName() returns @nullable String. - ActivityMcpClient: baseOptions field and callTool(..., summary) param use @nullable instead of Optional<>. 2-arg callTool passes null; McpToolCallback passes a plain String. - ChatModelTypes.ChatModelActivityInput record: @nullable on modelName and modelOptions fields so deserialized readers see the nullability in the signature (per the reviewer's concern about the deserialization-side typesafety). Consistent with org.springframework.lang.Nullable already used elsewhere in this module (TemporalChatClient, SpringAiPlugin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: use javax.annotation.Nullable for consistency Switch the five files in this module that imported org.springframework.lang.Nullable over to javax.annotation.Nullable to match the dominant convention in sdk-java (197 usages vs. 7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: plan — ActivityOptions overloads and non-retryable error classification Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: amend plan — ActivityOptions overloads fix summaries UX wart The activity-summaries branch only overlays Summaries when the factories (forModel/create) built the stub. Users who need custom timeouts today fall back to the public constructor, which silently drops UI Summaries. The ActivityOptions overloads planned here are the proper fix: they let users customize the stub and keep Summary labels. Plan now also covers deprecating the public constructors with javadoc pointing at the factories. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: accept ActivityOptions and classify non-retryable AI errors - ActivityChatModel.forDefault(ActivityOptions) and forModel(String, ActivityOptions) overloads added. New public defaultActivityOptions() returns the plugin's default bundle so callers can tweak one field without losing the other sensible defaults. - ActivityMcpClient.create(ActivityOptions) + defaultActivityOptions() added, mirroring the chat side. - Default RetryOptions for chat calls now mark org.springframework.ai.retry.NonTransientAiException and java.lang.IllegalArgumentException non-retryable. Default options for MCP calls mark IllegalArgumentException non-retryable. User-supplied ActivityOptions pass through verbatim — the plugin does not augment them. - new ActivityChatModel(...) and new ActivityMcpClient(activity) constructors are @deprecated with javadoc pointing at the factories — they still work at runtime but skip the UI Summary labels the plugin-owned stub path attaches, which is now called out explicitly. - README: new "Activity options and retry behavior" section documents the defaults, how to customize, and the Summary/factory connection. - Tests: two new suites — ActivityOptionsAndRetryTest covers the non-retryable classification (1 attempt for NonTransientAiException, 3 attempts for transient RuntimeException, custom task queue landing on the scheduled activity); ActivitySummaryTest gains a regression test asserting forDefault(customOptions) still emits UI Summaries. - build.gradle: spring-ai-retry added as a testImplementation so tests can reference NonTransientAiException directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: drop PLAN.md Planning scratchpad — not part of the shipped artifact. Removed before merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: remove (Duration, int) factory overloads Now that forDefault(ActivityOptions) / forModel(String, ActivityOptions) / create(ActivityOptions) exist, the (Duration, int) convenience overloads are asymmetric dead weight — they expose two of N ActivityOptions fields as positional parameters, and callers wanting anything else (heartbeats, task queue, custom retry backoff, ...) have to drop to the ActivityOptions path anyway. Removed pre-release so the API surface is consistent: no-arg → plugin defaults; ActivityOptions arg → caller options. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: plan — per-model ActivityOptions registry Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> * temporal-spring-ai: drop PLAN.md Planning scratchpad — not part of the shipped artifact. Removed before merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: make per-model options compositional via 'default' catch-all Address reviewer feedback on #2855: the three-way implicit fallback (specific entry → library defaults) was ambiguous to readers of forDefault() / forModel(name) — nothing locally tells the caller which rung they land on. But a strict either-or (global OR exhaustive per-model) breaks compositionality when a third-party starter contributes a ChatModel bean your config didn't enumerate. Resolution: keep per-model overrides, and reserve ChatModelTypes.DEFAULT_MODEL_NAME as a user-declared catch-all key in perModelOptions. Lookup is now map[name] ?? map["default"] ?? library defaults. SpringAiPlugin validates that every perModelOptions key either matches a registered ChatModel bean or equals the catch-all name; typos fail at plugin construction, not silently at call time. Updates javadoc on SpringAiPlugin, ActivityChatModel.forModel(String), and ChatModelActivityOptions. Collapses the duplicate "Activity options" README section into one with an example of the catch-all pattern. Adds tests for the catch-all lookup, specific-wins-over- catch-all, and typo-key rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c402f55 commit 79c9fe5

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
@@ -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;
@@ -102,16 +104,29 @@ private ActivityChatModel(
102104
}
103105

104106
/**
105-
* Creates an ActivityChatModel for the default chat model with the plugin's default {@link
106-
* ActivityOptions} (2-minute start-to-close timeout, 3 attempts, clearly permanent AI errors
107-
* marked non-retryable).
107+
* Creates an ActivityChatModel for the default chat model.
108+
*
109+
* <p>Options resolution order:
110+
*
111+
* <ol>
112+
* <li>An entry registered on {@link SpringAiPlugin} under {@link
113+
* ChatModelTypes#DEFAULT_MODEL_NAME} in the per-model {@code ActivityOptions} map, if any.
114+
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
115+
* clearly permanent AI errors marked non-retryable).
116+
* </ol>
117+
*
118+
* <p>Callers who want to set explicit options should use {@link #forDefault(ActivityOptions)} —
119+
* explicit options bypass the registry entirely.
108120
*
109121
* <p><strong>Must be called from workflow code.</strong>
110122
*
111123
* @return an ActivityChatModel for the default chat model
112124
*/
113125
public static ActivityChatModel forDefault() {
114-
return forDefault(defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
126+
ActivityOptions options =
127+
SpringAiPluginOptions.optionsFor(ChatModelTypes.DEFAULT_MODEL_NAME)
128+
.orElseGet(ActivityChatModel::defaultActivityOptions);
129+
return forDefault(options);
115130
}
116131

117132
/**
@@ -130,8 +145,21 @@ public static ActivityChatModel forDefault(ActivityOptions options) {
130145
}
131146

132147
/**
133-
* Creates an ActivityChatModel for a specific chat model by bean name with the plugin's default
134-
* {@link ActivityOptions}.
148+
* Creates an ActivityChatModel for a specific chat model by bean name.
149+
*
150+
* <p>Options resolution order:
151+
*
152+
* <ol>
153+
* <li>An entry registered on {@link SpringAiPlugin} under {@code modelName} in the per-model
154+
* {@code ActivityOptions} map, if any.
155+
* <li>An entry registered under {@link ChatModelTypes#DEFAULT_MODEL_NAME} in the per-model map,
156+
* which acts as a user-declared catch-all for models without a specific entry.
157+
* <li>The plugin's default {@link ActivityOptions} (2-minute start-to-close, 3 attempts,
158+
* clearly permanent AI errors marked non-retryable).
159+
* </ol>
160+
*
161+
* <p>Callers who want to set explicit options should use {@link #forModel(String,
162+
* ActivityOptions)} — explicit options bypass the registry entirely.
135163
*
136164
* <p><strong>Must be called from workflow code.</strong>
137165
*
@@ -140,7 +168,11 @@ public static ActivityChatModel forDefault(ActivityOptions options) {
140168
* @throws IllegalArgumentException if no model with that name exists (at activity runtime)
141169
*/
142170
public static ActivityChatModel forModel(String modelName) {
143-
return forModel(modelName, defaultActivityOptions(DEFAULT_TIMEOUT, DEFAULT_MAX_ATTEMPTS));
171+
ActivityOptions options =
172+
SpringAiPluginOptions.optionsFor(modelName)
173+
.or(() -> SpringAiPluginOptions.optionsFor(ChatModelTypes.DEFAULT_MODEL_NAME))
174+
.orElseGet(ActivityChatModel::defaultActivityOptions);
175+
return forModel(modelName, options);
144176
}
145177

146178
/**

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)