mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
test(agent): cover patchReasoningContent consumer
This commit is contained in:
parent
45bae0c8ab
commit
84370566de
@ -408,7 +408,14 @@ public class AgentGraphBuilder {
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
|
||||
// PR-1.2 (RFC-049 L1-B): propagate the bound model's capability so ReasoningNode
|
||||
// can gate the ThinkingLevelHolder override explicitly, rather than inferring
|
||||
// capability from reasoningEffort == null.
|
||||
boolean supportsReasoningEffort = primaryModelConfig != null
|
||||
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort();
|
||||
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
|
||||
supportsReasoningEffort,
|
||||
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
|
||||
ActionNode actionNode = new ActionNode(executor, streamTracker);
|
||||
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
|
||||
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
|
||||
@ -1037,7 +1044,8 @@ public class AgentGraphBuilder {
|
||||
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
|
||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||
MultiValueMap<String, String> additionalHttpHeader) {
|
||||
chatRequest = patchReasoningContent(chatRequest);
|
||||
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||
chatRequest = patchReasoningContent(chatRequest, provider);
|
||||
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
|
||||
chatRequest = patchVideoMediaContent(chatRequest);
|
||||
if (kimiSearchEnabled) {
|
||||
@ -1056,7 +1064,8 @@ public class AgentGraphBuilder {
|
||||
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
|
||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||
MultiValueMap<String, String> additionalHttpHeader) {
|
||||
chatRequest = patchReasoningContent(chatRequest);
|
||||
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||
chatRequest = patchReasoningContent(chatRequest, provider);
|
||||
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
|
||||
chatRequest = patchVideoMediaContent(chatRequest);
|
||||
if (kimiSearchEnabled) {
|
||||
@ -1108,13 +1117,28 @@ public class AgentGraphBuilder {
|
||||
}
|
||||
|
||||
private String resolveReasoningEffort(String modelName, Map<String, Object> kwargs, ModelFamily family) {
|
||||
// generateKwargs 显式覆盖始终优先
|
||||
// PR-1.1 (RFC-049 L1-A): Only families that actually accept reasoning_effort may receive
|
||||
// it. Previously only the default-inject branch checked capability; the generateKwargs
|
||||
// override branch did not, so a provider-level `reasoningEffort: "high"` would leak to
|
||||
// deepseek-chat / kimi-k2 / deepseek-reasoner etc., triggering the incident documented
|
||||
// in RFC-049 (DeepSeek "reasoning_content missing" 400).
|
||||
if (!family.supportsReasoningEffort()) {
|
||||
Object overridden = findOptionValue(kwargs, "reasoningEffort");
|
||||
if (overridden != null) {
|
||||
log.warn("Dropping reasoningEffort='{}' from generateKwargs — model '{}' (family={}) "
|
||||
+ "does not accept reasoning_effort. For DeepSeek thinking use "
|
||||
+ "extra_body.thinking; for Kimi thinking the model activates it natively.",
|
||||
overridden, modelName, family);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// generateKwargs 显式覆盖始终优先(仅在白名单族内)
|
||||
Object value = findOptionValue(kwargs, "reasoningEffort");
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
return text.trim();
|
||||
}
|
||||
// 仅支持 reasoning_effort 的模型族才自动注入默认值
|
||||
if (family.isThinking() && family.supportsReasoningEffort()) {
|
||||
if (family.isThinking()) {
|
||||
return "medium";
|
||||
}
|
||||
return null;
|
||||
@ -1376,62 +1400,135 @@ public class AgentGraphBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* 修补 assistant 消息缺失的 reasoningContent 字段。
|
||||
* <p>
|
||||
* Spring AI 1.1.3 在将 AssistantMessage 转回 ChatCompletionMessage 时不会设置 reasoningContent,
|
||||
* 导致某些启用 thinking 模式的 API(如 Kimi K2.5)在多轮对话中报错:
|
||||
* "thinking is enabled but reasoning_content is missing in assistant tool call message"
|
||||
* <p>
|
||||
* 触发条件(放宽):
|
||||
* <ul>
|
||||
* <li>条件 A:请求明确设置了 reasoningEffort</li>
|
||||
* <li>条件 B:消息历史中已有 assistant 消息携带 reasoningContent(说明模型天然启用了 thinking)</li>
|
||||
* </ul>
|
||||
* 修复策略:为缺失 reasoningContent 的 assistant tool_call 消息注入空字符串 "" 以满足 API 校验。
|
||||
* 使用 record canonical constructor 重建 ChatCompletionRequest,避免反射修改不可变字段。
|
||||
* Consume the {@link AssistantThinkingRelay} entry and rebuild the outbound
|
||||
* {@link OpenAiApi.ChatCompletionRequest} so that assistant tool-call / thinking
|
||||
* messages carry the correct {@code reasoning_content}.
|
||||
*
|
||||
* <p>PR-2 (RFC-049 §2.3.2): This is the consumer side of the relay.
|
||||
* {@code NodeStreamingChatHelper.doStreamCall} stashes per-assistant thinking
|
||||
* keyed on a token embedded in {@code request.user()}. Here we:
|
||||
* <ol>
|
||||
* <li>{@link AssistantThinkingRelay#take(String)} the entry and restore
|
||||
* {@code request.user()} to {@code entry.originalUser()} (internal token
|
||||
* never reaches the provider).</li>
|
||||
* <li>Compute {@code lastUserIdx} (the boundary of the current user turn),
|
||||
* symmetric to {@code stripThinkingFromPrompt}. Assistant messages at
|
||||
* {@code i <= lastUserIdx} are prior-turn history: their
|
||||
* {@code reasoning_content} must stay null. Only {@code i > lastUserIdx}
|
||||
* messages are eligible for patching.</li>
|
||||
* <li>Select a {@link FallbackPolicy} by {@code providerId}. When relay has
|
||||
* a real value, we use it; when empty, the policy decides whether to
|
||||
* inject {@code " "} (legacy tolerance: KIMI/OPENAI/DEFAULT) or leave
|
||||
* {@code null} to surface an explicit provider error (DEEPSEEK).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The relay iterator advances for every assistant message (including
|
||||
* prior-turn ones) to stay positionally aligned with the producer's extraction
|
||||
* in {@code NodeStreamingChatHelper.extractAssistantThinkings}.
|
||||
*/
|
||||
private static OpenAiApi.ChatCompletionRequest patchReasoningContent(OpenAiApi.ChatCompletionRequest request) {
|
||||
static OpenAiApi.ChatCompletionRequest patchReasoningContent(
|
||||
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
|
||||
if (request.messages() == null || request.messages().isEmpty()) {
|
||||
return request;
|
||||
}
|
||||
|
||||
// 判断是否处于 thinking 模式
|
||||
boolean thinkingMode = request.reasoningEffort() != null;
|
||||
// 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 — unchanged from the prior design except that 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) {
|
||||
thinkingMode = requiresReasoningContentPatch(request.model());
|
||||
}
|
||||
if (!thinkingMode) {
|
||||
thinkingMode = request.messages().stream().anyMatch(msg ->
|
||||
msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
|
||||
&& msg.reasoningContent() != null);
|
||||
}
|
||||
if (!thinkingMode) {
|
||||
return request;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 检查是否有需要补丁的消息
|
||||
boolean needsPatch = request.messages().stream().anyMatch(msg ->
|
||||
msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
|
||||
&& msg.toolCalls() != null && !msg.toolCalls().isEmpty()
|
||||
&& msg.reasoningContent() == null);
|
||||
if (!needsPatch) {
|
||||
return request;
|
||||
}
|
||||
|
||||
// 重建消息列表,为缺失 reasoningContent 的 assistant tool call 消息注入 ""
|
||||
List<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
|
||||
if (msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
|
||||
&& msg.toolCalls() != null && !msg.toolCalls().isEmpty()
|
||||
&& msg.reasoningContent() == null) {
|
||||
return new OpenAiApi.ChatCompletionMessage(
|
||||
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
|
||||
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
|
||||
msg.annotations(), " ");
|
||||
// 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;
|
||||
}
|
||||
return msg;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 用 record canonical constructor 重建 request(不用反射)
|
||||
FallbackPolicy policy = FallbackPolicy.forProvider(provider);
|
||||
java.util.Iterator<String> it = (entry != null)
|
||||
? entry.thinkings().iterator()
|
||||
: java.util.Collections.emptyIterator();
|
||||
|
||||
// 4. Walk messages, patching only in-turn assistants; always advance iterator
|
||||
// for all assistants so producer/consumer positions stay aligned.
|
||||
boolean anyPatched = false;
|
||||
List<OpenAiApi.ChatCompletionMessage> patched = new ArrayList<>(request.messages().size());
|
||||
for (int i = 0; i < request.messages().size(); i++) {
|
||||
OpenAiApi.ChatCompletionMessage msg = request.messages().get(i);
|
||||
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.ASSISTANT) {
|
||||
patched.add(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
String next = it.hasNext() ? it.next() : null;
|
||||
|
||||
// Already has a real value: leave alone
|
||||
if (msg.reasoningContent() != null && !msg.reasoningContent().isBlank()) {
|
||||
patched.add(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cross-turn assistant: never patch (symmetric with stripThinkingFromPrompt)
|
||||
if (i <= lastUserIdx) {
|
||||
patched.add(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean hasToolCalls = msg.toolCalls() != null && !msg.toolCalls().isEmpty();
|
||||
if (!hasToolCalls && !policy.patchNonToolCall) {
|
||||
patched.add(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
String injected;
|
||||
if (next != null && !next.isEmpty()) {
|
||||
injected = next;
|
||||
} else {
|
||||
injected = policy.emptyFallback;
|
||||
if (injected == null && policy.warnOnMissingReal) {
|
||||
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
|
||||
+ "but relay has no value for assistant message at index {}; "
|
||||
+ "leaving null so provider returns explicit error.",
|
||||
providerIdOrUnknown(provider), i);
|
||||
}
|
||||
}
|
||||
if (injected == null && msg.reasoningContent() == null) {
|
||||
// No change — keep original
|
||||
patched.add(msg);
|
||||
continue;
|
||||
}
|
||||
patched.add(new OpenAiApi.ChatCompletionMessage(
|
||||
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
|
||||
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
|
||||
msg.annotations(), injected));
|
||||
anyPatched = true;
|
||||
}
|
||||
|
||||
boolean userChanged = request.user() != null && !request.user().equals(sanitizedUser)
|
||||
|| (request.user() == null && sanitizedUser != null);
|
||||
if (!anyPatched && !userChanged) {
|
||||
return request;
|
||||
}
|
||||
|
||||
// 5. Rebuild with patched messages + sanitized user.
|
||||
return new OpenAiApi.ChatCompletionRequest(
|
||||
patched,
|
||||
request.model(),
|
||||
@ -1458,7 +1555,7 @@ public class AgentGraphBuilder {
|
||||
request.tools(),
|
||||
request.toolChoice(),
|
||||
request.parallelToolCalls(),
|
||||
request.user(),
|
||||
sanitizedUser,
|
||||
request.reasoningEffort(),
|
||||
request.webSearchOptions(),
|
||||
request.verbosity(),
|
||||
@ -1468,6 +1565,211 @@ public class AgentGraphBuilder {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-2 (RFC-049 §2.3.2): Provider-keyed policy for how {@code patchReasoningContent}
|
||||
* should behave when the relay has no real thinking for an in-turn assistant message.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code emptyFallback}: value to inject when relay has no real value —
|
||||
* {@code null} means leave {@code reasoning_content} null (DeepSeek);
|
||||
* {@code " "} preserves Spring AI 1.1.4 legacy tolerance (Kimi/OpenAI/unknown).</li>
|
||||
* <li>{@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null} fires —
|
||||
* only DeepSeek wants this, because there a missing value means we have a bug.</li>
|
||||
* <li>{@code patchNonToolCall}: whether to patch assistant messages without tool_calls —
|
||||
* DeepSeek's contract applies to all in-turn assistant messages, others only
|
||||
* to tool_call messages (historical behavior).</li>
|
||||
* </ul>
|
||||
*
|
||||
* {@code DEFAULT} intentionally 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 — noop would regress
|
||||
* those into new 400s.
|
||||
*/
|
||||
private enum FallbackPolicy {
|
||||
DEEPSEEK(null, true, true),
|
||||
KIMI (" ", false, false),
|
||||
OPENAI (" ", false, false),
|
||||
DEFAULT (" ", false, false);
|
||||
|
||||
final String emptyFallback;
|
||||
final boolean warnOnMissingReal;
|
||||
final boolean patchNonToolCall;
|
||||
|
||||
FallbackPolicy(String emptyFallback, boolean warnOnMissingReal, boolean patchNonToolCall) {
|
||||
this.emptyFallback = emptyFallback;
|
||||
this.warnOnMissingReal = warnOnMissingReal;
|
||||
this.patchNonToolCall = patchNonToolCall;
|
||||
}
|
||||
|
||||
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;
|
||||
default -> DEFAULT;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a {@link OpenAiApi.ChatCompletionRequest} with only the {@code user} field
|
||||
* replaced. Used when {@code 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()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-1.3 (RFC-049 L1-C): Provider-first sanitization of {@code reasoning_effort}.
|
||||
*
|
||||
* <p>Authoritative judgement uses the target {@code provider.getProviderId()} as a
|
||||
* whitelist (default-deny). Only OpenAI official providers are allowed to carry
|
||||
* {@code reasoning_effort}; everything else — known non-supporters (DeepSeek / Kimi /
|
||||
* DashScope / Ollama / …) and any unrecognized providerId (self-hosted gateways,
|
||||
* OpenRouter / Together / aggregators) — is stripped unconditionally.
|
||||
*
|
||||
* <p>The reason we intentionally distrust {@code request.model()} here: MateClaw's
|
||||
* failover chain (RFC-009) can reuse the same {@code Prompt} and {@code OpenAiChatOptions}
|
||||
* across providers, and {@code OpenAiChatOptions.model} was set to the primary's model
|
||||
* name (e.g. {@code gpt-5}). If the sanitizer only checked {@code ModelFamily.detect(
|
||||
* request.model())}, a failover hop from GPT-5 → DeepSeek would see model name
|
||||
* "gpt-5" → OPENAI_REASONING → {@code supportsReasoningEffort == true} and quietly
|
||||
* forward the primary's {@code reasoning_effort} to DeepSeek, re-triggering the
|
||||
* incident this RFC exists to fix.
|
||||
*
|
||||
* <p>Only when the provider is on the whitelist do we fall through to the
|
||||
* {@link ModelFamily} check (e.g. within OpenAI, {@code gpt-4} still wouldn't support
|
||||
* reasoning_effort). Outside the whitelist, no runtime check on model is trusted.
|
||||
*
|
||||
* <p>Adding a new provider to the whitelist must be an explicit PR with a sanitizer
|
||||
* test — do not add a catch-all default-allow branch.
|
||||
*/
|
||||
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 sanitizer 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) ? "<unknown>" : p.getProviderId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a {@link OpenAiApi.ChatCompletionRequest} with a new {@code reasoningEffort}
|
||||
* value (typically {@code null} to strip). Mirrors the record canonical-constructor
|
||||
* pattern used by {@link #stripReasoningEffortIfIncompatible}.
|
||||
*/
|
||||
private static OpenAiApi.ChatCompletionRequest rebuildWithReasoningEffort(
|
||||
OpenAiApi.ChatCompletionRequest request, String newReasoningEffort) {
|
||||
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(),
|
||||
newReasoningEffort,
|
||||
request.webSearchOptions(),
|
||||
request.verbosity(),
|
||||
request.promptCacheKey(),
|
||||
request.safetyIdentifier(),
|
||||
request.extraBody()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-5 兼容性:在 /v1/chat/completions 路径下,tools 与 reasoning_effort 不可同时存在。
|
||||
* <p>
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Relays per-request assistant {@code reasoning_content} from the producer
|
||||
* ({@code NodeStreamingChatHelper}, which sees {@code AssistantMessage.metadata})
|
||||
* to the consumer ({@code AgentGraphBuilder.patchReasoningContent}, which rebuilds
|
||||
* the outbound {@code ChatCompletionRequest}).
|
||||
*
|
||||
* <p>Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to
|
||||
* {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the
|
||||
* caller does not propagate across the producer/consumer boundary. The relay
|
||||
* token travels inside the request object itself
|
||||
* ({@code OpenAiApi.ChatCompletionRequest.user}), which survives scheduler hops
|
||||
* without needing Reactor context propagation config.
|
||||
*
|
||||
* <p>The {@link RelayEntry} carries both the per-assistant thinking list and the
|
||||
* caller's <em>original</em> {@code user} field — the producer overwrites
|
||||
* {@code OpenAiChatOptions.user} with the relay token before handing the
|
||||
* {@code Prompt} to Spring AI, so by the time the consumer runs,
|
||||
* {@code request.user()} only contains the token. The consumer restores the
|
||||
* caller's original value from the entry when rebuilding the outbound request.
|
||||
* The internal token is never sent to the provider.
|
||||
*
|
||||
* <p>Ownership: the producer is responsible for calling {@link #discard(String)}
|
||||
* in a {@code finally} block as a belt-and-suspenders cleanup. The consumer's
|
||||
* {@link #take(String)} already removes the entry on the happy path, so
|
||||
* {@code discard} is a no-op in that case; it becomes the only cleanup when the
|
||||
* consumer never runs (e.g., a Reactor error before the request is dispatched).
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class AssistantThinkingRelay {
|
||||
|
||||
/**
|
||||
* Per-request relay payload.
|
||||
*
|
||||
* @param thinkings per-assistant {@code reasoning_content} in message order;
|
||||
* empty string means "this assistant had no thinking"
|
||||
* @param originalUser the caller's original {@code OpenAiChatOptions.user} value
|
||||
* before the producer overwrote it with the relay token;
|
||||
* may be {@code null}
|
||||
*/
|
||||
public record RelayEntry(List<String> thinkings, String originalUser) {
|
||||
public RelayEntry {
|
||||
thinkings = List.copyOf(thinkings);
|
||||
}
|
||||
}
|
||||
|
||||
private static final ConcurrentHashMap<String, RelayEntry> MAP = new ConcurrentHashMap<>();
|
||||
|
||||
/** Prefix must be distinctive enough that a caller-provided {@code user} value
|
||||
* can never collide with a relay token. */
|
||||
public static final String TOKEN_PREFIX = "__mc_thinking_";
|
||||
|
||||
private AssistantThinkingRelay() {}
|
||||
|
||||
/**
|
||||
* Stash per-assistant thinking (in message order) plus the caller's original
|
||||
* {@code user} field. Returns the token to embed in
|
||||
* {@code OpenAiChatOptions.user}.
|
||||
*/
|
||||
public static String stash(List<String> thinkingsInOrder, String originalUser) {
|
||||
String token = TOKEN_PREFIX + UUID.randomUUID();
|
||||
MAP.put(token, new RelayEntry(thinkingsInOrder, originalUser));
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Consume and remove entry. Returns {@code null} if {@code user} is not a
|
||||
* relay token or the entry was already taken. */
|
||||
public static RelayEntry take(String user) {
|
||||
if (!isToken(user)) return null;
|
||||
return MAP.remove(user);
|
||||
}
|
||||
|
||||
/** Whether the given {@code user} field value is a relay token produced by
|
||||
* {@link #stash(List, String)}. */
|
||||
public static boolean isToken(String user) {
|
||||
return user != null && user.startsWith(TOKEN_PREFIX);
|
||||
}
|
||||
|
||||
/** Defensive cleanup; idempotent — safe to call even after {@link #take}. */
|
||||
public static void discard(String token) {
|
||||
if (token != null) MAP.remove(token);
|
||||
}
|
||||
|
||||
// ---------- test hooks ----------
|
||||
|
||||
/** Visible for tests: current map size. Production code must not use. */
|
||||
static int size() {
|
||||
return MAP.size();
|
||||
}
|
||||
|
||||
/** Visible for tests: clear all entries. Production code must not use. */
|
||||
static void clearAll() {
|
||||
MAP.clear();
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,12 @@ package vip.mate.agent.graph;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import vip.mate.agent.AssistantThinkingRelay;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import reactor.core.Disposable;
|
||||
@ -567,6 +569,67 @@ public class NodeStreamingChatHelper {
|
||||
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
|
||||
String conversationId, String phase,
|
||||
boolean broadcast, int attempt) {
|
||||
// PR-2 L4 (RFC-049 §2.4.2): normalize as a pre-egress step (not only on retry).
|
||||
// Strip reasoning_content from prior-turn AssistantMessages (i <= lastUserIdx),
|
||||
// preserving in-turn thinking (i > lastUserIdx) so DeepSeek's contract holds.
|
||||
// The returned Prompt shares `options` by reference with the input prompt.
|
||||
Prompt outbound = stripThinkingFromPrompt(prompt);
|
||||
|
||||
// PR-2 L3 (RFC-049 §2.3.2): producer-side relay stash. Extract per-assistant
|
||||
// thinking from the normalized prompt (cross-turn positions are already "" due
|
||||
// to strip), stash with the caller's original `user` field, and overwrite
|
||||
// `options.user` with the relay token. The consumer in
|
||||
// AgentGraphBuilder.patchReasoningContent restores the original user when
|
||||
// rebuilding the outbound ChatCompletionRequest; the token never reaches the
|
||||
// provider. We only activate relay on OpenAiChatOptions paths — Anthropic has
|
||||
// its own thinking mechanism (extended thinking via AnthropicChatOptions.thinking).
|
||||
String relayToken = null;
|
||||
String originalUser = null;
|
||||
org.springframework.ai.openai.OpenAiChatOptions oaiOptsForRelay = null;
|
||||
if (outbound.getOptions() instanceof org.springframework.ai.openai.OpenAiChatOptions oaiOpts) {
|
||||
List<String> thinkings = extractAssistantThinkings(outbound);
|
||||
if (thinkings.stream().anyMatch(s -> !s.isEmpty())) {
|
||||
originalUser = oaiOpts.getUser();
|
||||
relayToken = AssistantThinkingRelay.stash(thinkings, originalUser);
|
||||
oaiOpts.setUser(relayToken);
|
||||
oaiOptsForRelay = oaiOpts;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt);
|
||||
} finally {
|
||||
// Idempotent: if consumer already took the entry, discard is a no-op.
|
||||
if (relayToken != null) {
|
||||
AssistantThinkingRelay.discard(relayToken);
|
||||
if (oaiOptsForRelay != null) {
|
||||
oaiOptsForRelay.setUser(originalUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-2: Extract per-assistant {@code reasoningContent} from a Prompt's messages in
|
||||
* order. Non-assistant messages are skipped; assistants with no metadata or no
|
||||
* reasoningContent yield {@code ""} so the returned list's positional index aligns
|
||||
* with the assistant-message index as seen by the consumer.
|
||||
*/
|
||||
private static List<String> extractAssistantThinkings(Prompt prompt) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (Message m : prompt.getInstructions()) {
|
||||
if (m instanceof AssistantMessage am) {
|
||||
Map<String, Object> meta = am.getMetadata();
|
||||
Object rc = meta != null ? meta.get("reasoningContent") : null;
|
||||
out.add(rc instanceof String s ? s : "");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
|
||||
String conversationId, String phase,
|
||||
boolean broadcast, int attempt) {
|
||||
if (attempt > 0) {
|
||||
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
||||
// 加入 jitter 防止雷群效应(Hermes 风格)
|
||||
@ -852,9 +915,7 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
}
|
||||
|
||||
AssistantMessage assembledMessage = !finalToolCalls.isEmpty()
|
||||
? AssistantMessage.builder().content(fullContent).toolCalls(finalToolCalls).build()
|
||||
: new AssistantMessage(fullContent);
|
||||
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
|
||||
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
@ -883,15 +944,7 @@ public class NodeStreamingChatHelper {
|
||||
}
|
||||
}
|
||||
|
||||
AssistantMessage assembledMessage;
|
||||
if (!finalToolCalls.isEmpty()) {
|
||||
assembledMessage = AssistantMessage.builder()
|
||||
.content(fullContent)
|
||||
.toolCalls(finalToolCalls)
|
||||
.build();
|
||||
} else {
|
||||
assembledMessage = new AssistantMessage(fullContent);
|
||||
}
|
||||
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
|
||||
|
||||
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
|
||||
return new StreamResult(fullContent, fullThinking, assembledMessage,
|
||||
@ -899,6 +952,33 @@ public class NodeStreamingChatHelper {
|
||||
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-2 L2 (RFC-049): Build an {@link AssistantMessage} that persists the per-turn
|
||||
* {@code fullThinking} into the message's properties under key {@code "reasoningContent"}.
|
||||
*
|
||||
* <p>This is the linchpin of the structural fix: without writing thinking back into
|
||||
* the AssistantMessage that enters the next ReAct round's state, the outbound
|
||||
* request's {@code reasoning_content} is lost (Spring AI 1.1.4's
|
||||
* {@code OpenAiChatModel.lambda$createRequest$20} hardcodes {@code null} on the
|
||||
* outbound conversion, so the relay in {@code AssistantThinkingRelay} is the only
|
||||
* way back — see RFC-049 §2.3 L3).
|
||||
*
|
||||
* <p>Note the Spring AI naming asymmetry: the builder method is
|
||||
* {@code .properties(Map)} but the reader is {@code getMetadata()} (see
|
||||
* {@link #stripThinkingFromPrompt} L937).
|
||||
*/
|
||||
private static AssistantMessage buildAssistantMessageWithThinking(
|
||||
String fullContent, String fullThinking, List<AssistantMessage.ToolCall> finalToolCalls) {
|
||||
AssistantMessage.Builder builder = AssistantMessage.builder().content(fullContent);
|
||||
if (finalToolCalls != null && !finalToolCalls.isEmpty()) {
|
||||
builder.toolCalls(finalToolCalls);
|
||||
}
|
||||
if (fullThinking != null && !fullThinking.isEmpty()) {
|
||||
builder.properties(Map.of("reasoningContent", fullThinking));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token / cache usage to the optional metrics aggregator.
|
||||
* Called only from successful assembly paths ({@link #assembleResult}
|
||||
@ -917,26 +997,47 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
/** 构建纯错误 StreamResult(无任何内容) */
|
||||
/**
|
||||
* 从 Prompt 中剥离旧 AssistantMessage 的 thinking/reasoningContent metadata。
|
||||
* 保留最新一条 AssistantMessage 的 thinking(可能是模型需要的签名)。
|
||||
* Strip {@code reasoningContent} from AssistantMessages that belong to <em>prior</em>
|
||||
* user turns, keeping thinking for messages within the <strong>current</strong> user
|
||||
* turn intact.
|
||||
*
|
||||
* <p>PR-2 L4 (RFC-049 §2.4.1): The old semantics "keep only the last AssistantMessage's
|
||||
* thinking" broke DeepSeek's contract for multi-round tool-calls within a single user
|
||||
* turn (DeepSeek requires all in-turn assistant thinking to be passed back on subsequent
|
||||
* rounds). Now the boundary is the most recent {@link UserMessage}: AssistantMessages at
|
||||
* index {@code <= lastUserIdx} are prior-turn history (their thinking must be stripped
|
||||
* per DeepSeek's "reset across user turns" rule); AssistantMessages at {@code > lastUserIdx}
|
||||
* are in-turn (their thinking must be preserved).
|
||||
*
|
||||
* <p>PR-2 L4 (RFC-049 §2.4.2): This method is called as a normal pre-egress step from
|
||||
* {@link #doStreamCall}, not only from the {@code THINKING_BLOCK_ERROR} retry path. The
|
||||
* retry path still calls it too (idempotent), serving as defensive re-application.
|
||||
*
|
||||
* <p>Note: {@code Prompt.getOptions()} is preserved by reference into the returned
|
||||
* {@code Prompt} (this is existing behavior). Callers rely on that — mutations to
|
||||
* {@code options.user} via {@link AssistantThinkingRelay} must stay visible after
|
||||
* normalize.
|
||||
*/
|
||||
private Prompt stripThinkingFromPrompt(Prompt prompt) {
|
||||
static Prompt stripThinkingFromPrompt(Prompt prompt) {
|
||||
List<Message> messages = prompt.getInstructions();
|
||||
// 找最后一个 AssistantMessage
|
||||
int lastAssistantIdx = -1;
|
||||
|
||||
// Find most recent UserMessage — boundary of the current user turn
|
||||
int lastUserIdx = -1;
|
||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||
if (messages.get(i) instanceof AssistantMessage) {
|
||||
lastAssistantIdx = i;
|
||||
if (messages.get(i) instanceof UserMessage) {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
List<Message> cleaned = new ArrayList<>();
|
||||
|
||||
int strippedCount = 0;
|
||||
List<Message> cleaned = new ArrayList<>(messages.size());
|
||||
for (int i = 0; i < messages.size(); i++) {
|
||||
Message msg = messages.get(i);
|
||||
if (msg instanceof AssistantMessage am && i != lastAssistantIdx) {
|
||||
// Only strip prior-turn assistant thinking (i <= lastUserIdx); in-turn (i > lastUserIdx) stays
|
||||
if (msg instanceof AssistantMessage am && i <= lastUserIdx) {
|
||||
Map<String, Object> meta = am.getMetadata();
|
||||
if (meta != null && meta.containsKey("reasoningContent")) {
|
||||
// 用 builder 重建 AssistantMessage,去掉 reasoningContent
|
||||
Map<String, Object> cleanMeta = new java.util.HashMap<>(meta);
|
||||
cleanMeta.remove("reasoningContent");
|
||||
AssistantMessage.Builder builder = AssistantMessage.builder()
|
||||
@ -949,13 +1050,17 @@ public class NodeStreamingChatHelper {
|
||||
builder.media(am.getMedia());
|
||||
}
|
||||
cleaned.add(builder.build());
|
||||
strippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cleaned.add(msg);
|
||||
}
|
||||
log.info("[ThinkingRecovery] Stripped thinking blocks from {} messages, last assistant at index {}",
|
||||
messages.size(), lastAssistantIdx);
|
||||
if (strippedCount > 0) {
|
||||
log.debug("[ThinkingRecovery] Stripped reasoningContent from {} prior-turn assistant messages "
|
||||
+ "(lastUserIdx={}, total={})",
|
||||
strippedCount, lastUserIdx, messages.size());
|
||||
}
|
||||
return new Prompt(cleaned, prompt.getOptions());
|
||||
}
|
||||
|
||||
|
||||
@ -58,6 +58,13 @@ public class ReasoningNode implements NodeAction {
|
||||
private final ChatModel chatModel;
|
||||
private final List<ToolCallback> toolCallbacks;
|
||||
private final String reasoningEffort;
|
||||
/**
|
||||
* PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts
|
||||
* {@code reasoning_effort}. Drives the capability gate in
|
||||
* {@link #resolveEffectiveReasoningEffort()} so that a front-end {@code ThinkingLevelHolder}
|
||||
* override is dropped on chat-type models that cannot honor it.
|
||||
*/
|
||||
private final boolean supportsReasoningEffort;
|
||||
private final NodeStreamingChatHelper streamingHelper;
|
||||
private final ConversationWindowManager conversationWindowManager;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
@ -86,9 +93,31 @@ public class ReasoningNode implements NodeAction {
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
||||
// Backward-compatible delegate. Callers that have not migrated to the explicit
|
||||
// supportsReasoningEffort parameter inherit the pre-PR-1 behavior: treat the bound
|
||||
// model as supporting reasoning_effort iff reasoningEffort was resolved to a non-null
|
||||
// value at construction time. New callers (AgentGraphBuilder) should use the
|
||||
// 9-arg constructor below.
|
||||
this(chatModel, toolSet, reasoningEffort, reasoningEffort != null,
|
||||
streamingHelper, conversationWindowManager, streamTracker,
|
||||
maxOutputTokens, wikiContextService);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-1.2 (RFC-049): Primary constructor with explicit {@code supportsReasoningEffort}
|
||||
* capability flag — avoids inferring capability from {@code reasoningEffort == null},
|
||||
* which fails for a future "supports but not auto-enabled" scenario.
|
||||
*/
|
||||
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
|
||||
boolean supportsReasoningEffort,
|
||||
NodeStreamingChatHelper streamingHelper,
|
||||
ConversationWindowManager conversationWindowManager,
|
||||
ChatStreamTracker streamTracker, int maxOutputTokens,
|
||||
vip.mate.wiki.service.WikiContextService wikiContextService) {
|
||||
this.chatModel = chatModel;
|
||||
this.toolCallbacks = toolSet.callbacks();
|
||||
this.reasoningEffort = reasoningEffort;
|
||||
this.supportsReasoningEffort = supportsReasoningEffort;
|
||||
this.streamingHelper = streamingHelper;
|
||||
this.conversationWindowManager = conversationWindowManager;
|
||||
this.streamTracker = streamTracker;
|
||||
@ -120,6 +149,7 @@ public class ReasoningNode implements NodeAction {
|
||||
this.chatModel = chatModel;
|
||||
this.toolCallbacks = toolCallbacks;
|
||||
this.reasoningEffort = null;
|
||||
this.supportsReasoningEffort = false;
|
||||
this.streamingHelper = null;
|
||||
this.conversationWindowManager = null;
|
||||
this.streamTracker = null;
|
||||
@ -520,6 +550,13 @@ public class ReasoningNode implements NodeAction {
|
||||
* 解析有效的 reasoningEffort。
|
||||
* 优先级:ThinkingLevelHolder(请求级) > 构造时的 reasoningEffort(Agent/模型默认)。
|
||||
* "off" 会清除 reasoningEffort(返回 null)。
|
||||
*
|
||||
* <p>PR-1.2 (RFC-049 L1-B): If the bound model's family does not support
|
||||
* {@code reasoning_effort} (as declared via {@link #supportsReasoningEffort} at
|
||||
* construction time), the front-end thinking-level override is ignored.
|
||||
* Chat-type models like {@code deepseek-chat} must not be forced into thinking mode
|
||||
* just because the user ticked "deep thinking" in the UI — this is a product
|
||||
* contract, not a runtime option.
|
||||
*/
|
||||
private String resolveEffectiveReasoningEffort() {
|
||||
String requestLevel = ThinkingLevelHolder.get();
|
||||
@ -527,6 +564,11 @@ public class ReasoningNode implements NodeAction {
|
||||
if ("off".equalsIgnoreCase(requestLevel)) {
|
||||
return null;
|
||||
}
|
||||
if (!this.supportsReasoningEffort) {
|
||||
log.debug("[ReasoningNode] Ignoring thinkingLevel='{}' — bound model family does not support reasoning_effort",
|
||||
requestLevel);
|
||||
return null;
|
||||
}
|
||||
// thinkingLevel → reasoningEffort 映射
|
||||
return switch (requestLevel.toLowerCase()) {
|
||||
case "low" -> "low";
|
||||
|
||||
@ -11,7 +11,6 @@ import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.util.StringUtils;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@ -119,20 +118,20 @@ public class StepExecutionNode implements NodeAction {
|
||||
|
||||
try {
|
||||
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
|
||||
ChatOptions options;
|
||||
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
|
||||
// producer in NodeStreamingChatHelper.doStreamCall can attach the
|
||||
// user-token. Using ToolCallingChatOptions when reasoningEffort is
|
||||
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
|
||||
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
|
||||
// would 400 again.
|
||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.build();
|
||||
if (StringUtils.hasText(reasoningEffort)) {
|
||||
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.reasoningEffort(reasoningEffort)
|
||||
.build();
|
||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||
options = oaiOpts;
|
||||
} else {
|
||||
options = ToolCallingChatOptions.builder()
|
||||
.toolCallbacks(toolSet.callbacks())
|
||||
.internalToolExecutionEnabled(false)
|
||||
.build();
|
||||
oaiOpts.setReasoningEffort(reasoningEffort);
|
||||
}
|
||||
oaiOpts.setInternalToolExecutionEnabled(false);
|
||||
ChatOptions options = oaiOpts;
|
||||
|
||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||
chatModel, new Prompt(messages, options), conversationId,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user