package vip.mate.llm.chatmodel; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.openai.api.OpenAiApi; import vip.mate.llm.model.ModelFamily; import vip.mate.llm.model.ModelProviderEntity; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Provider-aware rewrites applied to an outbound OpenAI-compatible * {@link OpenAiApi.ChatCompletionRequest} just before it hits the wire. * *
{@link OpenAiCompatibleChatModelBuilder} runs these in a fixed order on both * the blocking and streaming chat-completion paths. Each method is a pure * transformation: it returns the original request unchanged when it has nothing * to do, or a rebuilt request (the Spring AI record is immutable) otherwise. * *
The rewrites exist because OpenAI-compatible providers diverge in ways * Spring AI's {@code OpenAiChatOptions} cannot express — reasoning-content * replay contracts, reasoning-effort acceptance, strict tool-choice validation, * video media encoding, and Kimi's built-in web search tool. */ @Slf4j final class OpenAiRequestRewriter { private OpenAiRequestRewriter() {} // ==================== reasoning_content patching ==================== /** * Consume the {@link AssistantThinkingRelay} entry and rebuild the outbound * request so assistant tool-call / thinking messages carry the correct * {@code reasoning_content}. * *
This is the consumer side of the relay. The producer * ({@code NodeStreamingChatHelper}) stashes per-assistant thinking keyed on a * token embedded in {@code request.user()}. Here we: *
The relay iterator advances for every assistant message (including
* prior-turn ones) to stay positionally aligned with the producer's extraction.
*/
static OpenAiApi.ChatCompletionRequest patchReasoningContent(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
// 1. Consume relay (if any) and compute the sanitized user field.
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(request.user());
String sanitizedUser = (entry != null)
? entry.originalUser()
: (AssistantThinkingRelay.isToken(request.user()) ? null : request.user());
// 2. Detect thinking mode — relay presence is also a trigger.
boolean thinkingMode = request.reasoningEffort() != null
|| requiresReasoningContentPatch(request.model())
|| request.messages().stream().anyMatch(m ->
m.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& m.reasoningContent() != null)
|| entry != null;
if (!thinkingMode) {
// Nothing to patch but we may still need to strip a leaked relay token from user.
return request.user() != null && !request.user().equals(sanitizedUser)
? rebuildWithUser(request, sanitizedUser)
: request;
}
// 3. Find lastUserIdx so we can skip cross-turn assistants.
int lastUserIdx = -1;
for (int i = request.messages().size() - 1; i >= 0; i--) {
if (request.messages().get(i).role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
lastUserIdx = i;
break;
}
}
FallbackPolicy policy = FallbackPolicy.forProvider(provider);
java.util.Iterator {@code DEFAULT} keeps the legacy {@code " "} tolerance rather than going
* no-op: an unrecognized provider (self-hosted DeepSeek-like backend, custom
* OpenAI-compatible gateway) might still require the patch.
*/
private enum FallbackPolicy {
DEEPSEEK (" ", false, true, true),
KIMI (" ", false, false, false),
OPENAI (" ", false, false, false),
XIAOMI_MIMO (" ", false, true, true),
DEFAULT (" ", false, false, false);
final String emptyFallback;
final boolean warnOnMissingReal;
final boolean patchNonToolCall;
/** Whether to also patch prior-turn assistants ({@code i <= lastUserIdx}). */
final boolean patchCrossTurn;
FallbackPolicy(String emptyFallback, boolean warnOnMissingReal,
boolean patchNonToolCall, boolean patchCrossTurn) {
this.emptyFallback = emptyFallback;
this.warnOnMissingReal = warnOnMissingReal;
this.patchNonToolCall = patchNonToolCall;
this.patchCrossTurn = patchCrossTurn;
}
static FallbackPolicy forProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return DEFAULT;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI;
case "xiaomi-mimo" -> XIAOMI_MIMO;
default -> DEFAULT;
};
}
}
/**
* Rebuild a request with only the {@code user} field replaced. Used when
* {@link #patchReasoningContent} has no assistant-message changes but must
* strip a relay token from the outbound {@code user} field.
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithUser(
OpenAiApi.ChatCompletionRequest request, String newUser) {
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(),
request.toolChoice(),
request.parallelToolCalls(),
newUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
private static boolean requiresReasoningContentPatch(String modelName) {
ModelFamily family = ModelFamily.detect(modelName);
return family.isThinking();
}
/**
* Look up cached reasoning content for cross-turn assistant messages.
* Returns the cached value, or {@code null} if no cache hit (caller falls
* back to the policy's empty fallback).
*/
private static String resolveCrossTurnReasoning(
OpenAiApi.ChatCompletionMessage msg, boolean isCrossTurn) {
if (!isCrossTurn) return null;
if (msg.toolCalls() == null || msg.toolCalls().isEmpty()) return null;
List Authoritative judgement uses {@code provider.getProviderId()} as a
* whitelist (default-deny). Only official OpenAI providers may carry
* {@code reasoning_effort}; everything else — known non-supporters and any
* unrecognized providerId (self-hosted gateways, aggregators) — is stripped.
*
* {@code request.model()} is intentionally distrusted here: the failover
* chain can reuse the same {@code OpenAiChatOptions} across providers, so a
* failover hop from a GPT-5 primary to DeepSeek would still carry model name
* "gpt-5". Checking only the model family would let the primary's
* {@code reasoning_effort} leak to DeepSeek.
*
* Only when the provider is whitelisted do we fall through to the
* {@link ModelFamily} check.
*/
static OpenAiApi.ChatCompletionRequest sanitizeReasoningEffortForProvider(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request == null || request.reasoningEffort() == null) {
return request;
}
if (!isReasoningEffortWhitelistedProvider(provider)) {
log.warn("[reasoning_effort sanitizer] provider={} is not on the reasoning_effort "
+ "whitelist (only openai/azure-openai are); stripping value='{}' "
+ "(request.model()='{}' may be leaked from failover primary).",
providerIdOrUnknown(provider), request.reasoningEffort(), request.model());
return rebuildWithReasoningEffort(request, null);
}
ModelFamily targetFamily = ModelFamily.detect(request.model());
if (!targetFamily.supportsReasoningEffort()) {
log.warn("[reasoning_effort sanitizer] provider={} model={} family={} does not "
+ "support reasoning_effort; stripping value='{}'.",
provider.getProviderId(), request.model(), targetFamily, request.reasoningEffort());
return rebuildWithReasoningEffort(request, null);
}
return request;
}
/**
* Whitelist of providers known to accept {@code reasoning_effort} on
* {@code /v1/chat/completions} (or {@code /v1/responses}). Anything else is
* denied. Adding a provider here must come with a corresponding test case.
*/
static boolean isReasoningEffortWhitelistedProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return false;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "openai", "azure-openai" -> true;
default -> false;
};
}
private static String providerIdOrUnknown(ModelProviderEntity p) {
return (p == null || p.getProviderId() == null) ? " When a gpt-5* model carries both, {@code reasoning_effort} is removed and
* a warning is logged. To use {@code reasoning_effort}, switch to the
* {@code /v1/responses} endpoint via the {@code completionsPath} generate kwarg.
*/
static OpenAiApi.ChatCompletionRequest stripReasoningEffortIfIncompatible(
OpenAiApi.ChatCompletionRequest request) {
if (request.reasoningEffort() == null) {
return request;
}
if (request.tools() == null || request.tools().isEmpty()) {
return request;
}
String model = request.model();
if (model == null || !model.trim().toLowerCase().startsWith("gpt-5")) {
return request;
}
log.warn("[GPT-5 compat] model {} carries both tools and reasoning_effort on "
+ "chat/completions; removing reasoning_effort to avoid a 400. "
+ "To use reasoning_effort, set completionsPath to /v1/responses",
model);
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(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
null, // reasoningEffort — removed
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
// ==================== tool_choice / media ====================
/**
* Strip {@code tool_choice="auto"} from outbound requests.
*
* Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is
* non-empty is equivalent to {@code "auto"}. Stripping the explicit literal:
* Explicit values other than {@code "auto"} are passed through unchanged.
*/
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()
);
}
/**
* Convert video content blocks that Spring AI mis-serializes as
* {@code image_url} into {@code video_url} format.
*
* Spring AI's {@code MediaContent} has no video_url type, so every non-audio
* / non-pdf media block is serialized as {@code image_url}. Models such as
* Zhipu GLM-5V require video to use {@code video_url}; otherwise they report
* an image parse error. This walks user-message content and rewrites any
* {@code data:video/*} {@code image_url} into {@code video_url}.
*/
@SuppressWarnings("unchecked")
static OpenAiApi.ChatCompletionRequest patchVideoMediaContent(OpenAiApi.ChatCompletionRequest request) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
boolean needsPatch = false;
for (var msg : request.messages()) {
if (msg.role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
Object raw = msg.rawContent();
if (raw instanceof List> parts) {
for (Object part : parts) {
// MediaContent record
if (part instanceof OpenAiApi.ChatCompletionMessage.MediaContent mc
&& "image_url".equals(mc.type())
&& mc.imageUrl() != null
&& mc.imageUrl().url() != null
&& mc.imageUrl().url().startsWith("data:video/")) {
needsPatch = true;
break;
}
// Map form (Spring AI represents content parts as LinkedHashMap internally)
if (part instanceof java.util.Map,?> map) {
Object type = map.get("type");
if ("image_url".equals(type)) {
Object imgUrlObj = map.get("image_url");
if (imgUrlObj instanceof java.util.Map,?> imgUrl) {
Object url = imgUrl.get("url");
if (url instanceof String urlStr && urlStr.startsWith("data:video/")) {
needsPatch = true;
break;
}
}
}
}
}
}
}
if (needsPatch) break;
}
if (!needsPatch) {
return request;
}
List
*
*
*
*
*
*