Skip to content

Commit 911dd02

Browse files
temporal-spring-ai: pass provider-specific ChatOptions through the activity boundary (#2857)
* temporal-spring-ai: plan — provider-specific ChatOptions pass-through Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: amend plan — switch to serialized-blob pass-through Option 2 (serialize the full ChatOptions subclass as JSON + class name, rehydrate on the activity side) is strictly better than the opaque map. Full fidelity, no per-provider allow-list, no builder gymnastics. The earlier rationale for option 1 ("drags provider classes into the workflow's classpath") was wrong: workflow and activity workers can be separate JVMs, but the user's workflow can only construct OpenAiChatOptions if spring-ai-openai is already on the workflow worker's classpath, and the activity worker has the same class because that's where the ChatModel bean runs. The precondition holds whenever the feature is actually used. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * temporal-spring-ai: pass provider-specific ChatOptions through the activity boundary Serialize the caller's ChatOptions as (class name, JSON) on the workflow side and rehydrate the exact subclass on the activity side. Every field survives — including provider-specific ones like OpenAI reasoning_effort or Anthropic thinking-budget settings — without the plugin needing per-provider allow-lists, reflection into builder types, or compile deps on spring-ai-openai/anthropic/etc. Changes: - ChatModelTypes.ModelOptions: two new nullable fields, chatOptionsClass and chatOptionsJson. Older callers that only set the common scalar fields still work via a convenience constructor. - ActivityChatModel: serialize the caller's ChatOptions with a plain ObjectMapper when non-null; a Jackson mixin skips the tool-callback bag (toolCallbacks/toolNames/toolContext) since those cross the boundary via the separate `tools` field. Serialization failure is logged at debug and leaves the blob fields null — activity side then uses the common-field path. - ChatModelActivityImpl: primary path rehydrates from the serialized blob and, if the result is a ToolCallingChatOptions, re-attaches the stub tool callbacks plus forces internalToolExecutionEnabled=false. Fallback path (no blob, class-not-found, deser error) builds a plain ToolCallingChatOptions from the common scalar fields, identical to the prior behavior. Tests: - ProviderOptionsPassthroughTest.customChatOptionsSubclass... — a test-local CustomChatOptions extending DefaultToolCallingChatOptions with an extra reasoningEffort field round-trips, and common fields (temperature/maxTokens) come through the same path. - ProviderOptionsPassthroughTest.nullChatOptions... — workflow that doesn't set any options still works via the fallback path. The workflow calls ActivityChatModel.call(new Prompt(...)) directly instead of going through ChatClient, because ChatClient.defaultOptions coerces the caller's subclass into the client's internal options type before ActivityChatModel ever sees it. Users of ChatClient who want provider-specific options should set those on the ChatModel bean's default options (Spring AI merges those into every call). 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79c9fe5 commit 911dd02

4 files changed

Lines changed: 430 additions & 18 deletions

File tree

temporal-spring-ai/src/main/java/io/temporal/springai/activity/ChatModelActivityImpl.java

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package io.temporal.springai.activity;
22

3+
import com.fasterxml.jackson.databind.ObjectMapper;
34
import io.temporal.springai.model.ChatModelTypes;
45
import io.temporal.springai.model.ChatModelTypes.Message;
56
import java.net.URI;
67
import java.net.URISyntaxException;
78
import java.util.List;
89
import java.util.Map;
910
import java.util.stream.Collectors;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
1013
import org.springframework.ai.chat.messages.*;
1114
import org.springframework.ai.chat.model.ChatModel;
1215
import org.springframework.ai.chat.model.ChatResponse;
16+
import org.springframework.ai.chat.prompt.ChatOptions;
1317
import org.springframework.ai.chat.prompt.Prompt;
1418
import org.springframework.ai.content.Media;
1519
import org.springframework.ai.model.tool.ToolCallingChatOptions;
@@ -30,6 +34,25 @@
3034
*/
3135
public class ChatModelActivityImpl implements ChatModelActivity {
3236

37+
private static final Logger log = LoggerFactory.getLogger(ChatModelActivityImpl.class);
38+
39+
/**
40+
* Reads the caller's {@link ChatOptions} back out of the serialized JSON carried on {@link
41+
* ChatModelTypes.ModelOptions}. Plain Jackson — the workflow side wrote the blob with a matching
42+
* plain {@link ObjectMapper}.
43+
*/
44+
private static final ObjectMapper OPTIONS_MAPPER =
45+
new ObjectMapper().addMixIn(ToolCallingChatOptions.class, ToolCallingChatOptionsMixin.class);
46+
47+
/**
48+
* Mirror of the mixin in {@code ActivityChatModel} so deserialization ignores the same tool-bag
49+
* properties the workflow side skipped.
50+
*/
51+
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(
52+
value = {"toolCallbacks", "toolNames", "toolContext"},
53+
ignoreUnknown = true)
54+
private abstract static class ToolCallingChatOptionsMixin {}
55+
3356
private final Map<String, ChatModel> chatModels;
3457
private final String defaultModelName;
3558

@@ -77,6 +100,32 @@ private Prompt createPrompt(ChatModelTypes.ChatModelActivityInput input) {
77100
List<org.springframework.ai.chat.messages.Message> messages =
78101
input.messages().stream().map(this::toSpringMessage).collect(Collectors.toList());
79102

103+
List<ToolCallback> toolCallbacks = stubToolCallbacks(input);
104+
105+
// Primary path: rehydrate the caller's exact ChatOptions subclass from the serialized blob.
106+
// Preserves provider-specific fields (OpenAI reasoning_effort, Anthropic thinking budget,
107+
// etc.) that aren't representable in the common ModelOptions record.
108+
ChatOptions rehydrated = tryRehydrateChatOptions(input.modelOptions());
109+
if (rehydrated instanceof ToolCallingChatOptions tcOpts) {
110+
tcOpts.setInternalToolExecutionEnabled(false);
111+
if (!toolCallbacks.isEmpty()) {
112+
tcOpts.setToolCallbacks(toolCallbacks);
113+
}
114+
return Prompt.builder().messages(messages).chatOptions(tcOpts).build();
115+
}
116+
if (rehydrated != null) {
117+
// Caller's ChatOptions isn't a ToolCallingChatOptions. Accept it as-is; tool callbacks
118+
// can't be attached via this path, but most provider options in practice are
119+
// ToolCallingChatOptions subclasses so this branch is a rare fallback.
120+
log.debug(
121+
"Rehydrated ChatOptions {} is not a ToolCallingChatOptions; tool callbacks will be"
122+
+ " omitted for this call.",
123+
rehydrated.getClass().getName());
124+
return Prompt.builder().messages(messages).chatOptions(rehydrated).build();
125+
}
126+
127+
// Fallback path: no serialized blob, or rehydration failed. Build a ToolCallingChatOptions
128+
// from the common scalar fields.
80129
ToolCallingChatOptions.Builder optionsBuilder =
81130
ToolCallingChatOptions.builder()
82131
.internalToolExecutionEnabled(false); // Let workflow handle tool execution
@@ -93,24 +142,63 @@ private Prompt createPrompt(ChatModelTypes.ChatModelActivityInput input) {
93142
if (opts.stopSequences() != null) optionsBuilder.stopSequences(opts.stopSequences());
94143
}
95144

96-
// Add tool callbacks (stubs that provide definitions but won't be executed
97-
// since internalToolExecutionEnabled is false)
98-
if (!CollectionUtils.isEmpty(input.tools())) {
99-
List<ToolCallback> toolCallbacks =
100-
input.tools().stream()
101-
.map(
102-
tool ->
103-
createStubToolCallback(
104-
tool.function().name(),
105-
tool.function().description(),
106-
tool.function().jsonSchema()))
107-
.collect(Collectors.toList());
145+
if (!toolCallbacks.isEmpty()) {
108146
optionsBuilder.toolCallbacks(toolCallbacks);
109147
}
110148

111-
ToolCallingChatOptions chatOptions = optionsBuilder.build();
149+
return Prompt.builder().messages(messages).chatOptions(optionsBuilder.build()).build();
150+
}
151+
152+
private List<ToolCallback> stubToolCallbacks(ChatModelTypes.ChatModelActivityInput input) {
153+
if (CollectionUtils.isEmpty(input.tools())) {
154+
return List.of();
155+
}
156+
return input.tools().stream()
157+
.map(
158+
tool ->
159+
createStubToolCallback(
160+
tool.function().name(),
161+
tool.function().description(),
162+
tool.function().jsonSchema()))
163+
.collect(Collectors.toList());
164+
}
112165

113-
return Prompt.builder().messages(messages).chatOptions(chatOptions).build();
166+
/**
167+
* Attempts to rehydrate the caller's exact {@link ChatOptions} subclass from the serialized blob
168+
* in {@code modelOptions}. Returns {@code null} if the blob is absent or rehydration fails, in
169+
* which case the caller should use the common-field fallback.
170+
*/
171+
private ChatOptions tryRehydrateChatOptions(ChatModelTypes.ModelOptions modelOptions) {
172+
if (modelOptions == null
173+
|| modelOptions.chatOptionsClass() == null
174+
|| modelOptions.chatOptionsJson() == null) {
175+
return null;
176+
}
177+
String className = modelOptions.chatOptionsClass();
178+
try {
179+
Class<?> cls = Class.forName(className, true, Thread.currentThread().getContextClassLoader());
180+
if (!ChatOptions.class.isAssignableFrom(cls)) {
181+
log.warn(
182+
"Serialized ChatOptions class {} is not a ChatOptions; falling back to common fields.",
183+
className);
184+
return null;
185+
}
186+
return (ChatOptions) OPTIONS_MAPPER.readValue(modelOptions.chatOptionsJson(), cls);
187+
} catch (ClassNotFoundException e) {
188+
log.warn(
189+
"Could not load ChatOptions class {} on the activity side; falling back to common"
190+
+ " fields. This typically means spring-ai-<provider> is not on this worker's"
191+
+ " classpath.",
192+
className);
193+
return null;
194+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
195+
log.warn(
196+
"Could not deserialize ChatOptions of type {} on the activity side; falling back to"
197+
+ " common fields. Cause: {}",
198+
className,
199+
e.getMessage());
200+
return null;
201+
}
114202
}
115203

116204
private org.springframework.ai.chat.messages.Message toSpringMessage(Message message) {

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

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

3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
35
import io.temporal.activity.ActivityOptions;
46
import io.temporal.common.RetryOptions;
57
import io.temporal.springai.activity.ChatModelActivity;
@@ -65,6 +67,32 @@
6567
*/
6668
public class ActivityChatModel implements ChatModel {
6769

70+
private static final org.slf4j.Logger log =
71+
org.slf4j.LoggerFactory.getLogger(ActivityChatModel.class);
72+
73+
/**
74+
* Used to serialize the caller's {@link ChatOptions} into a string so the activity side can
75+
* rehydrate the exact subclass. Plain Jackson with no Temporal-specific configuration — the
76+
* output goes into a {@code String} field of {@link ChatModelTypes.ModelOptions}, which
77+
* Temporal's own data converter then handles as normal.
78+
*/
79+
private static final ObjectMapper OPTIONS_MAPPER =
80+
new ObjectMapper().addMixIn(ToolCallingChatOptions.class, ToolCallingChatOptionsMixin.class);
81+
82+
/**
83+
* Jackson mixin that skips {@link ToolCallingChatOptions}'s tool-callback bag on serialization.
84+
* Tool definitions cross the activity boundary via {@link ChatModelTypes.FunctionTool} — the
85+
* actual callbacks are re-stubbed on the activity side — so we don't need to ship them, and their
86+
* concrete implementations (method tool callbacks, activity proxies, etc.) are not
87+
* Jackson-friendly.
88+
*/
89+
@com.fasterxml.jackson.annotation.JsonIgnoreProperties({
90+
"toolCallbacks",
91+
"toolNames",
92+
"toolContext"
93+
})
94+
private abstract static class ToolCallingChatOptionsMixin {}
95+
6896
/** Default timeout for chat model activity calls (2 minutes). */
6997
public static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(2);
7098

@@ -301,10 +329,24 @@ private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt)
301329
.flatMap(msg -> toActivityMessages(msg).stream())
302330
.collect(Collectors.toList());
303331

304-
// Convert options
332+
// Convert options — carry both the common scalars (fallback path) and a serialized blob of
333+
// the caller's exact ChatOptions subclass (primary path on the activity side, which
334+
// preserves provider-specific fields like OpenAI reasoning_effort).
305335
ChatModelTypes.ModelOptions modelOptions = null;
306336
if (prompt.getOptions() != null) {
307337
ChatOptions opts = prompt.getOptions();
338+
String chatOptionsClass = null;
339+
String chatOptionsJson = null;
340+
try {
341+
chatOptionsJson = OPTIONS_MAPPER.writeValueAsString(opts);
342+
chatOptionsClass = opts.getClass().getName();
343+
} catch (JsonProcessingException e) {
344+
log.debug(
345+
"Could not JSON-serialize ChatOptions of type {}; activity will fall back to"
346+
+ " common-field path. Cause: {}",
347+
opts.getClass().getName(),
348+
e.getMessage());
349+
}
308350
modelOptions =
309351
new ChatModelTypes.ModelOptions(
310352
opts.getModel(),
@@ -314,7 +356,9 @@ private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt)
314356
opts.getStopSequences(),
315357
opts.getTemperature(),
316358
opts.getTopK(),
317-
opts.getTopP());
359+
opts.getTopP(),
360+
chatOptionsClass,
361+
chatOptionsJson);
318362
}
319363

320364
// Convert tool definitions

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,18 @@ public record Function(
233233
@JsonProperty("json_schema") String jsonSchema) {}
234234
}
235235

236-
/** Model options for the chat request. */
236+
/**
237+
* Model options for the chat request.
238+
*
239+
* <p>When {@code chatOptionsClass} and {@code chatOptionsJson} are both non-null, the activity
240+
* side attempts to rehydrate the caller's exact {@link
241+
* org.springframework.ai.chat.prompt.ChatOptions} subclass by loading the class and
242+
* JSON-deserializing the blob. That path carries every field the caller set, including
243+
* provider-specific ones like OpenAI {@code reasoning_effort} or Anthropic thinking-budget
244+
* settings. If class loading or deserialization fails, or if the workflow side couldn't serialize
245+
* the caller's options in the first place, the common scalar fields on this record are used as a
246+
* fallback.
247+
*/
237248
@JsonInclude(JsonInclude.Include.NON_NULL)
238249
@JsonIgnoreProperties(ignoreUnknown = true)
239250
public record ModelOptions(
@@ -244,5 +255,34 @@ public record ModelOptions(
244255
@JsonProperty("stop_sequences") List<String> stopSequences,
245256
@JsonProperty("temperature") Double temperature,
246257
@JsonProperty("top_k") Integer topK,
247-
@JsonProperty("top_p") Double topP) {}
258+
@JsonProperty("top_p") Double topP,
259+
@JsonProperty("chat_options_class") String chatOptionsClass,
260+
@JsonProperty("chat_options_json") String chatOptionsJson) {
261+
262+
/**
263+
* Convenience constructor for callers that only populate common scalar fields, keeping the
264+
* existing call sites (tests and the prior activity impl) working unchanged.
265+
*/
266+
public ModelOptions(
267+
String model,
268+
Double frequencyPenalty,
269+
Integer maxTokens,
270+
Double presencePenalty,
271+
List<String> stopSequences,
272+
Double temperature,
273+
Integer topK,
274+
Double topP) {
275+
this(
276+
model,
277+
frequencyPenalty,
278+
maxTokens,
279+
presencePenalty,
280+
stopSequences,
281+
temperature,
282+
topK,
283+
topP,
284+
null,
285+
null);
286+
}
287+
}
248288
}

0 commit comments

Comments
 (0)