fix(agent): strip tool_choice="auto" so strict OpenAI-compatible servers accept the request

Some self-hosted OpenAI-compatible serving frameworks return a 400 Bad Request
with a generic Pydantic "body=None / Field required" error when the outbound
request carries tool_choice="auto" but the server was launched without an
auto-tool-choice opt-in flag. The error message hides the real cause: the
request is rejected at validation time before the body is parsed, so the
upstream client sees only the generic body-missing error.

Per the OpenAI spec, omitting tool_choice when tools is non-empty is
functionally equivalent to "auto" — the server defaults to auto-pick.
Adding a stripAutoToolChoice patcher to the buildOpenAiApi chain:

- changes nothing on compliant servers (OpenAI / DashScope / DeepSeek / Kimi
  default to auto when tools are present)
- unblocks strict OpenAI-compatible self-hosted endpoints

Explicit values other than "auto" ({"none", "required", or a function
descriptor}) are passed through unchanged.

Run on both chatCompletionEntity and chatCompletionStream paths so both
buffered and streaming calls benefit.
This commit is contained in:
matevip 2026-04-27 20:35:24 +08:00
parent 03c8584910
commit 4898b79d49

View File

@ -1058,6 +1058,7 @@ public class AgentGraphBuilder {
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = patchReasoningContent(chatRequest, provider);
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = stripAutoToolChoice(chatRequest);
chatRequest = patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = injectKimiWebSearch(chatRequest);
@ -1078,6 +1079,7 @@ public class AgentGraphBuilder {
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = patchReasoningContent(chatRequest, provider);
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = stripAutoToolChoice(chatRequest);
chatRequest = patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
chatRequest = injectKimiWebSearch(chatRequest);
@ -1857,6 +1859,65 @@ public class AgentGraphBuilder {
return family.isThinking();
}
/**
* Strip {@code tool_choice="auto"} from outbound chat-completion requests.
*
* <p>Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is non-empty
* is functionally equivalent to {@code "auto"} (the server defaults to auto-pick).
* Stripping the explicit literal {@code "auto"}:
* <ul>
* <li>does not change behavior on compliant servers (e.g. OpenAI, DashScope) they
* still default to auto when tools are present</li>
* <li>unblocks strict OpenAI-compatible self-hosted serving frameworks that reject
* {@code tool_choice="auto"} at request validation time unless launched with an
* auto-tool-choice opt-in flag, which is a common reason custom endpoints
* respond with a generic 400 / "body=None" Pydantic error</li>
* </ul>
*
* <p>Explicit values other than {@code "auto"} ({@code "none"}, {@code "required"},
* or a specific function descriptor) are passed through unchanged.
*/
private static OpenAiApi.ChatCompletionRequest stripAutoToolChoice(OpenAiApi.ChatCompletionRequest request) {
Object tc = request.toolChoice();
if (tc == null || !"auto".equals(String.valueOf(tc))) {
return request;
}
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
null, // toolChoice strip "auto" so strict OpenAI-compatible servers accept the request
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* Spring AI 错误地序列化为 image_url 的视频内容块转换为 video_url 格式
* <p>