Skip to content

#1815 - Fix Gemini OpenAI-compatible guardrail IT sends gpt-5-mini instead of selected Gemini model - #1816

Open
simeshev wants to merge 7 commits into
1.5.0from
bug/1815-gemini-openai-compatible-guardrail-it-sends-gpt-5-mini-instead-of-selected-gemini-model
Open

#1815 - Fix Gemini OpenAI-compatible guardrail IT sends gpt-5-mini instead of selected Gemini model#1816
simeshev wants to merge 7 commits into
1.5.0from
bug/1815-gemini-openai-compatible-guardrail-it-sends-gpt-5-mini-instead-of-selected-gemini-model

Conversation

@simeshev

Copy link
Copy Markdown
Collaborator

Summary

Fixes OpenAI-compatible services sending requests with Spring AI/OpenAI’s default gpt-5-mini instead of the configured Embabel model.

Spring AI 2.0 does not merge OpenAiChatModel default options into runtime OpenAiChatOptions when request options are present. Embabel always supplies runtime options, so OpenAI-compatible providers need the resolved model id bound directly onto those request options.

Changes

  • Added withOpenAiModel(model) for OptionsConverter<*>.
  • Applied model binding to:
    • OpenAI-compatible factory services
    • Native OpenAI autoconfiguration
    • OpenAI custom autoconfiguration
    • Docker local model autoconfiguration
  • Preserved delegate converter behavior for temperature, topP, token limits, penalties, GPT-5 temperature omission, and MiniMax temperature clamping.
  • Added regression coverage for:
    • Converter-level model binding and validation
    • Factory-created OpenAI-compatible services
    • Prompt-level model propagation
    • OpenAI, OpenAI custom, Docker, and MiniMax wiring paths

Verification

  • mvn -fae clean verify passed across all 73 modules.
  • Targeted modified/added tests passed.
  • git diff --check and git diff --cached --check passed.

…stead of selected Gemini model

Bind the resolved service model onto request-level OpenAI chat options so Spring AI 2.0 does not fall back to `gpt-5-mini` for Gemini, MiniMax, LM Studio, Docker, custom OpenAI-compatible, or OpenAI services.

Add regression coverage for converter wrapping, factory-created services, and direct OpenAI-compatible autoconfiguration paths.

Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
Copilot AI review requested due to automatic review settings July 22, 2026 19:19

Copilot AI 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.

Pull request overview

This PR fixes OpenAI-compatible providers sending requests with Spring AI/OpenAI’s default model (gpt-5-mini) instead of the Embabel-configured model by explicitly binding the resolved model id onto request-level OpenAiChatOptions.

Changes:

  • Added OptionsConverter<*>.withOpenAiModel(model) to force the service’s resolved model onto request-level OpenAiChatOptions.
  • Applied model binding across OpenAI-compatible factory services and multiple autoconfiguration paths (OpenAI, OpenAI custom, Docker local models, MiniMax).
  • Added regression tests to ensure model propagation and delegate option preservation across converters and wiring paths.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
