Skip to content

Commit 96d9bb0

Browse files
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>
1 parent 979b752 commit 96d9bb0

4 files changed

Lines changed: 369 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;
@@ -77,6 +79,32 @@
7779
*/
7880
public class ActivityChatModel implements ChatModel {
7981

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

@@ -226,10 +254,24 @@ private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt)
226254
.flatMap(msg -> toActivityMessages(msg).stream())
227255
.collect(Collectors.toList());
228256

229-
// Convert options
257+
// Convert options — carry both the common scalars (fallback path) and a serialized blob of
258+
// the caller's exact ChatOptions subclass (primary path on the activity side, which
259+
// preserves provider-specific fields like OpenAI reasoning_effort).
230260
ChatModelTypes.ModelOptions modelOptions = null;
231261
if (prompt.getOptions() != null) {
232262
ChatOptions opts = prompt.getOptions();
263+
String chatOptionsClass = null;
264+
String chatOptionsJson = null;
265+
try {
266+
chatOptionsJson = OPTIONS_MAPPER.writeValueAsString(opts);
267+
chatOptionsClass = opts.getClass().getName();
268+
} catch (JsonProcessingException e) {
269+
log.debug(
270+
"Could not JSON-serialize ChatOptions of type {}; activity will fall back to"
271+
+ " common-field path. Cause: {}",
272+
opts.getClass().getName(),
273+
e.getMessage());
274+
}
233275
modelOptions =
234276
new ChatModelTypes.ModelOptions(
235277
opts.getModel(),
@@ -239,7 +281,9 @@ private ChatModelTypes.ChatModelActivityInput createActivityInput(Prompt prompt)
239281
opts.getStopSequences(),
240282
opts.getTemperature(),
241283
opts.getTopK(),
242-
opts.getTopP());
284+
opts.getTopP(),
285+
chatOptionsClass,
286+
chatOptionsJson);
243287
}
244288

245289
// 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
@@ -177,7 +177,18 @@ public record Function(
177177
@JsonProperty("json_schema") String jsonSchema) {}
178178
}
179179

180-
/** Model options for the chat request. */
180+
/**
181+
* Model options for the chat request.
182+
*
183+
* <p>When {@code chatOptionsClass} and {@code chatOptionsJson} are both non-null, the activity
184+
* side attempts to rehydrate the caller's exact {@link
185+
* org.springframework.ai.chat.prompt.ChatOptions} subclass by loading the class and
186+
* JSON-deserializing the blob. That path carries every field the caller set, including
187+
* provider-specific ones like OpenAI {@code reasoning_effort} or Anthropic thinking-budget
188+
* settings. If class loading or deserialization fails, or if the workflow side couldn't serialize
189+
* the caller's options in the first place, the common scalar fields on this record are used as a
190+
* fallback.
191+
*/
181192
@JsonInclude(JsonInclude.Include.NON_NULL)
182193
@JsonIgnoreProperties(ignoreUnknown = true)
183194
public record ModelOptions(
@@ -188,5 +199,34 @@ public record ModelOptions(
188199
@JsonProperty("stop_sequences") List<String> stopSequences,
189200
@JsonProperty("temperature") Double temperature,
190201
@JsonProperty("top_k") Integer topK,
191-
@JsonProperty("top_p") Double topP) {}
202+
@JsonProperty("top_p") Double topP,
203+
@JsonProperty("chat_options_class") String chatOptionsClass,
204+
@JsonProperty("chat_options_json") String chatOptionsJson) {
205+
206+
/**
207+
* Convenience constructor for callers that only populate common scalar fields, keeping the
208+
* existing call sites (tests and the prior activity impl) working unchanged.
209+
*/
210+
public ModelOptions(
211+
String model,
212+
Double frequencyPenalty,
213+
Integer maxTokens,
214+
Double presencePenalty,
215+
List<String> stopSequences,
216+
Double temperature,
217+
Integer topK,
218+
Double topP) {
219+
this(
220+
model,
221+
frequencyPenalty,
222+
maxTokens,
223+
presencePenalty,
224+
stopSequences,
225+
temperature,
226+
topK,
227+
topP,
228+
null,
229+
null);
230+
}
231+
}
192232
}

0 commit comments

Comments
 (0)