Skip to content

fix(spring-ai): warn when tool callbacks land on provider-neutral options - #1771

Open
caps-xia wants to merge 1 commit into
google:mainfrom
caps-xia:fix/wrapper-chatmodel-options-cce
Open

caps-xia wants to merge 1 commit into
google:mainfrom
caps-xia:fix/wrapper-chatmodel-options-cce

Conversation

@caps-xia

@caps-xia caps-xia commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Link to Issue or Description of Change

Fixes: #1731

Problem:

ChatModel.getOptions() has an interface default returning an empty DefaultChatOptions, so delegating/wrapping ChatModels that do not override it yield non-ToolCallingChatOptions defaults. With ADK tools attached, buildChatOptions(...) then constructs provider-neutral ToolCallingChatOptions, and provider models that cast Prompt.getOptions() to their own type (e.g. OpenAiChatModel.createRequest(...)) fail with a bare ClassCastException several frames away from the cause.

Solution:

Warn-only in buildChatOptions(...): when tool callbacks would land on provider-neutral options, log an actionable message naming the actual cause ("if the model is a wrapper, make getOptions() delegate to the underlying provider model's options, e.g. OpenAiChatOptions") right before the provider would fail. Behavior is unchanged — dummy/test models that ignore options keep working (the existing integration tests cover that path); the diagnosis goes from "unexplainable CCE" to one grep.

Testing Plan

Unit Tests:

  • Added WrappedModelOptionsNullProbeTest: a wrapper without getOptions() override + an ADK tool reproduces the exact failure chain (default DefaultChatOptions, not ToolCallingChatOptions, then the cast failure), asserting the provider-side ClassCastException still surfaces as before (warn-only, no behavior change).
  • Full module suite passes locally: 307 tests, 0 failures, BUILD SUCCESS (including the existing dummy-model integration tests).

Manual End-to-End (E2E) Tests:

Reproduced in production (failover wrapper, non-streaming path died on first call) and via the probe on current main — the WARN now fires immediately before the provider failure.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules. — N/A

Additional context

A fail-fast variant was considered and rejected: the upstream dummy-model integration tests legitimately rely on the same interface default, so throwing would break valid usages. Warn-only keeps every current behavior intact while making the failure diagnosable. If maintainers prefer a different remediation (e.g. docs-only, or constructing provider-typed options another way), happy to adjust.

@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @caps-xia, Thank you for your contribution and for taking the time to submit this pull request. Our team is currently reviewing your changes, and we will reach out if we need any further information.

@dosadczuk dosadczuk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clear issue and repro. The warning itself looks good. I have two small requests on MessageConverter and one on the test.

}

if (hasTools) {
if (modelDefaultOptions != null && !(modelDefaultOptions instanceof ToolCallingChatOptions)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the !(modelDefaultOptions instanceof ToolCallingChatOptions) check is always true here, because line 447 already returns in that case, so if (modelDefaultOptions != null) is enough. The comment at 458-464 repeats the log message. Could you drop it and shorten the message? For example:

logger.warn(
    "Model options {} are not ToolCallingChatOptions, so tools go out in generic options"
        + " that providers such as OpenAI reject with a ClassCastException. A wrapping"
        + " ChatModel should delegate getOptions() to the wrapped model.",
    modelDefaultOptions.getClass().getName());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — simplified to the null check, dropped the duplicated comment and shortened the message.

// Prompt.getOptions() to their own type (e.g. OpenAiChatModel.createRequest) will then
// fail with a bare ClassCastException whose cause is hard to spot. Warn so the cause is
// one grep away; dummy models that ignore options keep working.
logger.warn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this logs on every model call, including for test models that ignore options. Logging once per MessageConverter would cut the noise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the warning now logs once per MessageConverter.

* returns null) makes the bridge build provider-neutral ToolCallingChatOptions, which
* OpenAiChatModel.createRequest(...) cannot cast — ClassCastException on the first call.
*/
class WrappedModelOptionsNullProbeTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes with the MessageConverter change reverted. The mock throws the ClassCastException itself, and nothing checks the warning or the prompt options. Lines 29, 51 and 100 also describe a null default and a fail-fast IllegalStateException, which this PR doesn't do. Could you remove the file? If you want coverage, add a MessageConverterTest case next to testToolOptionsFallBackToGenericWhenNoProviderDefaults. It would pass ChatOptions.builder().build() with testTool() and assert the options are ToolCallingChatOptions with one callback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the old probe tested the mock, not the fix. Rewritten as MessageConverterProviderNeutralOptionsTest: it drives the converter directly, asserts the tool callbacks land on the options, and (via a logback ListAppender) that exactly one warning is logged and not repeated on a second call; a second test locks the no-warning path for real ToolCallingChatOptions defaults.

@dosadczuk dosadczuk added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. needs update and removed needs review labels Nov 13, 2026
…ions

ChatModel.getOptions() has an interface default returning an empty
DefaultChatOptions, so delegating/wrapping ChatModels that do not
override it yield non-ToolCallingChatOptions defaults. With ADK tools
attached, buildChatOptions then constructs provider-neutral
ToolCallingChatOptions, and provider models that cast
Prompt.getOptions() to their own type (e.g. OpenAiChatModel
.createRequest) fail with a bare ClassCastException several frames away
from the cause.

Log an actionable WARN in that case (dummy/test models that ignore
options keep working); reproduction and analysis in the linked issue.
@caps-xia
caps-xia force-pushed the fix/wrapper-chatmodel-options-cce branch from c68d8c0 to 9aee154 Compare November 15, 2026 09:36
@caps-xia

Copy link
Copy Markdown
Contributor Author

Thanks @dosadczuk for the review! All three points addressed in 9aee154 (single commit): the condition is simplified to the null check with a shortened one-off warning (logged once per MessageConverter), and the old probe test — which indeed tested the mock rather than the fix — is replaced by MessageConverterProviderNeutralOptionsTest, asserting the warning itself (exactly once, via a logback appender) plus the no-warning path for tool-calling defaults. Full module suite green locally (309 tests).

@caps-xia
caps-xia requested a review from dosadczuk November 15, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs update waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[spring-ai] Wrapping ChatModels without getOptions() override fail with ClassCastException when ADK tools are attached

3 participants