embabel-agent-openai/src/test/kotlin/com/embabel/agent/openai/OpenAiModelBindingOptionsConverterTest.kt New converter-focused regression coverage for model binding, validation, and preservation of delegate behavior.
embabel-agent-openai/src/test/kotlin/com/embabel/agent/openai/OpenAiCompatibleModelFactoryTest.kt Verifies factory-created OpenAI-compatible services and prompt options carry the service model.
embabel-agent-openai/src/main/kotlin/com/embabel/agent/openai/OpenAiCompatibleModelFactory.kt Wraps the provided options converter so request options are pinned to the resolved service model.
embabel-agent-openai/src/main/kotlin/com/embabel/agent/openai/converters.kt Introduces withOpenAiModel wrapper for binding the model onto request-level OpenAiChatOptions.
embabel-agent-autoconfigure/models/embabel-agent-openai-custom-autoconfigure/src/test/java/com/embabel/agent/autoconfigure/models/openai/custom/AgentOpenAiCustomAutoConfigurationTest.java Adds regression ensuring custom OpenAI-compatible services bind configured model on request options.
embabel-agent-autoconfigure/models/embabel-agent-openai-custom-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/openai/custom/OpenAiCustomModelsConfig.kt Ensures custom OpenAI model services bind their model id onto request options.
embabel-agent-autoconfigure/models/embabel-agent-openai-autoconfigure/src/test/java/com/embabel/agent/autoconfigure/models/openai/AgentOpenAiAutoConfigurationTest.java New regression test ensuring standard OpenAI wiring binds configured model on request options.
embabel-agent-autoconfigure/models/embabel-agent-openai-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/openai/OpenAiModelsConfig.kt Applies model binding to standard OpenAI model service creation.
embabel-agent-autoconfigure/models/embabel-agent-minimax-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/minimax/MiniMaxOptionsConverterTest.kt Adds regression coverage for binding MiniMax’s configured model id on OpenAI-compatible options.
embabel-agent-autoconfigure/models/embabel-agent-dockermodels-autoconfigure/src/test/java/com/embabel/agent/autoconfigure/models/docker/AgentDockerModelsAutoConfigurationTest.java Adds integration-style test ensuring discovered Docker models bind discovered id onto request options.
embabel-agent-autoconfigure/models/embabel-agent-dockermodels-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/docker/DockerLocalModelsConfig.kt Ensures Docker-discovered OpenAI-compatible models bind their id onto request-level options.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread embabel-agent-openai/src/main/kotlin/com/embabel/agent/openai/converters.kt Outdated
Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
@simeshev simeshev closed this Jul 22, 2026
@simeshev simeshev reopened this Jul 22, 2026
@simeshev
simeshev changed the base branch from main to 1.5.0 July 22, 2026 20:31
…NAI_API_KEY

                  DockerLocalModelsConfig.dockerLlmOf built its OpenAiChatModel with only the
                  sync client:

                      OpenAiChatModel.builder()
                          .openAiClient(openAiClient)   // sync client only
                          ...
                          .build()

                  In Spring AI 2.0, OpenAiChatModel.Builder.build() also requires an async
                  client. When .openAiClientAsync(...) is not supplied, the builder falls back
                  to OpenAiSetup.setupAsyncClient(...), which constructs a fresh client and
                  demands a credential from the environment (OPENAI_API_KEY).

                  Docker local endpoints have no API key, so on any environment where
                  OPENAI_API_KEY is unset (including CI) that fallback threw:

                      IllegalStateException: At least one credential source must be specified:
                      credential (apiKey), workloadIdentity, or adminApiKey

                  The per-model catch in dockerLocalModelsInitializer swallowed the exception
                  ("Failed to register Docker model ..."), so the model bean was never
                  registered. This was a latent production bug: any real Docker model would
                  silently fail to register whenever OPENAI_API_KEY was absent. The new
                  AgentDockerModelsAutoConfigurationTest regression test made the failure
                  visible, failing at context.getBean("dockerModel-docker-test-model") with
                  NoSuchBeanDefinitionException.

                  Root cause is a Spring AI 2.0 migration gap. The old OpenAiApi was replaced
                  by the openai-java SDK, which has separate sync/async clients.
                  OpenAiCompatibleModelFactory already handles this correctly by building both
                  clients from resolved credentials and wiring both into the chat model; the
                  Docker config was only updated to build the sync client and so inherited the
                  async-fallback trap.

                  Fix mirrors the factory: build an openAiClientAsync with the same "no-auth"
                  placeholder key and wire .openAiClientAsync(openAiClientAsync) into the
                  OpenAiChatModel builder, keeping model construction independent of ambient
                  environment credentials.

Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
@igordayen

Copy link
Copy Markdown
Contributor

Root cause:

Spring AI 2.0 changed merge behavior — when per-request options are present, the model bean's default options no
longer take effect. Embabel always passes per-request options (via AnthropicOptionsConverter). Those options are built
from AnthropicChatOptions.builder(), which bakes in model = DEFAULT_MODEL = "claude-haiku-4-5" in the constructor. So
every API call goes to haiku regardless of which embabel model was selected.

So, same in Anthropic.
Need to research a generic solution across all models then.

@igordayen igordayen 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.

@simeshev - I'm testing a generic solution applicable to all LLM providers. once gets finalized, it supersedes this PR

@azanux, @jasperblues @alexheifetz @johnsonr

simeshev added 2 commits July 22, 2026 21:30
Spring AI 2.0 stopped merging a ChatModel's configured options into a prompt
that already carries per-request options: OpenAiChatModel and
AnthropicChatModel buildRequestPrompt return the prompt unchanged when
getOptions() != null. Embabel always supplies per-request options via its
OptionsConverter, and provider option types coerce a null model to a baked-in
default in their constructor (OpenAiChatOptions -> gpt-5-mini,
AnthropicChatOptions -> claude-haiku-4-5). Net effect: every call ignored the
selected model. This is the same root cause as #1815 (OpenAI/Gemini) and it
also affects Anthropic and any OpenAI-compatible provider.

