Skip to content

temporal-spring-ai: pass provider-specific ChatOptions through the activity boundary#2857

Open
donald-pinckney wants to merge 4 commits intomasterfrom
spring-ai/provider-options-passthrough
Open

temporal-spring-ai: pass provider-specific ChatOptions through the activity boundary#2857
donald-pinckney wants to merge 4 commits intomasterfrom
spring-ai/provider-options-passthrough

Conversation

@donald-pinckney
Copy link
Copy Markdown
Contributor

@donald-pinckney donald-pinckney commented Apr 21, 2026

What was changed

  • ChatModelTypes.ModelOptions gets two new nullable fields, chatOptionsClass and chatOptionsJson, representing the caller's full ChatOptions as (class name, JSON). An overload constructor keeps older call sites (tests + the fallback path) building the record with only scalar fields.
  • ActivityChatModel serializes 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 activity boundary via the separate tools field. On serialization failure the blob fields stay null; the activity falls back to the common-field path with a debug log.
  • ChatModelActivityImpl prefers the serialized blob when both fields are present. If the rehydrated object is a ToolCallingChatOptions, it re-attaches the stubbed tool callbacks and forces internalToolExecutionEnabled = false. If class loading or deserialization fails, it falls back to the existing common-field builder path with a warning log.
  • ProviderOptionsPassthroughTest exercises three paths — direct ActivityChatModel.call(...) with a custom ChatOptions subclass, the idiomatic ChatClient.defaultOptions(...) entry point with the same subclass, and the null-options fallback.

Why?

The previous implementation limited users to the common Spring AI ChatOptions subset. Anyone needing OpenAI reasoning_effort, Anthropic thinking budget, structured-output schemas, or any other provider-specific knob had to fork the plugin. With this change every field of the caller's exact subclass survives the round trip.

Design note — why the serialized blob beats an opaque Map<String, Object>: workflow and activity workers are typically separate JVMs (Temporal's whole point), so "classpath" really can differ. But the precondition for this feature to be used at all is that the user's workflow constructs a provider-specific ChatOptions — which requires spring-ai-<provider> on the workflow worker's classpath — and the activity worker has the same class because that's where the ChatModel bean runs. Under that precondition, Class.forName + ObjectMapper.readValue works everywhere the feature is actually used, with full fidelity and no per-provider allow-list to maintain.

About ChatOptions.copy(): Spring AI's ChatClient calls chatOptions.copy() on the caller's options before passing to the model. Real provider classes (OpenAiChatOptions, AnthropicChatOptions, ...) all override copy() to return their own type with every field carried across. Any ChatOptions subclass that follows the same pattern — including user-defined ones like the test's CustomChatOptions — works through the ChatClient path as well as direct chatModel.call(new Prompt(...)). A subclass that inherits the parent's copy() would lose its subclass identity before this plugin ever sees it, regardless of anything this PR does.

donald-pinckney and others added 3 commits April 21, 2026 15:55
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
…tivity 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>
@donald-pinckney donald-pinckney force-pushed the spring-ai/provider-options-passthrough branch from 96d9bb0 to 4df96ec Compare April 21, 2026 23:58
Planning scratchpad — not part of the shipped artifact. Removed before merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@donald-pinckney donald-pinckney marked this pull request as ready for review April 22, 2026 19:53
@donald-pinckney donald-pinckney requested a review from a team as a code owner April 22, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant