mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
refactor(llm): decouple model construction from the agent graph layer (#147)
This commit is contained in:
parent
7b053052d7
commit
a88edbdd07
@ -24,7 +24,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration.class,
|
||||
// DashScopeAgent is the Bailian "Application Agent" (Bailian-hosted prompt+tool app),
|
||||
// not the chat model. We don't use it — model configuration is admin-UI driven and
|
||||
// built by AgentDashScopeChatModelBuilder. Its auto-config strictly requires
|
||||
// built by DashScopeChatModelBuilder. Its auto-config strictly requires
|
||||
// spring.ai.dashscope.api-key to be non-empty at startup, which makes the whole
|
||||
// ApplicationContext fail when users deploy via Docker without setting the key.
|
||||
com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAgentAutoConfiguration.class,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ import vip.mate.agent.event.AgentLifecycleEvent;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
/**
|
||||
* 请求级思考深度的 ThreadLocal 持有器。
|
||||
* <p>
|
||||
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode,
|
||||
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口。
|
||||
* <p>
|
||||
* 支持的值:off / low / medium / high / max,null 表示跟随模型默认。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class ThinkingLevelHolder {
|
||||
|
||||
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ThinkingLevelHolder() {}
|
||||
|
||||
public static void set(String level) {
|
||||
HOLDER.set(level);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的思考级别,null 表示未设置(跟随模型默认)
|
||||
*/
|
||||
public static String get() {
|
||||
return HOLDER.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.routing.AgentBindingResolver;
|
||||
import vip.mate.skill.acp.AcpSkillBridge;
|
||||
import vip.mate.skill.mcp.McpSkillBridge;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
@ -41,7 +42,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AgentBindingService {
|
||||
public class AgentBindingService implements AgentBindingResolver {
|
||||
|
||||
private final AgentSkillBindingMapper skillBindingMapper;
|
||||
private final AgentToolBindingMapper toolBindingMapper;
|
||||
@ -112,6 +113,7 @@ public class AgentBindingService {
|
||||
* 获取 Agent 绑定的 enabled skill ID 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
*/
|
||||
@Override
|
||||
public Set<Long> getBoundSkillIds(Long agentId) {
|
||||
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
@ -668,6 +670,7 @@ public class AgentBindingService {
|
||||
* <p>Used by {@code AgentGraphBuilder.buildFallbackChain} to bias the
|
||||
* fallback chain order per agent.</p>
|
||||
*/
|
||||
@Override
|
||||
public List<String> getPreferredProviderIds(Long agentId) {
|
||||
if (agentId == null) return Collections.emptyList();
|
||||
return listProviderPreferences(agentId).stream()
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
/**
|
||||
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
|
||||
* Delegates to {@link AgentGraphBuilder}'s helpers; see
|
||||
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
|
||||
* pattern and the {@code @Lazy} cycle break.
|
||||
*/
|
||||
@Component
|
||||
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
|
||||
public AgentOpenAiCompatibleChatModelBuilder(
|
||||
@Lazy AgentGraphBuilder agentGraphBuilder,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
this.agentGraphBuilder = agentGraphBuilder;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProtocol supportedProtocol() {
|
||||
return ModelProtocol.OPENAI_COMPATIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
// RFC-03 Lane B1 — pass model.requestTimeoutSeconds so providers /
|
||||
// models with extended-thinking p99s don't false-positive on the
|
||||
// hardcoded 180s read timeout.
|
||||
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
|
||||
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
|
||||
ChatModel raw = OpenAiChatModel.builder()
|
||||
.openAiApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retry)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
|
||||
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
|
||||
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
|
||||
// those directly — wrap with a per-request payload patcher. See
|
||||
// DeepSeekV4ThinkingDecorator javadoc.
|
||||
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
|
||||
return new DeepSeekV4ThinkingDecorator(raw);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@ package vip.mate.agent.context;
|
||||
* call (set on entry, cleared in {@code finally}). Once written into the
|
||||
* graph state under {@link vip.mate.agent.graph.state.MateClawStateKeys#CHAT_ORIGIN},
|
||||
* the rest of the runtime reads via the typed accessor — no further ThreadLocal
|
||||
* access. Mirrors {@link vip.mate.agent.ThinkingLevelHolder}.
|
||||
* access. Mirrors {@link vip.mate.llm.chatmodel.ThinkingLevelHolder}.
|
||||
*/
|
||||
public final class ChatOriginHolder {
|
||||
|
||||
|
||||
@ -9,8 +9,8 @@ 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 vip.mate.llm.chatmodel.AssistantThinkingRelay;
|
||||
|
||||
import reactor.core.Disposable;
|
||||
|
||||
@ -873,7 +873,7 @@ public class NodeStreamingChatHelper {
|
||||
thinkingAccum.append(thinkingDelta);
|
||||
// thinkingLevel=off 时不广播 thinking(模型仍可能产生,但前端不展示)
|
||||
boolean suppressThinking = "off".equalsIgnoreCase(
|
||||
vip.mate.agent.ThinkingLevelHolder.get());
|
||||
vip.mate.llm.chatmodel.ThinkingLevelHolder.get());
|
||||
if (broadcast && !suppressThinking) {
|
||||
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
|
||||
}
|
||||
|
||||
@ -501,7 +501,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
// 迭代控制:深度思考模式允许更多迭代(思考需要更多轮工具调用)
|
||||
// maxIterations<=0 表示软上限解除(由 LLM 自己决定何时收尾),加分要短路,
|
||||
// 否则 thinking-on 会把"无限"误算成 5(变成"5 步就停")。
|
||||
String thinkingLevel = vip.mate.agent.ThinkingLevelHolder.get();
|
||||
String thinkingLevel = vip.mate.llm.chatmodel.ThinkingLevelHolder.get();
|
||||
boolean thinkingOn = thinkingLevel != null && !"off".equalsIgnoreCase(thinkingLevel);
|
||||
int effectiveMaxIterations = (maxIterations <= 0)
|
||||
? 0
|
||||
|
||||
@ -18,7 +18,7 @@ import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
import vip.mate.llm.chatmodel.ThinkingLevelHolder;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.context.ConversationWindowManager;
|
||||
import vip.mate.agent.context.RuntimeContextInjector;
|
||||
|
||||
@ -13,7 +13,7 @@ import java.util.Optional;
|
||||
* <p>Combines {@link ClaudeCodeCredentialsReader},
|
||||
* {@link ClaudeCodeTokenRefresher}, and {@link ClaudeCodeCredentialsWriter}
|
||||
* to expose a single {@link #getValidToken()} entry point that
|
||||
* {@code AgentClaudeCodeChatModelBuilder} (PR-2) will call on every request.
|
||||
* {@code ClaudeCodeChatModelBuilder} (PR-2) will call on every request.
|
||||
*
|
||||
* <h2>Behavior</h2>
|
||||
* <ol>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -13,10 +13,8 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.cache.AnthropicCacheOptionsFactory;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
@ -30,12 +28,11 @@ import java.time.Duration;
|
||||
*
|
||||
* <p>Owns the full Anthropic construction logic — API client + chat options
|
||||
* including the extended-thinking budget mapping (low/medium/high/max →
|
||||
* 4k/8k/16k/32k thinking tokens) and prompt-cache options. PR-0b moved this
|
||||
* out of {@code AgentGraphBuilder}.</p>
|
||||
* 4k/8k/16k/32k thinking tokens) and prompt-cache options.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
public class AnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
@ -43,7 +40,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
private final AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
|
||||
|
||||
public AgentAnthropicChatModelBuilder(
|
||||
public AnthropicChatModelBuilder(
|
||||
ModelProviderService modelProviderService,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
@ -78,7 +75,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — accepts a per-model read-timeout override
|
||||
* Overload — accepts a per-model read-timeout override
|
||||
* (seconds). Null falls back to the default 180s.
|
||||
*/
|
||||
AnthropicApi buildAnthropicApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
|
||||
@ -108,8 +105,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Substrings used to detect Claude 4.7 model variants. Reference:
|
||||
* hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
|
||||
* Substrings used to detect Claude 4.7 model variants.
|
||||
* Claude 4.7 returns HTTP 400 if any of {@code temperature}, {@code top_p},
|
||||
* or {@code top_k} are set to non-default values, AND introduces an
|
||||
* "xhigh" thinking effort level between high and max.
|
||||
@ -173,7 +169,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
log.debug("Ignoring temperature/top_p for Claude 4.7 model {} (API rejects sampling params)",
|
||||
modelName);
|
||||
}
|
||||
// RFC-025: Anthropic rejects non-positive maxTokens — clamp here so a bad config
|
||||
// Anthropic rejects non-positive maxTokens — clamp here so a bad config
|
||||
// surfaces as a logged warning instead of an opaque API 400 mid-conversation.
|
||||
Integer configuredMax = runtimeModel.getMaxTokens();
|
||||
if (configuredMax != null && configuredMax > 0) {
|
||||
@ -186,7 +182,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
builder.maxTokens(4096);
|
||||
}
|
||||
}
|
||||
// RFC-014: prompt cache (system / tools / conversation history) — Spring AI 1.1.4+ first-class.
|
||||
// Prompt cache (system / tools / conversation history) — Spring AI 1.1.4+ first-class.
|
||||
builder.cacheOptions(anthropicCacheOptionsFactory.build());
|
||||
|
||||
return builder.internalToolExecutionEnabled(false).build();
|
||||
@ -197,16 +193,16 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
* where nginx caps the gateway at 60s but a real long thinking response
|
||||
* needs more — the upper retry layer takes over once we time out.
|
||||
*
|
||||
* <p>Package-private + static so {@code AgentClaudeCodeChatModelBuilder}
|
||||
* (RFC-062) can apply the same timeouts to its OAuth RestClient without
|
||||
* duplicating the snippet.</p>
|
||||
* <p>Package-private + static so {@code ClaudeCodeChatModelBuilder} can
|
||||
* apply the same timeouts to its OAuth RestClient without duplicating
|
||||
* the snippet.</p>
|
||||
*/
|
||||
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
|
||||
return applyHttpTimeouts(builder, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — accepts a per-model read-timeout override
|
||||
* Overload — accepts a per-model read-timeout override
|
||||
* (seconds). Null / zero / negative falls back to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_READ_TIMEOUT}
|
||||
* so unset model configs keep the historical 180s.
|
||||
*/
|
||||
@ -226,16 +222,16 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
|
||||
* stalled provider could hang the agent thread indefinitely while the
|
||||
* failover chain idles (no exception = no signal).
|
||||
* <p>
|
||||
* Mirrors AgentGraphBuilder.applyHttpTimeoutsToWebClient: same JDK
|
||||
* HttpClient + JdkClientHttpConnector path, so the dependency surface
|
||||
* doesn't pull in reactor-netty (excluded by this project's pom).
|
||||
* Uses the same JDK HttpClient + JdkClientHttpConnector path, so the
|
||||
* dependency surface doesn't pull in reactor-netty (excluded by this
|
||||
* project's pom).
|
||||
*/
|
||||
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
|
||||
return applyHttpTimeoutsToWebClient(builder, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — same per-model override semantics as
|
||||
* Overload — same per-model override semantics as
|
||||
* {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
|
||||
*/
|
||||
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@ -7,8 +7,8 @@ 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}).
|
||||
* to the consumer ({@link OpenAiRequestRewriter#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
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
@ -16,13 +16,12 @@ import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
/**
|
||||
* RFC-062: Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
|
||||
* Strategy implementation for {@link ModelProtocol#ANTHROPIC_CLAUDE_CODE}.
|
||||
*
|
||||
* <p>Sends Anthropic Messages API requests authenticated with the user's
|
||||
* Claude Code OAuth subscription token instead of an API key — letting users
|
||||
@ -56,9 +55,9 @@ import vip.mate.llm.model.ModelProviderEntity;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
public class ClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final AgentAnthropicChatModelBuilder anthropicBuilder;
|
||||
private final AnthropicChatModelBuilder anthropicBuilder;
|
||||
private final ClaudeCodeOAuthService oauthService;
|
||||
private final ClaudeCodeApiHeaders apiHeaders;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
@ -66,8 +65,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AgentClaudeCodeChatModelBuilder(
|
||||
AgentAnthropicChatModelBuilder anthropicBuilder,
|
||||
public ClaudeCodeChatModelBuilder(
|
||||
AnthropicChatModelBuilder anthropicBuilder,
|
||||
ClaudeCodeOAuthService oauthService,
|
||||
ClaudeCodeApiHeaders apiHeaders,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
@ -126,7 +125,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 overload — same OAuth-stamped Anthropic client, with a
|
||||
* Overload — same OAuth-stamped Anthropic client, with a
|
||||
* per-model read-timeout override threaded through to the underlying
|
||||
* RestClient + WebClient timeouts.
|
||||
*/
|
||||
@ -141,9 +140,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
// `anthropic-dangerous-direct-browser-access: true` on every request.
|
||||
// Spring AI's Java client doesn't, so Anthropic's edge fingerprint
|
||||
// sees the missing headers and treats the traffic as suspicious —
|
||||
// rate-limited harder than spec'd. Reference: openclaw
|
||||
// anthropic-transport-stream.ts:567-574.
|
||||
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
|
||||
// rate-limited harder than spec'd.
|
||||
RestClient.Builder restClientBuilder = AnthropicChatModelBuilder.applyHttpTimeouts(
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride)
|
||||
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
|
||||
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
|
||||
@ -161,7 +159,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
|
||||
// staring at SDK internals.
|
||||
.requestInterceptor(new RateLimitDiagnosticInterceptor());
|
||||
|
||||
WebClient.Builder webClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
|
||||
WebClient.Builder webClientBuilder = AnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
|
||||
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
|
||||
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
@ -24,8 +24,8 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* RFC-062: Claude Code OAuth identity transform applied to every Anthropic
|
||||
* request when the underlying auth is a Claude Code OAuth token.
|
||||
* Claude Code OAuth identity transform applied to every Anthropic request
|
||||
* when the underlying auth is a Claude Code OAuth token.
|
||||
*
|
||||
* <p>Anthropic's OAuth edge enforces an anti-abuse path that rate-limits
|
||||
* (and intermittently 5xxs) requests claiming Claude Code identity but
|
||||
@ -39,10 +39,6 @@ import java.util.Set;
|
||||
* <li>Sporadic 500s on the first call after a long idle period.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Reference: hermes-agent {@code anthropic_adapter._build_anthropic_messages_request}
|
||||
* lines 1571-1607 — same transforms applied unconditionally on
|
||||
* {@code is_oauth=True} requests.
|
||||
*
|
||||
* <h2>Transforms applied per call</h2>
|
||||
* <ol>
|
||||
* <li><b>System prompt prefix</b>: prepend
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
|
||||
@ -12,7 +12,6 @@ import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.chatmodel.ChatModelBuilder;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
@ -26,8 +25,8 @@ import java.util.Map;
|
||||
*
|
||||
* <p>Owns all DashScope-specific construction logic (api + options) plus the
|
||||
* fallback-chain helpers for resolving API key / Base URL when the provider
|
||||
* row is incomplete. PR-0b moved this code out of {@code AgentGraphBuilder}
|
||||
* so the agent package no longer carries any DashScope schema knowledge.</p>
|
||||
* row is incomplete, so the agent package no longer carries any DashScope
|
||||
* schema knowledge.</p>
|
||||
*
|
||||
* <p>DashScopeChatModel is injected via ObjectProvider so that the builder
|
||||
* degrades gracefully when DashScope auto-configuration is disabled or the
|
||||
@ -35,13 +34,13 @@ import java.util.Map;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
public class DashScopeChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider;
|
||||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||
private final ModelProviderService modelProviderService;
|
||||
|
||||
public AgentDashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
|
||||
public DashScopeChatModelBuilder(ObjectProvider<DashScopeChatModel> dashScopeChatModelProvider,
|
||||
DashScopeConnectionProperties dashScopeConnectionProperties,
|
||||
ModelProviderService modelProviderService) {
|
||||
this.dashScopeChatModelProvider = dashScopeChatModelProvider;
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
@ -10,7 +10,6 @@ import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@ -19,7 +18,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC: DeepSeek V4 thinking-mode payload patcher applied to every
|
||||
* DeepSeek V4 thinking-mode payload patcher applied to every
|
||||
* {@code deepseek-v4-flash} / {@code deepseek-v4-pro} request.
|
||||
*
|
||||
* <p>DeepSeek V4 extends OpenAI's chat-completions wire format with two
|
||||
@ -40,13 +39,10 @@ import java.util.Map;
|
||||
* {@code reasoning_content} must be stripped or DeepSeek echoes the old
|
||||
* thinking back into the response.
|
||||
*
|
||||
* <p>Reference: openclaw {@code plugin-sdk/provider-stream-shared.ts}
|
||||
* lines 185-213 ({@code createDeepSeekV4OpenAICompatibleThinkingWrapper}).
|
||||
*
|
||||
* <h2>Pipeline (per request)</h2>
|
||||
* <ol>
|
||||
* <li>Read {@link ThinkingLevelHolder} for the current request's thinking
|
||||
* level (set by AgentService before the call).</li>
|
||||
* level (set by the agent service before the call).</li>
|
||||
* <li>Clone {@link OpenAiChatOptions} and patch its {@code extraBody} +
|
||||
* {@code reasoningEffort} fields. Spring AI sends {@code extraBody}
|
||||
* verbatim in the JSON body, so the {@code thinking} key lands where
|
||||
@ -57,10 +53,6 @@ import java.util.Map;
|
||||
* entry to satisfy V4's replay contract.</li>
|
||||
* <li>Delegate to the wrapped {@link ChatModel}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Spring AI 1.1.4's {@link OpenAiChatOptions} exposes a public
|
||||
* {@code extraBody: Map<String, Object>} (verified via {@code javap}). No
|
||||
* byte-level body patching needed — the simple path works.
|
||||
*/
|
||||
@Slf4j
|
||||
public class DeepSeekV4ThinkingDecorator implements ChatModel {
|
||||
@ -116,9 +108,8 @@ public class DeepSeekV4ThinkingDecorator implements ChatModel {
|
||||
|
||||
/**
|
||||
* Map MateClaw's thinking levels (off/low/medium/high/max) to DeepSeek's
|
||||
* accepted reasoning_effort values. Aligns with openclaw
|
||||
* {@code resolveDeepSeekV4ReasoningEffort}: max collapses into high since
|
||||
* DeepSeek doesn't expose a "max" tier on V4.
|
||||
* accepted reasoning_effort values: max collapses into high since DeepSeek
|
||||
* does not expose a "max" tier on V4.
|
||||
*/
|
||||
static String mapEffort(String level) {
|
||||
if (level == null || level.isBlank()) return "medium";
|
||||
@ -3,18 +3,13 @@ package vip.mate.llm.chatmodel;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 — central resolver for the per-LLM-request HTTP read
|
||||
* timeout, so {@link vip.mate.llm.model.ModelConfigEntity#getRequestTimeoutSeconds()}
|
||||
* can override the legacy 180s default without each chatmodel builder
|
||||
* inventing its own fallback chain.
|
||||
* Central resolver for the per-LLM-request HTTP read timeout, so
|
||||
* {@link vip.mate.llm.model.ModelConfigEntity#getRequestTimeoutSeconds()}
|
||||
* can override the default 180s without each chatmodel builder inventing
|
||||
* its own fallback chain.
|
||||
*
|
||||
* <p>Used by:
|
||||
* <ul>
|
||||
* <li>{@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}</li>
|
||||
* <li>{@code AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient}</li>
|
||||
* <li>{@code AgentClaudeCodeChatModelBuilder} (via Anthropic helper)</li>
|
||||
* <li>{@code AgentGraphBuilder} legacy timeout helpers</li>
|
||||
* </ul>
|
||||
* <p>Used by the OpenAI-compatible, Anthropic and Claude Code chat model
|
||||
* builders to apply consistent connect / read timeouts.
|
||||
*
|
||||
* <p>Connect timeout stays at the canonical 10s — long-tail thinking
|
||||
* latency manifests on the read path, not on connect.
|
||||
@ -27,7 +22,7 @@ public final class HttpTimeouts {
|
||||
/**
|
||||
* Default read timeout when no per-model override is set. Matches the
|
||||
* historical hardcoded value so unset rows behave identically to the
|
||||
* pre-RFC-03 baseline.
|
||||
* earlier baseline.
|
||||
*/
|
||||
public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(180);
|
||||
|
||||
|
||||
@ -0,0 +1,472 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.model.ApiKey;
|
||||
import org.springframework.ai.model.NoopApiKey;
|
||||
import org.springframework.ai.model.SimpleApiKey;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.ai.retry.RetryUtils;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Strategy implementation of {@link ChatModelBuilder} for
|
||||
* {@link ModelProtocol#OPENAI_COMPATIBLE}.
|
||||
*
|
||||
* <p>Owns the full OpenAI-compatible construction path: the {@link OpenAiApi}
|
||||
* client (HTTP timeouts, header overrides, completions-path resolution), the
|
||||
* {@link OpenAiChatOptions} (temperature / max tokens / reasoning effort / web
|
||||
* search), and the outbound request-rewrite pipeline delegated to
|
||||
* {@link OpenAiRequestRewriter}. DeepSeek V4 reasoning models are wrapped with
|
||||
* {@link DeepSeekV4ThinkingDecorator}.
|
||||
*
|
||||
* <p>Depends only on infrastructure beans, so the {@code llm} package builds a
|
||||
* {@link ChatModel} without any dependency on the agent graph layer.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
||||
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ObjectProvider<RestClient.Builder> restClientBuilderProvider;
|
||||
private final ObjectProvider<WebClient.Builder> webClientBuilderProvider;
|
||||
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
|
||||
|
||||
public OpenAiCompatibleChatModelBuilder(
|
||||
ModelProviderService modelProviderService,
|
||||
ObjectMapper objectMapper,
|
||||
ObjectProvider<RestClient.Builder> restClientBuilderProvider,
|
||||
ObjectProvider<WebClient.Builder> webClientBuilderProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
this.modelProviderService = modelProviderService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.restClientBuilderProvider = restClientBuilderProvider;
|
||||
this.webClientBuilderProvider = webClientBuilderProvider;
|
||||
this.observationRegistryProvider = observationRegistryProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelProtocol supportedProtocol() {
|
||||
return ModelProtocol.OPENAI_COMPATIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
|
||||
// Pass model.requestTimeoutSeconds so providers / models with
|
||||
// extended-thinking p99s don't false-positive on the default read timeout.
|
||||
OpenAiApi api = buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
|
||||
OpenAiChatOptions options = buildOpenAiOptions(model, provider);
|
||||
ChatModel raw = OpenAiChatModel.builder()
|
||||
.openAiApi(api)
|
||||
.defaultOptions(options)
|
||||
.retryTemplate(retry)
|
||||
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
|
||||
.build();
|
||||
|
||||
// DeepSeek V4 (flash / pro) extends OpenAI's wire format with `thinking: {type}` and a
|
||||
// strict reasoning_content replay contract. Spring AI's OpenAiChatOptions can't express
|
||||
// those directly — wrap with a per-request payload patcher.
|
||||
if (ModelFamily.detect(model.getModelName()) == ModelFamily.DEEPSEEK_V4_REASONING) {
|
||||
return new DeepSeekV4ThinkingDecorator(raw);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ==================== chat options ====================
|
||||
|
||||
OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
|
||||
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder();
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
String modelName = runtimeModel.getModelName();
|
||||
ModelFamily family = ModelFamily.detect(modelName);
|
||||
|
||||
if (StringUtils.hasText(modelName)) {
|
||||
builder.model(modelName);
|
||||
}
|
||||
|
||||
// temperature: some model families force 1.0
|
||||
Double temperature = resolveOpenAiTemperature(modelName, runtimeModel.getTemperature(), kwargs, family);
|
||||
if (temperature != null) {
|
||||
builder.temperature(temperature);
|
||||
}
|
||||
|
||||
// max_tokens / max_completion_tokens: routed by model family
|
||||
if (family.suppressMaxTokens()) {
|
||||
// OPENAI_REASONING family: max_tokens forbidden, use max_completion_tokens.
|
||||
// fallback priority: kwargs.maxCompletionTokens > kwargs.maxTokens > config.maxTokens
|
||||
Integer kwargsMaxTokens = ProviderGenerateKwargs.resolveIntegerOption(
|
||||
"maxTokens", runtimeModel.getMaxTokens(), kwargs);
|
||||
Integer maxCompletionTokens = ProviderGenerateKwargs.resolveIntegerOption(
|
||||
"maxCompletionTokens", kwargsMaxTokens, kwargs);
|
||||
if (maxCompletionTokens != null) {
|
||||
builder.maxCompletionTokens(maxCompletionTokens);
|
||||
}
|
||||
log.debug("ModelFamily {} suppressed max_tokens, using max_completion_tokens={} for model {}",
|
||||
family, maxCompletionTokens, modelName);
|
||||
} else {
|
||||
// Other model families: use max_tokens normally
|
||||
Integer maxTokens = ProviderGenerateKwargs.resolveIntegerOption(
|
||||
"maxTokens", runtimeModel.getMaxTokens(), kwargs);
|
||||
if (maxTokens != null) {
|
||||
builder.maxTokens(maxTokens);
|
||||
}
|
||||
// Still allow maxCompletionTokens to be set explicitly via generateKwargs
|
||||
Integer maxCompletionTokens = ProviderGenerateKwargs.resolveIntegerOption(
|
||||
"maxCompletionTokens", null, kwargs);
|
||||
if (maxCompletionTokens != null) {
|
||||
builder.maxCompletionTokens(maxCompletionTokens);
|
||||
}
|
||||
}
|
||||
|
||||
// top_p: forbidden for some model families
|
||||
Double topP = resolveOpenAiTopP(modelName, runtimeModel.getTopP(), kwargs, family);
|
||||
if (topP != null) {
|
||||
builder.topP(topP);
|
||||
}
|
||||
|
||||
// reasoning_effort: injected only for supporting model families
|
||||
String reasoningEffort = ReasoningEffortResolver.resolveReasoningEffort(modelName, kwargs, family);
|
||||
if (StringUtils.hasText(reasoningEffort)) {
|
||||
builder.reasoningEffort(reasoningEffort);
|
||||
}
|
||||
|
||||
// built-in search: model-level field wins, provider generateKwargs as fallback
|
||||
boolean searchEnabled = Boolean.TRUE.equals(runtimeModel.getEnableSearch())
|
||||
|| Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
||||
if (searchEnabled) {
|
||||
String strategy = runtimeModel.getSearchStrategy();
|
||||
if (!StringUtils.hasText(strategy)) {
|
||||
strategy = (String) kwargs.get("searchStrategy");
|
||||
}
|
||||
OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize contextSize;
|
||||
try {
|
||||
contextSize = StringUtils.hasText(strategy)
|
||||
? OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.valueOf(strategy.toUpperCase())
|
||||
: OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.MEDIUM;
|
||||
} catch (IllegalArgumentException e) {
|
||||
contextSize = OpenAiApi.ChatCompletionRequest.WebSearchOptions.SearchContextSize.MEDIUM;
|
||||
}
|
||||
builder.webSearchOptions(new OpenAiApi.ChatCompletionRequest.WebSearchOptions(contextSize, null));
|
||||
}
|
||||
|
||||
OpenAiChatOptions options = builder.build();
|
||||
options.setInternalToolExecutionEnabled(false);
|
||||
// Do not set parallelToolCalls — setting it to false makes OpenAI return 400 when
|
||||
// there are no tools: "parallel_tool_calls is only allowed when 'tools' are specified".
|
||||
// Leaving it null keeps Spring AI from serializing the field; each node controls it
|
||||
// when tools are present.
|
||||
options.setStreamUsage(true);
|
||||
return options;
|
||||
}
|
||||
|
||||
private Double resolveOpenAiTemperature(String modelName, Double configuredTemperature,
|
||||
Map<String, Object> kwargs, ModelFamily family) {
|
||||
Double overriddenTemperature = ProviderGenerateKwargs.resolveDoubleOption(
|
||||
"temperature", configuredTemperature, kwargs);
|
||||
if (family.fixedTemperatureOne()) {
|
||||
if (overriddenTemperature == null || Double.compare(overriddenTemperature, 1.0d) != 0) {
|
||||
log.info("ModelFamily {} forced temperature=1.0 for model {}", family, modelName);
|
||||
}
|
||||
return 1.0d;
|
||||
}
|
||||
return overriddenTemperature;
|
||||
}
|
||||
|
||||
private Double resolveOpenAiTopP(String modelName, Double configuredTopP,
|
||||
Map<String, Object> kwargs, ModelFamily family) {
|
||||
if (family.suppressTopP()) {
|
||||
return null;
|
||||
}
|
||||
return ProviderGenerateKwargs.resolveDoubleOption("topP", configuredTopP, kwargs);
|
||||
}
|
||||
|
||||
// ==================== OpenAI API client ====================
|
||||
|
||||
/**
|
||||
* Build an {@link OpenAiApi} for the provider. Accepts a per-model
|
||||
* read-timeout override (seconds), threaded into both the sync RestClient and
|
||||
* the streaming WebClient. Null falls back to the default 180s.
|
||||
*/
|
||||
OpenAiApi buildOpenAiApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
|
||||
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
|
||||
throw new MateClawException("err.agent.provider_not_configured",
|
||||
"Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
|
||||
}
|
||||
String apiKey = provider.getApiKey();
|
||||
// Honor the provider's requireApiKey flag instead of hard-failing on every empty key.
|
||||
// Local + key-free providers (Ollama, LM Studio, MLX, llama.cpp, OpenCode) declare
|
||||
// requireApiKey=false; for them an empty / placeholder key means "no Authorization
|
||||
// header" — Spring AI's NoopApiKey expresses that.
|
||||
boolean keyRequired = !Boolean.FALSE.equals(provider.getRequireApiKey());
|
||||
if (keyRequired && !modelProviderService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.agent.provider_apikey_invalid",
|
||||
"Provider API Key 未配置或无效: " + provider.getProviderId());
|
||||
}
|
||||
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
throw new MateClawException("err.agent.provider_baseurl_missing",
|
||||
"Provider Base URL 未配置: " + provider.getProviderId());
|
||||
}
|
||||
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
|
||||
MultiValueMap<String, String> headers = buildOpenAiHeaders(kwargs);
|
||||
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
|
||||
RestClient.Builder restClientBuilder = applyHttpTimeouts(
|
||||
restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
|
||||
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
|
||||
webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
|
||||
|
||||
// Spring AI's OpenAiApi constructor sets User-Agent to "spring-ai" first, then addAll's
|
||||
// our headers, so a custom User-Agent is appended rather than replaced. For providers
|
||||
// that must masquerade as a specific client (e.g. kimi-code), force-override headers
|
||||
// via a RestClient/WebClient interceptor before the request goes out.
|
||||
Map<String, String> overrideHeaders = extractOverrideHeaders(kwargs);
|
||||
if (!overrideHeaders.isEmpty()) {
|
||||
restClientBuilder = restClientBuilder.requestInterceptor((request, body, execution) -> {
|
||||
HttpHeaders reqHeaders = request.getHeaders();
|
||||
overrideHeaders.forEach(reqHeaders::set);
|
||||
return execution.execute(request, body);
|
||||
});
|
||||
webClientBuilder = webClientBuilder.filter((request, next) -> {
|
||||
org.springframework.web.reactive.function.client.ClientRequest modified =
|
||||
org.springframework.web.reactive.function.client.ClientRequest.from(request)
|
||||
.headers(h -> overrideHeaders.forEach(h::set))
|
||||
.build();
|
||||
return next.exchange(modified);
|
||||
});
|
||||
}
|
||||
|
||||
boolean kimiSearchEnabled = isKimiProvider(provider)
|
||||
&& Boolean.TRUE.equals(kwargs.get("enableSearch"));
|
||||
|
||||
ApiKey apiKeyImpl = (keyRequired && StringUtils.hasText(apiKey))
|
||||
? new SimpleApiKey(apiKey.trim())
|
||||
: new NoopApiKey();
|
||||
return new OpenAiApi(
|
||||
baseUrl,
|
||||
apiKeyImpl,
|
||||
headers,
|
||||
completionsPath,
|
||||
"/v1/embeddings",
|
||||
restClientBuilder,
|
||||
webClientBuilder,
|
||||
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER) {
|
||||
@Override
|
||||
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
|
||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||
MultiValueMap<String, String> additionalHttpHeader) {
|
||||
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
||||
chatRequest = OpenAiRequestRewriter.stripAutoToolChoice(chatRequest);
|
||||
chatRequest = OpenAiRequestRewriter.patchVideoMediaContent(chatRequest);
|
||||
if (kimiSearchEnabled) {
|
||||
chatRequest = OpenAiRequestRewriter.injectKimiWebSearch(chatRequest);
|
||||
}
|
||||
logOpenAiRequest(provider, chatRequest);
|
||||
try {
|
||||
return super.chatCompletionEntity(chatRequest, additionalHttpHeader);
|
||||
} catch (WebClientResponseException e) {
|
||||
logOpenAiError(provider, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
|
||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||
MultiValueMap<String, String> additionalHttpHeader) {
|
||||
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
||||
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
||||
chatRequest = OpenAiRequestRewriter.stripAutoToolChoice(chatRequest);
|
||||
chatRequest = OpenAiRequestRewriter.patchVideoMediaContent(chatRequest);
|
||||
if (kimiSearchEnabled) {
|
||||
chatRequest = OpenAiRequestRewriter.injectKimiWebSearch(chatRequest);
|
||||
}
|
||||
logOpenAiRequest(provider, chatRequest);
|
||||
return super.chatCompletionStream(chatRequest, additionalHttpHeader)
|
||||
.doOnError(error -> {
|
||||
if (error instanceof WebClientResponseException e) {
|
||||
logOpenAiError(provider, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the provider is one of Kimi's first-party providers. Public so the
|
||||
* agent graph builder can surface a "built-in search active" log line.
|
||||
*/
|
||||
public static boolean isKimiProvider(ModelProviderEntity provider) {
|
||||
if (provider == null) return false;
|
||||
String id = provider.getProviderId();
|
||||
return "kimi-cn".equals(id) || "kimi-intl".equals(id);
|
||||
}
|
||||
|
||||
// ==================== URL / headers ====================
|
||||
|
||||
private String normalizeOpenAiBaseUrl(String baseUrl) {
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
return null;
|
||||
}
|
||||
String normalized = baseUrl.trim();
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
if (normalized.endsWith("/v1")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 3);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private MultiValueMap<String, String> buildOpenAiHeaders(Map<String, Object> kwargs) {
|
||||
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
|
||||
headers.add("User-Agent", "MateClaw/1.0");
|
||||
Object headerObject = kwargs.get("headers");
|
||||
if (headerObject instanceof Map<?, ?> headerMap) {
|
||||
headerMap.forEach((key, value) -> {
|
||||
if (key != null && value != null) {
|
||||
headers.set(String.valueOf(key), String.valueOf(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headers that must be force-overridden, read from
|
||||
* {@code generateKwargs.headers}. Used by a RestClient/WebClient interceptor
|
||||
* to bypass Spring AI's default User-Agent.
|
||||
*/
|
||||
private Map<String, String> extractOverrideHeaders(Map<String, Object> kwargs) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
Object headerObject = kwargs.get("headers");
|
||||
if (headerObject instanceof Map<?, ?> headerMap) {
|
||||
headerMap.forEach((key, value) -> {
|
||||
if (key != null && value != null) {
|
||||
result.put(String.valueOf(key), String.valueOf(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Trailing "/v{digits}" segment in a base URL — the OpenAI-compatible convention
|
||||
// (/v1 OpenAI, /v3 Volcano Ark, /v4 Zhipu). When the baseUrl already carries this
|
||||
// segment, the default /v1 prefix on the path must be stripped to avoid building
|
||||
// a broken URL like /api/v3/v1/chat/completions.
|
||||
private static final Pattern OPENAI_BASE_URL_VERSION_SUFFIX = Pattern.compile(".*/v\\d+$");
|
||||
|
||||
private String resolveOpenAiCompletionsPath(String baseUrl, Map<String, Object> kwargs) {
|
||||
Object raw = kwargs.get("completionsPath");
|
||||
boolean explicit = raw instanceof String value && StringUtils.hasText(value);
|
||||
String path = explicit ? ((String) raw).trim() : "/v1/chat/completions";
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
// An explicit completionsPath is honored as-is. Otherwise, dedupe the /v1
|
||||
// prefix when the baseUrl already ends with /v{N} (Volcano Engine Ark /v3,
|
||||
// Zhipu /v4, etc.).
|
||||
if (!explicit
|
||||
&& baseUrl != null
|
||||
&& OPENAI_BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()
|
||||
&& path.startsWith("/v1/")) {
|
||||
path = path.substring(3);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// ==================== HTTP timeouts ====================
|
||||
|
||||
/**
|
||||
* Configure an explicit timeout on the RestClient used for LLM calls so a
|
||||
* socket never hangs forever.
|
||||
*
|
||||
* <p>Uses {@link JdkClientHttpRequestFactory} (backed by the Java 11+
|
||||
* {@link HttpClient}) because it natively supports HTTP/2 / ALPN negotiation
|
||||
* and transparently decompresses {@code Content-Encoding: gzip} responses.
|
||||
*
|
||||
* <p>connectTimeout=10s; readTimeout defaults to 180s (covers an nginx 60s
|
||||
* gateway timeout plus headroom for a real long response; the upper retry
|
||||
* layer takes over once it times out).
|
||||
*/
|
||||
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
|
||||
rf.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
|
||||
return builder.requestFactory(rf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply equivalent timeouts to the WebClient backing OpenAI-compatible
|
||||
* STREAMING calls. Without this the streaming path uses a default WebClient
|
||||
* with neither connect nor read timeout, so a stalled provider can hang the
|
||||
* call indefinitely while the failover chain idles (no exception thrown).
|
||||
*
|
||||
* <p>Uses {@link org.springframework.http.client.reactive.JdkClientHttpConnector}
|
||||
* with the same {@link HttpClient} so the dependency surface stays clean
|
||||
* (reactor-netty is not on this project's classpath).
|
||||
*/
|
||||
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
|
||||
// Pin HTTP/1.1: many self-hosted OpenAI-compatible servers (vLLM, lmstudio,
|
||||
// llama.cpp, ollama — all uvicorn/ASGI based) only speak HTTP/1.1 over
|
||||
// cleartext and slam the socket on the JDK client's default H2C upgrade
|
||||
// probe, surfacing as "header parser received no bytes" with no body sent.
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(HttpTimeouts.CONNECT_TIMEOUT)
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
|
||||
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
|
||||
connector.setReadTimeout(HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
|
||||
return builder.clientConnector(connector);
|
||||
}
|
||||
|
||||
// ==================== logging ====================
|
||||
|
||||
private void logOpenAiRequest(ModelProviderEntity provider, OpenAiApi.ChatCompletionRequest chatRequest) {
|
||||
try {
|
||||
log.info("OpenAI-compatible request: provider={}, body={}",
|
||||
provider.getProviderId(), objectMapper.writeValueAsString(chatRequest));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize OpenAI-compatible request for {}: {}",
|
||||
provider.getProviderId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void logOpenAiError(ModelProviderEntity provider, WebClientResponseException e) {
|
||||
log.error("OpenAI-compatible error: provider={}, status={}, body={}",
|
||||
provider.getProviderId(), e.getStatusCode(), e.getResponseBodyAsString());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,736 @@
|
||||
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.
|
||||
*
|
||||
* <p>{@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.
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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:
|
||||
* <ol>
|
||||
* <li>{@link AssistantThinkingRelay#take(String)} the entry and restore
|
||||
* {@code request.user()} to {@code entry.originalUser()} so the
|
||||
* internal token never reaches the provider.</li>
|
||||
* <li>Compute {@code lastUserIdx} (the boundary of the current user turn).
|
||||
* Assistant messages at {@code i <= lastUserIdx} are prior-turn history:
|
||||
* their {@code reasoning_content} normally stays null. Only
|
||||
* {@code i > lastUserIdx} messages are eligible for patching, unless the
|
||||
* provider policy opts into cross-turn patching.</li>
|
||||
* <li>Select a {@link FallbackPolicy} by {@code providerId}. When the relay
|
||||
* has a real value we use it; when empty, the policy decides whether to
|
||||
* inject {@code " "} (legacy tolerance) or leave {@code null} to surface
|
||||
* an explicit provider error.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<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: usually skipped, since thinking resets across
|
||||
// user turns. But some providers require reasoning_content even on
|
||||
// prior-turn assistants and reject requests where any prior assistant
|
||||
// has it null. For policies with patchCrossTurn=true, fall through and
|
||||
// patch with the empty fallback (" ") so multi-turn conversations
|
||||
// don't 400.
|
||||
if (i <= lastUserIdx && !policy.patchCrossTurn) {
|
||||
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(),
|
||||
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(),
|
||||
sanitizedUser,
|
||||
request.reasoningEffort(),
|
||||
request.webSearchOptions(),
|
||||
request.verbosity(),
|
||||
request.promptCacheKey(),
|
||||
request.safetyIdentifier(),
|
||||
request.extraBody()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-keyed policy for how {@link #patchReasoningContent} behaves when
|
||||
* the relay has no real thinking for an in-turn assistant message.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code emptyFallback}: value to inject when the relay has no real
|
||||
* value — {@code null} means leave {@code reasoning_content} null;
|
||||
* {@code " "} preserves legacy tolerance.</li>
|
||||
* <li>{@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null}
|
||||
* fires.</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.</li>
|
||||
* <li>{@code patchCrossTurn}: whether to also patch prior-turn assistants
|
||||
* ({@code i <= lastUserIdx}). DeepSeek requires reasoning_content on
|
||||
* every assistant message in the request, including prior-turn history,
|
||||
* and MateClaw does not persist reasoning_content — so cross-turn
|
||||
* patching keeps multi-turn conversations from 400-ing.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@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),
|
||||
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;
|
||||
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();
|
||||
}
|
||||
|
||||
// ==================== reasoning_effort sanitizing ====================
|
||||
|
||||
/**
|
||||
* Provider-first sanitization of {@code reasoning_effort}.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@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.
|
||||
*
|
||||
* <p>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) ? "<unknown>" : p.getProviderId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a request with a new {@code reasoningEffort} value (typically
|
||||
* {@code null} to strip).
|
||||
*/
|
||||
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 compatibility: on the {@code /v1/chat/completions} path, {@code tools}
|
||||
* and {@code reasoning_effort} cannot both be present.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Per the OpenAI spec, omitting {@code tool_choice} when {@code tools} is
|
||||
* non-empty is equivalent to {@code "auto"}. Stripping the explicit literal:
|
||||
* <ul>
|
||||
* <li>does not change behavior on compliant servers — 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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
|
||||
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.USER || !(msg.rawContent() instanceof List<?> parts)) {
|
||||
return msg;
|
||||
}
|
||||
List<Object> newParts = new ArrayList<>();
|
||||
for (Object part : parts) {
|
||||
String videoDataUrl = null;
|
||||
|
||||
// Case 1: MediaContent record (native Spring AI construction)
|
||||
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/")) {
|
||||
videoDataUrl = mc.imageUrl().url();
|
||||
}
|
||||
// Case 2: Map form (Jackson deserialization or Spring AI internal Map)
|
||||
if (videoDataUrl == null && part instanceof java.util.Map<?,?> map
|
||||
&& "image_url".equals(map.get("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/")) {
|
||||
videoDataUrl = urlStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (videoDataUrl != null) {
|
||||
// Rewrite to video_url format
|
||||
newParts.add(Map.of(
|
||||
"type", "video_url",
|
||||
"video_url", Map.of("url", videoDataUrl)
|
||||
));
|
||||
} else {
|
||||
newParts.add(part);
|
||||
}
|
||||
}
|
||||
return new OpenAiApi.ChatCompletionMessage(
|
||||
newParts, msg.role(), msg.name(), msg.toolCallId(),
|
||||
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
|
||||
msg.annotations(), msg.reasoningContent());
|
||||
}).toList();
|
||||
|
||||
return new OpenAiApi.ChatCompletionRequest(
|
||||
patched,
|
||||
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(), request.reasoningEffort(),
|
||||
request.webSearchOptions(), request.verbosity(),
|
||||
request.promptCacheKey(), request.safetyIdentifier(),
|
||||
request.extraBody()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Kimi built-in search ====================
|
||||
|
||||
/**
|
||||
* Inject the {@code $web_search} built-in tool into a Kimi request.
|
||||
*
|
||||
* <p>Kimi's built-in search is enabled by declaring
|
||||
* {@code {"type":"builtin_function","function":{"name":"$web_search"}}} in the
|
||||
* tools array. Spring AI's {@code FunctionTool.Type} only has {@code FUNCTION},
|
||||
* so this injects the raw JSON structure via {@code extraBody} — overriding the
|
||||
* tools field with the original tools plus {@code $web_search}.
|
||||
*/
|
||||
static OpenAiApi.ChatCompletionRequest injectKimiWebSearch(OpenAiApi.ChatCompletionRequest request) {
|
||||
// Build the $web_search entry as a Map
|
||||
Map<String, Object> webSearchTool = Map.of(
|
||||
"type", "builtin_function",
|
||||
"function", Map.of("name", "$web_search")
|
||||
);
|
||||
|
||||
// Convert existing tools to List<Map> and append $web_search
|
||||
List<Map<String, Object>> allTools = new ArrayList<>();
|
||||
if (request.tools() != null) {
|
||||
for (OpenAiApi.FunctionTool tool : request.tools()) {
|
||||
Map<String, Object> toolMap = new LinkedHashMap<>();
|
||||
toolMap.put("type", "function");
|
||||
if (tool.getFunction() != null) {
|
||||
Map<String, Object> funcMap = new LinkedHashMap<>();
|
||||
funcMap.put("name", tool.getFunction().getName());
|
||||
if (tool.getFunction().getDescription() != null) {
|
||||
funcMap.put("description", tool.getFunction().getDescription());
|
||||
}
|
||||
if (tool.getFunction().getParameters() != null) {
|
||||
funcMap.put("parameters", tool.getFunction().getParameters());
|
||||
}
|
||||
if (tool.getFunction().getStrict() != null) {
|
||||
funcMap.put("strict", tool.getFunction().getStrict());
|
||||
}
|
||||
toolMap.put("function", funcMap);
|
||||
}
|
||||
allTools.add(toolMap);
|
||||
}
|
||||
}
|
||||
allTools.add(webSearchTool);
|
||||
|
||||
// Inject tools via extraBody (overrides the tools field), and clear the
|
||||
// original tools field to avoid duplicate serialization.
|
||||
Map<String, Object> extraBody = new LinkedHashMap<>();
|
||||
if (request.extraBody() != null) {
|
||||
extraBody.putAll(request.extraBody());
|
||||
}
|
||||
extraBody.put("tools", allTools);
|
||||
|
||||
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(),
|
||||
null, // tools — cleared, extraBody takes over
|
||||
request.toolChoice(),
|
||||
request.parallelToolCalls(),
|
||||
request.user(),
|
||||
request.reasoningEffort(),
|
||||
request.webSearchOptions(),
|
||||
request.verbosity(),
|
||||
request.promptCacheKey(),
|
||||
request.safetyIdentifier(),
|
||||
extraBody
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Reads typed values out of a provider's {@code generateKwargs} map.
|
||||
*
|
||||
* <p>A lookup tries the camelCase key first, then a snake_case fallback, and also
|
||||
* descends into a nested {@code chatOptions} map — so an admin may specify an
|
||||
* option under any of those shapes. Shared by the OpenAI-compatible chat model
|
||||
* builder and the reasoning-effort resolver.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProviderGenerateKwargs {
|
||||
|
||||
private ProviderGenerateKwargs() {}
|
||||
|
||||
/**
|
||||
* Find a raw option value by key, trying the camelCase form then a
|
||||
* snake_case fallback. Returns {@code null} when neither is present.
|
||||
*/
|
||||
public static Object findOptionValue(Map<String, Object> kwargs, String key) {
|
||||
Object direct = findKwarg(kwargs, key);
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
String snakeCase = key.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
|
||||
if (!snakeCase.equals(key)) {
|
||||
return findKwarg(kwargs, snakeCase);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Object findKwarg(Map<String, Object> kwargs, String key) {
|
||||
if (kwargs == null || kwargs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (kwargs.containsKey(key)) {
|
||||
return kwargs.get(key);
|
||||
}
|
||||
Object chatOptions = kwargs.get("chatOptions");
|
||||
if (chatOptions instanceof Map<?, ?> optionsMap) {
|
||||
return ((Map<String, Object>) optionsMap).get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a {@code Double} option, falling back to {@code fallback} when the
|
||||
* key is absent or holds a non-numeric value.
|
||||
*/
|
||||
public static Double resolveDoubleOption(String key, Double fallback, Map<String, Object> kwargs) {
|
||||
Object value = findOptionValue(kwargs, key);
|
||||
if (value instanceof Number number) {
|
||||
return number.doubleValue();
|
||||
}
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
try {
|
||||
return Double.parseDouble(text.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
log.warn("Invalid double generateKwargs value for {}: {}", key, text);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an {@code Integer} option, falling back to {@code fallback} when the
|
||||
* key is absent or holds a non-numeric value.
|
||||
*/
|
||||
public static Integer resolveIntegerOption(String key, Integer fallback, Map<String, Object> kwargs) {
|
||||
Object value = findOptionValue(kwargs, key);
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
try {
|
||||
return Integer.parseInt(text.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
log.warn("Invalid integer generateKwargs value for {}: {}", key, text);
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@ -0,0 +1,63 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.llm.model.ModelFamily;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Resolves the OpenAI-style {@code reasoning_effort} request parameter for a
|
||||
* model, given its name, the provider's generate kwargs and its {@link ModelFamily}.
|
||||
*
|
||||
* <p>Resolution rules:
|
||||
* <ul>
|
||||
* <li>Families that do not accept {@code reasoning_effort} always resolve to
|
||||
* {@code null}; an explicit kwargs override on such a model is dropped with
|
||||
* a warning, because sending the field would 400 on DeepSeek / Kimi-style
|
||||
* providers.</li>
|
||||
* <li>For accepting families, an explicit {@code reasoningEffort} in the
|
||||
* provider's generate kwargs wins.</li>
|
||||
* <li>Otherwise a thinking-capable family gets a default of {@code "medium"}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Pure function with no Spring dependencies, so it is shared by the
|
||||
* OpenAI-compatible chat model builder and the agent graph builder (which passes
|
||||
* the resolved value to its reasoning nodes).
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ReasoningEffortResolver {
|
||||
|
||||
private ReasoningEffortResolver() {}
|
||||
|
||||
/**
|
||||
* Resolve the effective {@code reasoning_effort} value, or {@code null} when
|
||||
* the model must not carry one.
|
||||
*/
|
||||
public static String resolveReasoningEffort(String modelName, Map<String, Object> kwargs, ModelFamily family) {
|
||||
// Only families that actually accept reasoning_effort may receive it.
|
||||
// Otherwise a provider-level `reasoningEffort` override would leak to
|
||||
// deepseek-chat / kimi-k2 / deepseek-reasoner etc., triggering a
|
||||
// "reasoning_content missing" 400.
|
||||
if (!family.supportsReasoningEffort()) {
|
||||
Object overridden = ProviderGenerateKwargs.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;
|
||||
}
|
||||
// An explicit generateKwargs override always wins (within accepting families).
|
||||
Object value = ProviderGenerateKwargs.findOptionValue(kwargs, "reasoningEffort");
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
return text.trim();
|
||||
}
|
||||
// Only thinking-capable families get a default reasoning effort.
|
||||
if (family.isThinking()) {
|
||||
return "medium";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
/**
|
||||
* Request-scoped {@link ThreadLocal} holder for the thinking depth.
|
||||
*
|
||||
* <p>Carries the front-end-selected thinking level from the agent service down
|
||||
* to the reasoning nodes and chat model builders, without mutating the cached
|
||||
* agent instance or the streaming interfaces.
|
||||
*
|
||||
* <p>Supported values: off / low / medium / high / max; {@code null} means
|
||||
* "follow the model default".
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public final class ThinkingLevelHolder {
|
||||
|
||||
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ThinkingLevelHolder() {}
|
||||
|
||||
public static void set(String level) {
|
||||
HOLDER.set(level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current request's thinking level; {@code null} means unset
|
||||
* (follow the model default).
|
||||
*/
|
||||
public static String get() {
|
||||
return HOLDER.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,7 @@ public class ModelConfigEntity {
|
||||
* RFC-03 Lane B1 — per-model HTTP read timeout (seconds).
|
||||
*
|
||||
* <p>Null / zero / negative → fall back to the global default of 180s
|
||||
* (existing behavior, see {@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}
|
||||
* (existing behavior, see {@code AnthropicChatModelBuilder.applyHttpTimeouts}
|
||||
* and the corresponding helper in {@code AgentGraphBuilder}). A positive
|
||||
* value overrides for this specific model.
|
||||
*
|
||||
|
||||
@ -53,7 +53,7 @@ public enum ModelFamily {
|
||||
* 也不强制 temperature=1。OpenClaw 实现参考 {@code extensions/deepseek/models.ts:28-81} 标记
|
||||
* {@code supportsReasoningEffort: true}。<br>
|
||||
* 约束:保留 max_tokens;支持 reasoning_effort;temperature/topP 用配置值。
|
||||
* thinking=true 让 {@link vip.mate.agent.chatmodel.DeepSeekV4ThinkingDecorator}
|
||||
* thinking=true 让 {@link vip.mate.llm.chatmodel.DeepSeekV4ThinkingDecorator}
|
||||
* 在请求体注入 OpenAI 协议外的 {@code thinking: {type: enabled|disabled}} 字段。
|
||||
*/
|
||||
DEEPSEEK_V4_REASONING(false, false, true, false, false, true),
|
||||
|
||||
@ -10,7 +10,7 @@ public enum ModelProtocol {
|
||||
/**
|
||||
* RFC-062: same Anthropic Messages API but authenticated with the user's
|
||||
* Claude Code OAuth token (Pro/Max subscription) instead of an API key.
|
||||
* Routed by {@code AgentClaudeCodeChatModelBuilder}.
|
||||
* Routed by {@code ClaudeCodeChatModelBuilder}.
|
||||
*/
|
||||
ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel"),
|
||||
GEMINI_NATIVE("gemini-native", "GeminiChatModel"),
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package vip.mate.llm.routing;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Read access to an agent's skill / provider bindings, as needed by
|
||||
* {@link ProviderRouter} for capability-aware routing.
|
||||
*
|
||||
* <p>Declared in the {@code llm} layer so the routing code depends only on
|
||||
* this abstraction. The {@code agent} layer supplies the implementation,
|
||||
* keeping the dependency direction {@code agent → llm}.
|
||||
*/
|
||||
public interface AgentBindingResolver {
|
||||
|
||||
/**
|
||||
* Skill ids bound to the agent, or {@code null} when the agent has no
|
||||
* explicit bindings (meaning "use the global default").
|
||||
*/
|
||||
Set<Long> getBoundSkillIds(Long agentId);
|
||||
|
||||
/**
|
||||
* Provider ids the agent prefers, in priority order; empty when none.
|
||||
*/
|
||||
List<String> getPreferredProviderIds(Long agentId);
|
||||
}
|
||||
@ -3,7 +3,6 @@ package vip.mate.llm.routing;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
||||
@ -20,27 +19,22 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* RFC-090 §9.2 调整 C — diagnostics-first ProviderRouter.
|
||||
* Capability-aware provider routing.
|
||||
*
|
||||
* <p>This first iteration does not yet rewrite the fallback chain order
|
||||
* (the existing {@code AgentBindingService.getPreferredProviderIds} +
|
||||
* {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain} flow is
|
||||
* already in place). Instead it:
|
||||
* <p>Given an agent's bound skills, aggregates the {@code requires-model}
|
||||
* capabilities they declare and uses that to:
|
||||
* <ul>
|
||||
* <li>{@link #diagnosePrimary} — WARN when the chosen primary model is
|
||||
* missing a capability the bound skills require;</li>
|
||||
* <li>{@link #reorderForCapabilities} — lift providers that satisfy the
|
||||
* required modalities to the head of the fallback chain;</li>
|
||||
* <li>{@link #selectPrimary} — pick a primary model that satisfies the
|
||||
* required modalities, falling back to the global default.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <ol>
|
||||
* <li>Aggregates {@code requires-model} from the agent's bound skills'
|
||||
* manifests.</li>
|
||||
* <li>Compares the union against the primary model's resolved
|
||||
* capability set ({@link ModelCapabilityService#resolve}).</li>
|
||||
* <li>Logs a clear WARN if a capability is missing — surfacing the
|
||||
* same gap RFC-085's "ready" badge would render in UI.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Promoting this to actual chain re-ordering (i.e. "prefer providers
|
||||
* that satisfy modelNeeds") is straightforward once we have the data
|
||||
* for it: add a phase between {@code reorderByPreferences} and the
|
||||
* model build loop. That phase is intentionally not in this commit so
|
||||
* we can ship the diagnostics path independently and watch it in dev.
|
||||
* <p>Binding data is read through {@link AgentBindingResolver}, an
|
||||
* abstraction declared in this package so the routing layer never depends
|
||||
* on the agent layer directly.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@ -48,7 +42,7 @@ import java.util.Set;
|
||||
public class ProviderRouter {
|
||||
|
||||
private final SkillRuntimeService skillRuntimeService;
|
||||
private final AgentBindingService bindingService;
|
||||
private final AgentBindingResolver bindingService;
|
||||
private final ModelCapabilityService capabilityService;
|
||||
private final ModelConfigService modelConfigService;
|
||||
|
||||
@ -134,7 +128,7 @@ public class ProviderRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== chain reorder (RFC-090 §9.2 调整 C) ====================
|
||||
// ==================== chain reorder ====================
|
||||
|
||||
/**
|
||||
* Re-rank an already preference-ordered provider list so providers
|
||||
@ -142,10 +136,9 @@ public class ProviderRouter {
|
||||
* float to the head. Stable order otherwise — providers that don't
|
||||
* satisfy keep their existing relative order.
|
||||
*
|
||||
* <p>Called by {@link vip.mate.agent.AgentGraphBuilder#buildFallbackChain}
|
||||
* after the user-preferences reorder. Only acts when bound skills
|
||||
* actually declared {@code requires-model}; otherwise returns the
|
||||
* input untouched.
|
||||
* <p>Called when building the fallback chain, after the user-preferences
|
||||
* reorder. Only acts when bound skills actually declared
|
||||
* {@code requires-model}; otherwise returns the input untouched.
|
||||
*/
|
||||
public List<ModelProviderEntity> reorderForCapabilities(Long agentId,
|
||||
List<ModelProviderEntity> ordered) {
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-001 (Claude 4.7 contract): {@link AgentAnthropicChatModelBuilder#isClaude47}
|
||||
* must correctly classify the model variants we'll see in production.
|
||||
*
|
||||
* <p>Reference: hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
|
||||
* Claude 4.7 forbids temperature / top_p / top_k entirely — the builder relies
|
||||
* on this detector to skip those fields rather than letting Anthropic 400.
|
||||
*/
|
||||
class AgentAnthropicChatModelBuilderClaude47Test {
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects hyphenated direct-API model names")
|
||||
void detect_hyphenated() {
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-haiku-4-7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)")
|
||||
void detect_dotted() {
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4.7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects OpenRouter-style prefixed model ids")
|
||||
void detect_openrouterPrefix() {
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names")
|
||||
void detect_negatives() {
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-6"));
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5"));
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"),
|
||||
"3.7 must not match 4.7");
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet"));
|
||||
// The "claude" prefix guard prevents non-Anthropic models from spuriously
|
||||
// matching even if they contain "4-7" / "4.7" substrings.
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("gpt-4-7"),
|
||||
"Non-Claude models must NOT match — claude prefix guard active");
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 null-safe")
|
||||
void detect_nullSafe() {
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(null));
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*")
|
||||
void detect_3_7_vs_4_7() {
|
||||
// Both contain "-7" but only the second contains "4-7" as a substring.
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"),
|
||||
"Date-stamped 4-7 variants must still match");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link AnthropicChatModelBuilder#isClaude47} must correctly classify the
|
||||
* Claude 4.7 model variants we'll see in production.
|
||||
*
|
||||
* <p>Claude 4.7 forbids temperature / top_p / top_k entirely — the builder
|
||||
* relies on this detector to skip those fields rather than letting Anthropic 400.
|
||||
*/
|
||||
class AnthropicChatModelBuilderClaude47Test {
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects hyphenated direct-API model names")
|
||||
void detect_hyphenated() {
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4-7"));
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7"));
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-haiku-4-7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)")
|
||||
void detect_dotted() {
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4.7"));
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects OpenRouter-style prefixed model ids")
|
||||
void detect_openrouterPrefix() {
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7"));
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7"));
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names")
|
||||
void detect_negatives() {
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-opus-4-6"));
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5"));
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"),
|
||||
"3.7 must not match 4.7");
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet"));
|
||||
// The "claude" prefix guard prevents non-Anthropic models from spuriously
|
||||
// matching even if they contain "4-7" / "4.7" substrings.
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("gpt-4-7"),
|
||||
"Non-Claude models must NOT match — claude prefix guard active");
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 null-safe")
|
||||
void detect_nullSafe() {
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47(null));
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*")
|
||||
void detect_3_7_vs_4_7() {
|
||||
// Both contain "-7" but only the second contains "4-7" as a substring.
|
||||
assertFalse(AnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219"));
|
||||
assertTrue(AnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"),
|
||||
"Date-stamped 4-7 variants must still match");
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@ -16,9 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-2: {@link AssistantThinkingRelay} — RelayEntry carries both
|
||||
* per-assistant thinking and the caller's original {@code user} field, so the
|
||||
* consumer can restore it when rebuilding the outbound request.
|
||||
* {@link AssistantThinkingRelay} — RelayEntry carries both per-assistant thinking
|
||||
* and the caller's original {@code user} field, so the consumer can restore it
|
||||
* when rebuilding the outbound request.
|
||||
*/
|
||||
class AssistantThinkingRelayTest {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@ -38,17 +38,17 @@ import static org.mockito.Mockito.when;
|
||||
* tests can exercise the full assembly path without mocking the API client.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AgentClaudeCodeChatModelBuilderTest {
|
||||
class ClaudeCodeChatModelBuilderTest {
|
||||
|
||||
@Mock
|
||||
private AgentAnthropicChatModelBuilder anthropicBuilder;
|
||||
private AnthropicChatModelBuilder anthropicBuilder;
|
||||
|
||||
@Mock
|
||||
private ClaudeCodeOAuthService oauthService;
|
||||
|
||||
private ClaudeCodeApiHeaders apiHeaders;
|
||||
|
||||
private AgentClaudeCodeChatModelBuilder builder;
|
||||
private ClaudeCodeChatModelBuilder builder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@ -60,7 +60,7 @@ class AgentClaudeCodeChatModelBuilderTest {
|
||||
};
|
||||
apiHeaders = new ClaudeCodeApiHeaders(detector);
|
||||
|
||||
builder = new AgentClaudeCodeChatModelBuilder(
|
||||
builder = new ClaudeCodeChatModelBuilder(
|
||||
anthropicBuilder,
|
||||
oauthService,
|
||||
apiHeaders,
|
||||
@ -82,7 +82,7 @@ class AgentClaudeCodeChatModelBuilderTest {
|
||||
// Sanity check: the NoopApiKey path passes Spring AI's notNull assertion
|
||||
// and the OAuth headers attach without throwing. If this test ever
|
||||
// fails, the most likely cause is a Spring AI upgrade tightening the
|
||||
// ApiKey contract — see AgentClaudeCodeChatModelBuilder javadoc.
|
||||
// ApiKey contract — see ClaudeCodeChatModelBuilder javadoc.
|
||||
AnthropicApi api = builder.buildOauthAnthropicApi("sk-ant-oat01-test-token");
|
||||
assertNotNull(api);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@ -12,7 +12,6 @@ import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@ -109,8 +108,8 @@ class DeepSeekV4ThinkingDecoratorTest {
|
||||
@Test
|
||||
@DisplayName("mapEffort: low/medium/high passthrough; max collapses to high; unknown → medium")
|
||||
void mapEffort_levels() {
|
||||
// openclaw resolveDeepSeekV4ReasoningEffort folds "max" into "high"
|
||||
// because DeepSeek doesn't expose a max tier. Pin both ends of the rule.
|
||||
// "max" folds into "high" because DeepSeek doesn't expose a max tier.
|
||||
// Pin both ends of the rule.
|
||||
assertEquals("low", DeepSeekV4ThinkingDecorator.mapEffort("low"));
|
||||
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("medium"));
|
||||
assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("high"));
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@ -21,8 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-2 consumer-side tests for
|
||||
* {@link AgentGraphBuilder#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}.
|
||||
* Consumer-side tests for
|
||||
* {@link OpenAiRequestRewriter#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}.
|
||||
*
|
||||
* <p>Covers four orthogonal dimensions:
|
||||
* <ul>
|
||||
@ -101,7 +101,7 @@ class PatchReasoningContentTest {
|
||||
), "caller-user-1");
|
||||
|
||||
// model is "test-model" which maps to STANDARD family → requiresReasoningContentPatch returns false
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
assertSame(req, out, "no thinking signal → no rebuild");
|
||||
assertEquals("caller-user-1", out.user(), "user field untouched");
|
||||
}
|
||||
@ -117,7 +117,7 @@ class PatchReasoningContentTest {
|
||||
assistantPlain("hi")
|
||||
), fakeToken);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("openai"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("openai"));
|
||||
assertNotSame(req, out, "rebuild expected to strip leaked token");
|
||||
assertNull(out.user(), "leaked token must be sanitized to null");
|
||||
}
|
||||
@ -136,7 +136,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a1", null) // i=2, position 1 in thinkings → "in-turn-think"
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("original-caller-42", out.user(), "sanitizedUser must equal entry.originalUser()");
|
||||
assertEquals("in-turn-think", out.messages().get(2).reasoningContent(),
|
||||
@ -167,7 +167,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a2", null) // i=4, in-turn (4 > 3)
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals(" ", out.messages().get(2).reasoningContent(),
|
||||
"cross-turn A1 gets ' ' fallback so DeepSeek thinking-mode validation passes");
|
||||
@ -193,7 +193,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a4", null) // i=5 in-turn
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
// DEEPSEEK patchCrossTurn=true: cross-turn now also gets ' ' fallback.
|
||||
// Iterator alignment is preserved: A1/A2 consume the empty entries '',
|
||||
@ -220,7 +220,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a1", null) // in-turn
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"DeepSeek: ' ' fallback restores forward progress when relay has no real value");
|
||||
@ -243,7 +243,7 @@ class PatchReasoningContentTest {
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"Kimi tolerates ' ' — preserve legacy behavior");
|
||||
@ -266,7 +266,7 @@ class PatchReasoningContentTest {
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("custom-gateway"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("custom-gateway"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"DEFAULT keeps legacy ' ' for unrecognized providers — avoid regressing self-hosted backends");
|
||||
@ -285,7 +285,7 @@ class PatchReasoningContentTest {
|
||||
assistantPlain("plain answer") // no tool_calls
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("thinking-for-plain", out.messages().get(1).reasoningContent(),
|
||||
"DeepSeek contract requires reasoning_content even on non-tool_call assistants when in thinking mode");
|
||||
@ -307,7 +307,7 @@ class PatchReasoningContentTest {
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn"));
|
||||
|
||||
assertNull(out.messages().get(1).reasoningContent(),
|
||||
"Kimi only patches tool_call assistants; plain assistants are untouched");
|
||||
@ -326,7 +326,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a1", "pre-existing-real-thinking") // already has a value
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("pre-existing-real-thinking", out.messages().get(1).reasoningContent(),
|
||||
"non-blank existing reasoning_content must not be overwritten by relay");
|
||||
@ -338,7 +338,7 @@ class PatchReasoningContentTest {
|
||||
@DisplayName("Empty messages list: no-op, returns same instance")
|
||||
void emptyMessages_noop() {
|
||||
ChatCompletionRequest req = request(List.of(), null);
|
||||
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
|
||||
assertSame(req, OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -349,7 +349,7 @@ class PatchReasoningContentTest {
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
|
||||
assertSame(req, OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek")));
|
||||
}
|
||||
|
||||
// ---------- Fewer relay entries than assistants: defensive policy fallback ----------
|
||||
@ -368,7 +368,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a2", null)
|
||||
)), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("real-1", out.messages().get(1).reasoningContent());
|
||||
assertEquals(" ", out.messages().get(2).reasoningContent(),
|
||||
@ -394,7 +394,7 @@ class PatchReasoningContentTest {
|
||||
assistantToolCall("a2", null) // i=3, in-turn (3 > 2)
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("kimi-cn"));
|
||||
|
||||
assertNull(out.messages().get(1).reasoningContent(),
|
||||
"KIMI does not patch cross-turn — thinking resets across user turns");
|
||||
@ -420,7 +420,7 @@ class PatchReasoningContentTest {
|
||||
new ChatCompletionMessage("plain a2", Role.ASSISTANT)
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
ChatCompletionRequest out = OpenAiRequestRewriter.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"DEEPSEEK plain prior-turn assistant gets ' ' so request validates");
|
||||
@ -1,4 +1,4 @@
|
||||
package vip.mate.agent;
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@ -14,8 +14,8 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-1.3 verification — covers §5.2 Case E3.1 / E3.2 / E3.3 plus the
|
||||
* whitelist positive path.
|
||||
* Verification of {@link OpenAiRequestRewriter#sanitizeReasoningEffortForProvider}
|
||||
* and {@link OpenAiRequestRewriter#isReasoningEffortWhitelistedProvider}.
|
||||
*
|
||||
* <p>The sanitizer is provider-first with default-deny: only providerId in
|
||||
* {@code {openai, azure-openai}} is allowed to carry {@code reasoning_effort}.
|
||||
@ -77,41 +77,41 @@ class ReasoningEffortSanitizerTest {
|
||||
@Test
|
||||
@DisplayName("Whitelist: openai is allowed")
|
||||
void whitelist_openai() {
|
||||
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("openai")));
|
||||
assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("openai")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: azure-openai is allowed")
|
||||
void whitelist_azureOpenai() {
|
||||
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("azure-openai")));
|
||||
assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("azure-openai")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: case-insensitive (Azure-OpenAI)")
|
||||
void whitelist_caseInsensitive() {
|
||||
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI")));
|
||||
assertTrue(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: deepseek is denied")
|
||||
void denylist_deepseek() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("deepseek")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("deepseek")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: kimi family denied")
|
||||
void denylist_kimi() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-cn")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-intl")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-code")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-cn")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-intl")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("kimi-code")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: dashscope / ollama / anthropic denied")
|
||||
void denylist_misc() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("dashscope")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("ollama")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("anthropic")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("dashscope")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("ollama")));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(provider("anthropic")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -119,19 +119,19 @@ class ReasoningEffortSanitizerTest {
|
||||
void denylist_unknownProvider() {
|
||||
// This is the critical regression guard: if anyone re-adds a default-allow
|
||||
// branch to isReasoningEffortWhitelistedProvider, this case fails first.
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(
|
||||
provider("my-custom-openai-compat-gateway")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(
|
||||
provider("openrouter")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(
|
||||
provider("together")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: null provider / null providerId denied")
|
||||
void denylist_nulls() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(null));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(new ModelProviderEntity()));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(null));
|
||||
assertFalse(OpenAiRequestRewriter.isReasoningEffortWhitelistedProvider(new ModelProviderEntity()));
|
||||
}
|
||||
|
||||
// ---------- sanitizeReasoningEffortForProvider ----------
|
||||
@ -140,7 +140,7 @@ class ReasoningEffortSanitizerTest {
|
||||
@DisplayName("Sanitize no-op: request has no reasoning_effort")
|
||||
void sanitize_noop_noReasoningEffort() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", null);
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
|
||||
assertSame(req, out, "should return same instance when reasoning_effort is already null");
|
||||
}
|
||||
|
||||
@ -149,7 +149,7 @@ class ReasoningEffortSanitizerTest {
|
||||
void sanitize_failover_deepseek_strips() {
|
||||
// Simulate failover: OpenAiChatOptions.model still leaked as "gpt-5" on the deepseek request.
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
|
||||
assertNull(out.reasoningEffort(), "deepseek is not on the whitelist — strip regardless of model name");
|
||||
// Other fields preserved
|
||||
assertEquals("gpt-5", out.model());
|
||||
@ -160,7 +160,7 @@ class ReasoningEffortSanitizerTest {
|
||||
void sanitize_failover_otherDenied_strips() {
|
||||
for (String pid : List.of("kimi-cn", "kimi-intl", "kimi-code", "dashscope", "ollama", "anthropic")) {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "medium");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider(pid));
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider(pid));
|
||||
assertNull(out.reasoningEffort(), "provider=" + pid + " must strip");
|
||||
}
|
||||
}
|
||||
@ -169,7 +169,7 @@ class ReasoningEffortSanitizerTest {
|
||||
@DisplayName("§5.2 Case E3.3: unknown provider strips (default-deny regression guard)")
|
||||
void sanitize_unknownProvider_strips() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(
|
||||
req, provider("my-custom-openai-compat-gateway"));
|
||||
assertNull(out.reasoningEffort(),
|
||||
"unknown provider must strip (default-deny) — if this fails, someone re-added default-allow");
|
||||
@ -179,7 +179,7 @@ class ReasoningEffortSanitizerTest {
|
||||
@DisplayName("Whitelist + supporting model: keep reasoning_effort (gpt-5 on openai)")
|
||||
void sanitize_whitelisted_supportingModel_keeps() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("openai"));
|
||||
assertSame(req, out, "gpt-5 on openai should pass through unchanged");
|
||||
assertEquals("high", out.reasoningEffort());
|
||||
}
|
||||
@ -189,7 +189,7 @@ class ReasoningEffortSanitizerTest {
|
||||
void sanitize_whitelisted_nonSupportingModel_strips() {
|
||||
// gpt-4 is NOT OPENAI_REASONING family — reasoning_effort is not applicable there.
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-4", "medium");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("openai"));
|
||||
assertNull(out.reasoningEffort(),
|
||||
"gpt-4 is whitelisted-provider but non-supporting-family — family gate should strip");
|
||||
}
|
||||
@ -198,7 +198,7 @@ class ReasoningEffortSanitizerTest {
|
||||
@DisplayName("Azure OpenAI with supporting model: keep reasoning_effort")
|
||||
void sanitize_azureOpenai_supporting_keeps() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "low");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("azure-openai"));
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, provider("azure-openai"));
|
||||
assertEquals("low", out.reasoningEffort());
|
||||
}
|
||||
|
||||
@ -206,7 +206,7 @@ class ReasoningEffortSanitizerTest {
|
||||
@DisplayName("Null provider: strip (defensive)")
|
||||
void sanitize_nullProvider_strips() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, null);
|
||||
OpenAiApi.ChatCompletionRequest out = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(req, null);
|
||||
assertNull(out.reasoningEffort());
|
||||
}
|
||||
}
|
||||
@ -67,7 +67,7 @@ class ModelConfigServiceDefaultModelTest {
|
||||
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
|
||||
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.getDefaultModel();
|
||||
|
||||
@ -89,8 +89,8 @@ class ModelConfigServiceDefaultModelTest {
|
||||
// First selectOne → the is_default=true model
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
// dashscope is NOT configured, zhipu IS
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(false);
|
||||
when(modelProviderService.isProviderConfigured("zhipu")).thenReturn(true);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(false);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured("zhipu")).thenReturn(true);
|
||||
// Full-scan returns both; zhipu comes second but dashscope is skipped
|
||||
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(dashscopeDefault, zhipuModel));
|
||||
@ -110,7 +110,7 @@ class ModelConfigServiceDefaultModelTest {
|
||||
ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false);
|
||||
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
when(modelProviderService.isProviderConfigured(any())).thenReturn(false);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured(any())).thenReturn(false);
|
||||
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(dashscopeDefault, zhipuModel));
|
||||
|
||||
@ -140,7 +140,7 @@ class ModelConfigServiceDefaultModelTest {
|
||||
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
|
||||
// With null providerService, isProviderConfigured returns true (lenient bootstrap)
|
||||
// With null providerService, isProviderEnabledAndConfigured returns true (lenient bootstrap)
|
||||
ModelConfigEntity result = service.getDefaultModel();
|
||||
assertEquals("dashscope", result.getProvider());
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ class ModelConfigServiceResolveModelTest {
|
||||
// resolveModel skips its own selectOne for null/blank input, then calls getDefaultModel(),
|
||||
// which itself runs one selectOne lookup for the default flag.
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel(null);
|
||||
|
||||
@ -88,7 +88,7 @@ class ModelConfigServiceResolveModelTest {
|
||||
void blankNameFallsBack() {
|
||||
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel(" ");
|
||||
|
||||
@ -113,7 +113,7 @@ class ModelConfigServiceResolveModelTest {
|
||||
assertEquals("claude-3-5-sonnet", result.getModelName());
|
||||
// Exactly one lookup — getDefaultModel must NOT be called.
|
||||
verify(modelConfigMapper, times(1)).selectOne(any());
|
||||
verify(modelProviderService, never()).isProviderConfigured(any());
|
||||
verify(modelProviderService, never()).isProviderEnabledAndConfigured(any());
|
||||
}
|
||||
|
||||
// ── Unmatched → fall back to default ───────────────────────────────────────
|
||||
@ -126,7 +126,7 @@ class ModelConfigServiceResolveModelTest {
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(null) // 1st: name lookup misses
|
||||
.thenReturn(defaultModel); // 2nd: default flag lookup
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
when(modelProviderService.isProviderEnabledAndConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel("ghost-model");
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.memory.model.DreamReportEntity;
|
||||
import vip.mate.memory.model.MemoryRecallEntity;
|
||||
import vip.mate.memory.repository.DreamReportMapper;
|
||||
@ -36,13 +37,14 @@ class HilEditValidationTest {
|
||||
@Mock private MorningCardService morningCardService;
|
||||
@Mock private MemoryHilService hilService;
|
||||
@Mock private DreamEventBroadcaster eventBroadcaster;
|
||||
@Mock private AuthService authService;
|
||||
|
||||
private DreamController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new DreamController(dreamReportMapper, recallMapper,
|
||||
morningCardService, hilService, eventBroadcaster);
|
||||
morningCardService, hilService, eventBroadcaster, authService);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Loading…
Reference in New Issue
Block a user