Fix it once at the single chokepoint. SpringAiLlmService now binds the model
configured on the underlying ChatModel (chatModel.getOptions().getModel())
onto the converted request options in both createMessageSender and
createMessageStreamer:
```
    internal fun bindModel(options: ChatOptions, model: String?): ChatOptions =
        if (model.isNullOrBlank()) options
        else options.mutate().model(model).build()
```
- Source is the bean's configured model, not the service name, so there is no
  name == wire-id assumption; it restores the pre-2.0 merge semantics.
- mutate() dispatches to each provider's overridden builder, preserving the
  concrete option type (OpenAiChatOptions/AnthropicChatOptions) and all fields.
- Null/blank guard leaves converter output untouched for models that expose no
  configured default (bare doubles, exotic providers).

The per-site withOpenAiModel() wrappers (the #1815 fix) are now redundant but
harmless: they bind model = modelId, then this layer re-binds the identical id
read from the same bean, so the operation is idempotent. They can be retired in
a follow-up; left in place here to keep the change focused.

Tests (test-first): SpringAiLlmServiceModelBindingTest reproduces the bug
(red: expected gpt-4.1 but was gpt-5-mini) then passes, plus direct bindModel
unit tests; AnthropicModelBindingTest proves end-to-end that the selected
Sonnet model reaches the wire via the unchanged AnthropicOptionsConverter.
Updated three tests whose strict ChatModel mocks now need a getOptions() stub.

Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
…l binding

SpringAiLlmService now binds the selected model onto request options for
every provider (reading it from the ChatModel's configured options), so the
OpenAI-compatible withOpenAiModel() wrapper is redundant: each call site was
just re-binding, idempotently, the same model id the generic layer already
reads from the same bean. Two mechanisms doing one job is worse than one.

Production:
- Delete the withOpenAiModel extension and pass the raw converter at all four
  call sites: OpenAiCompatibleModelFactory (Gemini/MiniMax/LM Studio/BYOK),
  OpenAiModelsConfig, OpenAiCustomModelsConfig, DockerLocalModelsConfig.

Tests:
- Delete OpenAiModelBindingOptionsConverterTest (covered the wrapper; its
  override/field-preservation coverage now lives in
  SpringAiLlmServiceModelBindingTest).
- OpenAiCompatibleModelFactoryTest: replace the two converter-level binding
  tests with one asserting the load-bearing invariant — the factory bakes the
  model into ChatModel.getOptions(), which the generic binder reads.
- Retarget the Docker/OpenAI/OpenAI-custom autoconfig regression tests to
  assert service.getChatModel().getOptions().getModel() instead of converter
  output, keeping config-site coverage at the correct layer.
- Drop the wrapper test from MiniMaxOptionsConverterTest.

No behavior change: binding moves from the converter to the shared call-time
path. Model construction still bakes the selected model into every ChatModel
bean, so the wire model is unchanged.

Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
@simeshev

Copy link
Copy Markdown
Collaborator Author

@igordayen - I have generalized the fix and removed OpenAI - specific converter.

simeshev added 2 commits July 22, 2026 22:51
…ches the wire

Unit tests with mocks can prove request options carry the right model, but
not that the provider actually served it. These live ITs close that gap and
guard both variants of #1815:
- cross-endpoint (Gemini via OpenAI-compat): a wrong model id is rejected by
  the endpoint, so a successful call is itself the proof;
- same-provider silent (OpenAI, Anthropic): both the selected and the default
  model are valid, so the only tell is the model the provider reports back.

To assert the served model, surface it: LlmMessageResponse gains a `model`
field, populated in SpringAiLlmMessageSender from the response metadata
(also useful observability — which model actually answered).

ITs (gated by *_API_KEY, aborted rather than failed when the key lacks model
access, so CI-safe):
- OpenAiServedModelBindingIT: served model is the selected gpt-5.4, not the
  gpt-5-mini OpenAiChatOptions default.
- GeminiServedModelBindingIT: blocking + streaming calls reach the Gemini
  OpenAI-compatible endpoint with a gemini model (the reported #1815 case).
- AnthropicServedModelBindingIT: served a sonnet, not the claude-haiku-4-5
  default.
All four pass against live OpenAI/Gemini/Anthropic APIs.

Reading response metadata.model tripped strict ChatResponseMetadata mocks
that only stubbed usage; made those mocks relaxed in the four affected test
files so getModel() returns "" instead of throwing.

Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
…ches the wire

Unit tests with mocks can prove request options carry the right model, but
not that the provider actually served it. These live ITs close that gap and
guard both variants of #1815:
- cross-endpoint (Gemini via OpenAI-compat): a wrong model id is rejected by
  the endpoint, so a successful call is itself the proof;
- same-provider silent (OpenAI, Anthropic): both the selected and the default
  model are valid, so the only tell is the model the provider reports back.

To assert the served model, surface it: LlmMessageResponse gains a `model`
field, populated in SpringAiLlmMessageSender from the response metadata
(also useful observability — which model actually answered).

ITs (gated by *_API_KEY, aborted rather than failed when the key lacks model
access, so CI-safe):
- OpenAiServedModelBindingIT: served model is the selected gpt-5.4, not the
  gpt-5-mini OpenAiChatOptions default.
- GeminiServedModelBindingIT: blocking + streaming calls reach the Gemini
  OpenAI-compatible endpoint with a gemini model (the reported #1815 case).
- AnthropicServedModelBindingIT: served a sonnet, not the claude-haiku-4-5
  default.
All four pass against live OpenAI/Gemini/Anthropic APIs.

Reading response metadata.model tripped strict ChatResponseMetadata mocks
that only stubbed usage; made those mocks relaxed in the four affected test
files so getModel() returns "" instead of throwing.

Signed-off-by: Slava Imeshev <imeshev@yahoo.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
69.4% Coverage on New Code (required ≥ 73%)

See analysis details on SonarQube Cloud

@igordayen

igordayen commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The call chain for a normal doTransform call:

ToolLoopLlmOperations.doTransform()
└─ calls createMessageSender(options) ← virtual dispatch
└─ ChatClientLlmOperations.createMessageSender() ← OVERRIDE wins
└─ was: optionsConverter.convertOptions(options) → haiku ✗
└─ ours: springAiLlm.buildChatOptions(options) → sonnet ✓

The PR fixes SpringAiLlmService.createMessageSender — but that method is never reached because ChatClientLlmOperations
overrides it completely. It doesn't call super.createMessageSender(). Java/Kotlin virtual dispatch goes to the most
specific override:

// SpringAiLlmService — PR fixes this
override fun createMessageSender(options: LlmOptions): LlmMessageSender {
val chatOptions = bindConfiguredModel(optionsConverter.convertOptions(options)) // ← never called
...
}

// ChatClientLlmOperations — overrides above, PR doesn't touch this
override fun createMessageSender(options: LlmOptions): LlmMessageSender {
val chatOptions = springAiLlm.buildChatOptions(options) // ← our fix
// was: springAiLlm.optionsConverter.convertOptions(options) // ← haiku, what PR leaves behind
...
}

Similarly for the two extra sites in ChatClientLlmOperations (lines 351 and 510) and both sites in
StreamingChatClientOperations — they call optionsConverter.convertOptions() directly, completely bypassing any fix in
SpringAiLlmService.

The PR's approach works for providers that use SpringAiLlmService directly without overriding createMessageSender.

@igordayen igordayen 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.

@simeshev - may I suggest considering the PR I just finally released:
#1818
Upon extensive testing of:

  • embabel-examples
  • embabel-agent-experimental
  • edge cases

100% Coverage on new code, Sonar clean.

Note - embabel-agent-experimental includes testing of LlmMessageSender providers directly.

Thank you for implementing a generalized approach. Appreciate if you could consider adding your newly added IT tests on top of #1818

Thanks

val message: Message,
val textContent: String,
val usage: Usage? = null,
val model: String? = null,

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.

Is this enhancement not in scope for this PR?

@@ -139,4 +139,3 @@ class UnfoldingToolInjectionStrategy : ToolInjectionStrategy {
val INSTANCE = UnfoldingToolInjectionStrategy()
}

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.

How is this related?

message = embabelMessage,
textContent = assistantMessage.text ?: "",
usage = usage,
model = response.metadata?.model,

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.

Sonar violation

* [model] is null or blank, so models that expose no configured default (bare test doubles,
* exotic providers) keep the converter's output.
*/
internal fun bindModel(options: ChatOptions, model: String?): ChatOptions =

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.

If the model is null or blank, you'd just be calling .mutate().build(), which clones the options unchanged — same result as returning options directly.

.joinToString(" | ")
.lowercase()
return listOf(
"not_found",

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.

How was it sourced? sufficiently representative?

*
* Requires GEMINI_API_KEY. Skipped (aborted) if the key lacks access to the chosen model.
*/
class GeminiServedModelBindingIT {

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.

nice addition

@igordayen

igordayen commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

@simeshev, could you please consider adding your newly added IT tests on top of #1818? Thanks.
@simeshev - Slava, do you still want to keep this PR open? Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gemini OpenAI-compatible guardrail IT sends gpt-5-mini instead of selected Gemini model

4 participants